first commit
This commit is contained in:
51
calcom/apps/web/components/AddToHomescreen.tsx
Normal file
51
calcom/apps/web/components/AddToHomescreen.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
export default function AddToHomescreen() {
|
||||
const { t } = useLocale();
|
||||
const [closeBanner, setCloseBanner] = useState(false);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
if (window.matchMedia("(display-mode: standalone)").matches) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return !closeBanner ? (
|
||||
<div className="fixed inset-x-0 bottom-0 pb-2 sm:hidden sm:pb-5">
|
||||
<div className="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">
|
||||
<div className="rounded-lg p-2 shadow-lg sm:p-3" style={{ background: "#2F333D" }}>
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<div className="flex w-0 flex-1 items-center">
|
||||
<span className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast flex rounded-lg bg-opacity-30 p-2">
|
||||
<svg
|
||||
className="h-7 w-7 fill-current text-[#5B93F9]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 50 50"
|
||||
enableBackground="new 0 0 50 50">
|
||||
<path d="M30.3 13.7L25 8.4l-5.3 5.3-1.4-1.4L25 5.6l6.7 6.7z" />
|
||||
<path d="M24 7h2v21h-2z" />
|
||||
<path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z" />
|
||||
</svg>
|
||||
</span>
|
||||
<p className="text-inverted ms-3 text-xs font-medium dark:text-white">
|
||||
<span className="inline">{t("add_to_homescreen")}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="order-2 flex-shrink-0 sm:order-3">
|
||||
<button
|
||||
onClick={() => setCloseBanner(true)}
|
||||
type="button"
|
||||
className="-mr-1 flex rounded-md p-2 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
|
||||
<span className="sr-only">{t("dismiss")}</span>
|
||||
<Icon name="x" className="text-inverted h-6 w-6 dark:text-white" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
135
calcom/apps/web/components/AppListCard.tsx
Normal file
135
calcom/apps/web/components/AppListCard.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { Avatar, Badge, Icon, ListItemText } from "@calcom/ui";
|
||||
|
||||
type ShouldHighlight =
|
||||
| {
|
||||
slug: string;
|
||||
shouldHighlight: true;
|
||||
}
|
||||
| {
|
||||
shouldHighlight?: never;
|
||||
slug?: never;
|
||||
};
|
||||
|
||||
type AppListCardProps = {
|
||||
logo?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
isDefault?: boolean;
|
||||
isTemplate?: boolean;
|
||||
invalidCredential?: boolean;
|
||||
children?: ReactNode;
|
||||
credentialOwner?: CredentialOwner;
|
||||
className?: string;
|
||||
} & ShouldHighlight;
|
||||
|
||||
const schema = z.object({ hl: z.string().optional() });
|
||||
|
||||
export default function AppListCard(props: AppListCardProps) {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
logo,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
isDefault,
|
||||
slug,
|
||||
shouldHighlight,
|
||||
isTemplate,
|
||||
invalidCredential,
|
||||
children,
|
||||
credentialOwner,
|
||||
className,
|
||||
} = props;
|
||||
const {
|
||||
data: { hl },
|
||||
} = useTypedQuery(schema);
|
||||
const router = useRouter();
|
||||
const [highlight, setHighlight] = useState(shouldHighlight && hl === slug);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const searchParams = useCompatSearchParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldHighlight && highlight && searchParams !== null && pathname !== null) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
_searchParams.delete("hl");
|
||||
_searchParams.delete("category"); // this comes from params, not from search params
|
||||
|
||||
setHighlight(false);
|
||||
|
||||
const stringifiedSearchParams = _searchParams.toString();
|
||||
|
||||
router.replace(`${pathname}${stringifiedSearchParams !== "" ? `?${stringifiedSearchParams}` : ""}`);
|
||||
}, 3000);
|
||||
}
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [highlight, pathname, router, searchParams, shouldHighlight]);
|
||||
|
||||
return (
|
||||
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100", className)}>
|
||||
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
|
||||
{logo ? (
|
||||
<img
|
||||
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
||||
src={logo}
|
||||
alt={`${title} logo`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex grow flex-col gap-y-1 truncate">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{isDefault && <Badge variant="green">{t("default")}</Badge>}
|
||||
{isTemplate && <Badge variant="red">Template</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<ListItemText component="p">{description}</ListItemText>
|
||||
{invalidCredential && (
|
||||
<div className="flex gap-x-2 pt-2">
|
||||
<Icon name="circle-alert" className="h-8 w-8 text-red-500 sm:h-4 sm:w-4" />
|
||||
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
|
||||
{t("invalid_credential")}
|
||||
</ListItemText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{credentialOwner && (
|
||||
<div>
|
||||
<Badge variant="gray">
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
className="mr-2"
|
||||
alt={credentialOwner.name || "Nameless"}
|
||||
size="xs"
|
||||
imageSrc={getPlaceholderAvatar(credentialOwner.avatar, credentialOwner?.name as string)}
|
||||
/>
|
||||
{credentialOwner.name}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions}
|
||||
</div>
|
||||
{children && <div className="w-full">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
calcom/apps/web/components/EnterprisePage.tsx
Normal file
69
calcom/apps/web/components/EnterprisePage.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { UpgradeTip } from "@calcom/features/tips";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, ButtonGroup, Icon } from "@calcom/ui";
|
||||
|
||||
export default function EnterprisePage() {
|
||||
const { t } = useLocale();
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Icon name="globe" className="h-5 w-5 text-red-500" />,
|
||||
title: t("branded_subdomain"),
|
||||
description: t("branded_subdomain_description"),
|
||||
},
|
||||
{
|
||||
icon: <Icon name="bar-chart" className="h-5 w-5 text-blue-500" />,
|
||||
title: t("org_insights"),
|
||||
description: t("org_insights_description"),
|
||||
},
|
||||
{
|
||||
icon: <Icon name="paintbrush" className="h-5 w-5 text-pink-500" />,
|
||||
title: t("extensive_whitelabeling"),
|
||||
description: t("extensive_whitelabeling_description"),
|
||||
},
|
||||
{
|
||||
icon: <Icon name="users" className="h-5 w-5 text-orange-500" />,
|
||||
title: t("unlimited_teams"),
|
||||
description: t("unlimited_teams_description"),
|
||||
},
|
||||
{
|
||||
icon: <Icon name="credit-card" className="h-5 w-5 text-green-500" />,
|
||||
title: t("unified_billing"),
|
||||
description: t("unified_billing_description"),
|
||||
},
|
||||
{
|
||||
icon: <Icon name="lock" className="h-5 w-5 text-purple-500" />,
|
||||
title: t("advanced_managed_events"),
|
||||
description: t("advanced_managed_events_description"),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<ShellMain heading="Enterprise" subtitle={t("enterprise_description")}>
|
||||
<UpgradeTip
|
||||
plan="enterprise"
|
||||
title={t("create_your_org")}
|
||||
description={t("create_your_org_description")}
|
||||
features={features}
|
||||
background="/tips/enterprise"
|
||||
buttons={
|
||||
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<ButtonGroup>
|
||||
<Button color="primary" href="https://i.cal.com/sales/enterprise?duration=25" target="_blank">
|
||||
{t("contact_sales")}
|
||||
</Button>
|
||||
<Button color="minimal" href="https://cal.com/enterprise" target="_blank">
|
||||
{t("learn_more")}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
}>
|
||||
<>Create Org</>
|
||||
</UpgradeTip>
|
||||
</ShellMain>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
calcom/apps/web/components/I18nLanguageHandler.tsx
Normal file
16
calcom/apps/web/components/I18nLanguageHandler.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CALCOM_VERSION } from "@calcom/lib/constants";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
export function useViewerI18n(locale: string) {
|
||||
return trpc.viewer.public.i18n.useQuery(
|
||||
{ locale, CalComVersion: CALCOM_VERSION },
|
||||
{
|
||||
/**
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
**/
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
4
calcom/apps/web/components/Loader.tsx
Normal file
4
calcom/apps/web/components/Loader.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* @deprecated Use custom Skeletons instead
|
||||
**/
|
||||
export { Loader as default } from "@calcom/ui";
|
||||
100
calcom/apps/web/components/PageWrapper.tsx
Normal file
100
calcom/apps/web/components/PageWrapper.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { DefaultSeo } from "next-seo";
|
||||
import { Inter } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import Head from "next/head";
|
||||
import Script from "next/script";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { buildCanonical } from "@calcom/lib/next-seo.config";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
import AppProviders from "@lib/app-providers";
|
||||
import { seoConfig } from "@lib/config/next-seo.config";
|
||||
|
||||
export interface CalPageWrapper {
|
||||
(props?: AppProps): JSX.Element;
|
||||
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
||||
}
|
||||
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
|
||||
const calFont = localFont({
|
||||
src: "../fonts/CalSans-SemiBold.woff2",
|
||||
variable: "--font-cal",
|
||||
preload: true,
|
||||
display: "swap",
|
||||
weight: "600",
|
||||
});
|
||||
|
||||
function PageWrapper(props: AppProps) {
|
||||
const { Component, pageProps, err, router } = props;
|
||||
let pageStatus = "200";
|
||||
|
||||
if (router.pathname === "/404") {
|
||||
pageStatus = "404";
|
||||
} else if (router.pathname === "/500") {
|
||||
pageStatus = "500";
|
||||
}
|
||||
|
||||
// On client side don't let nonce creep into DOM
|
||||
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
|
||||
// See https://github.com/kentcdodds/nonce-hydration-issues
|
||||
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
|
||||
const nonce = typeof window !== "undefined" ? (pageProps.nonce ? "" : undefined) : pageProps.nonce;
|
||||
const providerProps = {
|
||||
...props,
|
||||
pageProps: {
|
||||
...props.pageProps,
|
||||
nonce,
|
||||
},
|
||||
};
|
||||
// Use the layout defined at the page level, if available
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
|
||||
const path = router.asPath;
|
||||
|
||||
return (
|
||||
<AppProviders {...providerProps}>
|
||||
<Head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover"
|
||||
/>
|
||||
</Head>
|
||||
<DefaultSeo
|
||||
// Set canonical to https://cal.com or self-hosted URL
|
||||
canonical={
|
||||
IS_CALCOM
|
||||
? buildCanonical({ path, origin: "https://cal.com" }) // cal.com & .dev
|
||||
: buildCanonical({ path, origin: WEBAPP_URL }) // self-hosted
|
||||
}
|
||||
{...seoConfig.defaultNextSeo}
|
||||
/>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||
/>
|
||||
|
||||
<style jsx global>{`
|
||||
:root {
|
||||
--font-inter: ${interFont.style.fontFamily};
|
||||
--font-cal: ${calFont.style.fontFamily};
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{getLayout(
|
||||
Component.requiresLicense ? (
|
||||
<LicenseRequired>
|
||||
<Component {...pageProps} err={err} />
|
||||
</LicenseRequired>
|
||||
) : (
|
||||
<Component {...pageProps} err={err} />
|
||||
)
|
||||
)}
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageWrapper;
|
||||
72
calcom/apps/web/components/PageWrapperAppDir.tsx
Normal file
72
calcom/apps/web/components/PageWrapperAppDir.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { type DehydratedState } from "@tanstack/react-query";
|
||||
import type { SSRConfig } from "next-i18next";
|
||||
// import I18nLanguageHandler from "@components/I18nLanguageHandler";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers-app-dir";
|
||||
import AppProviders from "@lib/app-providers-app-dir";
|
||||
|
||||
export interface CalPageWrapper {
|
||||
(props?: AppProps): JSX.Element;
|
||||
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
||||
}
|
||||
|
||||
export type PageWrapperProps = Readonly<{
|
||||
getLayout: ((page: React.ReactElement) => ReactNode) | null;
|
||||
children: React.ReactNode;
|
||||
requiresLicense: boolean;
|
||||
nonce: string | undefined;
|
||||
themeBasis: string | null;
|
||||
dehydratedState?: DehydratedState;
|
||||
isThemeSupported?: boolean;
|
||||
isBookingPage?: boolean;
|
||||
i18n?: SSRConfig;
|
||||
}>;
|
||||
|
||||
function PageWrapper(props: PageWrapperProps) {
|
||||
const pathname = usePathname();
|
||||
let pageStatus = "200";
|
||||
|
||||
if (pathname === "/404") {
|
||||
pageStatus = "404";
|
||||
} else if (pathname === "/500") {
|
||||
pageStatus = "500";
|
||||
}
|
||||
|
||||
// On client side don't let nonce creep into DOM
|
||||
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
|
||||
// See https://github.com/kentcdodds/nonce-hydration-issues
|
||||
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
|
||||
const nonce = typeof window !== "undefined" ? (props.nonce ? "" : undefined) : props.nonce;
|
||||
const providerProps: PageWrapperProps = {
|
||||
...props,
|
||||
nonce,
|
||||
};
|
||||
|
||||
const getLayout: (page: React.ReactElement) => ReactNode = props.getLayout ?? ((page) => page);
|
||||
|
||||
return (
|
||||
<AppProviders {...providerProps}>
|
||||
{/* <I18nLanguageHandler locales={props.router.locales || []} /> */}
|
||||
<>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||
/>
|
||||
{getLayout(
|
||||
props.requiresLicense ? <LicenseRequired>{props.children}</LicenseRequired> : <>{props.children}</>
|
||||
)}
|
||||
</>
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageWrapper;
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { FunctionComponent, SVGProps } from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
interface AdditionalCalendarSelectorProps {
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
const AdditionalCalendarSelector = ({ isPending }: AdditionalCalendarSelectorProps): JSX.Element | null => {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: true });
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => {
|
||||
const options = data.items.map((item) => ({
|
||||
label: item.name,
|
||||
slug: item.slug,
|
||||
image: item.logo,
|
||||
type: item.type,
|
||||
}));
|
||||
options.push({
|
||||
label: "Add new calendars",
|
||||
slug: "add-new",
|
||||
image: "",
|
||||
type: "new_other",
|
||||
});
|
||||
return (
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon="plus" color="secondary" {...(isPending && { loading: isPending })}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{options.map((data) => (
|
||||
<DropdownMenuItem key={data.slug} className="focus:outline-none">
|
||||
{data.slug === "add-new" ? (
|
||||
<DropdownItem StartIcon="plus" color="minimal" href="/apps/categories/calendar">
|
||||
{t("install_new_calendar_app")}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={data.type}
|
||||
render={(installProps) => {
|
||||
const props = { ...installProps } as FunctionComponent<SVGProps<SVGSVGElement>>;
|
||||
return (
|
||||
<DropdownItem {...props} color="minimal" type="button">
|
||||
<span className="flex items-center gap-x-2">
|
||||
{data.image && <img className="h-5 w-5" src={data.image} alt={data.label} />}
|
||||
{`${t("add")} ${data.label}`}
|
||||
</span>
|
||||
</DropdownItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdditionalCalendarSelector;
|
||||
31
calcom/apps/web/components/apps/App.tsx
Normal file
31
calcom/apps/web/components/apps/App.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
||||
import type { AppPageProps } from "./AppPage";
|
||||
import { AppPage } from "./AppPage";
|
||||
|
||||
const ShellHeading = () => {
|
||||
const { t } = useLocale();
|
||||
return <span className="block py-2">{t("app_store")}</span>;
|
||||
};
|
||||
|
||||
export default function WrappedApp(props: AppPageProps) {
|
||||
return (
|
||||
<Shell smallHeading isPublic hideHeadingOnMobile heading={<ShellHeading />} backPath="/apps" withoutSeo>
|
||||
<HeadSeo
|
||||
title={props.name}
|
||||
description={props.description}
|
||||
app={{ slug: props.logo, name: props.name, description: props.description }}
|
||||
/>
|
||||
{props.licenseRequired ? (
|
||||
<LicenseRequired>
|
||||
<AppPage {...props} />
|
||||
</LicenseRequired>
|
||||
) : (
|
||||
<AppPage {...props} />
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
242
calcom/apps/web/components/apps/AppList.tsx
Normal file
242
calcom/apps/web/components/apps/AppList.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { getEventLocationTypeFromApp, type EventLocationType } from "@calcom/app-store/locations";
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
||||
import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
import { trpc, type RouterOutputs } from "@calcom/trpc";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
List,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import AppListCard from "@components/AppListCard";
|
||||
|
||||
interface AppListProps {
|
||||
variant?: AppCategories;
|
||||
data: RouterOutputs["viewer"]["integrations"];
|
||||
handleDisconnect: (credentialId: number) => void;
|
||||
listClassName?: string;
|
||||
}
|
||||
|
||||
export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppListProps) => {
|
||||
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
|
||||
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const onSuccessCallback = useCallback(() => {
|
||||
setBulkUpdateModal(true);
|
||||
showToast("Default app updated successfully", "success");
|
||||
}, []);
|
||||
|
||||
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast("Default app updated successfully", "success");
|
||||
utils.viewer.getUsersDefaultConferencingApp.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(`Error: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const ChildAppCard = ({
|
||||
item,
|
||||
}: {
|
||||
item: RouterOutputs["viewer"]["integrations"]["items"][number] & {
|
||||
credentialOwner?: CredentialOwner;
|
||||
};
|
||||
}) => {
|
||||
const appSlug = item?.slug;
|
||||
const appIsDefault =
|
||||
appSlug === defaultConferencingApp?.appSlug ||
|
||||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
|
||||
return (
|
||||
<AppListCard
|
||||
key={item.name}
|
||||
description={item.description}
|
||||
title={item.name}
|
||||
logo={item.logo}
|
||||
isDefault={appIsDefault}
|
||||
shouldHighlight
|
||||
slug={item.slug}
|
||||
invalidCredential={item?.invalidCredentialIds ? item.invalidCredentialIds.length > 0 : false}
|
||||
credentialOwner={item?.credentialOwner}
|
||||
actions={
|
||||
!item.credentialOwner?.readOnly ? (
|
||||
<div className="flex justify-end">
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon="ellipsis" variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon="video"
|
||||
onClick={() => {
|
||||
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
|
||||
if (locationType?.linkType === "static") {
|
||||
setLocationType({ ...locationType, slug: appSlug });
|
||||
} else {
|
||||
updateDefaultAppMutation.mutate({
|
||||
appSlug,
|
||||
});
|
||||
setBulkUpdateModal(true);
|
||||
}
|
||||
}}>
|
||||
{t("set_as_default")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<ConnectOrDisconnectIntegrationMenuItem
|
||||
credentialId={item.credentialOwner?.credentialId || item.userCredentialIds[0]}
|
||||
type={item.type}
|
||||
isGlobal={item.isGlobal}
|
||||
installed
|
||||
invalidCredentialIds={item.invalidCredentialIds}
|
||||
handleDisconnect={handleDisconnect}
|
||||
teamId={item.credentialOwner ? item.credentialOwner?.teamId : undefined}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
) : null
|
||||
}>
|
||||
<AppSettings slug={item.slug} />
|
||||
</AppListCard>
|
||||
);
|
||||
};
|
||||
|
||||
const appsWithTeamCredentials = data.items.filter((app) => app.teams.length);
|
||||
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
|
||||
const appCards = [];
|
||||
|
||||
if (app.userCredentialIds.length) {
|
||||
appCards.push(<ChildAppCard item={app} />);
|
||||
}
|
||||
for (const team of app.teams) {
|
||||
if (team) {
|
||||
appCards.push(
|
||||
<ChildAppCard
|
||||
item={{
|
||||
...app,
|
||||
credentialOwner: {
|
||||
name: team.name,
|
||||
avatar: team.logoUrl,
|
||||
teamId: team.teamId,
|
||||
credentialId: team.credentialId,
|
||||
readOnly: !team.isAdmin,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return appCards;
|
||||
});
|
||||
|
||||
const { t } = useLocale();
|
||||
const updateLocationsMutation = trpc.viewer.eventTypes.bulkUpdateToDefaultLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.getUsersDefaultConferencingApp.invalidate();
|
||||
setBulkUpdateModal(false);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<List className={listClassName}>
|
||||
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
|
||||
{data.items
|
||||
.filter((item) => item.invalidCredentialIds)
|
||||
.map((item) => {
|
||||
if (!item.teams.length) return <ChildAppCard item={item} />;
|
||||
})}
|
||||
</List>
|
||||
{locationType && (
|
||||
<AppSetDefaultLinkDialog
|
||||
locationType={locationType}
|
||||
setLocationType={() => setLocationType(undefined)}
|
||||
onSuccess={onSuccessCallback}
|
||||
/>
|
||||
)}
|
||||
|
||||
{bulkUpdateModal && (
|
||||
<BulkEditDefaultForEventsModal
|
||||
bulkUpdateFunction={updateLocationsMutation.mutate}
|
||||
open={bulkUpdateModal}
|
||||
setOpen={setBulkUpdateModal}
|
||||
isPending={updateLocationsMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function ConnectOrDisconnectIntegrationMenuItem(props: {
|
||||
credentialId: number;
|
||||
type: App["type"];
|
||||
isGlobal?: boolean;
|
||||
installed?: boolean;
|
||||
invalidCredentialIds?: number[];
|
||||
teamId?: number;
|
||||
handleDisconnect: (credentialId: number, teamId?: number) => void;
|
||||
}) {
|
||||
const { type, credentialId, isGlobal, installed, handleDisconnect, teamId } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const handleOpenChange = () => {
|
||||
utils.viewer.integrations.invalidate();
|
||||
};
|
||||
|
||||
if (credentialId || type === "stripe_payment" || isGlobal) {
|
||||
return (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => handleDisconnect(credentialId, teamId)}
|
||||
disabled={isGlobal}
|
||||
StartIcon="trash">
|
||||
{t("remove_app")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (!installed) {
|
||||
return (
|
||||
<div className="flex items-center truncate">
|
||||
<Alert severity="warning" title={t("not_installed")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
render={(buttonProps) => (
|
||||
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
|
||||
{t("install")}
|
||||
</Button>
|
||||
)}
|
||||
onChanged={handleOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
421
calcom/apps/web/components/apps/AppPage.tsx
Normal file
421
calcom/apps/web/components/apps/AppPage.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { IframeHTMLAttributes } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
|
||||
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
|
||||
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App as AppType } from "@calcom/types/App";
|
||||
import { Badge, Button, Icon, SkeletonButton, SkeletonText, showToast } from "@calcom/ui";
|
||||
|
||||
import { InstallAppButtonChild } from "./InstallAppButtonChild";
|
||||
|
||||
export type AppPageProps = {
|
||||
name: string;
|
||||
description: AppType["description"];
|
||||
type: AppType["type"];
|
||||
isGlobal?: AppType["isGlobal"];
|
||||
logo: string;
|
||||
slug: string;
|
||||
variant: string;
|
||||
body: React.ReactNode;
|
||||
categories: string[];
|
||||
author: string;
|
||||
pro?: boolean;
|
||||
price?: number;
|
||||
commission?: number;
|
||||
feeType?: AppType["feeType"];
|
||||
docs?: string;
|
||||
website?: string;
|
||||
email: string; // required
|
||||
tos?: string;
|
||||
privacy?: string;
|
||||
licenseRequired: AppType["licenseRequired"];
|
||||
teamsPlanRequired: AppType["teamsPlanRequired"];
|
||||
descriptionItems?: Array<string | { iframe: IframeHTMLAttributes<HTMLIFrameElement> }>;
|
||||
isTemplate?: boolean;
|
||||
disableInstall?: boolean;
|
||||
dependencies?: string[];
|
||||
concurrentMeetings: AppType["concurrentMeetings"];
|
||||
paid?: AppType["paid"];
|
||||
};
|
||||
|
||||
export const AppPage = ({
|
||||
name,
|
||||
type,
|
||||
logo,
|
||||
slug,
|
||||
variant,
|
||||
body,
|
||||
categories,
|
||||
author,
|
||||
price = 0,
|
||||
commission,
|
||||
isGlobal = false,
|
||||
feeType,
|
||||
docs,
|
||||
website,
|
||||
email,
|
||||
tos,
|
||||
privacy,
|
||||
teamsPlanRequired,
|
||||
descriptionItems,
|
||||
isTemplate,
|
||||
dependencies,
|
||||
concurrentMeetings,
|
||||
paid,
|
||||
}: AppPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: (data) => {
|
||||
if (data?.setupPending) return;
|
||||
setIsLoading(false);
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||
setIsLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* @todo Refactor to eliminate the isLoading state by using mutation.isPending directly.
|
||||
* Currently, the isLoading state is used to manage the loading indicator due to the delay in loading the next page,
|
||||
* which is caused by heavy queries in getServersideProps. This causes the loader to turn off before the page changes.
|
||||
*/
|
||||
const [isLoading, setIsLoading] = useState<boolean>(mutation.isPending);
|
||||
|
||||
const handleAppInstall = () => {
|
||||
setIsLoading(true);
|
||||
if (isConferencing(categories)) {
|
||||
mutation.mutate({
|
||||
type,
|
||||
variant,
|
||||
slug,
|
||||
returnTo:
|
||||
WEBAPP_URL +
|
||||
getAppOnboardingUrl({
|
||||
slug,
|
||||
step: AppOnboardingSteps.EVENT_TYPES_STEP,
|
||||
}),
|
||||
});
|
||||
} else if (
|
||||
!doesAppSupportTeamInstall({
|
||||
appCategories: categories,
|
||||
concurrentMeetings: concurrentMeetings,
|
||||
isPaid: !!paid,
|
||||
})
|
||||
) {
|
||||
mutation.mutate({ type });
|
||||
} else {
|
||||
router.push(getAppOnboardingUrl({ slug, step: AppOnboardingSteps.ACCOUNTS_STEP }));
|
||||
}
|
||||
};
|
||||
|
||||
const priceInDollar = Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price);
|
||||
|
||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
||||
|
||||
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery({ appType: type });
|
||||
|
||||
useEffect(
|
||||
function refactorMeWithoutEffect() {
|
||||
const data = appDbQuery.data;
|
||||
|
||||
const credentialsCount = data?.credentials.length || 0;
|
||||
setShowDisconnectIntegration(
|
||||
data?.userAdminTeams.length ? credentialsCount >= data?.userAdminTeams.length : credentialsCount > 0
|
||||
);
|
||||
setExistingCredentials(data?.credentials.map((credential) => credential.id) || []);
|
||||
},
|
||||
[appDbQuery.data]
|
||||
);
|
||||
|
||||
const dependencyData = trpc.viewer.appsRouter.queryForDependencies.useQuery(dependencies, {
|
||||
enabled: !!dependencies,
|
||||
});
|
||||
|
||||
const disableInstall =
|
||||
dependencyData.data && dependencyData.data.some((dependency) => !dependency.installed);
|
||||
|
||||
// const disableInstall = requiresGCal && !gCalInstalled.data;
|
||||
|
||||
// variant not other allows, an app to be shown in calendar category without requiring an actual calendar connection e.g. vimcal
|
||||
// Such apps, can only be installed once.
|
||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1 && variant !== "other";
|
||||
useEffect(() => {
|
||||
if (searchParams?.get("defaultInstall") === "true") {
|
||||
mutation.mutate({ type, variant, slug, defaultInstall: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||
{hasDescriptionItems && (
|
||||
<div className="align-center bg-subtle -ml-4 -mr-4 mb-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap p-4 md:-ml-8 md:-mr-8 md:mb-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md">
|
||||
{descriptionItems ? (
|
||||
descriptionItems.map((descriptionItem, index) =>
|
||||
typeof descriptionItem === "object" ? (
|
||||
<div
|
||||
key={`iframe-${index}`}
|
||||
className="mr-4 max-h-full min-h-[315px] min-w-[90%] max-w-full snap-center last:mb-0 lg:mb-4 lg:mr-0 [&_iframe]:h-full [&_iframe]:min-h-[315px] [&_iframe]:w-full">
|
||||
<iframe allowFullScreen {...descriptionItem.iframe} />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
key={descriptionItem}
|
||||
src={descriptionItem}
|
||||
alt={`Screenshot of app ${name}`}
|
||||
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<SkeletonText />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"sticky top-0 -mt-4 max-w-xl basis-2/5 pb-12 text-sm lg:pb-0",
|
||||
hasDescriptionItems && "lg:ml-8"
|
||||
)}>
|
||||
<div className="mb-8 flex pt-4">
|
||||
<header>
|
||||
<div className="mb-4 flex items-center">
|
||||
<img
|
||||
className={classNames(logo.includes("-dark") && "dark:invert", "min-h-16 min-w-16 h-16 w-16")}
|
||||
src={logo}
|
||||
alt={name}
|
||||
/>
|
||||
<h1 className="font-cal text-emphasis ml-4 text-3xl">{name}</h1>
|
||||
</div>
|
||||
<h2 className="text-default text-sm font-medium">
|
||||
<Link
|
||||
href={`categories/${categories[0]}`}
|
||||
className="bg-subtle text-emphasis rounded-md p-1 text-xs capitalize">
|
||||
{categories[0]}
|
||||
</Link>{" "}
|
||||
{paid && (
|
||||
<>
|
||||
<Badge className="mr-1">
|
||||
{Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(paid.priceInUsd)}
|
||||
/{t("month")}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
•{" "}
|
||||
<a target="_blank" rel="noreferrer" href={website}>
|
||||
{t("published_by", { author })}
|
||||
</a>
|
||||
</h2>
|
||||
{isTemplate && (
|
||||
<Badge variant="red" className="mt-4">
|
||||
Template - Available in Dev Environment only for testing
|
||||
</Badge>
|
||||
)}
|
||||
</header>
|
||||
</div>
|
||||
{!appDbQuery.isPending ? (
|
||||
isGlobal ||
|
||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
||||
<div className="flex space-x-3">
|
||||
<Button StartIcon="check" color="secondary" disabled>
|
||||
{existingCredentials.length > 0
|
||||
? t("active_install", { count: existingCredentials.length })
|
||||
: t("default")}
|
||||
</Button>
|
||||
{!isGlobal && (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
disableInstall={disableInstall}
|
||||
teamsPlanRequired={teamsPlanRequired}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
...props,
|
||||
onClick: () => {
|
||||
handleAppInstall();
|
||||
},
|
||||
loading: isLoading,
|
||||
};
|
||||
}
|
||||
return <InstallAppButtonChild multiInstall paid={paid} {...props} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : showDisconnectIntegration ? (
|
||||
<DisconnectIntegration
|
||||
buttonProps={{ color: "secondary" }}
|
||||
label={t("disconnect")}
|
||||
credentialId={existingCredentials[0]}
|
||||
onSuccess={() => {
|
||||
appDbQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
disableInstall={disableInstall}
|
||||
teamsPlanRequired={teamsPlanRequired}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
...props,
|
||||
onClick: () => {
|
||||
handleAppInstall();
|
||||
},
|
||||
loading: isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<InstallAppButtonChild credentials={appDbQuery.data?.credentials} paid={paid} {...props} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<SkeletonButton className="h-10 w-24" />
|
||||
)}
|
||||
|
||||
{dependencies &&
|
||||
(!dependencyData.isPending ? (
|
||||
<div className="mt-6">
|
||||
<AppDependencyComponent appName={name} dependencyData={dependencyData.data} />
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonButton className="mt-6 h-20 grow" />
|
||||
))}
|
||||
|
||||
{price !== 0 && !paid && (
|
||||
<span className="block text-right">
|
||||
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
|
||||
{feeType === "monthly" && `/${t("month")}`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="prose-sm prose prose-a:text-default prose-headings:text-emphasis prose-code:text-default prose-strong:text-default text-default mt-8">
|
||||
{body}
|
||||
</div>
|
||||
{!paid && (
|
||||
<>
|
||||
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
|
||||
<span className="text-default">
|
||||
{teamsPlanRequired ? (
|
||||
t("teams_plan_required")
|
||||
) : price === 0 ? (
|
||||
t("free_to_use_apps")
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && `/${t("month")}`}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h4 className="text-emphasis mb-2 mt-8 font-semibold ">{t("contact")}</h4>
|
||||
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
||||
{docs && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis text-sm font-normal no-underline hover:underline"
|
||||
href={docs}>
|
||||
<Icon name="book-open" className="text-subtle -mt-1 mr-1 inline h-4 w-4" />
|
||||
{t("documentation")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{website && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={website}>
|
||||
<Icon name="external-link" className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
{website.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{email && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={`mailto:${email}`}>
|
||||
<Icon name="mail" className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{tos && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={tos}>
|
||||
<Icon name="file" className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
{t("terms_of_service")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{privacy && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={privacy}>
|
||||
<Icon name="shield" className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<hr className="border-subtle my-8 border" />
|
||||
<span className="leading-1 text-subtle block text-xs">
|
||||
{t("every_app_published", { appName: APP_NAME, companyName: COMPANY_NAME })}
|
||||
</span>
|
||||
<a className="mt-2 block text-xs text-red-500" href={`mailto:${SUPPORT_MAIL_ADDRESS}`}>
|
||||
<Icon name="flag" className="inline h-3 w-3" /> {t("report_app")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
289
calcom/apps/web/components/apps/CalendarListContainer.tsx
Normal file
289
calcom/apps/web/components/apps/CalendarListContainer.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import Link from "next/link";
|
||||
import { Fragment, useEffect } from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
EmptyScreen,
|
||||
Label,
|
||||
List,
|
||||
ShellSubHeading,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
import AppListCard from "@components/AppListCard";
|
||||
import AdditionalCalendarSelector from "@components/apps/AdditionalCalendarSelector";
|
||||
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
||||
|
||||
type Props = {
|
||||
onChanged: () => unknown | Promise<unknown>;
|
||||
fromOnboarding?: boolean;
|
||||
destinationCalendarId?: string;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
function CalendarList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: false });
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => (
|
||||
<List>
|
||||
{data.items.map((item) => (
|
||||
<AppListCard
|
||||
title={item.name}
|
||||
key={item.name}
|
||||
logo={item.logo}
|
||||
description={item.description}
|
||||
shouldHighlight
|
||||
slug={item.slug}
|
||||
actions={
|
||||
<InstallAppButton
|
||||
type={item.type}
|
||||
render={(buttonProps) => (
|
||||
<Button color="secondary" {...buttonProps}>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
onChanged={() => props.onChanged()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// todo: @hariom extract this into packages/apps-store as "GeneralAppSettings"
|
||||
function ConnectedCalendarsList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.viewer.connectedCalendars.useQuery(undefined, {
|
||||
suspense: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const { fromOnboarding, isPending } = props;
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
empty={() => null}
|
||||
success={({ data }) => {
|
||||
if (!data.connectedCalendars.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-subtle mt-6 rounded-lg border">
|
||||
<div className="border-subtle border-b p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-emphasis text-base font-semibold leading-5">
|
||||
{t("check_for_conflicts")}
|
||||
</h4>
|
||||
<p className="text-default text-sm leading-tight">{t("select_calendars")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col xl:flex-row xl:space-x-5">
|
||||
{!!data.connectedCalendars.length && (
|
||||
<div className="flex items-center">
|
||||
<AdditionalCalendarSelector isPending={isPending} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<List noBorderTreatment className="p-6 pt-2">
|
||||
{data.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.calendars ? (
|
||||
<AppListCard
|
||||
shouldHighlight
|
||||
slug={item.integration.slug}
|
||||
title={item.integration.name}
|
||||
logo={item.integration.logo}
|
||||
description={item.primary?.email ?? item.integration.description}
|
||||
className="border-subtle mt-4 rounded-lg border"
|
||||
actions={
|
||||
<div className="flex w-32 justify-end">
|
||||
<DisconnectIntegration
|
||||
credentialId={item.credentialId}
|
||||
trashIcon
|
||||
onSuccess={props.onChanged}
|
||||
buttonProps={{ className: "border border-default" }}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<div className="border-subtle border-t">
|
||||
{!fromOnboarding && (
|
||||
<>
|
||||
<p className="text-subtle px-5 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-4 px-5 py-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
name={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
isChecked={cal.isSelected}
|
||||
destination={cal.externalId === props.destinationCalendarId}
|
||||
credentialId={cal.credentialId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppListCard>
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t("something_went_wrong")}
|
||||
message={
|
||||
<span>
|
||||
<Link href={`/apps/${item.integration.slug}`}>{item.integration.name}</Link>:{" "}
|
||||
{t("calendar_error")}
|
||||
</span>
|
||||
}
|
||||
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
|
||||
actions={
|
||||
<div className="flex w-32 justify-end md:pr-1">
|
||||
<DisconnectIntegration
|
||||
credentialId={item.credentialId}
|
||||
trashIcon
|
||||
onSuccess={props.onChanged}
|
||||
buttonProps={{ className: "border border-default" }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarListContainer(props: { heading?: boolean; fromOnboarding?: boolean }) {
|
||||
const { t } = useLocale();
|
||||
const { heading = true, fromOnboarding } = props;
|
||||
const { error, setQuery: setError } = useRouterQuery("error");
|
||||
|
||||
useEffect(() => {
|
||||
if (error === "account_already_linked") {
|
||||
showToast(t(error), "error", { id: error });
|
||||
setError(undefined);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const utils = trpc.useUtils();
|
||||
const onChanged = () =>
|
||||
Promise.allSettled([
|
||||
utils.viewer.integrations.invalidate(
|
||||
{ variant: "calendar", onlyInstalled: true },
|
||||
{
|
||||
exact: true,
|
||||
}
|
||||
),
|
||||
utils.viewer.connectedCalendars.invalidate(),
|
||||
]);
|
||||
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||
const installedCalendars = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: true });
|
||||
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.connectedCalendars.invalidate();
|
||||
},
|
||||
});
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
customLoader={<SkeletonLoader />}
|
||||
success={({ data }) => {
|
||||
return (
|
||||
<>
|
||||
{!!data.connectedCalendars.length || !!installedCalendars.data?.items.length ? (
|
||||
<>
|
||||
{heading && (
|
||||
<>
|
||||
<div className="border-subtle mb-6 mt-8 rounded-lg border">
|
||||
<div className="p-6">
|
||||
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
|
||||
{t("add_to_calendar")}
|
||||
</h2>
|
||||
|
||||
<p className="text-subtle text-sm leading-tight">
|
||||
{t("add_to_calendar_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t">
|
||||
<div className="border-subtle flex w-full flex-col space-y-3 border-y-0 p-6">
|
||||
<div>
|
||||
<Label className="text-default mb-0 font-medium">{t("add_events_to")}</Label>
|
||||
<DestinationCalendarSelector
|
||||
hidePlaceholder
|
||||
value={data.destinationCalendar?.externalId}
|
||||
onChange={mutation.mutate}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectedCalendarsList
|
||||
onChanged={onChanged}
|
||||
fromOnboarding={fromOnboarding}
|
||||
destinationCalendarId={data.destinationCalendar?.externalId}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : fromOnboarding ? (
|
||||
<>
|
||||
{!!query.data?.connectedCalendars.length && (
|
||||
<ShellSubHeading
|
||||
className="mt-4"
|
||||
title={<SubHeadingTitleWithConnections title={t("connect_additional_calendar")} />}
|
||||
/>
|
||||
)}
|
||||
<CalendarList onChanged={onChanged} />
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon="calendar"
|
||||
headline={t("no_category_apps", {
|
||||
category: t("calendar").toLowerCase(),
|
||||
})}
|
||||
description={t(`no_category_apps_description_calendar`)}
|
||||
buttonRaw={
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="connect-calendar-apps"
|
||||
href="/apps/categories/calendar">
|
||||
{t(`connect_calendar_apps`)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
calcom/apps/web/components/apps/InstallAppButtonChild.tsx
Normal file
46
calcom/apps/web/components/apps/InstallAppButtonChild.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { AppFrontendPayload } from "@calcom/types/App";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
export const InstallAppButtonChild = ({
|
||||
multiInstall,
|
||||
credentials,
|
||||
paid,
|
||||
...props
|
||||
}: {
|
||||
multiInstall?: boolean;
|
||||
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
||||
paid?: AppFrontendPayload["paid"];
|
||||
} & ButtonProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
|
||||
|
||||
// Paid apps don't support team installs at the moment
|
||||
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
|
||||
if (paid) {
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
disabled={shouldDisableInstallation}
|
||||
color="primary"
|
||||
size="base">
|
||||
{paid.trial ? t("start_paid_trial") : t("subscribe")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
disabled={shouldDisableInstallation}
|
||||
color="primary"
|
||||
size="base"
|
||||
{...props}>
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { FC } from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import { Avatar, StepCard } from "@calcom/ui";
|
||||
|
||||
type AccountSelectorProps = {
|
||||
avatar?: string;
|
||||
name: string;
|
||||
alreadyInstalled: boolean;
|
||||
onClick: () => void;
|
||||
loading: boolean;
|
||||
testId: string;
|
||||
};
|
||||
|
||||
const AccountSelector: FC<AccountSelectorProps> = ({
|
||||
avatar,
|
||||
alreadyInstalled,
|
||||
name,
|
||||
onClick,
|
||||
loading,
|
||||
testId,
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [selected, setSelected] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1",
|
||||
(alreadyInstalled || loading) && "cursor-not-allowed",
|
||||
selected && loading && "bg-muted animate-pulse"
|
||||
)}
|
||||
data-testid={testId}
|
||||
onClick={() => {
|
||||
if (!alreadyInstalled && !loading) {
|
||||
setSelected(true);
|
||||
onClick();
|
||||
}
|
||||
}}>
|
||||
<Avatar
|
||||
alt={avatar || ""}
|
||||
imageSrc={getPlaceholderAvatar(avatar, name)} // if no image, use default avatar
|
||||
size="sm"
|
||||
/>
|
||||
<div className="text-md pt-0.5 font-medium text-gray-500">
|
||||
{name}
|
||||
{alreadyInstalled ? <span className="ml-1 text-sm text-gray-400">{t("already_installed")}</span> : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type PersonalAccountProps = Pick<User, "id" | "avatarUrl" | "name"> & { alreadyInstalled: boolean };
|
||||
|
||||
export type TeamsProp = (Pick<Team, "id" | "name" | "logoUrl"> & {
|
||||
alreadyInstalled: boolean;
|
||||
})[];
|
||||
|
||||
type AccountStepCardProps = {
|
||||
teams?: TeamsProp;
|
||||
personalAccount: PersonalAccountProps;
|
||||
onSelect: (id?: number) => void;
|
||||
loading: boolean;
|
||||
installableOnTeams: boolean;
|
||||
};
|
||||
|
||||
export const AccountsStepCard: FC<AccountStepCardProps> = ({
|
||||
teams,
|
||||
personalAccount,
|
||||
onSelect,
|
||||
loading,
|
||||
installableOnTeams,
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<StepCard>
|
||||
<div className="text-sm font-medium text-gray-400">{t("install_app_on")}</div>
|
||||
<div className={classNames("mt-2 flex flex-col gap-2 ")}>
|
||||
<AccountSelector
|
||||
testId="install-app-button-personal"
|
||||
avatar={personalAccount.avatarUrl ?? ""}
|
||||
name={personalAccount.name ?? ""}
|
||||
alreadyInstalled={personalAccount.alreadyInstalled}
|
||||
onClick={() => onSelect()}
|
||||
loading={loading}
|
||||
/>
|
||||
{installableOnTeams &&
|
||||
teams?.map((team) => (
|
||||
<AccountSelector
|
||||
key={team.id}
|
||||
testId={`install-app-button-team${team.id}`}
|
||||
alreadyInstalled={team.alreadyInstalled}
|
||||
avatar={team.logoUrl ?? ""}
|
||||
name={team.name}
|
||||
onClick={() => onSelect(team.id)}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
|
||||
import { X } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { FC } from "react";
|
||||
import React, { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import { Button, Form } from "@calcom/ui";
|
||||
|
||||
import EventTypeAppSettingsWrapper from "@components/apps/installation/EventTypeAppSettingsWrapper";
|
||||
import EventTypeConferencingAppSettings from "@components/apps/installation/EventTypeConferencingAppSettings";
|
||||
|
||||
import { locationsResolver } from "~/event-types/views/event-types-single-view";
|
||||
|
||||
export type TFormType = {
|
||||
id: number;
|
||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
locations: LocationObject[];
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
||||
seatsPerTimeSlot: number | null;
|
||||
};
|
||||
|
||||
export type ConfigureStepCardProps = {
|
||||
slug: string;
|
||||
userName: string;
|
||||
categories: AppCategories[];
|
||||
credentialId?: number;
|
||||
loading?: boolean;
|
||||
isConferencing: boolean;
|
||||
formPortalRef: React.RefObject<HTMLDivElement>;
|
||||
eventTypes: TEventType[] | undefined;
|
||||
setConfigureStep: Dispatch<SetStateAction<boolean>>;
|
||||
handleSetUpLater: () => void;
|
||||
};
|
||||
|
||||
type EventTypeAppSettingsFormProps = Pick<
|
||||
ConfigureStepCardProps,
|
||||
"slug" | "userName" | "categories" | "credentialId" | "loading" | "isConferencing"
|
||||
> & {
|
||||
eventType: TEventType;
|
||||
handleDelete: () => void;
|
||||
onSubmit: ({
|
||||
locations,
|
||||
bookingFields,
|
||||
metadata,
|
||||
}: {
|
||||
metadata?: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
bookingFields?: z.infer<typeof eventTypeBookingFields>;
|
||||
locations?: LocationObject[];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSettingsFormProps>(
|
||||
function EventTypeAppSettingsForm(props, ref) {
|
||||
const { handleDelete, onSubmit, eventType, loading, isConferencing } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const formMethods = useForm<TFormType>({
|
||||
defaultValues: {
|
||||
id: eventType.id,
|
||||
metadata: eventType?.metadata ?? undefined,
|
||||
locations: eventType?.locations ?? undefined,
|
||||
bookingFields: eventType?.bookingFields ?? undefined,
|
||||
seatsPerTimeSlot: eventType?.seatsPerTimeSlot ?? undefined,
|
||||
},
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
locations: locationsResolver(t),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={formMethods}
|
||||
id={`eventtype-${eventType.id}`}
|
||||
handleSubmit={() => {
|
||||
const metadata = formMethods.getValues("metadata");
|
||||
const locations = formMethods.getValues("locations");
|
||||
const bookingFields = formMethods.getValues("bookingFields");
|
||||
onSubmit({ metadata, locations, bookingFields });
|
||||
}}>
|
||||
<div>
|
||||
<div className="sm:border-subtle bg-default relative border p-4 dark:bg-black sm:rounded-md">
|
||||
<div>
|
||||
<span className="text-default font-semibold ltr:mr-1 rtl:ml-1">{eventType.title}</span>{" "}
|
||||
<small className="text-subtle hidden font-normal sm:inline">
|
||||
/{eventType.team ? eventType.team.slug : props.userName}/{eventType.slug}
|
||||
</small>
|
||||
</div>
|
||||
{isConferencing ? (
|
||||
<EventTypeConferencingAppSettings {...props} />
|
||||
) : (
|
||||
<EventTypeAppSettingsWrapper {...props} />
|
||||
)}
|
||||
<X
|
||||
data-testid={`remove-event-type-${eventType.id}`}
|
||||
className="absolute right-4 top-4 h-4 w-4 cursor-pointer"
|
||||
onClick={() => !loading && handleDelete()}
|
||||
/>
|
||||
<button type="submit" className="hidden" form={`eventtype-${eventType.id}`} ref={ref}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
|
||||
loading,
|
||||
formPortalRef,
|
||||
eventTypes,
|
||||
setConfigureStep,
|
||||
handleSetUpLater,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { control, getValues } = useFormContext<TEventTypesForm>();
|
||||
const { fields, update } = useFieldArray({
|
||||
control,
|
||||
name: "eventTypes",
|
||||
keyName: "fieldId",
|
||||
});
|
||||
|
||||
const submitRefs = useRef<Array<React.RefObject<HTMLButtonElement>>>([]);
|
||||
submitRefs.current = fields.map(
|
||||
(_ref, index) => (submitRefs.current[index] = React.createRef<HTMLButtonElement>())
|
||||
);
|
||||
const mainForSubmitRef = useRef<HTMLButtonElement>(null);
|
||||
const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState(
|
||||
fields.filter((field) => field.selected).map((field) => ({ id: field.id, updated: false }))
|
||||
);
|
||||
const [submit, setSubmit] = useState(false);
|
||||
const allUpdated = updatedEventTypesStatus.every((item) => item.updated);
|
||||
|
||||
useEffect(() => {
|
||||
setUpdatedEventTypesStatus((prev) =>
|
||||
prev.filter((state) => fields.some((field) => field.id === state.id && field.selected))
|
||||
);
|
||||
if (!fields.some((field) => field.selected)) {
|
||||
setConfigureStep(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (submit && allUpdated && mainForSubmitRef.current) {
|
||||
mainForSubmitRef.current?.click();
|
||||
setSubmit(false);
|
||||
}
|
||||
}, [submit, allUpdated, getValues, mainForSubmitRef]);
|
||||
|
||||
return (
|
||||
formPortalRef?.current &&
|
||||
createPortal(
|
||||
<div className="mt-8">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
field.selected && (
|
||||
<EventTypeAppSettingsForm
|
||||
key={field.fieldId}
|
||||
eventType={field}
|
||||
loading={loading}
|
||||
handleDelete={() => {
|
||||
const eventMetadataDb = eventTypes?.find(
|
||||
(eventType) => eventType.id == field.id
|
||||
)?.metadata;
|
||||
update(index, { ...field, selected: false, metadata: eventMetadataDb });
|
||||
}}
|
||||
onSubmit={(data) => {
|
||||
update(index, { ...field, ...data });
|
||||
setUpdatedEventTypesStatus((prev) =>
|
||||
prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item))
|
||||
);
|
||||
}}
|
||||
ref={submitRefs.current[index]}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button form="outer-event-type-form" type="submit" className="hidden" ref={mainForSubmitRef}>
|
||||
Save
|
||||
</button>
|
||||
<Button
|
||||
className="text-md mt-6 w-full justify-center"
|
||||
type="button"
|
||||
data-testid="configure-step-save"
|
||||
onClick={() => {
|
||||
submitRefs.current.reverse().map((ref) => ref.current?.click());
|
||||
setSubmit(true);
|
||||
}}
|
||||
loading={loading}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full flex-row justify-center">
|
||||
<Button
|
||||
color="minimal"
|
||||
data-testid="set-up-later"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
handleSetUpLater();
|
||||
}}
|
||||
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
|
||||
{t("set_up_later")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
formPortalRef?.current
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { TEventType } from "@pages/apps/installation/[[...step]]";
|
||||
import { useEffect, type FC } from "react";
|
||||
|
||||
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
|
||||
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
|
||||
import useAppsData from "@lib/hooks/useAppsData";
|
||||
|
||||
import type { ConfigureStepCardProps } from "@components/apps/installation/ConfigureStepCard";
|
||||
|
||||
type EventTypeAppSettingsWrapperProps = Pick<
|
||||
ConfigureStepCardProps,
|
||||
"slug" | "userName" | "categories" | "credentialId"
|
||||
> & {
|
||||
eventType: TEventType;
|
||||
};
|
||||
|
||||
const EventTypeAppSettingsWrapper: FC<EventTypeAppSettingsWrapperProps> = ({
|
||||
slug,
|
||||
eventType,
|
||||
categories,
|
||||
credentialId,
|
||||
}) => {
|
||||
const { getAppDataGetter, getAppDataSetter } = useAppsData();
|
||||
|
||||
useEffect(() => {
|
||||
const appDataSetter = getAppDataSetter(slug as EventTypeAppsList, categories, credentialId);
|
||||
appDataSetter("enabled", true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EventTypeAppSettings
|
||||
slug={slug}
|
||||
eventType={eventType}
|
||||
getAppData={getAppDataGetter(slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(slug as EventTypeAppsList, categories, credentialId)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default EventTypeAppSettingsWrapper;
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { TEventType } from "@pages/apps/installation/[[...step]]";
|
||||
import { useMemo } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
|
||||
|
||||
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
import { Skeleton, Label } from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import type { TFormType } from "@components/apps/installation/ConfigureStepCard";
|
||||
import type { TLocationOptions } from "@components/eventtype/Locations";
|
||||
import type { TEventTypeLocation } from "@components/eventtype/Locations";
|
||||
import Locations from "@components/eventtype/Locations";
|
||||
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
|
||||
|
||||
const LocationsWrapper = ({
|
||||
eventType,
|
||||
slug,
|
||||
}: {
|
||||
eventType: TEventType & {
|
||||
locationOptions?: TLocationOptions;
|
||||
};
|
||||
slug: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<TFormType>();
|
||||
|
||||
const prefillLocation = useMemo(() => {
|
||||
let res: SingleValueLocationOption | undefined = undefined;
|
||||
for (const item of eventType?.locationOptions || []) {
|
||||
for (const option of item.options) {
|
||||
if (option.slug === slug) {
|
||||
res = {
|
||||
...option,
|
||||
};
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}, [slug, eventType?.locationOptions]);
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Skeleton as={Label} loadingClassName="w-16" htmlFor="locations">
|
||||
{t("location")}
|
||||
</Skeleton>
|
||||
<Locations
|
||||
showAppStoreLink={false}
|
||||
isChildrenManagedEventType={false}
|
||||
isManagedEventType={false}
|
||||
disableLocationProp={false}
|
||||
eventType={eventType as TEventTypeLocation}
|
||||
destinationCalendar={eventType.destinationCalendar}
|
||||
locationOptions={eventType.locationOptions || []}
|
||||
prefillLocation={prefillLocation}
|
||||
team={null}
|
||||
getValues={formMethods.getValues as unknown as UseFormGetValues<LocationFormValues>}
|
||||
setValue={formMethods.setValue as unknown as UseFormSetValue<LocationFormValues>}
|
||||
control={formMethods.control as unknown as Control<LocationFormValues>}
|
||||
formState={formMethods.formState as unknown as FormState<LocationFormValues>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypeConferencingAppSettings = ({ eventType, slug }: { eventType: TEventType; slug: string }) => {
|
||||
const locationsQuery = trpc.viewer.locationOptions.useQuery({});
|
||||
const { t } = useLocale();
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<SkeletonText className="my-2 h-8 w-full" />
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={locationsQuery}
|
||||
customLoader={<SkeletonLoader />}
|
||||
success={({ data }) => {
|
||||
let updatedEventType: TEventType & {
|
||||
locationOptions?: TLocationOptions;
|
||||
} = { ...eventType };
|
||||
|
||||
if (updatedEventType.schedulingType === SchedulingType.MANAGED) {
|
||||
updatedEventType = {
|
||||
...updatedEventType,
|
||||
locationOptions: [
|
||||
{
|
||||
label: t("default"),
|
||||
options: [
|
||||
{
|
||||
label: t("members_default_location"),
|
||||
value: "",
|
||||
icon: "/user-check.svg",
|
||||
},
|
||||
],
|
||||
},
|
||||
...data,
|
||||
],
|
||||
};
|
||||
} else {
|
||||
updatedEventType = { ...updatedEventType, locationOptions: data };
|
||||
}
|
||||
return <LocationsWrapper eventType={updatedEventType} slug={slug} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeConferencingAppSettings;
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { ScrollableArea, Badge, Button } from "@calcom/ui";
|
||||
|
||||
type EventTypesCardProps = {
|
||||
userName: string;
|
||||
setConfigureStep: Dispatch<SetStateAction<boolean>>;
|
||||
handleSetUpLater: () => void;
|
||||
};
|
||||
|
||||
export const EventTypesStepCard: FC<EventTypesCardProps> = ({
|
||||
setConfigureStep,
|
||||
userName,
|
||||
handleSetUpLater,
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { control } = useFormContext<TEventTypesForm>();
|
||||
const { fields, update } = useFieldArray({
|
||||
control,
|
||||
name: "eventTypes",
|
||||
keyName: "fieldId",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:border-subtle bg-default mt-10 border dark:bg-black sm:rounded-md">
|
||||
<ScrollableArea className="rounded-md">
|
||||
<ul className="border-subtle max-h-97 !static w-full divide-y">
|
||||
{fields.map((field, index) => (
|
||||
<EventTypeCard
|
||||
handleSelect={() => update(index, { ...field, selected: !field.selected })}
|
||||
userName={userName}
|
||||
key={field.fieldId}
|
||||
{...field}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollableArea>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="text-md mt-6 w-full justify-center"
|
||||
data-testid="save-event-types"
|
||||
onClick={() => {
|
||||
setConfigureStep(true);
|
||||
}}
|
||||
disabled={!fields.some((field) => field.selected === true)}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full flex-row justify-center">
|
||||
<Button
|
||||
color="minimal"
|
||||
data-testid="set-up-later"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
handleSetUpLater();
|
||||
}}
|
||||
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
|
||||
{t("set_up_later")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type EventTypeCardProps = TEventType & { userName: string; handleSelect: () => void };
|
||||
|
||||
const EventTypeCard: FC<EventTypeCardProps> = ({
|
||||
title,
|
||||
description,
|
||||
id,
|
||||
metadata,
|
||||
length,
|
||||
selected,
|
||||
slug,
|
||||
handleSelect,
|
||||
team,
|
||||
userName,
|
||||
}) => {
|
||||
const parsedMetaData = EventTypeMetaDataSchema.safeParse(metadata);
|
||||
const durations =
|
||||
parsedMetaData.success &&
|
||||
parsedMetaData.data?.multipleDuration &&
|
||||
Boolean(parsedMetaData.data?.multipleDuration.length)
|
||||
? [length, ...parsedMetaData.data?.multipleDuration?.filter((duration) => duration !== length)].sort()
|
||||
: [length];
|
||||
return (
|
||||
<div
|
||||
data-testid={`select-event-type-${id}`}
|
||||
className="hover:bg-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3"
|
||||
onClick={() => handleSelect()}>
|
||||
<input
|
||||
id={`${id}`}
|
||||
checked={selected}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label htmlFor={`${id}`} className="cursor-pointer text-sm">
|
||||
<li>
|
||||
<div>
|
||||
<span className="text-default font-semibold ltr:mr-1 rtl:ml-1">{title}</span>{" "}
|
||||
<small className="text-subtle hidden font-normal sm:inline">
|
||||
/{team ? team.slug : userName}/{slug}
|
||||
</small>
|
||||
</div>
|
||||
{Boolean(description) && (
|
||||
<div className="text-subtle line-clamp-4 break-words text-sm sm:max-w-[650px] [&>*:not(:first-child)]:hidden [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex flex-row flex-wrap gap-2">
|
||||
{Boolean(durations.length) &&
|
||||
durations.map((duration) => (
|
||||
<Badge key={`event-type-${id}-duration-${duration}`} variant="gray" startIcon="clock">
|
||||
{duration}m
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
calcom/apps/web/components/apps/installation/StepHeader.tsx
Normal file
19
calcom/apps/web/components/apps/installation/StepHeader.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
type StepHeaderProps = {
|
||||
children?: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
export const StepHeader: FC<StepHeaderProps> = ({ children, title, subtitle }) => {
|
||||
return (
|
||||
<div>
|
||||
<header>
|
||||
<p className="font-cal mb-3 text-[28px] font-medium capitalize leading-7">{title}</p>
|
||||
|
||||
<p className="text-subtle font-sans text-sm font-normal">{subtitle}</p>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
calcom/apps/web/components/apps/layouts/AppsLayout.tsx
Normal file
42
calcom/apps/web/components/apps/layouts/AppsLayout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmptyScreen } from "@calcom/ui";
|
||||
|
||||
type AppsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
actions?: (className?: string) => JSX.Element;
|
||||
emptyStore?: boolean;
|
||||
} & Omit<ComponentProps<typeof ShellMain>, "actions">;
|
||||
|
||||
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const isAdmin = session.data?.user.role === "ADMIN";
|
||||
|
||||
if (session.status === "loading") return <></>;
|
||||
return (
|
||||
<ShellMain {...rest} actions={actions?.("block")} hideHeadingOnMobile>
|
||||
<div className="flex flex-col xl:flex-row">
|
||||
<main className="w-full">
|
||||
{emptyStore ? (
|
||||
<EmptyScreen
|
||||
Icon="circle-alert"
|
||||
headline={isAdmin ? t("no_apps") : t("no_apps_configured")}
|
||||
description={isAdmin ? t("enable_in_settings") : t("please_contact_admin")}
|
||||
buttonText={isAdmin ? t("apps_settings") : ""}
|
||||
buttonOnClick={() => router.push("/settings/admin/apps/calendar")}
|
||||
/>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ShellMain>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import AppCategoryNavigation from "@calcom/app-store/_components/AppCategoryNavigation";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
|
||||
export default function InstalledAppsLayout({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
return (
|
||||
<Shell
|
||||
{...rest}
|
||||
title="Installed Apps"
|
||||
description="Manage your installed apps or change settings"
|
||||
hideHeadingOnMobile>
|
||||
<AppCategoryNavigation baseURL="/apps/installed" containerClassname="min-w-0 w-full">
|
||||
{children}
|
||||
</AppCategoryNavigation>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
export const getLayout = (page: React.ReactElement) => <InstalledAppsLayout>{page}</InstalledAppsLayout>;
|
||||
29
calcom/apps/web/components/auth/BackupCode.tsx
Normal file
29
calcom/apps/web/components/auth/BackupCode.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, TextField } from "@calcom/ui";
|
||||
|
||||
export default function TwoFactor({ center = true }) {
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
|
||||
return (
|
||||
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
|
||||
<Label className="mt-4">{t("backup_code")}</Label>
|
||||
|
||||
<p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>
|
||||
|
||||
<TextField
|
||||
id="backup-code"
|
||||
label=""
|
||||
defaultValue=""
|
||||
placeholder="XXXXX-XXXXX"
|
||||
minLength={10} // without dash
|
||||
maxLength={11} // with dash
|
||||
required
|
||||
{...methods.register("backupCode")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
calcom/apps/web/components/auth/Turnstile.tsx
Normal file
12
calcom/apps/web/components/auth/Turnstile.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { TurnstileProps } from "react-turnstile";
|
||||
import Turnstile from "react-turnstile";
|
||||
|
||||
import { CLOUDFLARE_SITE_ID } from "@calcom/lib/constants";
|
||||
|
||||
type Props = Omit<TurnstileProps, "sitekey">;
|
||||
|
||||
export default function TurnstileWidget(props: Props) {
|
||||
if (!CLOUDFLARE_SITE_ID || process.env.NEXT_PUBLIC_IS_E2E) return null;
|
||||
|
||||
return <Turnstile {...props} sitekey={CLOUDFLARE_SITE_ID} theme="auto" />;
|
||||
}
|
||||
50
calcom/apps/web/components/auth/TwoFactor.tsx
Normal file
50
calcom/apps/web/components/auth/TwoFactor.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useDigitInput from "react-digit-input";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, Input } from "@calcom/ui";
|
||||
|
||||
export default function TwoFactor({ center = true, autoFocus = true }) {
|
||||
const [value, onChange] = useState("");
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
|
||||
const digits = useDigitInput({
|
||||
acceptedCharacters: /^[0-9]$/,
|
||||
length: 6,
|
||||
value,
|
||||
onChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (value) methods.setValue("totpCode", value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const className = "h-12 w-12 !text-xl text-center";
|
||||
|
||||
return (
|
||||
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
|
||||
<Label className="mt-4">{t("2fa_code")}</Label>
|
||||
|
||||
<p className="text-subtle mb-4 text-sm">{t("2fa_enabled_instructions")}</p>
|
||||
|
||||
<input type="hidden" value={value} {...methods.register("totpCode")} />
|
||||
|
||||
<div className="flex flex-row justify-between">
|
||||
{digits.map((digit, index) => (
|
||||
<Input
|
||||
key={`2fa${index}`}
|
||||
className={className}
|
||||
name={`2fa${index + 1}`}
|
||||
inputMode="decimal"
|
||||
{...digit}
|
||||
autoFocus={autoFocus && index === 0}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
calcom/apps/web/components/auth/layouts/AdminLayout.tsx
Normal file
38
calcom/apps/web/components/auth/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import SettingsLayout from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import type Shell from "@calcom/features/shell/Shell";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { ErrorBoundary } from "@calcom/ui";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
const pathname = usePathname();
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
// Force redirect on component level
|
||||
useEffect(() => {
|
||||
if (session.data && session.data.user.role !== UserPermissionRole.ADMIN) {
|
||||
router.replace("/settings/my-account/profile");
|
||||
}
|
||||
}, [session, router]);
|
||||
|
||||
const isAppsPage = pathname?.startsWith("/settings/admin/apps");
|
||||
return (
|
||||
<SettingsLayout {...rest}>
|
||||
<div className="divide-subtle bg-default mx-auto flex max-w-4xl flex-row divide-y">
|
||||
<div className={isAppsPage ? "min-w-0" : "flex flex-1 [&>*]:flex-1"}>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export const getLayout = (page: React.ReactElement) => <AdminLayout>{page}</AdminLayout>;
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import SettingsLayout from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import type Shell from "@calcom/features/shell/Shell";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { ErrorBoundary } from "@calcom/ui";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
const pathname = usePathname();
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
// Force redirect on component level
|
||||
useEffect(() => {
|
||||
if (session.data && session.data.user.role !== UserPermissionRole.ADMIN) {
|
||||
router.replace("/settings/my-account/profile");
|
||||
}
|
||||
}, [session, router]);
|
||||
|
||||
const isAppsPage = pathname?.startsWith("/settings/admin/apps");
|
||||
return (
|
||||
<SettingsLayout {...rest}>
|
||||
<div className="divide-subtle mx-auto flex max-w-4xl flex-row divide-y">
|
||||
<div className={isAppsPage ? "min-w-0" : "flex flex-1 [&>*]:flex-1"}>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export const getLayout = (page: React.ReactElement) => <AdminLayout>{page}</AdminLayout>;
|
||||
54
calcom/apps/web/components/availability/SkeletonLoader.tsx
Normal file
54
calcom/apps/web/components/availability/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Button, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li>
|
||||
<div className="flex items-center justify-between py-5 ltr:pl-4 rtl:pr-4 sm:ltr:pl-0 sm:rtl:pr-0">
|
||||
<div className="items-between flex w-full flex-col justify-center sm:px-6">
|
||||
<SkeletonText className="my-1 h-4 w-32" />
|
||||
<SkeletonText className="my-1 h-2 w-24" />
|
||||
<SkeletonText className="my-1 h-2 w-40" />
|
||||
</div>
|
||||
<Button
|
||||
className="mx-5"
|
||||
type="button"
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
StartIcon="ellipsis"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export const SelectSkeletonLoader = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<li
|
||||
className={classNames(
|
||||
"border-subtle group flex w-full items-center justify-between rounded-sm border px-[10px] py-3",
|
||||
className
|
||||
)}>
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonText className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
997
calcom/apps/web/components/booking/BookingListItem.tsx
Normal file
997
calcom/apps/web/components/booking/BookingListItem.tsx
Normal file
@@ -0,0 +1,997 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
|
||||
import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations";
|
||||
import {
|
||||
getEventLocationType,
|
||||
getSuccessPageLocationMessage,
|
||||
guessEventLocationType,
|
||||
} from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
// TODO: Use browser locale, implement Intl in Dayjs maybe?
|
||||
import "@calcom/dayjs/locales";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { formatTime } from "@calcom/lib/date-fns";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { ActionType } from "@calcom/ui";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Icon,
|
||||
MeetingTimeInTimezones,
|
||||
showToast,
|
||||
TableActions,
|
||||
TextAreaField,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
|
||||
loggedInUser: {
|
||||
userId: number | undefined;
|
||||
userTimeZone: string | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
userEmail: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
const bookerUrl = useBookerUrl();
|
||||
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
|
||||
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isBookingInPast = new Date(booking.endTime) < new Date();
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
const isRejected = booking.status === BookingStatus.REJECTED;
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
|
||||
const paymentAppData = getPaymentAppData(booking.eventType);
|
||||
|
||||
const location = booking.location as ReturnType<typeof getEventLocationValue>;
|
||||
const locationVideoCallUrl = bookingMetadataSchema.parse(booking?.metadata || {})?.videoCallUrl;
|
||||
|
||||
const locationToDisplay = getSuccessPageLocationMessage(
|
||||
locationVideoCallUrl ? locationVideoCallUrl : location,
|
||||
t,
|
||||
booking.status
|
||||
);
|
||||
const provider = guessEventLocationType(location);
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
confirmed: confirm,
|
||||
reason: rejectionReason,
|
||||
};
|
||||
/**
|
||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
*/
|
||||
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const getSeatReferenceUid = () => {
|
||||
if (!booking.seatsReferences[0]) {
|
||||
return undefined;
|
||||
}
|
||||
return booking.seatsReferences[0].referenceUid;
|
||||
};
|
||||
|
||||
const pendingActions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
||||
onClick: () => {
|
||||
setRejectionDialogIsOpen(true);
|
||||
},
|
||||
icon: "ban",
|
||||
disabled: mutation.isPending,
|
||||
},
|
||||
// For bookings with payment, only confirm if the booking is paid for
|
||||
...((isPending && !paymentAppData.enabled) ||
|
||||
(paymentAppData.enabled && !!paymentAppData.price && booking.paid)
|
||||
? [
|
||||
{
|
||||
id: "confirm",
|
||||
bookingId: booking.id,
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
|
||||
onClick: () => {
|
||||
bookingConfirm(true);
|
||||
},
|
||||
icon: "check" as const,
|
||||
disabled: mutation.isPending,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
let bookedActions: ActionType[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"),
|
||||
/* When cancelling we need to let the UI and the API know if the intention is to
|
||||
cancel all remaining bookings or just that booking instance. */
|
||||
href: `/booking/${booking.uid}?cancel=true${
|
||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||
}${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}
|
||||
`,
|
||||
icon: "x" as const,
|
||||
},
|
||||
{
|
||||
id: "edit_booking",
|
||||
label: t("edit"),
|
||||
actions: [
|
||||
{
|
||||
id: "reschedule",
|
||||
icon: "clock" as const,
|
||||
label: t("reschedule_booking"),
|
||||
href: `${bookerUrl}/reschedule/${booking.uid}${
|
||||
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "reschedule_request",
|
||||
icon: "send" as const,
|
||||
iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
|
||||
label: t("send_reschedule_request"),
|
||||
onClick: () => {
|
||||
setIsOpenRescheduleDialog(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "change_location",
|
||||
label: t("edit_location"),
|
||||
onClick: () => {
|
||||
setIsOpenLocationDialog(true);
|
||||
},
|
||||
icon: "map-pin" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const chargeCardActions: ActionType[] = [
|
||||
{
|
||||
id: "charge_card",
|
||||
label: cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee"),
|
||||
disabled: cardCharged,
|
||||
onClick: () => {
|
||||
setChargeCardDialogIsOpen(true);
|
||||
},
|
||||
icon: "credit-card" as const,
|
||||
},
|
||||
];
|
||||
|
||||
if (isTabRecurring && isRecurring) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||
}
|
||||
|
||||
if (isBookingInPast && isPending && !isConfirmed) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "cancel");
|
||||
}
|
||||
|
||||
const RequestSentMessage = () => {
|
||||
return (
|
||||
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
|
||||
{t("reschedule_request_sent")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const startTime = dayjs(booking.startTime)
|
||||
.tz(userTimeZone)
|
||||
.locale(language)
|
||||
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
setIsOpenLocationDialog(false);
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const saveLocation = (
|
||||
newLocationType: EventLocationType["type"],
|
||||
details: {
|
||||
[key: string]: string;
|
||||
}
|
||||
) => {
|
||||
let newLocation = newLocationType as string;
|
||||
const eventLocationType = getEventLocationType(newLocationType);
|
||||
if (eventLocationType?.organizerInputType) {
|
||||
newLocation = details[Object.keys(details)[0]];
|
||||
}
|
||||
setLocationMutation.mutate({ bookingId: booking.id, newLocation, details });
|
||||
};
|
||||
|
||||
// Getting accepted recurring dates to show
|
||||
const recurringDates = booking.recurringInfo?.bookings[BookingStatus.ACCEPTED]
|
||||
.concat(booking.recurringInfo?.bookings[BookingStatus.CANCELLED])
|
||||
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
|
||||
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
|
||||
|
||||
const buildBookingLink = () => {
|
||||
const urlSearchParams = new URLSearchParams({
|
||||
allRemainingBookings: isTabRecurring.toString(),
|
||||
});
|
||||
if (booking.attendees[0]) urlSearchParams.set("email", booking.attendees[0].email);
|
||||
return `/booking/${booking.uid}?${urlSearchParams.toString()}`;
|
||||
};
|
||||
|
||||
const bookingLink = buildBookingLink();
|
||||
|
||||
const title = booking.title;
|
||||
|
||||
const showViewRecordingsButton = !!(booking.isRecorded && isBookingInPast && isConfirmed);
|
||||
const showCheckRecordingButton =
|
||||
isBookingInPast &&
|
||||
isConfirmed &&
|
||||
!booking.isRecorded &&
|
||||
(!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "");
|
||||
|
||||
const showRecordingActions: ActionType[] = [
|
||||
{
|
||||
id: "view_recordings",
|
||||
label: showCheckRecordingButton ? t("check_for_recordings") : t("view_recordings"),
|
||||
onClick: () => {
|
||||
setViewRecordingsDialogIsOpen(true);
|
||||
},
|
||||
color: showCheckRecordingButton ? "secondary" : "primary",
|
||||
disabled: mutation.isPending,
|
||||
},
|
||||
];
|
||||
|
||||
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
|
||||
const attendeeList = booking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
id: attendee.id,
|
||||
noShow: attendee.noShow || false,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
isOpenDialog={isOpenRescheduleDialog}
|
||||
setIsOpenDialog={setIsOpenRescheduleDialog}
|
||||
bookingUId={booking.uid}
|
||||
/>
|
||||
<EditLocationDialog
|
||||
booking={booking}
|
||||
saveLocation={saveLocation}
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
teamId={booking.eventType?.team?.id}
|
||||
/>
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
isOpenDialog={chargeCardDialogIsOpen}
|
||||
setIsOpenDialog={setChargeCardDialogIsOpen}
|
||||
bookingId={booking.id}
|
||||
paymentAmount={booking.payment[0].amount}
|
||||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{(showViewRecordingsButton || showCheckRecordingButton) && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
disabled={mutation.isPending}
|
||||
data-testid="rejection-confirm"
|
||||
onClick={() => {
|
||||
bookingConfirm(false);
|
||||
}}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
|
||||
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
|
||||
<Link href={bookingLink}>
|
||||
<div className="cursor-pointer py-4">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle text-sm">
|
||||
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={userTimeFormat}
|
||||
userTimezone={userTimeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
/>
|
||||
</div>
|
||||
{!isPending && (
|
||||
<div>
|
||||
{(provider?.label || locationToDisplay?.startsWith("https://")) &&
|
||||
locationToDisplay.startsWith("http") && (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
rel="noreferrer"
|
||||
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
className="h-4 w-4 rounded-sm"
|
||||
alt={`${provider?.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label
|
||||
? t("join_event_location", { eventLocationType: provider?.label })
|
||||
: t("join_meeting")}
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isPending && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.paid && !booking.payment[0] ? (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
||||
{t("error_collecting_card")}
|
||||
</Badge>
|
||||
) : booking.paid ? (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="green" data-testid="paid_badge">
|
||||
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="text-muted mt-2 text-sm">
|
||||
<RecurringBookingsTooltip
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
booking={booking}
|
||||
recurringDates={recurringDates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td data-testid="title-and-attendees" className={`w-full px-4${isRejected ? " line-through" : ""}`}>
|
||||
<Link href={bookingLink}>
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||
<div className="flex w-full items-center justify-between sm:hidden">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle pr-2 text-sm">
|
||||
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={userTimeFormat}
|
||||
userTimezone={userTimeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
{showPendingPayment && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="text-muted text-sm sm:hidden">
|
||||
<RecurringBookingsTooltip
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
booking={booking}
|
||||
recurringDates={recurringDates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="cursor-pointer py-4">
|
||||
<div
|
||||
title={title}
|
||||
className={classNames(
|
||||
"max-w-10/12 sm:max-w-56 text-emphasis text-sm font-medium leading-6 md:max-w-full",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{title}
|
||||
<span> </span>
|
||||
|
||||
{showPendingPayment && (
|
||||
<Badge className="hidden sm:inline-flex" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div
|
||||
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
|
||||
title={booking.description}>
|
||||
"{booking.description}"
|
||||
</div>
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<DisplayAttendees
|
||||
attendees={attendeeList}
|
||||
user={booking.user}
|
||||
currentEmail={userEmail}
|
||||
bookingUid={booking.uid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="flex w-full justify-end py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
|
||||
{isUpcoming && !isCancelled ? (
|
||||
<>
|
||||
{isPending && (userId === booking.user?.id || booking.isUserTeamAdminOrOwner) && (
|
||||
<TableActions actions={pendingActions} />
|
||||
)}
|
||||
{isConfirmed && <TableActions actions={bookedActions} />}
|
||||
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
|
||||
</>
|
||||
) : null}
|
||||
{isBookingInPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{(showViewRecordingsButton || showCheckRecordingButton) && (
|
||||
<TableActions actions={showRecordingActions} />
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
{booking.status === "ACCEPTED" && booking.paid && booking.payment[0]?.paymentOption === "HOLD" && (
|
||||
<div className="ml-2">
|
||||
<TableActions actions={chargeCardActions} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface RecurringBookingsTooltipProps {
|
||||
booking: BookingItemProps;
|
||||
recurringDates: Date[];
|
||||
userTimeZone: string | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
}
|
||||
|
||||
const RecurringBookingsTooltip = ({
|
||||
booking,
|
||||
recurringDates,
|
||||
userTimeZone,
|
||||
userTimeFormat,
|
||||
}: RecurringBookingsTooltipProps) => {
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
const now = new Date();
|
||||
const recurringCount = recurringDates.filter((recurringDate) => {
|
||||
return (
|
||||
recurringDate >= now &&
|
||||
!booking.recurringInfo?.bookings[BookingStatus.CANCELLED]
|
||||
.map((date) => date.toString())
|
||||
.includes(recurringDate.toString())
|
||||
);
|
||||
}).length;
|
||||
|
||||
return (
|
||||
(booking.recurringInfo &&
|
||||
booking.eventType?.recurringEvent?.freq &&
|
||||
(booking.listingStatus === "recurring" ||
|
||||
booking.listingStatus === "unconfirmed" ||
|
||||
booking.listingStatus === "cancelled") && (
|
||||
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringDates.map((aDate, key) => {
|
||||
const pastOrCancelled =
|
||||
aDate < now ||
|
||||
booking.recurringInfo?.bookings[BookingStatus.CANCELLED]
|
||||
.map((date) => date.toString())
|
||||
.includes(aDate.toString());
|
||||
return (
|
||||
<p key={key} className={classNames(pastOrCancelled && "line-through")}>
|
||||
{formatTime(aDate, userTimeFormat, userTimeZone)}
|
||||
{" - "}
|
||||
{dayjs(aDate).locale(language).format("D MMMM YYYY")}
|
||||
</p>
|
||||
);
|
||||
})}>
|
||||
<div className="text-default">
|
||||
<Icon
|
||||
name="refresh-ccw"
|
||||
strokeWidth="3"
|
||||
className="text-muted float-left mr-1 mt-1.5 inline-block h-3 w-3"
|
||||
/>
|
||||
<p className="mt-1 pl-5 text-xs">
|
||||
{booking.status === BookingStatus.ACCEPTED
|
||||
? `${t("event_remaining_other", {
|
||||
count: recurringCount,
|
||||
})}`
|
||||
: getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
recurringCount: booking.recurringInfo.count,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)) ||
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
interface UserProps {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const FirstAttendee = ({
|
||||
user,
|
||||
currentEmail,
|
||||
}: {
|
||||
user: UserProps;
|
||||
currentEmail: string | null | undefined;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
return user.email === currentEmail ? (
|
||||
<div className="inline-block">{t("you")}</div>
|
||||
) : (
|
||||
<a
|
||||
key={user.email}
|
||||
className=" hover:text-blue-500"
|
||||
href={`mailto:${user.email}`}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{user.name}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
type AttendeeProps = {
|
||||
name?: string;
|
||||
email: string;
|
||||
id: number;
|
||||
noShow: boolean;
|
||||
};
|
||||
|
||||
type NoShowProps = {
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
};
|
||||
|
||||
const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
const { email, name, bookingUid, isBookingInPast, noShow: noShowAttendee } = attendeeProps;
|
||||
const { t } = useLocale();
|
||||
|
||||
const [noShow, setNoShow] = useState(noShowAttendee);
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
|
||||
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(
|
||||
t("messageKey" in data && data.messageKey ? data.messageKey : data.message, { x: name || email }),
|
||||
"success"
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
function toggleNoShow({
|
||||
attendee,
|
||||
bookingUid,
|
||||
}: {
|
||||
attendee: { email: string; noShow: boolean };
|
||||
bookingUid: string;
|
||||
}) {
|
||||
noShowMutation.mutate({ bookingUid, attendees: [attendee] });
|
||||
setNoShow(!noShow);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
data-testid="guest"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="radix-state-open:text-blue-500 hover:text-blue-500">
|
||||
{noShow ? (
|
||||
<s>
|
||||
{name || email} <Icon name="eye-off" className="inline h-4" />
|
||||
</s>
|
||||
) : (
|
||||
<>{name || email}</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon="mail"
|
||||
href={`mailto:${email}`}
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<a href={`mailto:${email}`}>{t("email")}</a>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
copyToClipboard(email);
|
||||
setOpenDropdown(false);
|
||||
showToast(t("email_copied"), "success");
|
||||
}}>
|
||||
{!isCopied ? t("copy") : t("copied")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{isBookingInPast && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
{noShow ? (
|
||||
<DropdownItem
|
||||
data-testid="unmark-no-show"
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
toggleNoShow({ attendee: { noShow: false, email }, bookingUid });
|
||||
e.preventDefault();
|
||||
}}
|
||||
StartIcon="eye">
|
||||
{t("unmark_as_no_show")}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<DropdownItem
|
||||
data-testid="mark-no-show"
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
toggleNoShow({ attendee: { noShow: true, email }, bookingUid });
|
||||
e.preventDefault();
|
||||
}}
|
||||
StartIcon="eye-off">
|
||||
{t("mark_as_no_show")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
type GroupedAttendeeProps = {
|
||||
attendees: AttendeeProps[];
|
||||
bookingUid: string;
|
||||
};
|
||||
|
||||
const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
const { bookingUid } = groupedAttendeeProps;
|
||||
const attendees = groupedAttendeeProps.attendees.map((attendee) => {
|
||||
return {
|
||||
id: attendee.id,
|
||||
email: attendee.email,
|
||||
name: attendee.name,
|
||||
noShow: attendee.noShow || false,
|
||||
};
|
||||
});
|
||||
const { t } = useLocale();
|
||||
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(t("messageKey" in data && data.messageKey ? data.messageKey : data.message), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
const { control, handleSubmit } = useForm<{
|
||||
attendees: AttendeeProps[];
|
||||
}>({
|
||||
defaultValues: {
|
||||
attendees,
|
||||
},
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const { fields } = useFieldArray({
|
||||
control,
|
||||
name: "attendees",
|
||||
});
|
||||
|
||||
const onSubmit = (data: { attendees: AttendeeProps[] }) => {
|
||||
const filteredData = data.attendees.slice(1);
|
||||
noShowMutation.mutate({ bookingUid, attendees: filteredData });
|
||||
setOpenDropdown(false);
|
||||
};
|
||||
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
|
||||
return (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
data-testid="more-guests"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
|
||||
{t("plus_more", { count: attendees.length - 1 })}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="text-xs font-medium uppercase">
|
||||
{t("mark_as_no_show_title")}
|
||||
</DropdownMenuLabel>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{fields.slice(1).map((field, index) => (
|
||||
<Controller
|
||||
key={field.id}
|
||||
name={`attendees.${index + 1}.noShow`}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value || false}
|
||||
onCheckedChange={onChange}
|
||||
className="pr-8 focus:outline-none"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(!value);
|
||||
}}>
|
||||
<span className={value ? "line-through" : ""}>{field.email}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<div className=" flex justify-end p-2">
|
||||
<Button
|
||||
data-testid="update-no-show"
|
||||
color="secondary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit(onSubmit)();
|
||||
}}>
|
||||
{t("mark_as_no_show_title")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { t } = useLocale();
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
const [selectedEmail, setSelectedEmail] = useState("");
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
open={openDropdown}
|
||||
onOpenChange={(value) => {
|
||||
setOpenDropdown(value);
|
||||
setSelectedEmail("");
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
|
||||
{t("plus_more", { count: guests.length - 1 })}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("guests")}</DropdownMenuLabel>
|
||||
{guests.slice(1).map((guest) => (
|
||||
<DropdownMenuItem key={guest.id}>
|
||||
<DropdownItem
|
||||
className="pr-6 focus:outline-none"
|
||||
StartIcon={selectedEmail === guest.email ? "circle-check" : undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedEmail(guest.email);
|
||||
}}>
|
||||
<span className={`${selectedEmail !== guest.email ? "pl-6" : ""}`}>{guest.email}</span>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<div className=" flex justify-end space-x-2 p-2">
|
||||
<Link href={`mailto:${selectedEmail}`}>
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={selectedEmail.length === 0}
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{t("email")}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={selectedEmail.length === 0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
copyToClipboard(selectedEmail);
|
||||
showToast(t("email_copied"), "success");
|
||||
}}>
|
||||
{!isCopied ? t("copy") : t("copied")}
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const DisplayAttendees = ({
|
||||
attendees,
|
||||
user,
|
||||
currentEmail,
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
user: UserProps | null;
|
||||
currentEmail?: string | null;
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
attendees.sort((a, b) => a.id - b.id);
|
||||
|
||||
return (
|
||||
<div className="text-emphasis text-sm">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
{attendees.length > 1 ? <span>, </span> : <span> {t("and")} </span>}
|
||||
<Attendee {...attendees[0]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
{attendees.length > 1 && (
|
||||
<>
|
||||
<div className="text-emphasis inline-block text-sm"> {t("and")} </div>
|
||||
{attendees.length > 2 ? (
|
||||
<Tooltip
|
||||
content={attendees.slice(1).map((attendee) => (
|
||||
<p key={attendee.email}>
|
||||
<Attendee {...attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
</p>
|
||||
))}>
|
||||
{isBookingInPast ? (
|
||||
<GroupedAttendees attendees={attendees} bookingUid={bookingUid} />
|
||||
) : (
|
||||
<GroupedGuests guests={attendees} />
|
||||
)}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Attendee {...attendees[1]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingListItem;
|
||||
139
calcom/apps/web/components/booking/CancelBooking.tsx
Normal file
139
calcom/apps/web/components/booking/CancelBooking.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Button, Icon, TextArea } from "@calcom/ui";
|
||||
|
||||
type Props = {
|
||||
booking: {
|
||||
title?: string;
|
||||
uid?: string;
|
||||
id?: number;
|
||||
};
|
||||
profile: {
|
||||
name: string | null;
|
||||
slug: string | null;
|
||||
};
|
||||
recurringEvent: RecurringEvent | null;
|
||||
team?: string | null;
|
||||
setIsCancellationMode: (value: boolean) => void;
|
||||
theme: string | null;
|
||||
allRemainingBookings: boolean;
|
||||
seatReferenceUid?: string;
|
||||
bookingCancelledEventProps: {
|
||||
booking: unknown;
|
||||
organizer: {
|
||||
name: string;
|
||||
email: string;
|
||||
timeZone?: string;
|
||||
};
|
||||
eventType: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { booking, allRemainingBookings, seatReferenceUid, bookingCancelledEventProps } = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const telemetry = useTelemetry();
|
||||
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||
|
||||
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
|
||||
if (node !== null) {
|
||||
node.scrollIntoView({ behavior: "smooth" });
|
||||
node.focus();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mt-8">
|
||||
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Icon name="x" className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-emphasis text-lg font-medium leading-6" id="modal-title">
|
||||
{error}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!error && (
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<label className="text-default font-medium">{t("cancellation_reason")}</label>
|
||||
<TextArea
|
||||
data-testid="cancel_reason"
|
||||
ref={cancelBookingRef}
|
||||
placeholder={t("cancellation_reason_placeholder")}
|
||||
value={cancellationReason}
|
||||
onChange={(e) => setCancellationReason(e.target.value)}
|
||||
className="mb-4 mt-2 w-full "
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||
<div className="ml-auto flex w-full space-x-4 ">
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="secondary"
|
||||
onClick={() => props.setIsCancellationMode(false)}>
|
||||
{t("nevermind")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="confirm_cancel"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
|
||||
const res = await fetch("/api/cancel", {
|
||||
body: JSON.stringify({
|
||||
uid: booking?.uid,
|
||||
cancellationReason: cancellationReason,
|
||||
allRemainingBookings,
|
||||
// @NOTE: very important this shouldn't cancel with number ID use uid instead
|
||||
seatReferenceUid,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const bookingWithCancellationReason = {
|
||||
...(bookingCancelledEventProps.booking as object),
|
||||
cancellationReason,
|
||||
} as unknown;
|
||||
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
// tested by apps/web/playwright/booking-pages.e2e.ts
|
||||
sdkActionManager?.fire("bookingCancelled", {
|
||||
...bookingCancelledEventProps,
|
||||
booking: bookingWithCancellationReason,
|
||||
});
|
||||
router.refresh();
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError(
|
||||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
|
||||
"please_try_again"
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
loading={loading}>
|
||||
{props.allRemainingBookings ? t("cancel_all_remaining") : t("cancel_event")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
calcom/apps/web/components/booking/SkeletonLoader.tsx
Normal file
36
calcom/apps/web/components/booking/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText className="h-5 w-16" />
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:ml-5 sm:mt-0 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText className="h-6 w-16" />
|
||||
<SkeletonText className="h-6 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
82
calcom/apps/web/components/dialog/ChargeCardDialog.tsx
Normal file
82
calcom/apps/web/components/dialog/ChargeCardDialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
Icon,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
interface IRescheduleDialog {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
bookingId: number;
|
||||
paymentAmount: number;
|
||||
paymentCurrency: string;
|
||||
}
|
||||
|
||||
export const ChargeCardDialog = (props: IRescheduleDialog) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const { isOpenDialog, setIsOpenDialog, bookingId } = props;
|
||||
const [chargeError, setChargeError] = useState(false);
|
||||
const chargeCardMutation = trpc.viewer.payments.chargeCard.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.bookings.invalidate();
|
||||
setIsOpenDialog(false);
|
||||
showToast("Charge successful", "success");
|
||||
},
|
||||
onError: () => {
|
||||
setChargeError(true);
|
||||
},
|
||||
});
|
||||
|
||||
const currencyStringParams = {
|
||||
amount: props.paymentAmount / 100.0,
|
||||
formatParams: { amount: { currency: props.paymentCurrency } },
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className=" bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full">
|
||||
<Icon name="credit-card" className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<DialogHeader title={t("charge_card")} />
|
||||
<p>{t("charge_card_dialog_body", currencyStringParams)}</p>
|
||||
|
||||
{chargeError && (
|
||||
<div className="mt-4 flex text-red-500">
|
||||
<Icon name="triangle-alert" className="mr-2 h-5 w-5 " aria-hidden="true" />
|
||||
<p className="text-sm">{t("error_charging_card")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
data-testid="send_request"
|
||||
disabled={chargeCardMutation.isPending || chargeError}
|
||||
onClick={() =>
|
||||
chargeCardMutation.mutate({
|
||||
bookingId,
|
||||
})
|
||||
}>
|
||||
{t("charge_attendee", currencyStringParams)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
408
calcom/apps/web/components/dialog/EditLocationDialog.tsx
Normal file
408
calcom/apps/web/components/dialog/EditLocationDialog.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import { ErrorMessage } from "@hookform/error-message";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm, useWatch, useFormContext } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { EventLocationType, LocationObject } from "@calcom/app-store/locations";
|
||||
import {
|
||||
getEventLocationType,
|
||||
getHumanReadableLocationValue,
|
||||
getMessageForOrganizer,
|
||||
LocationType,
|
||||
OrganizerDefaultConferencingAppType,
|
||||
} from "@calcom/app-store/locations";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Icon, Input, Dialog, DialogContent, DialogFooter, Form, PhoneInput } from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||
import type { LocationOption } from "@components/ui/form/LocationSelect";
|
||||
import LocationSelect from "@components/ui/form/LocationSelect";
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
interface ISetLocationDialog {
|
||||
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
||||
selection?: LocationOption;
|
||||
booking?: BookingItem;
|
||||
defaultValues?: LocationObject[];
|
||||
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isOpenDialog: boolean;
|
||||
setSelectedLocation?: (param: LocationOption | undefined) => void;
|
||||
setEditingLocationType?: (param: string) => void;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
const LocationInput = (props: {
|
||||
eventLocationType: EventLocationType;
|
||||
locationFormMethods: ReturnType<typeof useForm>;
|
||||
id: string;
|
||||
required: boolean;
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
defaultValue?: string;
|
||||
}): JSX.Element | null => {
|
||||
const { eventLocationType, locationFormMethods, ...remainingProps } = props;
|
||||
const { control } = useFormContext() as typeof locationFormMethods;
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
return (
|
||||
<Input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={eventLocationType.variable}
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return <PhoneInput onChange={onChange} value={value} {...rest} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
const {
|
||||
saveLocation,
|
||||
selection,
|
||||
booking,
|
||||
setShowLocationModal,
|
||||
isOpenDialog,
|
||||
defaultValues,
|
||||
setSelectedLocation,
|
||||
setEditingLocationType,
|
||||
teamId,
|
||||
} = props;
|
||||
const { t } = useLocale();
|
||||
const locationsQuery = trpc.viewer.locationOptions.useQuery({ teamId });
|
||||
|
||||
useEffect(() => {
|
||||
if (selection) {
|
||||
locationFormMethods.setValue("locationType", selection?.value);
|
||||
if (selection?.address) {
|
||||
locationFormMethods.setValue("locationAddress", selection?.address);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selection]);
|
||||
|
||||
const locationFormSchema = z.object({
|
||||
locationType: z.string(),
|
||||
phone: z.string().optional().nullable(),
|
||||
locationAddress: z.string().optional(),
|
||||
credentialId: z.number().optional(),
|
||||
teamName: z.string().optional(),
|
||||
locationLink: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (
|
||||
eventLocationType &&
|
||||
!eventLocationType.default &&
|
||||
eventLocationType.linkType === "static" &&
|
||||
eventLocationType.urlRegExp
|
||||
) {
|
||||
const valid = z.string().regex(new RegExp(eventLocationType.urlRegExp)).safeParse(val).success;
|
||||
if (!valid) {
|
||||
const sampleUrl = eventLocationType.organizerInputPlaceholder;
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid URL for ${eventLocationType.label}. ${
|
||||
sampleUrl ? `Sample URL: ${sampleUrl}` : ""
|
||||
}`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = z.string().url().optional().safeParse(val).success;
|
||||
if (!valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid URL`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}),
|
||||
displayLocationPublicly: z.boolean().optional(),
|
||||
locationPhoneNumber: z
|
||||
.string()
|
||||
.nullable()
|
||||
.refine((val) => {
|
||||
if (val === null) return false;
|
||||
return isValidPhoneNumber(val);
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const locationFormMethods = useForm({
|
||||
mode: "onSubmit",
|
||||
resolver: zodResolver(locationFormSchema),
|
||||
});
|
||||
|
||||
const selectedLocation = useWatch({
|
||||
control: locationFormMethods.control,
|
||||
name: "locationType",
|
||||
});
|
||||
|
||||
const selectedAddrValue = useWatch({
|
||||
control: locationFormMethods.control,
|
||||
name: "locationAddress",
|
||||
});
|
||||
|
||||
const eventLocationType = getEventLocationType(selectedLocation);
|
||||
|
||||
const defaultLocation = defaultValues?.find(
|
||||
(location: { type: EventLocationType["type"]; address?: string }) => {
|
||||
if (location.type === LocationType.InPerson) {
|
||||
return location.type === eventLocationType?.type && location.address === selectedAddrValue;
|
||||
} else {
|
||||
return location.type === eventLocationType?.type;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const LocationOptions = (() => {
|
||||
if (eventLocationType && eventLocationType.organizerInputType && LocationInput) {
|
||||
if (!eventLocationType.variable) {
|
||||
console.error("eventLocationType.variable can't be undefined");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="locationInput" className="text-default block text-sm font-medium">
|
||||
{t(eventLocationType.messageForOrganizer || "")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<LocationInput
|
||||
locationFormMethods={locationFormMethods}
|
||||
eventLocationType={eventLocationType}
|
||||
id="locationInput"
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
required
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
defaultValue={
|
||||
defaultLocation ? defaultLocation[eventLocationType.defaultValueVariable] : undefined
|
||||
}
|
||||
/>
|
||||
<ErrorMessage
|
||||
errors={locationFormMethods.formState.errors}
|
||||
name={eventLocationType.variable}
|
||||
className="text-error mt-1 text-sm"
|
||||
as="p"
|
||||
/>
|
||||
</div>
|
||||
{!booking && (
|
||||
<div className="mt-3">
|
||||
<Controller
|
||||
name="displayLocationPublicly"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<CheckboxField
|
||||
data-testid="display-location"
|
||||
defaultChecked={defaultLocation?.displayLocationPublicly}
|
||||
description={t("display_location_label")}
|
||||
onChange={(e) =>
|
||||
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
|
||||
}
|
||||
informationIconText={t("display_location_info_badge")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <p className="text-default text-sm">{getMessageForOrganizer(selectedLocation, t)}</p>;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={(open) => setShowLocationModal(open)}>
|
||||
<DialogContent>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-subtle mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||
<Icon name="map-pin" className="text-emphasis h-6 w-6" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<h3 className="text-emphasis text-lg font-medium leading-6" id="modal-title">
|
||||
{t("edit_location")}
|
||||
</h3>
|
||||
{!booking && (
|
||||
<p className="text-default text-sm">
|
||||
<Trans i18nKey="cant_find_the_right_conferencing_app_visit_our_app_store">
|
||||
Can't find the right conferencing app? Visit our
|
||||
<Link
|
||||
className="cursor-pointer text-blue-500 underline"
|
||||
href="/apps/categories/conferencing">
|
||||
App Store
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left" />
|
||||
|
||||
{booking && (
|
||||
<>
|
||||
<p className="text-emphasis mb-2 ml-1 mt-6 text-sm font-bold">{t("current_location")}:</p>
|
||||
<p className="text-emphasis mb-2 ml-1 text-sm">
|
||||
{getHumanReadableLocationValue(booking.location, t)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<Form
|
||||
form={locationFormMethods}
|
||||
handleSubmit={async (values) => {
|
||||
const { locationType: newLocation, displayLocationPublicly } = values;
|
||||
|
||||
let details = {};
|
||||
if (newLocation === LocationType.InPerson) {
|
||||
details = {
|
||||
address: values.locationAddress,
|
||||
};
|
||||
}
|
||||
const eventLocationType = getEventLocationType(newLocation);
|
||||
|
||||
// TODO: There can be a property that tells if it is to be saved in `link`
|
||||
if (
|
||||
newLocation === LocationType.Link ||
|
||||
(!eventLocationType?.default && eventLocationType?.linkType === "static")
|
||||
) {
|
||||
details = { link: values.locationLink };
|
||||
}
|
||||
|
||||
if (newLocation === LocationType.UserPhone) {
|
||||
details = { hostPhoneNumber: values.locationPhoneNumber };
|
||||
}
|
||||
|
||||
if (eventLocationType?.organizerInputType) {
|
||||
details = {
|
||||
...details,
|
||||
displayLocationPublicly,
|
||||
};
|
||||
}
|
||||
|
||||
if (values.credentialId) {
|
||||
details = {
|
||||
...details,
|
||||
credentialId: values.credentialId,
|
||||
};
|
||||
}
|
||||
|
||||
if (values.teamName) {
|
||||
details = {
|
||||
...details,
|
||||
teamName: values.teamName,
|
||||
};
|
||||
}
|
||||
|
||||
saveLocation(newLocation, details);
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
locationFormMethods.unregister([
|
||||
"locationType",
|
||||
"locationLink",
|
||||
"locationAddress",
|
||||
"locationPhoneNumber",
|
||||
]);
|
||||
}}>
|
||||
<QueryCell
|
||||
query={locationsQuery}
|
||||
success={({ data }) => {
|
||||
if (!data.length) return null;
|
||||
const locationOptions = [...data].map((option) => {
|
||||
if (teamId) {
|
||||
// Let host's Default conferencing App option show for Team Event
|
||||
return option;
|
||||
}
|
||||
return {
|
||||
...option,
|
||||
options: option.options.filter((o) => o.value !== OrganizerDefaultConferencingAppType),
|
||||
};
|
||||
});
|
||||
if (booking) {
|
||||
locationOptions.map((location) =>
|
||||
location.options.filter((l) => !["phone", "attendeeInPerson"].includes(l.value))
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Controller
|
||||
name="locationType"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<div className="py-4">
|
||||
<LocationSelect
|
||||
maxMenuHeight={300}
|
||||
name="location"
|
||||
defaultValue={selection}
|
||||
options={locationOptions}
|
||||
isSearchable
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
locationFormMethods.setValue("locationType", val.value);
|
||||
if (!!val.credentialId) {
|
||||
locationFormMethods.setValue("credentialId", val.credentialId);
|
||||
locationFormMethods.setValue("teamName", val.teamName);
|
||||
}
|
||||
|
||||
locationFormMethods.unregister([
|
||||
"locationLink",
|
||||
"locationAddress",
|
||||
"locationPhoneNumber",
|
||||
]);
|
||||
locationFormMethods.clearErrors([
|
||||
"locationLink",
|
||||
"locationPhoneNumber",
|
||||
"locationAddress",
|
||||
]);
|
||||
setSelectedLocation?.(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{selectedLocation && LocationOptions}
|
||||
<DialogFooter className="relative">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
setEditingLocationType?.("");
|
||||
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button data-testid="update-location" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
83
calcom/apps/web/components/dialog/RescheduleDialog.tsx
Normal file
83
calcom/apps/web/components/dialog/RescheduleDialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
Icon,
|
||||
showToast,
|
||||
TextArea,
|
||||
} from "@calcom/ui";
|
||||
|
||||
interface IRescheduleDialog {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
bookingUId: string;
|
||||
}
|
||||
|
||||
export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props;
|
||||
const [rescheduleReason, setRescheduleReason] = useState("");
|
||||
|
||||
const { mutate: rescheduleApi, isPending } = trpc.viewer.bookings.requestReschedule.useMutation({
|
||||
async onSuccess() {
|
||||
showToast(t("reschedule_request_sent"), "success");
|
||||
setIsOpenDialog(false);
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError() {
|
||||
showToast(t("unexpected_error_try_again"), "error");
|
||||
// @TODO: notify sentry
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
|
||||
<Icon name="clock" className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<DialogHeader title={t("send_reschedule_request")} />
|
||||
<p className="text-subtle text-sm">{t("reschedule_modal_description")}</p>
|
||||
<p className="text-emphasis mb-2 mt-6 text-sm font-bold">
|
||||
{t("reason_for_reschedule_request")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</p>
|
||||
<TextArea
|
||||
data-testid="reschedule_reason"
|
||||
name={t("reason_for_reschedule")}
|
||||
value={rescheduleReason}
|
||||
onChange={(e) => setRescheduleReason(e.target.value)}
|
||||
className="mb-5 sm:mb-6"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
data-testid="send_request"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
rescheduleApi({
|
||||
bookingId,
|
||||
rescheduleReason,
|
||||
});
|
||||
}}>
|
||||
{t("send_reschedule_request")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
73
calcom/apps/web/components/error/error-page.tsx
Normal file
73
calcom/apps/web/components/error/error-page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
type Props = {
|
||||
statusCode?: number | null;
|
||||
error?: Error | HttpError | null;
|
||||
message?: string;
|
||||
/** Display debugging information */
|
||||
displayDebug?: boolean;
|
||||
children?: never;
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
displayDebug: false,
|
||||
};
|
||||
|
||||
const ErrorDebugPanel: React.FC<{ error: Props["error"]; children?: never }> = (props) => {
|
||||
const { error: e } = props;
|
||||
|
||||
const debugMap = [
|
||||
["error.message", e?.message],
|
||||
["error.name", e?.name],
|
||||
["error.class", e instanceof Error ? e.constructor.name : undefined],
|
||||
["http.url", e instanceof HttpError ? e.url : undefined],
|
||||
["http.status", e instanceof HttpError ? e.statusCode : undefined],
|
||||
["http.cause", e instanceof HttpError ? e.cause?.message : undefined],
|
||||
["error.stack", e instanceof Error ? e.stack : undefined],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-default overflow-hidden shadow sm:rounded-lg">
|
||||
<div className="border-subtle border-t px-4 py-5 sm:p-0">
|
||||
<dl className="sm:divide-subtle sm:divide-y">
|
||||
{debugMap.map(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
return (
|
||||
<div key={key} className="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
|
||||
<dt className="text-emphasis text-sm font-bold">{key}</dt>
|
||||
<dd className="text-emphasis mt-1 text-sm sm:col-span-2 sm:mt-0">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorPage: React.FC<Props> = (props) => {
|
||||
const { message, statusCode, error, displayDebug } = { ...defaultProps, ...props };
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-default min-h-screen px-4">
|
||||
<main className="mx-auto max-w-xl pb-6 pt-16 sm:pt-24">
|
||||
<div className="text-center">
|
||||
<p className="text-emphasis text-sm font-semibold uppercase tracking-wide">{statusCode}</p>
|
||||
<h1 className="text-emphasis mt-2 text-4xl font-extrabold tracking-tight sm:text-5xl">
|
||||
{message}
|
||||
</h1>
|
||||
</div>
|
||||
</main>
|
||||
{displayDebug && (
|
||||
<div className="flex-wrap">
|
||||
<ErrorDebugPanel error={error} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
301
calcom/apps/web/components/eventtype/AIEventController.tsx
Normal file
301
calcom/apps/web/components/eventtype/AIEventController.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { useFormContext, Controller } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
EmptyScreen,
|
||||
SettingsToggle,
|
||||
Divider,
|
||||
TextField,
|
||||
TextAreaField,
|
||||
PhoneInput,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type AIEventControllerProps = {
|
||||
eventType: EventTypeSetup;
|
||||
isTeamEvent: boolean;
|
||||
};
|
||||
|
||||
export default function AIEventController({ eventType, isTeamEvent }: AIEventControllerProps) {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const [aiEventState, setAIEventState] = useState<boolean>(eventType?.aiPhoneCallConfig?.enabled ?? false);
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const isOrg = !!session.data?.user?.org?.id;
|
||||
|
||||
if (session.status === "loading") return <></>;
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<div className="block items-start sm:flex">
|
||||
{!isOrg || !isTeamEvent ? (
|
||||
<EmptyScreen
|
||||
headline={t("Cal.ai")}
|
||||
Icon="sparkles"
|
||||
description={t("upgrade_to_cal_ai_phone_number_description")}
|
||||
buttonRaw={<Button href="/enterprise">{t("upgrade")}</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||
aiEventState && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("Cal.ai")}
|
||||
description={t("use_cal_ai_to_make_call_description")}
|
||||
checked={aiEventState}
|
||||
data-testid="instant-event-check"
|
||||
onCheckedChange={(e) => {
|
||||
if (!e) {
|
||||
formMethods.setValue("aiPhoneCallConfig.enabled", false, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setAIEventState(false);
|
||||
} else {
|
||||
formMethods.setValue("aiPhoneCallConfig.enabled", true, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setAIEventState(true);
|
||||
}
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
{aiEventState && <AISettings eventType={eventType} />}
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LicenseRequired>
|
||||
);
|
||||
}
|
||||
|
||||
const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [calApiKey, setCalApiKey] = useState("");
|
||||
|
||||
const createCallMutation = trpc.viewer.organizations.createPhoneCall.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (!!data?.call_id) {
|
||||
showToast("Phone Call Created successfully", "success");
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
// const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
// const NewPhoneButton = () => {
|
||||
// const { t } = useLocale();
|
||||
// return (
|
||||
// <Button
|
||||
// color="primary"
|
||||
// data-testid="new_phone_number"
|
||||
// StartIcon={Plus}
|
||||
// onClick={() => setCreateModalOpen(true)}>
|
||||
// {t("New Phone number")}
|
||||
// </Button>
|
||||
// );
|
||||
// };
|
||||
|
||||
// v1 will require the user to log in to Retellai.com to create a phone number, and an agent and
|
||||
// authorize it with the Cal.com API key / OAuth
|
||||
// const retellAuthorized = true; // TODO: call retellAPI here
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = formMethods.getValues("aiPhoneCallConfig");
|
||||
|
||||
const data = await AIPhoneSettingSchema.parseAsync({
|
||||
generalPrompt: values.generalPrompt,
|
||||
beginMessage: values.beginMessage,
|
||||
enabled: values.enabled,
|
||||
guestName: values.guestName,
|
||||
guestEmail: values.guestEmail.trim().length ? values.guestEmail : undefined,
|
||||
guestCompany: values.guestCompany.trim().length ? values.guestCompany : undefined,
|
||||
eventTypeId: eventType.id,
|
||||
numberToCall: values.numberToCall,
|
||||
yourPhoneNumber: values.yourPhoneNumber,
|
||||
calApiKey,
|
||||
});
|
||||
|
||||
createCallMutation.mutate(data);
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const fieldName = err.issues?.[0]?.path?.[0];
|
||||
const message = err.issues?.[0]?.message;
|
||||
showToast(`Error on ${fieldName}: ${message} `, "error");
|
||||
} else {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
<>
|
||||
<Label>{t("your_phone_number")}</Label>
|
||||
<Controller
|
||||
name="aiPhoneCallConfig.yourPhoneNumber"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
placeholder={t("your_phone_number")}
|
||||
id="aiPhoneCallConfig.yourPhoneNumber"
|
||||
name="aiPhoneCallConfig.yourPhoneNumber"
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Label>{t("number_to_call")}</Label>
|
||||
<Controller
|
||||
name="aiPhoneCallConfig.numberToCall"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
placeholder={t("phone_number")}
|
||||
id="aiPhoneCallConfig.numberToCall"
|
||||
name="aiPhoneCallConfig.numberToCall"
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
</>
|
||||
|
||||
<TextField
|
||||
type="text"
|
||||
hint="Variable: {{name}}"
|
||||
label={t("guest_name")}
|
||||
placeholder="Jane Doe"
|
||||
{...formMethods.register("aiPhoneCallConfig.guestName")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="text"
|
||||
hint="Variable: {{email}}"
|
||||
label={t("guest_email")}
|
||||
placeholder="jane@acme.com"
|
||||
{...formMethods.register("aiPhoneCallConfig.guestEmail")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="text"
|
||||
hint="Variable: {{company}}"
|
||||
label={t("guest_company")}
|
||||
placeholder="Acme"
|
||||
{...formMethods.register("aiPhoneCallConfig.guestCompany")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="text"
|
||||
hint="For eg:- cal_live_0123.."
|
||||
label={t("provide_api_key")}
|
||||
name="calApiKey"
|
||||
placeholder="Cal API Key"
|
||||
value={calApiKey}
|
||||
onChange={(e) => {
|
||||
setCalApiKey(e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<TextAreaField
|
||||
rows={3}
|
||||
required
|
||||
placeholder={t("general_prompt")}
|
||||
label={t("general_prompt")}
|
||||
{...formMethods.register("aiPhoneCallConfig.generalPrompt")}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("aiPhoneCallConfig.generalPrompt", e.target.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextAreaField
|
||||
rows={3}
|
||||
placeholder={t("begin_message")}
|
||||
label={t("begin_message")}
|
||||
{...formMethods.register("aiPhoneCallConfig.beginMessage")}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("aiPhoneCallConfig.beginMessage", e.target.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={createCallMutation.isPending}
|
||||
loading={createCallMutation.isPending}
|
||||
onClick={handleSubmit}>
|
||||
{t("make_a_call")}
|
||||
</Button>
|
||||
|
||||
{/* TODO:<small className="block opacity-60">
|
||||
Want to automate outgoing phone calls? Read our{" "}
|
||||
<Link className="underline" href="https://cal.com/docs">
|
||||
API docs
|
||||
</Link>{" "}
|
||||
and learn how to build workflows.
|
||||
</small> */}
|
||||
</div>
|
||||
|
||||
{/* TODO:
|
||||
<>
|
||||
<EmptyScreen
|
||||
Icon={Phone}
|
||||
headline={t("Create your phone number")}
|
||||
description={t(
|
||||
"This phone number can be called by guests but can also do proactive outbound calls by the AI agent."
|
||||
)}
|
||||
buttonRaw={
|
||||
<div className="flex justify-between gap-2">
|
||||
<NewPhoneButton />
|
||||
<Button color="secondary">{t("learn_more")}</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Dialog open={createModalOpen} onOpenChange={(isOpen) => !isOpen && setCreateModalOpen(false)}>
|
||||
<DialogContent
|
||||
enableOverflow
|
||||
title={t("Create phone number")}
|
||||
description={t("This number can later be called or can do proactive outbound calls")}>
|
||||
<div className="mb-12 mt-4">
|
||||
<TextField placeholder="+415" hint="Area Code" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
*/}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Dialog, DialogContent, Button, DialogFooter } from "@calcom/ui";
|
||||
|
||||
interface AssignmentWarningDialogProps {
|
||||
isOpenAssignmentWarnDialog: boolean;
|
||||
setIsOpenAssignmentWarnDialog: Dispatch<SetStateAction<boolean>>;
|
||||
pendingRoute: string;
|
||||
leaveWithoutAssigningHosts: MutableRefObject<boolean>;
|
||||
id: number;
|
||||
}
|
||||
|
||||
const AssignmentWarningDialog = (props: AssignmentWarningDialogProps) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
isOpenAssignmentWarnDialog,
|
||||
setIsOpenAssignmentWarnDialog,
|
||||
pendingRoute,
|
||||
leaveWithoutAssigningHosts,
|
||||
id,
|
||||
} = props;
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Dialog open={isOpenAssignmentWarnDialog} onOpenChange={setIsOpenAssignmentWarnDialog}>
|
||||
<DialogContent
|
||||
title={t("leave_without_assigning_anyone")}
|
||||
description={`${t("leave_without_adding_attendees")} ${t("no_availability_shown_to_bookers")}`}
|
||||
Icon="circle-alert"
|
||||
enableOverflow
|
||||
type="confirmation">
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpenAssignmentWarnDialog(false);
|
||||
router.replace(`/event-types/${id}?tabName=team`);
|
||||
}}
|
||||
color="minimal">
|
||||
{t("go_back_and_assign")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpenAssignmentWarnDialog(false);
|
||||
leaveWithoutAssigningHosts.current = true;
|
||||
router.replace(pendingRoute);
|
||||
}}>
|
||||
{t("leave_without_assigning")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default AssignmentWarningDialog;
|
||||
164
calcom/apps/web/components/eventtype/CustomEventTypeModal.tsx
Normal file
164
calcom/apps/web/components/eventtype/CustomEventTypeModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { FC } from "react";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
|
||||
import type { EventNameObjectType } from "@calcom/core/event";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import { validateCustomEventName } from "@calcom/core/event";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogClose, DialogFooter, DialogContent, TextField } from "@calcom/ui";
|
||||
|
||||
interface FormValues {
|
||||
customEventName: string;
|
||||
}
|
||||
|
||||
interface CustomEventTypeModalFormProps {
|
||||
placeHolder: string;
|
||||
close: () => void;
|
||||
setValue: (value: string) => void;
|
||||
event: EventNameObjectType;
|
||||
defaultValue: string;
|
||||
isNameFieldSplit: boolean;
|
||||
}
|
||||
|
||||
const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
|
||||
const { t } = useLocale();
|
||||
const { placeHolder, close, setValue, event, isNameFieldSplit } = props;
|
||||
const { register, handleSubmit, watch, getValues } = useFormContext<FormValues>();
|
||||
const onSubmit: SubmitHandler<FormValues> = (data) => {
|
||||
setValue(data.customEventName);
|
||||
close();
|
||||
};
|
||||
|
||||
// const customEventName = watch("customEventName");
|
||||
const previewText = getEventName({ ...event, eventName: watch("customEventName") });
|
||||
const placeHolder_ = watch("customEventName") === "" ? previewText : placeHolder;
|
||||
|
||||
return (
|
||||
<form
|
||||
id="custom-event-name"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const isEmpty = getValues("customEventName") === "";
|
||||
if (isEmpty) {
|
||||
setValue("");
|
||||
}
|
||||
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}>
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
placeholder={placeHolder_}
|
||||
{...register("customEventName", {
|
||||
validate: (value) =>
|
||||
validateCustomEventName(value, t("invalid_event_name_variables"), event.bookingFields),
|
||||
})}
|
||||
className="mb-0"
|
||||
/>
|
||||
<div className="pt-6 text-sm">
|
||||
<div className="bg-subtle mb-6 rounded-md p-2">
|
||||
<h1 className="text-emphasis mb-2 ml-1 font-medium">{t("available_variables")}</h1>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Event type title}`}</p>
|
||||
<p className="text-emphasis">{t("event_name_info")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser}`}</p>
|
||||
<p className="text-emphasis">{t("your_full_name")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser first name}`}</p>
|
||||
<p className="text-emphasis">{t("organizer_first_name")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler}`}</p>
|
||||
<p className="text-emphasis">{t("scheduler_full_name")}</p>
|
||||
</div>
|
||||
{isNameFieldSplit && (
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler first name}`}</p>
|
||||
<p className="text-emphasis">{t("scheduler_first_name")}</p>
|
||||
</div>
|
||||
)}
|
||||
{isNameFieldSplit && (
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler last name}`}</p>
|
||||
<p className="text-emphasis">{t("scheduler_last_name")}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-1 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Location}`}</p>
|
||||
<p className="text-emphasis">{t("location_info")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mb-2 text-[14px] font-medium leading-4">{t("preview")}</h1>
|
||||
<div
|
||||
className="flex h-[212px] w-full rounded-md border-y bg-cover bg-center dark:invert"
|
||||
style={{
|
||||
backgroundImage: "url(/calendar-preview.svg)",
|
||||
}}>
|
||||
<div className="m-auto flex items-center justify-center self-stretch">
|
||||
<div className="bg-subtle ml-11 mt-3 box-border h-[110px] w-[120px] flex-col items-start gap-1 rounded-md border border-solid border-black text-[12px] leading-3">
|
||||
<p className="text-emphasis overflow-hidden text-ellipsis p-1.5 font-medium">{previewText}</p>
|
||||
<p className="text-default ml-1.5 text-[10px] font-normal">8 - 10 AM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomEventTypeModalProps {
|
||||
placeHolder: string;
|
||||
defaultValue: string;
|
||||
close: () => void;
|
||||
setValue: (value: string) => void;
|
||||
event: EventNameObjectType;
|
||||
isNameFieldSplit: boolean;
|
||||
}
|
||||
|
||||
const CustomEventTypeModal: FC<CustomEventTypeModalProps> = (props) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { defaultValue, placeHolder, close, setValue, event, isNameFieldSplit } = props;
|
||||
|
||||
const methods = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
customEventName: defaultValue,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={close}>
|
||||
<DialogContent
|
||||
title={t("custom_event_name")}
|
||||
description={t("custom_event_name_description")}
|
||||
type="creation"
|
||||
enableOverflow>
|
||||
<FormProvider {...methods}>
|
||||
<CustomEventTypeModalForm
|
||||
event={event}
|
||||
close={close}
|
||||
setValue={setValue}
|
||||
placeHolder={placeHolder}
|
||||
defaultValue={defaultValue}
|
||||
isNameFieldSplit={isNameFieldSplit}
|
||||
/>
|
||||
</FormProvider>
|
||||
<DialogFooter>
|
||||
<DialogClose>{t("cancel")}</DialogClose>
|
||||
|
||||
<Button form="custom-event-name" type="submit" color="primary">
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomEventTypeModal;
|
||||
10
calcom/apps/web/components/eventtype/EventAITab.tsx
Normal file
10
calcom/apps/web/components/eventtype/EventAITab.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
|
||||
import AIEventController from "./AIEventController";
|
||||
|
||||
export const EventAITab = ({
|
||||
eventType,
|
||||
isTeamEvent,
|
||||
}: Pick<EventTypeSetupProps, "eventType"> & { isTeamEvent: boolean }) => {
|
||||
return <AIEventController eventType={eventType} isTeamEvent={isTeamEvent} />;
|
||||
};
|
||||
584
calcom/apps/web/components/eventtype/EventAdvancedTab.tsx
Normal file
584
calcom/apps/web/components/eventtype/EventAdvancedTab.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { EventNameObjectType } from "@calcom/core/event";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
import type { EditableSchema } from "@calcom/features/form-builder/schema";
|
||||
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import cx from "@calcom/lib/classNames";
|
||||
import { APP_NAME, IS_VISUAL_REGRESSION_TESTING, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Badge,
|
||||
CheckboxField,
|
||||
Icon,
|
||||
Label,
|
||||
SelectField,
|
||||
SettingsToggle,
|
||||
Switch,
|
||||
TextField,
|
||||
Tooltip,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
|
||||
const CustomEventTypeModal = dynamic(() => import("@components/eventtype/CustomEventTypeModal"));
|
||||
|
||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!formMethods.getValues("hashedLink"));
|
||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl"));
|
||||
const [useEventTypeDestinationCalendarEmail, setUseEventTypeDestinationCalendarEmail] = useState(
|
||||
formMethods.getValues("useEventTypeDestinationCalendarEmail")
|
||||
);
|
||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||
const bookingFields: Prisma.JsonObject = {};
|
||||
|
||||
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
|
||||
const selectedThemeIsDark =
|
||||
user?.theme === "dark" ||
|
||||
(!user?.theme && typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
|
||||
formMethods.getValues().bookingFields.forEach(({ name }) => {
|
||||
bookingFields[name] = `${name} input`;
|
||||
});
|
||||
|
||||
const nameBookingField = formMethods.getValues().bookingFields.find((field) => field.name === "name");
|
||||
const isSplit = (nameBookingField && nameBookingField.variant === "firstAndLastName") ?? false;
|
||||
|
||||
const eventNameObject: EventNameObjectType = {
|
||||
attendeeName: t("scheduler"),
|
||||
eventType: formMethods.getValues("title"),
|
||||
eventName: formMethods.getValues("eventName"),
|
||||
host: formMethods.getValues("users")[0]?.name || "Nameless",
|
||||
bookingFields: bookingFields,
|
||||
t,
|
||||
};
|
||||
|
||||
const [requiresConfirmation, setRequiresConfirmation] = useState(
|
||||
formMethods.getValues("requiresConfirmation")
|
||||
);
|
||||
const placeholderHashedLink = `${WEBSITE_URL}/d/${hashedUrl}/${formMethods.getValues("slug")}`;
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
const multiLocation = (formMethods.getValues("locations") || []).length > 1;
|
||||
const noShowFeeEnabled =
|
||||
formMethods.getValues("metadata")?.apps?.stripe?.enabled === true &&
|
||||
formMethods.getValues("metadata")?.apps?.stripe?.paymentOption === "HOLD";
|
||||
|
||||
useEffect(() => {
|
||||
!hashedUrl && setHashedUrl(generateHashedLink(formMethods.getValues("users")[0]?.id ?? team?.id));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formMethods.getValues("users"), hashedUrl, team?.id]);
|
||||
|
||||
const toggleGuests = (enabled: boolean) => {
|
||||
const bookingFields = formMethods.getValues("bookingFields");
|
||||
formMethods.setValue(
|
||||
"bookingFields",
|
||||
bookingFields.map((field) => {
|
||||
if (field.name === "guests") {
|
||||
return {
|
||||
...field,
|
||||
hidden: !enabled,
|
||||
editable: (!enabled ? "system-but-hidden" : "system-but-optional") as z.infer<
|
||||
typeof EditableSchema
|
||||
>,
|
||||
};
|
||||
}
|
||||
return field;
|
||||
}),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
};
|
||||
|
||||
const { isChildrenManagedEventType, isManagedEventType, shouldLockDisableProps } = useLockedFieldsManager({
|
||||
eventType,
|
||||
translate: t,
|
||||
formMethods,
|
||||
});
|
||||
const eventNamePlaceholder = getEventName({
|
||||
...eventNameObject,
|
||||
eventName: formMethods.watch("eventName"),
|
||||
});
|
||||
|
||||
const successRedirectUrlLocked = shouldLockDisableProps("successRedirectUrl");
|
||||
const seatsLocked = shouldLockDisableProps("seatsPerTimeSlotEnabled");
|
||||
const requiresBookerEmailVerificationProps = shouldLockDisableProps("requiresBookerEmailVerification");
|
||||
const hideCalendarNotesLocked = shouldLockDisableProps("hideCalendarNotes");
|
||||
const lockTimeZoneToggleOnBookingPageLocked = shouldLockDisableProps("lockTimeZoneToggleOnBookingPage");
|
||||
|
||||
const closeEventNameTip = () => setShowEventNameTip(false);
|
||||
const displayDestinationCalendarSelector =
|
||||
!!connectedCalendarsQuery.data?.connectedCalendars.length && (!team || isChildrenManagedEventType);
|
||||
|
||||
const verifiedSecondaryEmails = [
|
||||
{
|
||||
label: user?.email || "",
|
||||
value: -1,
|
||||
},
|
||||
...(user?.secondaryEmails || [])
|
||||
.filter((secondaryEmail) => !!secondaryEmail.emailVerified)
|
||||
.map((secondaryEmail) => ({ label: secondaryEmail.email, value: secondaryEmail.id })),
|
||||
];
|
||||
const selectedSecondaryEmailId = formMethods.getValues("secondaryEmailId") || -1;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/**
|
||||
* Only display calendar selector if user has connected calendars AND if it's not
|
||||
* a team event. Since we don't have logic to handle each attendee calendar (for now).
|
||||
* This will fallback to each user selected destination calendar.
|
||||
*/}
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||
{displayDestinationCalendarSelector && (
|
||||
<div className="flex w-full flex-col">
|
||||
<Label className="text-emphasis mb-0 font-medium">{t("add_to_calendar")}</Label>
|
||||
<Controller
|
||||
name="destinationCalendar"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DestinationCalendarSelector
|
||||
value={value ? value.externalId : undefined}
|
||||
onChange={onChange}
|
||||
hidePlaceholder
|
||||
hideAdvancedText
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-subtle text-sm">{t("select_which_cal")}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
{...shouldLockDisableProps("eventName")}
|
||||
placeholder={eventNamePlaceholder}
|
||||
{...formMethods.register("eventName")}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
aria-label="edit custom name"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}>
|
||||
<Icon name="pencil" className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{displayDestinationCalendarSelector && (
|
||||
<div className="w-full">
|
||||
<Switch
|
||||
tooltip={t("if_enabled_email_address_as_organizer")}
|
||||
label={
|
||||
<>
|
||||
{t("display_add_to_calendar_organizer")}
|
||||
<Icon
|
||||
name="info"
|
||||
className="text-default hover:text-attention hover:bg-attention ms-1 inline h-4 w-4 rounded-md"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
checked={useEventTypeDestinationCalendarEmail}
|
||||
onCheckedChange={(val) => {
|
||||
setUseEventTypeDestinationCalendarEmail(val);
|
||||
formMethods.setValue("useEventTypeDestinationCalendarEmail", val, { shouldDirty: true });
|
||||
if (val) {
|
||||
showToast(t("reconnect_calendar_to_use"), "warning");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!useEventTypeDestinationCalendarEmail && verifiedSecondaryEmails.length > 0 && !team && (
|
||||
<div className={cx("flex w-full flex-col", displayDestinationCalendarSelector && "pl-11")}>
|
||||
<SelectField
|
||||
placeholder={
|
||||
selectedSecondaryEmailId === -1 && (
|
||||
<span className="text-default min-w-0 overflow-hidden truncate whitespace-nowrap">
|
||||
<Badge variant="blue">{t("default")}</Badge> {user?.email || ""}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
onChange={(option) =>
|
||||
formMethods.setValue("secondaryEmailId", option?.value, { shouldDirty: true })
|
||||
}
|
||||
value={verifiedSecondaryEmails.find(
|
||||
(secondaryEmail) =>
|
||||
selectedSecondaryEmailId !== -1 && secondaryEmail.value === selectedSecondaryEmailId
|
||||
)}
|
||||
options={verifiedSecondaryEmails}
|
||||
/>
|
||||
<p className="text-subtle mt-2 text-sm">{t("display_email_as_organizer")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} isOuterBorder={true} />
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<FormBuilder
|
||||
title={t("booking_questions_title")}
|
||||
description={t("booking_questions_description")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
{...shouldLockDisableProps("bookingFields")}
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<RequiresConfirmationController
|
||||
eventType={eventType}
|
||||
seatsEnabled={seatsEnabled}
|
||||
metadata={formMethods.getValues("metadata")}
|
||||
requiresConfirmation={requiresConfirmation}
|
||||
onRequiresConfirmation={setRequiresConfirmation}
|
||||
/>
|
||||
<Controller
|
||||
name="requiresBookerEmailVerification"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||
title={t("requires_booker_email_verification")}
|
||||
data-testid="requires-booker-email-verification"
|
||||
{...requiresBookerEmailVerificationProps}
|
||||
description={t("description_requires_booker_email_verification")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="hideCalendarNotes"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||
data-testid="disable-notes"
|
||||
title={t("disable_notes")}
|
||||
{...hideCalendarNotesLocked}
|
||||
description={t("disable_notes_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="successRedirectUrl"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||
redirectUrlVisible && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("redirect_success_booking")}
|
||||
data-testid="redirect-success-booking"
|
||||
{...successRedirectUrlLocked}
|
||||
description={t("redirect_url_description")}
|
||||
checked={redirectUrlVisible}
|
||||
onCheckedChange={(e) => {
|
||||
setRedirectUrlVisible(e);
|
||||
onChange(e ? value : "");
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label={t("redirect_success_booking")}
|
||||
labelSrOnly
|
||||
disabled={successRedirectUrlLocked.disabled}
|
||||
placeholder={t("external_redirect_url")}
|
||||
data-testid="external-redirect-url"
|
||||
required={redirectUrlVisible}
|
||||
type="text"
|
||||
{...formMethods.register("successRedirectUrl")}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Controller
|
||||
name="forwardParamsSuccessRedirect"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CheckboxField
|
||||
description={t("forward_params_redirect")}
|
||||
disabled={successRedirectUrlLocked.disabled}
|
||||
onChange={(e) => onChange(e)}
|
||||
checked={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"p-1 text-sm text-orange-600",
|
||||
formMethods.getValues("successRedirectUrl") ? "block" : "hidden"
|
||||
)}
|
||||
data-testid="redirect-url-warning">
|
||||
{t("redirect_url_warning")}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||
hashedLinkVisible && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
data-testid="hashedLinkCheck"
|
||||
title={t("enable_private_url")}
|
||||
//Badge={
|
||||
// <a //i im Kreis (für weitere Infos) wird deaktiviert
|
||||
// data-testid="hashedLinkCheck-info"
|
||||
// target="_blank"
|
||||
// rel="noreferrer"
|
||||
// href="https://bls.media/cal">
|
||||
// <Icon name="info" className="ml-1.5 h-4 w-4 cursor-pointer" />
|
||||
// </a>
|
||||
//}
|
||||
{...shouldLockDisableProps("hashedLink")}
|
||||
description={t("private_link_description", { appName: APP_NAME })}
|
||||
checked={hashedLinkVisible}
|
||||
onCheckedChange={(e) => {
|
||||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined, { shouldDirty: true });
|
||||
setHashedLinkVisible(e);
|
||||
}}>
|
||||
{!isManagedEventType && (
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
{!IS_VISUAL_REGRESSION_TESTING && (
|
||||
<TextField
|
||||
disabled
|
||||
name="hashedLink"
|
||||
label={t("private_link_label")}
|
||||
data-testid="generated-hash-url"
|
||||
labelSrOnly
|
||||
type="text"
|
||||
hint={t("private_link_hint")}
|
||||
defaultValue={placeholderHashedLink}
|
||||
addOnSuffix={
|
||||
<Tooltip
|
||||
content={
|
||||
formMethods.getValues("hashedLink") ? t("copy_to_clipboard") : t("enabled_after_update")
|
||||
}>
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
aria-label="copy link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
if (formMethods.getValues("hashedLink")) {
|
||||
showToast(t("private_link_copied"), "success");
|
||||
} else {
|
||||
showToast(t("enabled_after_update_description"), "warning");
|
||||
}
|
||||
}}>
|
||||
<Icon name="copy" className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsToggle>
|
||||
<Controller
|
||||
name="seatsPerTimeSlotEnabled"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||
value && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
data-testid="offer-seats-toggle"
|
||||
title={t("offer_seats")}
|
||||
{...seatsLocked}
|
||||
description={t("offer_seats_description")}
|
||||
checked={value}
|
||||
disabled={noShowFeeEnabled || multiLocation}
|
||||
tooltip={
|
||||
multiLocation
|
||||
? t("multilocation_doesnt_support_seats")
|
||||
: noShowFeeEnabled
|
||||
? t("no_show_fee_doesnt_support_seats")
|
||||
: undefined
|
||||
}
|
||||
onCheckedChange={(e) => {
|
||||
// Enabling seats will disable guests and requiring confirmation until fully supported
|
||||
if (e) {
|
||||
toggleGuests(false);
|
||||
formMethods.setValue("requiresConfirmation", false, { shouldDirty: true });
|
||||
setRequiresConfirmation(false);
|
||||
formMethods.setValue("metadata.multipleDuration", undefined, { shouldDirty: true });
|
||||
formMethods.setValue("seatsPerTimeSlot", eventType.seatsPerTimeSlot ?? 2, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("seatsPerTimeSlot", null);
|
||||
toggleGuests(true);
|
||||
}
|
||||
onChange(e);
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<Controller
|
||||
name="seatsPerTimeSlot"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div>
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
labelSrOnly
|
||||
label={t("number_of_seats")}
|
||||
type="number"
|
||||
disabled={seatsLocked.disabled}
|
||||
defaultValue={value}
|
||||
min={1}
|
||||
containerClassName="max-w-80"
|
||||
addOnSuffix={<>{t("seats")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
data-testid="seats-per-time-slot"
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<Controller
|
||||
name="seatsShowAttendees"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CheckboxField
|
||||
data-testid="show-attendees"
|
||||
description={t("show_attendees")}
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) => onChange(e)}
|
||||
checked={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Controller
|
||||
name="seatsShowAvailabilityCount"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CheckboxField
|
||||
description={t("show_available_seats_count")}
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) => onChange(e)}
|
||||
checked={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
{noShowFeeEnabled && <Alert severity="warning" title={t("seats_and_no_show_fee_error")} />}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="lockTimeZoneToggleOnBookingPage"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||
title={t("lock_timezone_toggle_on_booking_page")}
|
||||
{...lockTimeZoneToggleOnBookingPageLocked}
|
||||
description={t("description_lock_timezone_toggle_on_booking_page")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
data-testid="lock-timezone-toggle"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{allowDisablingAttendeeConfirmationEmails(workflows) && (
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.attendee"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||
title={t("disable_attendees_confirmation_emails")}
|
||||
description={t("disable_attendees_confirmation_emails_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{allowDisablingHostConfirmationEmails(workflows) && (
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.host"
|
||||
defaultValue={!!formMethods.getValues("seatsPerTimeSlot")}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||
title={t("disable_host_confirmation_emails")}
|
||||
description={t("disable_host_confirmation_emails_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showEventNameTip && (
|
||||
<CustomEventTypeModal
|
||||
close={closeEventNameTip}
|
||||
setValue={(val: string) => formMethods.setValue("eventName", val, { shouldDirty: true })}
|
||||
defaultValue={formMethods.getValues("eventName")}
|
||||
placeHolder={eventNamePlaceholder}
|
||||
isNameFieldSplit={isSplit}
|
||||
event={eventNameObject}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
190
calcom/apps/web/components/eventtype/EventAppsTab.tsx
Normal file
190
calcom/apps/web/components/eventtype/EventAppsTab.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface";
|
||||
import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
|
||||
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, EmptyScreen } from "@calcom/ui";
|
||||
|
||||
import useAppsData from "@lib/hooks/useAppsData";
|
||||
|
||||
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
|
||||
EventTypeAppCardComponentProps["eventType"];
|
||||
|
||||
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
const { t } = useLocale();
|
||||
const { data: eventTypeApps, isPending } = trpc.viewer.integrations.useQuery({
|
||||
extendsFeature: "EventType",
|
||||
teamId: eventType.team?.id || eventType.parent?.teamId,
|
||||
});
|
||||
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const installedApps =
|
||||
eventTypeApps?.items.filter((app) => app.userCredentialIds.length || app.teams.length) || [];
|
||||
const notInstalledApps =
|
||||
eventTypeApps?.items.filter((app) => !app.userCredentialIds.length && !app.teams.length) || [];
|
||||
|
||||
const { getAppDataGetter, getAppDataSetter, eventTypeFormMetadata } = useAppsData();
|
||||
|
||||
const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
|
||||
eventType,
|
||||
translate: t,
|
||||
formMethods,
|
||||
});
|
||||
const appsDisableProps = shouldLockDisableProps("apps", { simple: true });
|
||||
const lockedText = appsDisableProps.isLocked ? "locked" : "unlocked";
|
||||
|
||||
const appsWithTeamCredentials = eventTypeApps?.items.filter((app) => app.teams.length) || [];
|
||||
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
|
||||
const appCards = [];
|
||||
|
||||
if (app.userCredentialIds.length) {
|
||||
appCards.push(
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(
|
||||
app.slug as EventTypeAppsList,
|
||||
app.categories,
|
||||
app.userCredentialIds[0]
|
||||
)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
eventTypeFormMetadata={eventTypeFormMetadata}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
for (const team of app.teams) {
|
||||
if (team) {
|
||||
appCards.push(
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.categories, team.credentialId)}
|
||||
key={app.slug + team?.credentialId}
|
||||
app={{
|
||||
...app,
|
||||
// credentialIds: team?.credentialId ? [team.credentialId] : [],
|
||||
credentialOwner: {
|
||||
name: team.name,
|
||||
avatar: team.logoUrl,
|
||||
teamId: team.teamId,
|
||||
credentialId: team.credentialId,
|
||||
},
|
||||
}}
|
||||
eventType={eventType}
|
||||
eventTypeFormMetadata={eventTypeFormMetadata}
|
||||
disabled={shouldLockDisableProps("apps").disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return appCards;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="before:border-0">
|
||||
{(isManagedEventType || isChildrenManagedEventType) && (
|
||||
<Alert
|
||||
severity={appsDisableProps.isLocked ? "neutral" : "green"}
|
||||
className="mb-2"
|
||||
title={
|
||||
<Trans i18nKey={`${lockedText}_${isManagedEventType ? "for_members" : "by_team_admins"}`}>
|
||||
{lockedText[0].toUpperCase()}
|
||||
{lockedText.slice(1)} {isManagedEventType ? "for members" : "by team admins"}
|
||||
</Trans>
|
||||
}
|
||||
actions={<div className="flex h-full items-center">{appsDisableProps.LockedIcon}</div>}
|
||||
message={
|
||||
<Trans
|
||||
i18nKey={`apps_${lockedText}_${
|
||||
isManagedEventType ? "for_members" : "by_team_admins"
|
||||
}_description`}>
|
||||
{isManagedEventType ? "Members" : "You"}{" "}
|
||||
{appsDisableProps.isLocked
|
||||
? "will be able to see the active apps but will not be able to edit any app settings"
|
||||
: "will be able to see the active apps and will be able to edit any app settings"}
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !installedApps?.length ? (
|
||||
<EmptyScreen
|
||||
Icon="grid-3x3"
|
||||
headline={t("empty_installed_apps_headline")}
|
||||
description={t("empty_installed_apps_description")}
|
||||
buttonRaw={
|
||||
appsDisableProps.disabled ? (
|
||||
<Button StartIcon="lock" color="secondary" disabled>
|
||||
{t("locked_by_team_admin")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button target="_blank" color="secondary" href="/apps">
|
||||
{t("empty_installed_apps_button")}{" "}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
|
||||
{installedApps.map((app) => {
|
||||
if (!app.teams.length)
|
||||
return (
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(
|
||||
app.slug as EventTypeAppsList,
|
||||
app.categories,
|
||||
app.userCredentialIds[0]
|
||||
)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
eventTypeFormMetadata={eventTypeFormMetadata}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{!appsDisableProps.disabled && (
|
||||
<div className="bg-muted mt-6 rounded-md p-8">
|
||||
{!isPending && notInstalledApps?.length ? (
|
||||
<>
|
||||
<h2 className="text-emphasis mb-2 text-xl font-semibold leading-5 tracking-[0.01em]">
|
||||
{t("available_apps_lower_case")}
|
||||
</h2>
|
||||
<p className="text-default mb-6 text-sm font-normal">
|
||||
<Trans i18nKey="available_apps_desc">
|
||||
View popular apps below and explore more in our
|
||||
<Link className="cursor-pointer underline" href="/apps">
|
||||
App Store
|
||||
</Link>
|
||||
</Trans>
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
<div className="bg-default border-subtle divide-subtle divide-y rounded-md border before:border-0">
|
||||
{notInstalledApps?.map((app) => (
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.categories)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
eventTypeFormMetadata={eventTypeFormMetadata}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
320
calcom/apps/web/components/eventtype/EventAvailabilityTab.tsx
Normal file
320
calcom/apps/web/components/eventtype/EventAvailabilityTab.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import { useState, memo, useEffect } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { OptionProps, SingleValueProps } from "react-select";
|
||||
import { components } from "react-select";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { AvailabilityOption, FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import { weekStartNum } from "@calcom/lib/weekstart";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Badge, Button, Icon, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||
|
||||
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||
const { label, isDefault, isManaged = false } = props.data;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<span>{label}</span>
|
||||
{isDefault && (
|
||||
<Badge variant="blue" className="ml-2">
|
||||
{t("default")}
|
||||
</Badge>
|
||||
)}
|
||||
{isManaged && (
|
||||
<Badge variant="gray" className="ml-2">
|
||||
{t("managed")}
|
||||
</Badge>
|
||||
)}
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
||||
const { label, isDefault, isManaged = false } = props.data;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<components.SingleValue {...props}>
|
||||
<span>{label}</span>
|
||||
{isDefault && (
|
||||
<Badge variant="blue" className="ml-2">
|
||||
{t("default")}
|
||||
</Badge>
|
||||
)}
|
||||
{isManaged && (
|
||||
<Badge variant="gray" className="ml-2">
|
||||
{t("managed")}
|
||||
</Badge>
|
||||
)}
|
||||
</components.SingleValue>
|
||||
);
|
||||
};
|
||||
|
||||
const format = (date: Date, hour12: boolean) =>
|
||||
Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hourCycle: hour12 ? "h12" : "h24",
|
||||
}).format(new Date(dayjs.utc(date).format("YYYY-MM-DDTHH:mm:ss")));
|
||||
|
||||
const EventTypeScheduleDetails = memo(
|
||||
({
|
||||
isManagedEventType,
|
||||
selectedScheduleValue,
|
||||
}: {
|
||||
isManagedEventType: boolean;
|
||||
selectedScheduleValue: AvailabilityOption | undefined;
|
||||
}) => {
|
||||
const { data: loggedInUser } = useMeQuery();
|
||||
const timeFormat = loggedInUser?.timeFormat;
|
||||
const { t, i18n } = useLocale();
|
||||
const { watch } = useFormContext<FormValues>();
|
||||
|
||||
const scheduleId = watch("schedule");
|
||||
const { isPending, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{
|
||||
scheduleId:
|
||||
scheduleId || loggedInUser?.defaultScheduleId || selectedScheduleValue?.value || undefined,
|
||||
isManagedEventType,
|
||||
},
|
||||
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId || !!selectedScheduleValue }
|
||||
);
|
||||
|
||||
const weekStart = weekStartNum(loggedInUser?.weekStart);
|
||||
|
||||
const filterDays = (dayNum: number) =>
|
||||
schedule?.schedule.filter((item) => item.days.includes((dayNum + weekStart) % 7)) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-subtle space-y-4 border-x p-6">
|
||||
<ol className="table border-collapse text-sm">
|
||||
{weekdayNames(i18n.language, weekStart, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32 ",
|
||||
!isAvailable ? "text-subtle line-through" : "text-default"
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isPending ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div key={i} className="text-default flex items-center leading-4">
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ms-4">-</span>
|
||||
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
|
||||
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
|
||||
<Icon name="globe" className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
{!!schedule?.id && !schedule.isManaged && !schedule.readOnly && (
|
||||
<Button
|
||||
href={`/availability/${schedule.id}`}
|
||||
disabled={isPending}
|
||||
color="minimal"
|
||||
EndIcon="external-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("edit_availability")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
EventTypeScheduleDetails.displayName = "EventTypeScheduleDetails";
|
||||
|
||||
const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { shouldLockIndicator, shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } =
|
||||
useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||
const { watch, setValue, getValues } = formMethods;
|
||||
const watchSchedule = watch("schedule");
|
||||
const [options, setOptions] = useState<AvailabilityOption[]>([]);
|
||||
|
||||
const { data, isPending } = trpc.viewer.availability.list.useQuery(undefined);
|
||||
|
||||
useEffect(
|
||||
function refactorMeWithoutEffect() {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const schedules = data.schedules;
|
||||
|
||||
const options = schedules.map((schedule) => ({
|
||||
value: schedule.id,
|
||||
label: schedule.name,
|
||||
isDefault: schedule.isDefault,
|
||||
isManaged: false,
|
||||
}));
|
||||
|
||||
// We are showing a managed event for a team admin, so adding the option to let members choose their schedule
|
||||
if (isManagedEventType) {
|
||||
options.push({
|
||||
value: 0,
|
||||
label: t("members_default_schedule"),
|
||||
isDefault: false,
|
||||
isManaged: false,
|
||||
});
|
||||
}
|
||||
|
||||
// We are showing a managed event for a member and team owner selected their own schedule, so adding
|
||||
// the managed schedule option
|
||||
if (
|
||||
isChildrenManagedEventType &&
|
||||
watchSchedule &&
|
||||
!schedules.find((schedule) => schedule.id === watchSchedule)
|
||||
) {
|
||||
options.push({
|
||||
value: watchSchedule,
|
||||
label: eventType.scheduleName ?? t("default_schedule_name"),
|
||||
isDefault: false,
|
||||
isManaged: false,
|
||||
});
|
||||
}
|
||||
// We push the selected schedule from the event type if it's not part of the list response. This happens if the user is an admin but not the schedule owner.
|
||||
else if (eventType.schedule && !schedules.find((schedule) => schedule.id === eventType.schedule)) {
|
||||
options.push({
|
||||
value: eventType.schedule,
|
||||
label: eventType.scheduleName ?? t("default_schedule_name"),
|
||||
isDefault: false,
|
||||
isManaged: false,
|
||||
});
|
||||
}
|
||||
|
||||
setOptions(options);
|
||||
|
||||
const scheduleId = getValues("schedule");
|
||||
const value = options.find((option) =>
|
||||
scheduleId
|
||||
? option.value === scheduleId
|
||||
: isManagedEventType
|
||||
? option.value === 0
|
||||
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
|
||||
);
|
||||
|
||||
setValue("availability", value, { shouldDirty: true });
|
||||
},
|
||||
[data]
|
||||
);
|
||||
const availabilityValue = watch("availability");
|
||||
|
||||
useEffect(() => {
|
||||
if (!availabilityValue?.value) return;
|
||||
setValue("schedule", availabilityValue.value, { shouldDirty: true });
|
||||
}, [availabilityValue, setValue]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-subtle rounded-t-md border p-6">
|
||||
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
|
||||
{t("availability")}
|
||||
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator("availability")}
|
||||
</label>
|
||||
{isPending && <SelectSkeletonLoader />}
|
||||
{!isPending && (
|
||||
<Controller
|
||||
name="schedule"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Select
|
||||
placeholder={t("select")}
|
||||
options={options}
|
||||
isDisabled={shouldLockDisableProps("availability").disabled}
|
||||
isSearchable={false}
|
||||
onChange={(selected) => {
|
||||
field.onChange(selected?.value || null);
|
||||
if (selected?.value) setValue("availability", selected, { shouldDirty: true });
|
||||
}}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
value={availabilityValue}
|
||||
components={{ Option, SingleValue }}
|
||||
isMulti={false}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{availabilityValue?.value !== 0 ? (
|
||||
<EventTypeScheduleDetails
|
||||
selectedScheduleValue={availabilityValue}
|
||||
isManagedEventType={isManagedEventType || isChildrenManagedEventType}
|
||||
/>
|
||||
) : (
|
||||
isManagedEventType && (
|
||||
<p className="!mt-2 ml-1 text-sm text-gray-600">{t("members_default_schedule_description")}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
const { setValue } = useFormContext<FormValues>();
|
||||
return (
|
||||
<Controller
|
||||
name="metadata.config.useHostSchedulesForTeamEvent"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
checked={!value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(!checked);
|
||||
if (!checked) {
|
||||
setValue("schedule", null, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
title={t("choose_common_schedule_team_event")}
|
||||
description={t("choose_common_schedule_team_event_description")}>
|
||||
<EventTypeSchedule eventType={eventType} />
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventAvailabilityTab = ({
|
||||
eventType,
|
||||
isTeamEvent,
|
||||
}: {
|
||||
eventType: EventTypeSetup;
|
||||
isTeamEvent: boolean;
|
||||
}) => {
|
||||
return isTeamEvent && eventType.schedulingType !== SchedulingType.MANAGED ? (
|
||||
<UseCommonScheduleSettingsToggle eventType={eventType} />
|
||||
) : (
|
||||
<EventTypeSchedule eventType={eventType} />
|
||||
);
|
||||
};
|
||||
18
calcom/apps/web/components/eventtype/EventInstantTab.tsx
Normal file
18
calcom/apps/web/components/eventtype/EventInstantTab.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
|
||||
import InstantEventController from "./InstantEventController";
|
||||
|
||||
export const EventInstantTab = ({
|
||||
eventType,
|
||||
isTeamEvent,
|
||||
}: Pick<EventTypeSetupProps, "eventType"> & { isTeamEvent: boolean }) => {
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
|
||||
const requirePayment = paymentAppData.price > 0;
|
||||
|
||||
return (
|
||||
<InstantEventController paymentEnabled={requirePayment} eventType={eventType} isTeamEvent={isTeamEvent} />
|
||||
);
|
||||
};
|
||||
830
calcom/apps/web/components/eventtype/EventLimitsTab.tsx
Normal file
830
calcom/apps/web/components/eventtype/EventLimitsTab.tsx
Normal file
@@ -0,0 +1,830 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import type { Key } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import type { UseFormRegisterReturn, UseFormReturn } from "react-hook-form";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { SingleValue } from "react-select";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { getDefinedBufferTimes } from "@calcom/features/eventtypes/lib/getDefinedBufferTimes";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK } from "@calcom/lib/constants";
|
||||
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
||||
import findDurationType from "@calcom/lib/findDurationType";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
|
||||
import { PeriodType } from "@calcom/prisma/enums";
|
||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||
|
||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||
|
||||
type IPeriodType = (typeof PeriodType)[keyof typeof PeriodType];
|
||||
|
||||
/**
|
||||
* We technically have a ROLLING_WINDOW future limit option that isn't shown as a Radio Option. Because UX is better by providing it as a toggle with ROLLING Limit radio option.
|
||||
* Also, ROLLING_WINDOW reuses the same `periodDays` field and `periodCountCalendarDays` fields
|
||||
*
|
||||
* So we consider `periodType=ROLLING && rollingExcludeUnavailableDays=true` to be the ROLLING_WINDOW option
|
||||
* We can't set `periodType=ROLLING_WINDOW` directly because it is not a valid Radio Option in UI
|
||||
* So, here we can convert from periodType to uiValue any time.
|
||||
*/
|
||||
const getUiValueFromPeriodType = (periodType: PeriodType) => {
|
||||
if (periodType === PeriodType.ROLLING_WINDOW) {
|
||||
return {
|
||||
value: PeriodType.ROLLING,
|
||||
rollingExcludeUnavailableDays: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (periodType === PeriodType.ROLLING) {
|
||||
return {
|
||||
value: PeriodType.ROLLING,
|
||||
rollingExcludeUnavailableDays: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: periodType,
|
||||
rollingExcludeUnavailableDays: null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* It compliments `getUiValueFromPeriodType`
|
||||
*/
|
||||
const getPeriodTypeFromUiValue = (uiValue: { value: PeriodType; rollingExcludeUnavailableDays: boolean }) => {
|
||||
if (uiValue.value === PeriodType.ROLLING && uiValue.rollingExcludeUnavailableDays === true) {
|
||||
return PeriodType.ROLLING_WINDOW;
|
||||
}
|
||||
|
||||
return uiValue.value;
|
||||
};
|
||||
|
||||
function RangeLimitRadioItem({
|
||||
isDisabled,
|
||||
formMethods,
|
||||
radioValue,
|
||||
}: {
|
||||
radioValue: string;
|
||||
isDisabled: boolean;
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className={classNames("text-default mb-2 flex flex-wrap items-center text-sm")}>
|
||||
{!isDisabled && (
|
||||
<RadioGroup.Item
|
||||
id={radioValue}
|
||||
value={radioValue}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
)}
|
||||
<div>
|
||||
<span>{t("within_date_range")} </span>
|
||||
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
|
||||
<Controller
|
||||
name="periodDates"
|
||||
render={({ field: { onChange } }) => (
|
||||
<DateRangePicker
|
||||
dates={{
|
||||
startDate: formMethods.getValues("periodDates").startDate,
|
||||
endDate: formMethods.getValues("periodDates").endDate,
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
onDatesChange={({ startDate, endDate }) => {
|
||||
onChange({
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RollingLimitRadioItem({
|
||||
radioValue,
|
||||
isDisabled,
|
||||
formMethods,
|
||||
onChange,
|
||||
rollingExcludeUnavailableDays,
|
||||
}: {
|
||||
radioValue: IPeriodType;
|
||||
isDisabled: boolean;
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
onChange: (opt: { value: number } | null) => void;
|
||||
rollingExcludeUnavailableDays: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const options = [
|
||||
{ value: 0, label: t("business_days") },
|
||||
{ value: 1, label: t("calendar_days") },
|
||||
];
|
||||
const getSelectedOption = () =>
|
||||
options.find((opt) => opt.value === (formMethods.getValues("periodCountCalendarDays") === true ? 1 : 0));
|
||||
|
||||
const periodDaysWatch = formMethods.watch("periodDays");
|
||||
return (
|
||||
<div className={classNames("text-default mb-2 flex flex-wrap items-baseline text-sm")}>
|
||||
{!isDisabled && (
|
||||
<RadioGroup.Item
|
||||
id={radioValue}
|
||||
value={radioValue}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
type="number"
|
||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
placeholder="30"
|
||||
disabled={isDisabled}
|
||||
min={0}
|
||||
max={rollingExcludeUnavailableDays ? ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK : undefined}
|
||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||
/>
|
||||
<Select
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
isDisabled={isDisabled}
|
||||
onChange={onChange}
|
||||
name="periodCoundCalendarDays"
|
||||
value={getSelectedOption()}
|
||||
defaultValue={getSelectedOption()}
|
||||
/>
|
||||
<span className="me-2 ms-2"> {t("into_the_future")}</span>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<CheckboxField
|
||||
checked={!!rollingExcludeUnavailableDays}
|
||||
disabled={isDisabled}
|
||||
description={t("always_show_x_days", {
|
||||
x: periodDaysWatch,
|
||||
})}
|
||||
onChange={(e) => {
|
||||
const isChecked = e.target.checked;
|
||||
formMethods.setValue(
|
||||
"periodDays",
|
||||
Math.min(periodDaysWatch, ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK)
|
||||
);
|
||||
formMethods.setValue(
|
||||
"periodType",
|
||||
getPeriodTypeFromUiValue({
|
||||
value: PeriodType.ROLLING,
|
||||
rollingExcludeUnavailableDays: isChecked,
|
||||
}),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MinimumBookingNoticeInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<UseFormRegisterReturn<"minimumBookingNotice">, "ref">
|
||||
>(function MinimumBookingNoticeInput({ ...passThroughProps }, ref) {
|
||||
const { t } = useLocale();
|
||||
const { setValue, getValues } = useFormContext<FormValues>();
|
||||
const durationTypeOptions: {
|
||||
value: DurationType;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
label: t("minutes"),
|
||||
value: "minutes",
|
||||
},
|
||||
{
|
||||
label: t("hours"),
|
||||
value: "hours",
|
||||
},
|
||||
{
|
||||
label: t("days"),
|
||||
value: "days",
|
||||
},
|
||||
];
|
||||
|
||||
const [minimumBookingNoticeDisplayValues, setMinimumBookingNoticeDisplayValues] = useState<{
|
||||
type: DurationType;
|
||||
value: number;
|
||||
}>({
|
||||
type: findDurationType(getValues(passThroughProps.name)),
|
||||
value: convertToNewDurationType(
|
||||
"minutes",
|
||||
findDurationType(getValues(passThroughProps.name)),
|
||||
getValues(passThroughProps.name)
|
||||
),
|
||||
});
|
||||
// keep hidden field in sync with minimumBookingNoticeDisplayValues
|
||||
useEffect(() => {
|
||||
setValue(
|
||||
passThroughProps.name,
|
||||
convertToNewDurationType(
|
||||
minimumBookingNoticeDisplayValues.type,
|
||||
"minutes",
|
||||
minimumBookingNoticeDisplayValues.value
|
||||
),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}, [minimumBookingNoticeDisplayValues, setValue, passThroughProps.name]);
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-end">
|
||||
<div className="w-1/2 md:w-full">
|
||||
<InputField
|
||||
required
|
||||
disabled={passThroughProps.disabled}
|
||||
defaultValue={minimumBookingNoticeDisplayValues.value}
|
||||
onChange={(e) =>
|
||||
setMinimumBookingNoticeDisplayValues({
|
||||
...minimumBookingNoticeDisplayValues,
|
||||
value: parseInt(e.target.value || "0", 10),
|
||||
})
|
||||
}
|
||||
label={t("minimum_booking_notice")}
|
||||
type="number"
|
||||
placeholder="0"
|
||||
min={0}
|
||||
className="mb-0 h-9 rounded-[4px] ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
<input type="hidden" ref={ref} {...passThroughProps} />
|
||||
</div>
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={passThroughProps.disabled}
|
||||
className="mb-0 ml-2 h-9 w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||
defaultValue={durationTypeOptions.find(
|
||||
(option) => option.value === minimumBookingNoticeDisplayValues.type
|
||||
)}
|
||||
onChange={(input) => {
|
||||
if (input) {
|
||||
setMinimumBookingNoticeDisplayValues({
|
||||
...minimumBookingNoticeDisplayValues,
|
||||
type: input.value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
options={durationTypeOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager({
|
||||
eventType,
|
||||
translate: t,
|
||||
formMethods,
|
||||
});
|
||||
|
||||
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
|
||||
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
|
||||
const onlyFirstAvailableSlotLocked = shouldLockDisableProps("onlyShowFirstAvailableSlot");
|
||||
const periodTypeLocked = shouldLockDisableProps("periodType");
|
||||
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
|
||||
|
||||
const [offsetToggle, setOffsetToggle] = useState(formMethods.getValues("offsetStart") > 0);
|
||||
|
||||
// Preview how the offset will affect start times
|
||||
const watchOffsetStartValue = formMethods.watch("offsetStart");
|
||||
const offsetOriginalTime = new Date();
|
||||
offsetOriginalTime.setHours(9, 0, 0, 0);
|
||||
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + watchOffsetStartValue * 60 * 1000);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="beforeBufferTime">
|
||||
{t("before_event")}
|
||||
{shouldLockIndicator("beforeBufferTime")}
|
||||
</Label>
|
||||
<Controller
|
||||
name="beforeEventBuffer"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const beforeBufferOptions = [
|
||||
{
|
||||
label: t("event_buffer_default"),
|
||||
value: 0,
|
||||
},
|
||||
...getDefinedBufferTimes().map((minutes) => ({
|
||||
label: `${minutes} ${t("minutes")}`,
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
isDisabled={shouldLockDisableProps("beforeBufferTime").disabled}
|
||||
defaultValue={
|
||||
beforeBufferOptions.find((option) => option.value === value) || beforeBufferOptions[0]
|
||||
}
|
||||
options={beforeBufferOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="afterBufferTime">
|
||||
{t("after_event")}
|
||||
{shouldLockIndicator("afterBufferTime")}
|
||||
</Label>
|
||||
<Controller
|
||||
name="afterEventBuffer"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const afterBufferOptions = [
|
||||
{
|
||||
label: t("event_buffer_default"),
|
||||
value: 0,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||
label: `${minutes} ${t("minutes")}`,
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
isDisabled={shouldLockDisableProps("afterBufferTime").disabled}
|
||||
defaultValue={
|
||||
afterBufferOptions.find((option) => option.value === value) || afterBufferOptions[0]
|
||||
}
|
||||
options={afterBufferOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="minimumBookingNotice">
|
||||
{t("minimum_booking_notice")}
|
||||
{shouldLockIndicator("minimumBookingNotice")}
|
||||
</Label>
|
||||
<MinimumBookingNoticeInput
|
||||
disabled={shouldLockDisableProps("minimumBookingNotice").disabled}
|
||||
{...formMethods.register("minimumBookingNotice")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="slotInterval">
|
||||
{t("slot_interval")}
|
||||
{shouldLockIndicator("slotInterval")}
|
||||
</Label>
|
||||
<Controller
|
||||
name="slotInterval"
|
||||
render={() => {
|
||||
const slotIntervalOptions = [
|
||||
{
|
||||
label: t("slot_interval_default"),
|
||||
value: -1,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 75, 90, 105, 120].map((minutes) => ({
|
||||
label: `${minutes} ${t("minutes")}`,
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={shouldLockDisableProps("slotInterval").disabled}
|
||||
onChange={(val) => {
|
||||
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
defaultValue={
|
||||
slotIntervalOptions.find(
|
||||
(option) => option.value === formMethods.getValues("slotInterval")
|
||||
) || slotIntervalOptions[0]
|
||||
}
|
||||
options={slotIntervalOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name="bookingLimits"
|
||||
render={({ field: { value } }) => {
|
||||
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||
return (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
labelClassName="text-sm"
|
||||
title={t("limit_booking_frequency")}
|
||||
{...bookingLimitsLocked}
|
||||
description={t("limit_booking_frequency_description")}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue(
|
||||
"bookingLimits",
|
||||
{
|
||||
PER_DAY: 1,
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
} else {
|
||||
formMethods.setValue("bookingLimits", {}, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0">
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<IntervalLimitsManager
|
||||
disabled={bookingLimitsLocked.disabled}
|
||||
propertyName="bookingLimits"
|
||||
defaultLimit={1}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="onlyShowFirstAvailableSlot"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const isChecked = value;
|
||||
return (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
labelClassName="text-sm"
|
||||
title={t("only_show_first_available_slot")}
|
||||
description={t("only_show_first_available_slot_description")}
|
||||
checked={isChecked}
|
||||
{...onlyFirstAvailableSlotLocked}
|
||||
onCheckedChange={(active) => {
|
||||
onChange(active ?? false);
|
||||
}}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="durationLimits"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||
return (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_total_booking_duration")}
|
||||
description={t("limit_total_booking_duration_description")}
|
||||
{...durationLimitsLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
onChange({
|
||||
PER_DAY: 60,
|
||||
});
|
||||
} else {
|
||||
onChange({});
|
||||
}
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<IntervalLimitsManager
|
||||
propertyName="durationLimits"
|
||||
defaultLimit={60}
|
||||
disabled={durationLimitsLocked.disabled}
|
||||
step={15}
|
||||
textFieldSuffix={t("minutes")}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="periodType"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const isChecked = value && value !== "UNLIMITED";
|
||||
|
||||
const { value: watchPeriodTypeUiValue, rollingExcludeUnavailableDays } = getUiValueFromPeriodType(
|
||||
formMethods.watch("periodType")
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
{...periodTypeLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(isEnabled) => {
|
||||
if (isEnabled && !formMethods.getValues("periodDays")) {
|
||||
formMethods.setValue("periodDays", 30, { shouldDirty: true });
|
||||
}
|
||||
return onChange(isEnabled ? PeriodType.ROLLING : PeriodType.UNLIMITED);
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<RadioGroup.Root
|
||||
value={watchPeriodTypeUiValue}
|
||||
onValueChange={(val) => {
|
||||
formMethods.setValue(
|
||||
"periodType",
|
||||
getPeriodTypeFromUiValue({
|
||||
value: val as IPeriodType,
|
||||
rollingExcludeUnavailableDays: formMethods.getValues("rollingExcludeUnavailableDays"),
|
||||
}),
|
||||
{
|
||||
shouldDirty: true,
|
||||
}
|
||||
);
|
||||
}}>
|
||||
{(periodTypeLocked.disabled ? watchPeriodTypeUiValue === PeriodType.ROLLING : true) && (
|
||||
<RollingLimitRadioItem
|
||||
rollingExcludeUnavailableDays={!!rollingExcludeUnavailableDays}
|
||||
radioValue={PeriodType.ROLLING}
|
||||
isDisabled={periodTypeLocked.disabled}
|
||||
formMethods={formMethods}
|
||||
onChange={(opt) => {
|
||||
formMethods.setValue("periodCountCalendarDays", opt?.value === 1, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(periodTypeLocked.disabled ? watchPeriodTypeUiValue === PeriodType.RANGE : true) && (
|
||||
<RangeLimitRadioItem
|
||||
radioValue={PeriodType.RANGE}
|
||||
isDisabled={periodTypeLocked.disabled}
|
||||
formMethods={formMethods}
|
||||
/>
|
||||
)}
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
|
||||
offsetToggle && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("offset_toggle")}
|
||||
description={t("offset_toggle_description")}
|
||||
{...offsetStartLockedProps}
|
||||
checked={offsetToggle}
|
||||
onCheckedChange={(active) => {
|
||||
setOffsetToggle(active);
|
||||
if (!active) {
|
||||
formMethods.setValue("offsetStart", 0, { shouldDirty: true });
|
||||
}
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
containerClassName="max-w-80"
|
||||
label={t("offset_start")}
|
||||
{...formMethods.register("offsetStart", { setValueAs: (value) => Number(value) })}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
hint={t("offset_start_description", {
|
||||
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type IntervalLimitsKey = keyof IntervalLimit;
|
||||
|
||||
const INTERVAL_LIMIT_OPTIONS = ascendingLimitKeys.map((key) => ({
|
||||
value: key as keyof IntervalLimit,
|
||||
label: `Per ${intervalLimitKeyToUnit(key)}`,
|
||||
}));
|
||||
|
||||
type IntervalLimitItemProps = {
|
||||
key: Key;
|
||||
limitKey: IntervalLimitsKey;
|
||||
step: number;
|
||||
value: number;
|
||||
textFieldSuffix?: string;
|
||||
disabled?: boolean;
|
||||
selectOptions: { value: keyof IntervalLimit; label: string }[];
|
||||
hasDeleteButton?: boolean;
|
||||
onDelete: (intervalLimitsKey: IntervalLimitsKey) => void;
|
||||
onLimitChange: (intervalLimitsKey: IntervalLimitsKey, limit: number) => void;
|
||||
onIntervalSelect: (interval: SingleValue<{ value: keyof IntervalLimit; label: string }>) => void;
|
||||
};
|
||||
|
||||
const IntervalLimitItem = ({
|
||||
limitKey,
|
||||
step,
|
||||
value,
|
||||
textFieldSuffix,
|
||||
selectOptions,
|
||||
hasDeleteButton,
|
||||
disabled,
|
||||
onDelete,
|
||||
onLimitChange,
|
||||
onIntervalSelect,
|
||||
}: IntervalLimitItemProps) => {
|
||||
return (
|
||||
<div
|
||||
data-testid="add-limit"
|
||||
className="mb-4 flex max-h-9 items-center space-x-2 text-sm rtl:space-x-reverse"
|
||||
key={limitKey}>
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
|
||||
className="mb-0"
|
||||
placeholder={`${value}`}
|
||||
disabled={disabled}
|
||||
min={step}
|
||||
step={step}
|
||||
defaultValue={value}
|
||||
addOnSuffix={textFieldSuffix}
|
||||
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value || "0", 10))}
|
||||
/>
|
||||
<Select
|
||||
options={selectOptions}
|
||||
isSearchable={false}
|
||||
isDisabled={disabled}
|
||||
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
|
||||
onChange={onIntervalSelect}
|
||||
className="w-36"
|
||||
/>
|
||||
{hasDeleteButton && !disabled && (
|
||||
<Button
|
||||
variant="icon"
|
||||
StartIcon="trash-2"
|
||||
color="destructive"
|
||||
className="border-none"
|
||||
onClick={() => onDelete(limitKey)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type IntervalLimitsManagerProps<K extends "durationLimits" | "bookingLimits"> = {
|
||||
propertyName: K;
|
||||
defaultLimit: number;
|
||||
step: number;
|
||||
textFieldSuffix?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
|
||||
propertyName,
|
||||
defaultLimit,
|
||||
step,
|
||||
textFieldSuffix,
|
||||
disabled,
|
||||
}: IntervalLimitsManagerProps<K>) => {
|
||||
const { watch, setValue, control } = useFormContext<FormValues>();
|
||||
const watchIntervalLimits = watch(propertyName);
|
||||
const { t } = useLocale();
|
||||
|
||||
const [animateRef] = useAutoAnimate<HTMLUListElement>();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={propertyName}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const currentIntervalLimits = value;
|
||||
|
||||
const addLimit = () => {
|
||||
if (!currentIntervalLimits || !watchIntervalLimits) return;
|
||||
const currentKeys = Object.keys(watchIntervalLimits);
|
||||
|
||||
const [rest] = Object.values(INTERVAL_LIMIT_OPTIONS).filter(
|
||||
(option) => !currentKeys.includes(option.value)
|
||||
);
|
||||
if (!rest || !currentKeys.length) return;
|
||||
//currentDurationLimits is always defined so can be casted
|
||||
|
||||
setValue(
|
||||
propertyName,
|
||||
// @ts-expect-error FIXME Fix these typings
|
||||
{
|
||||
...watchIntervalLimits,
|
||||
[rest.value]: defaultLimit,
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ul ref={animateRef}>
|
||||
{currentIntervalLimits &&
|
||||
watchIntervalLimits &&
|
||||
Object.entries(currentIntervalLimits)
|
||||
.sort(([limitKeyA], [limitKeyB]) => {
|
||||
return (
|
||||
ascendingLimitKeys.indexOf(limitKeyA as IntervalLimitsKey) -
|
||||
ascendingLimitKeys.indexOf(limitKeyB as IntervalLimitsKey)
|
||||
);
|
||||
})
|
||||
.map(([key, value]) => {
|
||||
const limitKey = key as IntervalLimitsKey;
|
||||
return (
|
||||
<IntervalLimitItem
|
||||
key={key}
|
||||
limitKey={limitKey}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
textFieldSuffix={textFieldSuffix}
|
||||
hasDeleteButton={Object.keys(currentIntervalLimits).length > 1}
|
||||
selectOptions={INTERVAL_LIMIT_OPTIONS.filter(
|
||||
(option) => !Object.keys(currentIntervalLimits).includes(option.value)
|
||||
)}
|
||||
onLimitChange={(intervalLimitKey, val) =>
|
||||
// @ts-expect-error FIXME Fix these typings
|
||||
setValue(`${propertyName}.${intervalLimitKey}`, val, { shouldDirty: true })
|
||||
}
|
||||
onDelete={(intervalLimitKey) => {
|
||||
const current = currentIntervalLimits;
|
||||
delete current[intervalLimitKey];
|
||||
onChange(current);
|
||||
}}
|
||||
onIntervalSelect={(interval) => {
|
||||
const current = currentIntervalLimits;
|
||||
const currentValue = watchIntervalLimits[limitKey];
|
||||
|
||||
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
|
||||
delete current[limitKey];
|
||||
const newData = {
|
||||
...current,
|
||||
// Set limit to new selected value (in the example above this means we set the limit to per_week here).
|
||||
[interval?.value as IntervalLimitsKey]: currentValue,
|
||||
};
|
||||
onChange(newData);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{currentIntervalLimits && Object.keys(currentIntervalLimits).length <= 3 && !disabled && (
|
||||
<Button color="minimal" StartIcon="plus" onClick={addLimit}>
|
||||
{t("add_limit")}
|
||||
</Button>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
13
calcom/apps/web/components/eventtype/EventRecurringTab.tsx
Normal file
13
calcom/apps/web/components/eventtype/EventRecurringTab.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
|
||||
import RecurringEventController from "./RecurringEventController";
|
||||
|
||||
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
|
||||
const requirePayment = paymentAppData.price > 0;
|
||||
|
||||
return <RecurringEventController paymentEnabled={requirePayment} eventType={eventType} />;
|
||||
};
|
||||
268
calcom/apps/web/components/eventtype/EventSetupTab.tsx
Normal file
268
calcom/apps/web/components/eventtype/EventSetupTab.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
|
||||
import type { MultiValue } from "react-select";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { slugify } from "@calcom/lib/slugify";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import {
|
||||
Label,
|
||||
Select,
|
||||
SettingsToggle,
|
||||
Skeleton,
|
||||
TextField,
|
||||
Editor,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import Locations from "@components/eventtype/Locations";
|
||||
|
||||
const DescriptionEditor = ({ isEditable }: { isEditable: boolean }) => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [mounted, setIsMounted] = useState(false);
|
||||
const { t } = useLocale();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
return mounted ? (
|
||||
<Editor
|
||||
getText={() => md.render(formMethods.getValues("description") || "")}
|
||||
setText={(value: string) => formMethods.setValue("description", turndown(value), { shouldDirty: true })}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
placeholder={t("quick_video_meeting")}
|
||||
editable={isEditable}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
) : (
|
||||
<SkeletonContainer>
|
||||
<SkeletonText className="block h-24 w-full" />
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventSetupTab = (
|
||||
props: Pick<
|
||||
EventTypeSetupProps,
|
||||
"eventType" | "locationOptions" | "team" | "teamMembers" | "destinationCalendar"
|
||||
>
|
||||
) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { eventType, team } = props;
|
||||
const [multipleDuration, setMultipleDuration] = useState(
|
||||
formMethods.getValues("metadata")?.multipleDuration
|
||||
);
|
||||
const orgBranding = useOrgBranding();
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
|
||||
const multipleDurationOptions = [
|
||||
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
|
||||
].map((mins) => ({
|
||||
value: mins,
|
||||
label: t("multiple_duration_mins", { count: mins }),
|
||||
}));
|
||||
|
||||
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
|
||||
MultiValue<{
|
||||
value: number;
|
||||
label: string;
|
||||
}>
|
||||
>(multipleDurationOptions.filter((mdOpt) => multipleDuration?.includes(mdOpt.value)));
|
||||
const [defaultDuration, setDefaultDuration] = useState(
|
||||
selectedMultipleDuration.find((opt) => opt.value === formMethods.getValues("length")) ?? null
|
||||
);
|
||||
|
||||
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
|
||||
useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||
|
||||
const lengthLockedProps = shouldLockDisableProps("length");
|
||||
const descriptionLockedProps = shouldLockDisableProps("description");
|
||||
const urlLockedProps = shouldLockDisableProps("slug");
|
||||
const titleLockedProps = shouldLockDisableProps("title");
|
||||
const urlPrefix = orgBranding
|
||||
? orgBranding?.fullDomain.replace(/^(https?:|)\/\//, "")
|
||||
: `${WEBSITE_URL?.replace(/^(https?:|)\/\//, "")}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<TextField
|
||||
required
|
||||
label={t("title")}
|
||||
{...(isManagedEventType || isChildrenManagedEventType ? titleLockedProps : {})}
|
||||
defaultValue={eventType.title}
|
||||
data-testid="event-title"
|
||||
{...formMethods.register("title")}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="editor">
|
||||
{t("description")}
|
||||
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator("description")}
|
||||
</Label>
|
||||
<DescriptionEditor isEditable={!descriptionLockedProps.disabled} />
|
||||
</div>
|
||||
<TextField
|
||||
required
|
||||
label={t("URL")}
|
||||
{...(isManagedEventType || isChildrenManagedEventType ? urlLockedProps : {})}
|
||||
defaultValue={eventType.slug}
|
||||
data-testid="event-slug"
|
||||
addOnLeading={
|
||||
<>
|
||||
{urlPrefix}/
|
||||
{!isManagedEventType
|
||||
? team
|
||||
? (orgBranding ? "" : "team/") + team.slug
|
||||
: formMethods.getValues("users")[0].username
|
||||
: t("username_placeholder")}
|
||||
/
|
||||
</>
|
||||
}
|
||||
{...formMethods.register("slug", {
|
||||
setValueAs: (v) => slugify(v),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-subtle rounded-lg border p-6">
|
||||
{multipleDuration ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("available_durations")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
isMulti
|
||||
defaultValue={selectedMultipleDuration}
|
||||
name="metadata.multipleDuration"
|
||||
isSearchable={false}
|
||||
isDisabled={lengthLockedProps.disabled}
|
||||
className="h-auto !min-h-[36px] text-sm"
|
||||
options={multipleDurationOptions}
|
||||
value={selectedMultipleDuration}
|
||||
onChange={(options) => {
|
||||
let newOptions = [...options];
|
||||
newOptions = newOptions.sort((a, b) => {
|
||||
return a?.value - b?.value;
|
||||
});
|
||||
const values = newOptions.map((opt) => opt.value);
|
||||
setMultipleDuration(values);
|
||||
setSelectedMultipleDuration(newOptions);
|
||||
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
|
||||
if (newOptions.length > 0) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
formMethods.setValue("length", newOptions[0].value, { shouldDirty: true });
|
||||
} else {
|
||||
setDefaultDuration(null);
|
||||
}
|
||||
}
|
||||
if (newOptions.length === 1 && defaultDuration === null) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
formMethods.setValue("length", newOptions[0].value, { shouldDirty: true });
|
||||
}
|
||||
formMethods.setValue("metadata.multipleDuration", values, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("default_duration")}
|
||||
{shouldLockIndicator("length")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
value={defaultDuration}
|
||||
isSearchable={false}
|
||||
name="length"
|
||||
className="text-sm"
|
||||
isDisabled={lengthLockedProps.disabled}
|
||||
noOptionsMessage={() => t("default_duration_no_options")}
|
||||
options={selectedMultipleDuration}
|
||||
onChange={(option) => {
|
||||
setDefaultDuration(
|
||||
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
|
||||
);
|
||||
if (option) formMethods.setValue("length", option.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
data-testid="duration"
|
||||
{...(isManagedEventType || isChildrenManagedEventType ? lengthLockedProps : {})}
|
||||
label={t("duration")}
|
||||
defaultValue={formMethods.getValues("length") ?? 15}
|
||||
{...formMethods.register("length")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
min={1}
|
||||
/>
|
||||
)}
|
||||
{!lengthLockedProps.disabled && (
|
||||
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
||||
<SettingsToggle
|
||||
title={t("allow_booker_to_select_duration")}
|
||||
checked={multipleDuration !== undefined}
|
||||
disabled={seatsEnabled}
|
||||
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
|
||||
onCheckedChange={() => {
|
||||
if (multipleDuration !== undefined) {
|
||||
setMultipleDuration(undefined);
|
||||
setSelectedMultipleDuration([]);
|
||||
setDefaultDuration(null);
|
||||
formMethods.setValue("metadata.multipleDuration", undefined, { shouldDirty: true });
|
||||
formMethods.setValue("length", eventType.length, { shouldDirty: true });
|
||||
} else {
|
||||
setMultipleDuration([]);
|
||||
formMethods.setValue("metadata.multipleDuration", [], { shouldDirty: true });
|
||||
formMethods.setValue("length", 0, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-subtle rounded-lg border p-6">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16" htmlFor="locations">
|
||||
{t("location")}
|
||||
{/*improve shouldLockIndicator function to also accept eventType and then conditionally render
|
||||
based on Managed Event type or not.*/}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => (
|
||||
<Locations
|
||||
showAppStoreLink={true}
|
||||
isChildrenManagedEventType={isChildrenManagedEventType}
|
||||
isManagedEventType={isManagedEventType}
|
||||
disableLocationProp={shouldLockDisableProps("locations").disabled}
|
||||
getValues={formMethods.getValues as unknown as UseFormGetValues<LocationFormValues>}
|
||||
setValue={formMethods.setValue as unknown as UseFormSetValue<LocationFormValues>}
|
||||
control={formMethods.control as unknown as Control<LocationFormValues>}
|
||||
formState={formMethods.formState as unknown as FormState<LocationFormValues>}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
449
calcom/apps/web/components/eventtype/EventTeamTab.tsx
Normal file
449
calcom/apps/web/components/eventtype/EventTeamTab.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps, Host } from "pages/event-types/[type]";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ComponentProps, Dispatch, SetStateAction } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import type { Options } from "react-select";
|
||||
|
||||
import AddMembersWithSwitch, {
|
||||
mapUserToValue,
|
||||
} from "@calcom/features/eventtypes/components/AddMembersWithSwitch";
|
||||
import AssignAllTeamMembers from "@calcom/features/eventtypes/components/AssignAllTeamMembers";
|
||||
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import type { FormValues, TeamMember } from "@calcom/features/eventtypes/lib/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Label, Select, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
export const mapMemberToChildrenOption = (
|
||||
member: EventTypeSetupProps["teamMembers"][number],
|
||||
slug: string,
|
||||
pendingString: string
|
||||
) => {
|
||||
return {
|
||||
slug,
|
||||
hidden: false,
|
||||
created: false,
|
||||
owner: {
|
||||
id: member.id,
|
||||
name: member.name ?? "",
|
||||
email: member.email,
|
||||
username: member.username ?? "",
|
||||
membership: member.membership,
|
||||
eventTypeSlugs: member.eventTypes ?? [],
|
||||
avatar: member.avatar,
|
||||
profile: member.profile,
|
||||
},
|
||||
value: `${member.id ?? ""}`,
|
||||
label: `${member.name || member.email || ""}${!member.username ? ` (${pendingString})` : ""}`,
|
||||
};
|
||||
};
|
||||
|
||||
const ChildrenEventTypesList = ({
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
...rest
|
||||
}: {
|
||||
value: ReturnType<typeof mapMemberToChildrenOption>[];
|
||||
onChange?: (options: ReturnType<typeof mapMemberToChildrenOption>[]) => void;
|
||||
options?: Options<ReturnType<typeof mapMemberToChildrenOption>>;
|
||||
} & Omit<Partial<ComponentProps<typeof ChildrenEventTypeSelect>>, "onChange" | "value">) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="flex flex-col space-y-5">
|
||||
<div>
|
||||
<Label>{t("assign_to")}</Label>
|
||||
<ChildrenEventTypeSelect
|
||||
aria-label="assignment-dropdown"
|
||||
data-testid="assignment-dropdown"
|
||||
onChange={(options) => {
|
||||
onChange &&
|
||||
onChange(
|
||||
options.map((option) => ({
|
||||
...option,
|
||||
}))
|
||||
);
|
||||
}}
|
||||
value={value}
|
||||
options={options.filter((opt) => !value.find((val) => val.owner.id.toString() === opt.value))}
|
||||
controlShouldRenderValue={false}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FixedHostHelper = (
|
||||
<Trans i18nKey="fixed_host_helper">
|
||||
Add anyone who needs to attend the event.
|
||||
<Link
|
||||
className="underline underline-offset-2"
|
||||
target="_blank"
|
||||
href="https://cal.com/docs/enterprise-features/teams/round-robin-scheduling#fixed-hosts">
|
||||
Learn more
|
||||
</Link>
|
||||
</Trans>
|
||||
);
|
||||
|
||||
const FixedHosts = ({
|
||||
teamMembers,
|
||||
value,
|
||||
onChange,
|
||||
assignAllTeamMembers,
|
||||
setAssignAllTeamMembers,
|
||||
isRoundRobinEvent = false,
|
||||
}: {
|
||||
value: Host[];
|
||||
onChange: (hosts: Host[]) => void;
|
||||
teamMembers: TeamMember[];
|
||||
assignAllTeamMembers: boolean;
|
||||
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
|
||||
isRoundRobinEvent?: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { getValues, setValue } = useFormContext<FormValues>();
|
||||
|
||||
const hasActiveFixedHosts = isRoundRobinEvent && getValues("hosts").some((host) => host.isFixed);
|
||||
|
||||
const [isDisabled, setIsDisabled] = useState(hasActiveFixedHosts);
|
||||
|
||||
return (
|
||||
<div className="mt-5 rounded-lg">
|
||||
{!isRoundRobinEvent ? (
|
||||
<>
|
||||
<div className="border-subtle mt-5 rounded-t-md border p-6 pb-5">
|
||||
<Label className="mb-1 text-sm font-semibold">{t("fixed_hosts")}</Label>
|
||||
<p className="text-subtle max-w-full break-words text-sm leading-tight">{FixedHostHelper}</p>
|
||||
</div>
|
||||
<div className="border-subtle rounded-b-md border border-t-0">
|
||||
<AddMembersWithSwitch
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
automaticAddAllEnabled={!isRoundRobinEvent}
|
||||
isFixed={true}
|
||||
onActive={() =>
|
||||
setValue(
|
||||
"hosts",
|
||||
teamMembers.map((teamMember) => ({
|
||||
isFixed: true,
|
||||
userId: parseInt(teamMember.value, 10),
|
||||
priority: 2,
|
||||
})),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("fixed_hosts")}
|
||||
description={FixedHostHelper}
|
||||
checked={isDisabled}
|
||||
labelClassName="text-sm"
|
||||
descriptionClassName=" text-sm text-subtle"
|
||||
onCheckedChange={(checked) => {
|
||||
if (!checked) {
|
||||
const rrHosts = getValues("hosts")
|
||||
.filter((host) => !host.isFixed)
|
||||
.sort((a, b) => (b.priority ?? 2) - (a.priority ?? 2));
|
||||
setValue("hosts", rrHosts, { shouldDirty: true });
|
||||
}
|
||||
setIsDisabled(checked);
|
||||
}}
|
||||
childrenClassName="lg:ml-0">
|
||||
<div className="border-subtle flex flex-col gap-6 rounded-bl-md rounded-br-md border border-t-0">
|
||||
<AddMembersWithSwitch
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
automaticAddAllEnabled={!isRoundRobinEvent}
|
||||
isFixed={true}
|
||||
onActive={() =>
|
||||
setValue(
|
||||
"hosts",
|
||||
teamMembers.map((teamMember) => ({
|
||||
isFixed: true,
|
||||
userId: parseInt(teamMember.value, 10),
|
||||
priority: 2,
|
||||
})),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RoundRobinHosts = ({
|
||||
teamMembers,
|
||||
value,
|
||||
onChange,
|
||||
assignAllTeamMembers,
|
||||
setAssignAllTeamMembers,
|
||||
}: {
|
||||
value: Host[];
|
||||
onChange: (hosts: Host[]) => void;
|
||||
teamMembers: TeamMember[];
|
||||
assignAllTeamMembers: boolean;
|
||||
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { setValue } = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg ">
|
||||
<div className="border-subtle mt-5 rounded-t-md border p-6 pb-5">
|
||||
<Label className="mb-1 text-sm font-semibold">{t("round_robin_hosts")}</Label>
|
||||
<p className="text-subtle max-w-full break-words text-sm leading-tight">{t("round_robin_helper")}</p>
|
||||
</div>
|
||||
<div className="border-subtle rounded-b-md border border-t-0">
|
||||
<AddMembersWithSwitch
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
automaticAddAllEnabled={true}
|
||||
isFixed={false}
|
||||
onActive={() =>
|
||||
setValue(
|
||||
"hosts",
|
||||
teamMembers
|
||||
.map((teamMember) => ({
|
||||
isFixed: false,
|
||||
userId: parseInt(teamMember.value, 10),
|
||||
priority: 2,
|
||||
}))
|
||||
.sort((a, b) => b.priority - a.priority),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChildrenEventTypes = ({
|
||||
childrenEventTypeOptions,
|
||||
assignAllTeamMembers,
|
||||
setAssignAllTeamMembers,
|
||||
}: {
|
||||
childrenEventTypeOptions: ReturnType<typeof mapMemberToChildrenOption>[];
|
||||
assignAllTeamMembers: boolean;
|
||||
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const { setValue } = useFormContext<FormValues>();
|
||||
return (
|
||||
<div className="border-subtle mt-6 space-y-5 rounded-lg border px-4 py-6 sm:px-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AssignAllTeamMembers
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
onActive={() => setValue("children", childrenEventTypeOptions, { shouldDirty: true })}
|
||||
/>
|
||||
{!assignAllTeamMembers ? (
|
||||
<Controller<FormValues>
|
||||
name="children"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ChildrenEventTypesList value={value} options={childrenEventTypeOptions} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Hosts = ({
|
||||
teamMembers,
|
||||
assignAllTeamMembers,
|
||||
setAssignAllTeamMembers,
|
||||
}: {
|
||||
teamMembers: TeamMember[];
|
||||
assignAllTeamMembers: boolean;
|
||||
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { submitCount },
|
||||
} = useFormContext<FormValues>();
|
||||
const schedulingType = useWatch({
|
||||
control,
|
||||
name: "schedulingType",
|
||||
});
|
||||
const initialValue = useRef<{
|
||||
hosts: FormValues["hosts"];
|
||||
schedulingType: SchedulingType | null;
|
||||
submitCount: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Handles init & out of date initial value after submission.
|
||||
if (!initialValue.current || initialValue.current?.submitCount !== submitCount) {
|
||||
initialValue.current = { hosts: getValues("hosts"), schedulingType, submitCount };
|
||||
return;
|
||||
}
|
||||
setValue(
|
||||
"hosts",
|
||||
initialValue.current.schedulingType === schedulingType ? initialValue.current.hosts : [],
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}, [schedulingType, setValue, getValues, submitCount]);
|
||||
|
||||
return (
|
||||
<Controller<FormValues>
|
||||
name="hosts"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const schedulingTypeRender = {
|
||||
COLLECTIVE: (
|
||||
<FixedHosts
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
/>
|
||||
),
|
||||
ROUND_ROBIN: (
|
||||
<>
|
||||
<FixedHosts
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={(changeValue) => {
|
||||
onChange([...value.filter((host: Host) => !host.isFixed), ...changeValue]);
|
||||
}}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
isRoundRobinEvent={true}
|
||||
/>
|
||||
<RoundRobinHosts
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={(changeValue) => {
|
||||
onChange(
|
||||
[...value.filter((host: Host) => host.isFixed), ...changeValue].sort(
|
||||
(a, b) => b.priority - a.priority
|
||||
)
|
||||
);
|
||||
}}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
MANAGED: <></>,
|
||||
};
|
||||
return !!schedulingType ? schedulingTypeRender[schedulingType] : <></>;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventTeamTab = ({
|
||||
team,
|
||||
teamMembers,
|
||||
eventType,
|
||||
}: Pick<EventTypeSetupProps, "teamMembers" | "team" | "eventType">) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const schedulingTypeOptions: {
|
||||
value: SchedulingType;
|
||||
label: string;
|
||||
// description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "COLLECTIVE",
|
||||
label: t("collective"),
|
||||
// description: t("collective_description"),
|
||||
},
|
||||
{
|
||||
value: "ROUND_ROBIN",
|
||||
label: t("round_robin"),
|
||||
// description: t("round_robin_description"),
|
||||
},
|
||||
];
|
||||
const pendingMembers = (member: (typeof teamMembers)[number]) =>
|
||||
!!eventType.team?.parentId || !!member.username;
|
||||
const teamMembersOptions = teamMembers
|
||||
.filter(pendingMembers)
|
||||
.map((member) => mapUserToValue(member, t("pending")));
|
||||
const childrenEventTypeOptions = teamMembers.filter(pendingMembers).map((member) => {
|
||||
return mapMemberToChildrenOption(
|
||||
{ ...member, eventTypes: member.eventTypes.filter((et) => et !== eventType.slug) },
|
||||
eventType.slug,
|
||||
t("pending")
|
||||
);
|
||||
});
|
||||
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
|
||||
const { getValues, setValue } = useFormContext<FormValues>();
|
||||
const [assignAllTeamMembers, setAssignAllTeamMembers] = useState<boolean>(
|
||||
getValues("assignAllTeamMembers") ?? false
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{team && !isManagedEventType && (
|
||||
<>
|
||||
<div className="border-subtle flex flex-col rounded-md">
|
||||
<div className="border-subtle rounded-t-md border p-6 pb-5">
|
||||
<Label className="mb-1 text-sm font-semibold">{t("assignment")}</Label>
|
||||
<p className="text-subtle max-w-full break-words text-sm leading-tight">
|
||||
{t("assignment_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<Label>{t("scheduling_type")}</Label>
|
||||
<Controller<FormValues>
|
||||
name="schedulingType"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
options={schedulingTypeOptions}
|
||||
value={schedulingTypeOptions.find((opt) => opt.value === value)}
|
||||
className="w-full"
|
||||
onChange={(val) => {
|
||||
onChange(val?.value);
|
||||
setValue("assignAllTeamMembers", false, { shouldDirty: true });
|
||||
setAssignAllTeamMembers(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Hosts
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
teamMembers={teamMembersOptions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{team && isManagedEventType && (
|
||||
<ChildrenEventTypes
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
childrenEventTypeOptions={childrenEventTypeOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export type EventTypeDescriptionSafeProps = {
|
||||
eventType: { description: string | null; descriptionAsSafeHTML: string | null };
|
||||
};
|
||||
|
||||
export const EventTypeDescriptionSafeHTML = ({ eventType }: EventTypeDescriptionSafeProps) => {
|
||||
const props: JSX.IntrinsicElements["div"] = { suppressHydrationWarning: true };
|
||||
if (eventType.description)
|
||||
props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML || "" };
|
||||
return <div {...props} />;
|
||||
};
|
||||
|
||||
export default EventTypeDescriptionSafeHTML;
|
||||
520
calcom/apps/web/components/eventtype/EventTypeSingleLayout.tsx
Normal file
520
calcom/apps/web/components/eventtype/EventTypeSingleLayout.tsx
Normal file
@@ -0,0 +1,520 @@
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { Trans } from "next-i18next";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useMemo, useState, Suspense } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
||||
import type { FormValues, AvailabilityOption } from "@calcom/features/eventtypes/lib/types";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { trpc, TRPCClientError } from "@calcom/trpc/react";
|
||||
import type { DialogProps, VerticalTabItemProps } from "@calcom/ui";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
DropdownMenuSeparator,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
DropdownMenuTrigger,
|
||||
HorizontalTabs,
|
||||
Label,
|
||||
Icon,
|
||||
showToast,
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
VerticalDivider,
|
||||
VerticalTabs,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
eventType: EventTypeSetupProps["eventType"];
|
||||
currentUserMembership: EventTypeSetupProps["currentUserMembership"];
|
||||
team: EventTypeSetupProps["team"];
|
||||
disableBorder?: boolean;
|
||||
enabledAppsNumber: number;
|
||||
installedAppsNumber: number;
|
||||
enabledWorkflowsNumber: number;
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
isUpdateMutationLoading?: boolean;
|
||||
availability?: AvailabilityOption;
|
||||
isUserOrganizationAdmin: boolean;
|
||||
bookerUrl: string;
|
||||
activeWebhooksNumber: number;
|
||||
};
|
||||
|
||||
type getNavigationProps = {
|
||||
t: TFunction;
|
||||
length: number;
|
||||
id: number;
|
||||
multipleDuration?: EventTypeSetupProps["eventType"]["metadata"]["multipleDuration"];
|
||||
enabledAppsNumber: number;
|
||||
enabledWorkflowsNumber: number;
|
||||
installedAppsNumber: number;
|
||||
availability: AvailabilityOption | undefined;
|
||||
};
|
||||
|
||||
function getNavigation({
|
||||
length,
|
||||
id,
|
||||
multipleDuration,
|
||||
t,
|
||||
enabledAppsNumber,
|
||||
installedAppsNumber,
|
||||
enabledWorkflowsNumber,
|
||||
}: getNavigationProps) {
|
||||
const duration = multipleDuration?.map((duration) => ` ${duration}`) || length;
|
||||
|
||||
return [
|
||||
{
|
||||
name: "event_setup_tab_title",
|
||||
href: `/event-types/${id}?tabName=setup`,
|
||||
icon: "link",
|
||||
info: `${duration} ${t("minute_timeUnit")}`, // TODO: Get this from props
|
||||
},
|
||||
{
|
||||
name: "event_limit_tab_title",
|
||||
href: `/event-types/${id}?tabName=limits`,
|
||||
icon: "clock",
|
||||
info: `event_limit_tab_description`,
|
||||
},
|
||||
{
|
||||
name: "event_advanced_tab_title",
|
||||
href: `/event-types/${id}?tabName=advanced`,
|
||||
icon: "sliders-vertical",
|
||||
info: `event_advanced_tab_description`,
|
||||
},
|
||||
{
|
||||
name: "apps",
|
||||
href: `/event-types/${id}?tabName=apps`,
|
||||
icon: "grid-3x3",
|
||||
//TODO: Handle proper translation with count handling
|
||||
info: `${installedAppsNumber} apps, ${enabledAppsNumber} ${t("active")}`,
|
||||
},
|
||||
{
|
||||
name: "workflows",
|
||||
href: `/event-types/${id}?tabName=workflows`,
|
||||
icon: "zap",
|
||||
info: `${enabledWorkflowsNumber} ${t("active")}`,
|
||||
},
|
||||
] satisfies VerticalTabItemProps[];
|
||||
}
|
||||
|
||||
function DeleteDialog({
|
||||
isManagedEvent,
|
||||
eventTypeId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: { isManagedEvent: string; eventTypeId: number } & Pick<DialogProps, "open" | "onOpenChange">) {
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.eventTypes.invalidate();
|
||||
showToast(t("event_type_deleted_successfully"), "success");
|
||||
router.push("/event-types");
|
||||
onOpenChange?.(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
onOpenChange?.(false);
|
||||
} else if (err instanceof TRPCClientError) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<ConfirmationDialogContent
|
||||
isPending={deleteMutation.isPending}
|
||||
variety="danger"
|
||||
title={t(`delete${isManagedEvent}_event_type`)}
|
||||
confirmBtnText={t(`confirm_delete_event_type`)}
|
||||
loadingText={t(`confirm_delete_event_type`)}
|
||||
onConfirm={(e) => {
|
||||
e.preventDefault();
|
||||
deleteMutation.mutate({ id: eventTypeId });
|
||||
}}>
|
||||
<p className="mt-5">
|
||||
<Trans
|
||||
i18nKey={`delete${isManagedEvent}_event_type_description`}
|
||||
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
|
||||
<ul>
|
||||
<li>Members assigned to this event type will also have their event types deleted.</li>
|
||||
<li>Anyone who they've shared their link with will no longer be able to book using it.</li>
|
||||
</ul>
|
||||
</Trans>
|
||||
</p>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EventTypeSingleLayout({
|
||||
children,
|
||||
eventType,
|
||||
currentUserMembership,
|
||||
team,
|
||||
disableBorder,
|
||||
enabledAppsNumber,
|
||||
installedAppsNumber,
|
||||
enabledWorkflowsNumber,
|
||||
isUpdateMutationLoading,
|
||||
formMethods,
|
||||
availability,
|
||||
isUserOrganizationAdmin,
|
||||
bookerUrl,
|
||||
activeWebhooksNumber,
|
||||
}: Props) {
|
||||
const { t } = useLocale();
|
||||
const eventTypesLockedByOrg = eventType.team?.parent?.organizationSettings?.lockEventTypeCreationForUsers;
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const hasPermsToDelete =
|
||||
currentUserMembership?.role !== "MEMBER" ||
|
||||
!currentUserMembership ||
|
||||
formMethods.getValues("schedulingType") === SchedulingType.MANAGED ||
|
||||
isUserOrganizationAdmin;
|
||||
|
||||
const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
|
||||
eventType,
|
||||
translate: t,
|
||||
formMethods,
|
||||
});
|
||||
|
||||
const length = formMethods.watch("length");
|
||||
const multipleDuration = formMethods.watch("metadata")?.multipleDuration;
|
||||
|
||||
const watchSchedulingType = formMethods.watch("schedulingType");
|
||||
const watchChildrenCount = formMethods.watch("children").length;
|
||||
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
const requirePayment = paymentAppData.price > 0;
|
||||
|
||||
// Define tab navigation here
|
||||
const EventTypeTabs = useMemo(() => {
|
||||
const navigation: VerticalTabItemProps[] = getNavigation({
|
||||
t,
|
||||
length,
|
||||
multipleDuration,
|
||||
id: formMethods.getValues("id"),
|
||||
enabledAppsNumber,
|
||||
installedAppsNumber,
|
||||
enabledWorkflowsNumber,
|
||||
availability,
|
||||
});
|
||||
|
||||
if (!requirePayment) {
|
||||
navigation.splice(3, 0, {
|
||||
name: "recurring",
|
||||
href: `/event-types/${formMethods.getValues("id")}?tabName=recurring`,
|
||||
icon: "repeat",
|
||||
info: `recurring_event_tab_description`,
|
||||
});
|
||||
}
|
||||
navigation.splice(1, 0, {
|
||||
name: "availability",
|
||||
href: `/event-types/${formMethods.getValues("id")}?tabName=availability`,
|
||||
icon: "calendar",
|
||||
info:
|
||||
isManagedEventType || isChildrenManagedEventType
|
||||
? formMethods.getValues("schedule") === null
|
||||
? "members_default_schedule"
|
||||
: isChildrenManagedEventType
|
||||
? `${
|
||||
formMethods.getValues("scheduleName")
|
||||
? `${formMethods.getValues("scheduleName")} - ${t("managed")}`
|
||||
: `default_schedule_name`
|
||||
}`
|
||||
: formMethods.getValues("scheduleName") ?? `default_schedule_name`
|
||||
: formMethods.getValues("scheduleName") ?? `default_schedule_name`,
|
||||
});
|
||||
// If there is a team put this navigation item within the tabs
|
||||
if (team) {
|
||||
navigation.splice(2, 0, {
|
||||
name: "assignment",
|
||||
href: `/event-types/${formMethods.getValues("id")}?tabName=team`,
|
||||
icon: "users",
|
||||
info: `${t(watchSchedulingType?.toLowerCase() ?? "")}${
|
||||
isManagedEventType ? ` - ${t("number_member", { count: watchChildrenCount || 0 })}` : ""
|
||||
}`,
|
||||
});
|
||||
}
|
||||
const showWebhooks = !(isManagedEventType || isChildrenManagedEventType);
|
||||
if (showWebhooks) {
|
||||
if (team) {
|
||||
navigation.push({
|
||||
name: "instant_tab_title",
|
||||
href: `/event-types/${eventType.id}?tabName=instant`,
|
||||
icon: "phone-call",
|
||||
info: `instant_event_tab_description`,
|
||||
});
|
||||
}
|
||||
navigation.push({
|
||||
name: "webhooks",
|
||||
href: `/event-types/${formMethods.getValues("id")}?tabName=webhooks`,
|
||||
icon: "webhook",
|
||||
info: `${activeWebhooksNumber} ${t("active")}`,
|
||||
});
|
||||
}
|
||||
const hidden = true; // hidden while in alpha trial. you can access it with tabName=ai
|
||||
if (team && hidden) {
|
||||
navigation.push({
|
||||
name: "Cal.ai",
|
||||
href: `/event-types/${eventType.id}?tabName=ai`,
|
||||
icon: "sparkles",
|
||||
info: "cal_ai_event_tab_description", // todo `cal_ai_event_tab_description`,
|
||||
});
|
||||
}
|
||||
return navigation;
|
||||
}, [
|
||||
t,
|
||||
enabledAppsNumber,
|
||||
installedAppsNumber,
|
||||
enabledWorkflowsNumber,
|
||||
availability,
|
||||
isManagedEventType,
|
||||
isChildrenManagedEventType,
|
||||
team,
|
||||
length,
|
||||
requirePayment,
|
||||
multipleDuration,
|
||||
formMethods.getValues("id"),
|
||||
watchSchedulingType,
|
||||
watchChildrenCount,
|
||||
activeWebhooksNumber,
|
||||
]);
|
||||
|
||||
const permalink = `${bookerUrl}/${
|
||||
team ? `${!team.parentId ? "team/" : ""}${team.slug}` : formMethods.getValues("users")[0].username
|
||||
}/${eventType.slug}`;
|
||||
|
||||
const embedLink = `${
|
||||
team ? `team/${team.slug}` : formMethods.getValues("users")[0].username
|
||||
}/${formMethods.getValues("slug")}`;
|
||||
const isManagedEvent = formMethods.getValues("schedulingType") === SchedulingType.MANAGED ? "_managed" : "";
|
||||
// const title = formMethods.watch("title");
|
||||
return (
|
||||
<Shell
|
||||
backPath="/event-types"
|
||||
title={`${eventType.title} | ${t("event_type")}`}
|
||||
heading={eventType.title}
|
||||
CTA={
|
||||
<div className="flex items-center justify-end">
|
||||
{!formMethods.getValues("metadata")?.managedEventConfig && (
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
"sm:hover:bg-muted hidden cursor-pointer items-center rounded-md",
|
||||
formMethods.watch("hidden") ? "pl-2" : "",
|
||||
"lg:flex"
|
||||
)}>
|
||||
{formMethods.watch("hidden") && (
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
className="mt-2 hidden cursor-pointer self-center whitespace-nowrap pr-2 sm:inline">
|
||||
{t("hidden")}
|
||||
</Skeleton>
|
||||
)}
|
||||
<Tooltip
|
||||
sideOffset={4}
|
||||
content={
|
||||
formMethods.watch("hidden") ? t("show_eventtype_on_profile") : t("hide_from_profile")
|
||||
}
|
||||
side="bottom">
|
||||
<div className="self-center rounded-md p-2">
|
||||
<Switch
|
||||
id="hiddenSwitch"
|
||||
disabled={eventTypesLockedByOrg}
|
||||
checked={!formMethods.watch("hidden")}
|
||||
onCheckedChange={(e) => {
|
||||
formMethods.setValue("hidden", !e, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<VerticalDivider className="hidden lg:block" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TODO: Figure out why combined isnt working - works in storybook */}
|
||||
<ButtonGroup combined containerProps={{ className: "border-default hidden lg:flex" }}>
|
||||
{!isManagedEventType && (
|
||||
<>
|
||||
{/* We have to warp this in tooltip as it has a href which disabels the tooltip on buttons */}
|
||||
<Tooltip content={t("preview")} side="bottom" sideOffset={4}>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="preview-button"
|
||||
target="_blank"
|
||||
variant="icon"
|
||||
href={permalink}
|
||||
rel="noreferrer"
|
||||
StartIcon="external-link"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="link"
|
||||
tooltip={t("copy_link")}
|
||||
tooltipSide="bottom"
|
||||
tooltipOffset={4}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Link copied!", "success");
|
||||
}}
|
||||
/>
|
||||
<EventTypeEmbedButton
|
||||
embedUrl={encodeURIComponent(embedLink)}
|
||||
StartIcon="code"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
namespace={eventType.slug}
|
||||
tooltip={t("embed")}
|
||||
tooltipSide="bottom"
|
||||
tooltipOffset={4}
|
||||
eventId={formMethods.getValues("id")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isChildrenManagedEventType && (
|
||||
<Button
|
||||
color="destructive"
|
||||
variant="icon"
|
||||
StartIcon="trash"
|
||||
tooltip={t("delete")}
|
||||
tooltipSide="bottom"
|
||||
tooltipOffset={4}
|
||||
disabled={!hasPermsToDelete}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
|
||||
<VerticalDivider className="hidden lg:block" />
|
||||
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="lg:hidden" StartIcon="ellipsis" variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent style={{ minWidth: "200px" }}>
|
||||
<DropdownMenuItem className="focus:ring-muted">
|
||||
<DropdownItem
|
||||
target="_blank"
|
||||
type="button"
|
||||
StartIcon="external-link"
|
||||
href={permalink}
|
||||
rel="noreferrer">
|
||||
{t("preview")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:ring-muted">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Link copied!", "success");
|
||||
}}>
|
||||
{t("copy_link")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:ring-muted">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
StartIcon="trash"
|
||||
disabled={!hasPermsToDelete}
|
||||
onClick={() => setDeleteDialogOpen(true)}>
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
className="mt-2 inline cursor-pointer self-center pr-2 ">
|
||||
{formMethods.watch("hidden") ? t("show_eventtype_on_profile") : t("hide_from_profile")}
|
||||
</Skeleton>
|
||||
<Switch
|
||||
id="hiddenSwitch"
|
||||
checked={!formMethods.watch("hidden")}
|
||||
onCheckedChange={(e) => {
|
||||
formMethods.setValue("hidden", !e, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
<div className="border-default border-l-2" />
|
||||
<Button
|
||||
className="ml-4 lg:ml-0"
|
||||
type="submit"
|
||||
loading={isUpdateMutationLoading}
|
||||
disabled={!formMethods.formState.isDirty}
|
||||
data-testid="update-eventtype"
|
||||
form="event-type-form">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<Suspense fallback={<Icon name="loader" />}>
|
||||
<div className="flex flex-col xl:flex-row xl:space-x-6">
|
||||
<div className="hidden xl:block">
|
||||
<VerticalTabs
|
||||
className="primary-navigation"
|
||||
tabs={EventTypeTabs}
|
||||
sticky
|
||||
linkShallow
|
||||
itemClassname="items-start"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 md:mx-0 md:p-0 xl:hidden">
|
||||
<HorizontalTabs tabs={EventTypeTabs} linkShallow />
|
||||
</div>
|
||||
<div className="w-full ltr:mr-2 rtl:ml-2">
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default border-subtle mt-4 rounded-md sm:mx-0 xl:mt-0",
|
||||
disableBorder ? "border-0 " : "p-2 md:border md:p-6"
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
<DeleteDialog
|
||||
eventTypeId={eventType.id}
|
||||
isManagedEvent={isManagedEvent}
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
/>
|
||||
|
||||
<EventTypeEmbedDialog />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export { EventTypeSingleLayout };
|
||||
250
calcom/apps/web/components/eventtype/EventWebhooksTab.tsx
Normal file
250
calcom/apps/web/components/eventtype/EventWebhooksTab.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { Webhook } from "@prisma/client";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { WebhookForm } from "@calcom/features/webhooks/components";
|
||||
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
||||
|
||||
export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const { data: webhooks } = trpc.viewer.webhook.list.useQuery({ eventTypeId: eventType.id });
|
||||
|
||||
const { data: installedApps, isLoading } = trpc.viewer.integrations.useQuery({
|
||||
variant: "other",
|
||||
onlyInstalled: true,
|
||||
});
|
||||
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [webhookToEdit, setWebhookToEdit] = useState<Webhook>();
|
||||
|
||||
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
|
||||
async onSuccess() {
|
||||
setEditModalOpen(false);
|
||||
showToast(t("webhook_updated_successfully"), "success");
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
},
|
||||
onError(error) {
|
||||
showToast(`${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const createWebhookMutation = trpc.viewer.webhook.create.useMutation({
|
||||
async onSuccess() {
|
||||
setCreateModalOpen(false);
|
||||
showToast(t("webhook_created_successfully"), "success");
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
},
|
||||
onError(error) {
|
||||
showToast(`${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const onCreateWebhook = async (values: WebhookFormSubmitData) => {
|
||||
if (
|
||||
subscriberUrlReserved({
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
id: values.id,
|
||||
webhooks,
|
||||
eventTypeId: eventType.id,
|
||||
})
|
||||
) {
|
||||
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!values.payloadTemplate) {
|
||||
values.payloadTemplate = null;
|
||||
}
|
||||
|
||||
createWebhookMutation.mutate({
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
eventTriggers: values.eventTriggers,
|
||||
active: values.active,
|
||||
payloadTemplate: values.payloadTemplate,
|
||||
secret: values.secret,
|
||||
eventTypeId: eventType.id,
|
||||
});
|
||||
};
|
||||
|
||||
const NewWebhookButton = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="new_webhook"
|
||||
StartIcon="plus"
|
||||
onClick={() => setCreateModalOpen(true)}>
|
||||
{t("new_webhook")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const { shouldLockDisableProps, isChildrenManagedEventType, isManagedEventType } = useLockedFieldsManager({
|
||||
eventType,
|
||||
translate: t,
|
||||
formMethods,
|
||||
});
|
||||
const webhookLockedStatus = shouldLockDisableProps("webhooks");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{webhooks && !isLoading && (
|
||||
<>
|
||||
<div>
|
||||
<div>
|
||||
<>
|
||||
{isManagedEventType && (
|
||||
<Alert
|
||||
severity="neutral"
|
||||
className="mb-2"
|
||||
title={t("locked_for_members")}
|
||||
message={t("locked_webhooks_description")}
|
||||
/>
|
||||
)}
|
||||
{webhooks.length ? (
|
||||
<>
|
||||
<div className="border-subtle mb-2 rounded-md border p-8">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
|
||||
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||
{t("add_webhook_description", { appName: APP_NAME })}
|
||||
</p>
|
||||
</div>
|
||||
{isChildrenManagedEventType && !isManagedEventType ? (
|
||||
<Button StartIcon="lock" color="secondary" disabled>
|
||||
{t("locked_by_team_admin")}
|
||||
</Button>
|
||||
) : (
|
||||
<NewWebhookButton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-subtle my-8 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
return (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
lastItem={webhooks.length === index + 1}
|
||||
canEditWebhook={!webhookLockedStatus.disabled}
|
||||
onEditWebhook={() => {
|
||||
setEditModalOpen(true);
|
||||
setWebhookToEdit(webhook);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-default text-sm font-normal">
|
||||
<Trans i18nKey="edit_or_manage_webhooks">
|
||||
If you wish to edit or manage your web hooks, please head over to
|
||||
<Link
|
||||
className="cursor-pointer font-semibold underline"
|
||||
href="/settings/developer/webhooks">
|
||||
webhooks settings
|
||||
</Link>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon="webhook"
|
||||
headline={t("create_your_first_webhook")}
|
||||
description={t("first_event_type_webhook_description")}
|
||||
buttonRaw={
|
||||
isChildrenManagedEventType && !isManagedEventType ? (
|
||||
<Button StartIcon="lock" color="secondary" disabled>
|
||||
{t("locked_by_team_admin")}
|
||||
</Button>
|
||||
) : (
|
||||
<NewWebhookButton />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New webhook dialog */}
|
||||
<Dialog open={createModalOpen} onOpenChange={(isOpen) => !isOpen && setCreateModalOpen(false)}>
|
||||
<DialogContent
|
||||
enableOverflow
|
||||
title={t("create_webhook")}
|
||||
description={t("create_webhook_team_event_type")}>
|
||||
<WebhookForm
|
||||
noRoutingFormTriggers={true}
|
||||
onSubmit={onCreateWebhook}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
apps={installedApps?.items.map((app) => app.slug)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Edit webhook dialog */}
|
||||
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
|
||||
<DialogContent enableOverflow title={t("edit_webhook")}>
|
||||
<WebhookForm
|
||||
noRoutingFormTriggers={true}
|
||||
webhook={webhookToEdit}
|
||||
apps={installedApps?.items.map((app) => app.slug)}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
onSubmit={(values: WebhookFormSubmitData) => {
|
||||
if (
|
||||
subscriberUrlReserved({
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
id: webhookToEdit?.id,
|
||||
webhooks,
|
||||
eventTypeId: eventType.id,
|
||||
})
|
||||
) {
|
||||
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.changeSecret) {
|
||||
values.secret = values.newSecret.length ? values.newSecret : null;
|
||||
}
|
||||
|
||||
if (!values.payloadTemplate) {
|
||||
values.payloadTemplate = null;
|
||||
}
|
||||
|
||||
editWebhookMutation.mutate({
|
||||
id: webhookToEdit?.id || "",
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
eventTriggers: values.eventTriggers,
|
||||
active: values.active,
|
||||
payloadTemplate: values.payloadTemplate,
|
||||
secret: values.secret,
|
||||
eventTypeId: webhookToEdit?.eventTypeId || undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/ee/workflows/components/EventWorkflowsTab";
|
||||
331
calcom/apps/web/components/eventtype/InstantEventController.tsx
Normal file
331
calcom/apps/web/components/eventtype/InstantEventController.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import type { Webhook } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { useFormContext, Controller } from "react-hook-form";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { WebhookForm } from "@calcom/features/webhooks/components";
|
||||
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
EmptyScreen,
|
||||
SettingsToggle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
showToast,
|
||||
TextField,
|
||||
Label,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type InstantEventControllerProps = {
|
||||
eventType: EventTypeSetup;
|
||||
paymentEnabled: boolean;
|
||||
isTeamEvent: boolean;
|
||||
};
|
||||
|
||||
export default function InstantEventController({
|
||||
eventType,
|
||||
paymentEnabled,
|
||||
isTeamEvent,
|
||||
}: InstantEventControllerProps) {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const [instantEventState, setInstantEventState] = useState<boolean>(eventType?.isInstantEvent ?? false);
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||
|
||||
const instantLocked = shouldLockDisableProps("isInstantEvent");
|
||||
|
||||
const isOrg = !!session.data?.user?.org?.id;
|
||||
|
||||
if (session.status === "loading") return <></>;
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<div className="block items-start sm:flex">
|
||||
{!isOrg || !isTeamEvent ? (
|
||||
<EmptyScreen
|
||||
headline={t("instant_tab_title")}
|
||||
Icon="phone-call"
|
||||
description={t("uprade_to_create_instant_bookings")}
|
||||
buttonRaw={<Button href="/enterprise">{t("upgrade")}</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className={!paymentEnabled ? "w-full" : ""}>
|
||||
{paymentEnabled ? (
|
||||
<Alert severity="warning" title={t("warning_payment_instant_meeting_event")} />
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
className="mb-4"
|
||||
severity="warning"
|
||||
title={t("warning_instant_meeting_experimental")}
|
||||
/>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||
instantEventState && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("instant_tab_title")}
|
||||
{...instantLocked}
|
||||
description={t("instant_event_tab_description")}
|
||||
checked={instantEventState}
|
||||
data-testid="instant-event-check"
|
||||
onCheckedChange={(e) => {
|
||||
if (!e) {
|
||||
formMethods.setValue("isInstantEvent", false, { shouldDirty: true });
|
||||
setInstantEventState(false);
|
||||
} else {
|
||||
formMethods.setValue("isInstantEvent", true, { shouldDirty: true });
|
||||
setInstantEventState(true);
|
||||
}
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
{instantEventState && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Controller
|
||||
name="instantMeetingExpiryTimeOffsetInSeconds"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Label>{t("set_instant_meeting_expiry_time_offset_description")}</Label>
|
||||
<TextField
|
||||
required
|
||||
name="instantMeetingExpiryTimeOffsetInSeconds"
|
||||
labelSrOnly
|
||||
type="number"
|
||||
defaultValue={value}
|
||||
min={10}
|
||||
containerClassName="max-w-80"
|
||||
addOnSuffix={<>{t("seconds")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
data-testid="instant-meeting-expiry-time-offset"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<InstantMeetingWebhooks eventType={eventType} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LicenseRequired>
|
||||
);
|
||||
}
|
||||
|
||||
const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const { data: webhooks } = trpc.viewer.webhook.list.useQuery({
|
||||
eventTypeId: eventType.id,
|
||||
eventTriggers: [WebhookTriggerEvents.INSTANT_MEETING],
|
||||
});
|
||||
const { data: installedApps, isPending } = trpc.viewer.integrations.useQuery({
|
||||
variant: "other",
|
||||
onlyInstalled: true,
|
||||
});
|
||||
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [webhookToEdit, setWebhookToEdit] = useState<Webhook>();
|
||||
|
||||
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
|
||||
async onSuccess() {
|
||||
setEditModalOpen(false);
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
showToast(t("webhook_updated_successfully"), "success");
|
||||
},
|
||||
onError(error) {
|
||||
showToast(`${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const createWebhookMutation = trpc.viewer.webhook.create.useMutation({
|
||||
async onSuccess() {
|
||||
showToast(t("webhook_created_successfully"), "success");
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
setCreateModalOpen(false);
|
||||
},
|
||||
onError(error) {
|
||||
showToast(`${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const onCreateWebhook = async (values: WebhookFormSubmitData) => {
|
||||
if (
|
||||
subscriberUrlReserved({
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
id: values.id,
|
||||
webhooks,
|
||||
eventTypeId: eventType.id,
|
||||
})
|
||||
) {
|
||||
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!values.payloadTemplate) {
|
||||
values.payloadTemplate = null;
|
||||
}
|
||||
|
||||
createWebhookMutation.mutate({
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
eventTriggers: values.eventTriggers,
|
||||
active: values.active,
|
||||
payloadTemplate: values.payloadTemplate,
|
||||
secret: values.secret,
|
||||
eventTypeId: eventType.id,
|
||||
});
|
||||
};
|
||||
|
||||
const NewWebhookButton = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="new_webhook"
|
||||
StartIcon="plus"
|
||||
onClick={() => setCreateModalOpen(true)}>
|
||||
{t("new_webhook")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const { shouldLockDisableProps, isChildrenManagedEventType, isManagedEventType } = useLockedFieldsManager({
|
||||
eventType,
|
||||
formMethods,
|
||||
translate: t,
|
||||
});
|
||||
const webhookLockedStatus = shouldLockDisableProps("webhooks");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{webhooks && !isPending && (
|
||||
<>
|
||||
<div>
|
||||
{webhooks.length ? (
|
||||
<>
|
||||
<div className="border-subtle my-2 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
return (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
lastItem={webhooks.length === index + 1}
|
||||
canEditWebhook={!webhookLockedStatus.disabled}
|
||||
onEditWebhook={() => {
|
||||
setEditModalOpen(true);
|
||||
setWebhookToEdit(webhook);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-default text-sm font-normal">
|
||||
{t("warning_payment_instant_meeting_event")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EmptyScreen
|
||||
Icon="webhook"
|
||||
headline={t("create_your_first_webhook")}
|
||||
description={t("create_instant_meeting_webhook_description")}
|
||||
buttonRaw={
|
||||
isChildrenManagedEventType && !isManagedEventType ? (
|
||||
<Button StartIcon="lock" color="secondary" disabled>
|
||||
{t("locked_by_admin")}
|
||||
</Button>
|
||||
) : (
|
||||
<NewWebhookButton />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New webhook dialog */}
|
||||
<Dialog open={createModalOpen} onOpenChange={(isOpen) => !isOpen && setCreateModalOpen(false)}>
|
||||
<DialogContent
|
||||
enableOverflow
|
||||
title={t("create_webhook")}
|
||||
description={t("create_webhook_team_event_type")}>
|
||||
<WebhookForm
|
||||
noRoutingFormTriggers={true}
|
||||
onSubmit={onCreateWebhook}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
apps={installedApps?.items.map((app) => app.slug)}
|
||||
selectOnlyInstantMeetingOption={true}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Edit webhook dialog */}
|
||||
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
|
||||
<DialogContent enableOverflow title={t("edit_webhook")}>
|
||||
<WebhookForm
|
||||
noRoutingFormTriggers={true}
|
||||
webhook={webhookToEdit}
|
||||
apps={installedApps?.items.map((app) => app.slug)}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
onSubmit={(values: WebhookFormSubmitData) => {
|
||||
if (
|
||||
subscriberUrlReserved({
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
id: webhookToEdit?.id,
|
||||
webhooks,
|
||||
eventTypeId: eventType.id,
|
||||
})
|
||||
) {
|
||||
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.changeSecret) {
|
||||
values.secret = values.newSecret.length ? values.newSecret : null;
|
||||
}
|
||||
|
||||
if (!values.payloadTemplate) {
|
||||
values.payloadTemplate = null;
|
||||
}
|
||||
|
||||
editWebhookMutation.mutate({
|
||||
id: webhookToEdit?.id || "",
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
eventTriggers: values.eventTriggers,
|
||||
active: values.active,
|
||||
payloadTemplate: values.payloadTemplate,
|
||||
secret: values.secret,
|
||||
eventTypeId: webhookToEdit?.eventTypeId || undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
447
calcom/apps/web/components/eventtype/Locations.tsx
Normal file
447
calcom/apps/web/components/eventtype/Locations.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { ErrorMessage } from "@hookform/error-message";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFieldArray } from "react-hook-form";
|
||||
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
|
||||
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon, Input, PhoneInput, Button, showToast } from "@calcom/ui";
|
||||
|
||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
|
||||
import LocationSelect from "@components/ui/form/LocationSelect";
|
||||
|
||||
export type TEventTypeLocation = Pick<EventTypeSetupProps["eventType"], "locations">;
|
||||
export type TLocationOptions = Pick<EventTypeSetupProps, "locationOptions">["locationOptions"];
|
||||
export type TDestinationCalendar = { integration: string } | null;
|
||||
export type TPrefillLocation = { credentialId?: number; type: string };
|
||||
|
||||
type LocationsProps = {
|
||||
team: { id: number } | null;
|
||||
destinationCalendar: TDestinationCalendar;
|
||||
showAppStoreLink: boolean;
|
||||
isChildrenManagedEventType?: boolean;
|
||||
isManagedEventType?: boolean;
|
||||
disableLocationProp?: boolean;
|
||||
getValues: UseFormGetValues<LocationFormValues>;
|
||||
setValue: UseFormSetValue<LocationFormValues>;
|
||||
control: Control<LocationFormValues>;
|
||||
formState: FormState<LocationFormValues>;
|
||||
eventType: TEventTypeLocation;
|
||||
locationOptions: TLocationOptions;
|
||||
prefillLocation?: SingleValueLocationOption;
|
||||
};
|
||||
|
||||
const getLocationFromType = (type: EventLocationType["type"], locationOptions: TLocationOptions) => {
|
||||
for (const locationOption of locationOptions) {
|
||||
const option = locationOption.options.find((option) => option.value === type);
|
||||
if (option) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getLocationInfo = ({
|
||||
eventType,
|
||||
locationOptions,
|
||||
}: {
|
||||
eventType: TEventTypeLocation;
|
||||
locationOptions: TLocationOptions;
|
||||
}) => {
|
||||
const locationAvailable =
|
||||
eventType.locations &&
|
||||
eventType.locations.length > 0 &&
|
||||
locationOptions.some((op) => op.options.find((opt) => opt.value === eventType.locations[0].type));
|
||||
const locationDetails = eventType.locations &&
|
||||
eventType.locations.length > 0 &&
|
||||
!locationAvailable && {
|
||||
slug: eventType.locations[0].type.replace("integrations:", "").replace(":", "-").replace("_video", ""),
|
||||
name: eventType.locations[0].type
|
||||
.replace("integrations:", "")
|
||||
.replace(":", " ")
|
||||
.replace("_video", "")
|
||||
.split(" ")
|
||||
.map((word) => word[0].toUpperCase() + word.slice(1))
|
||||
.join(" "),
|
||||
};
|
||||
return { locationAvailable, locationDetails };
|
||||
};
|
||||
|
||||
const Locations: React.FC<LocationsProps> = ({
|
||||
isChildrenManagedEventType,
|
||||
disableLocationProp,
|
||||
isManagedEventType,
|
||||
getValues,
|
||||
setValue,
|
||||
control,
|
||||
formState,
|
||||
team,
|
||||
eventType,
|
||||
prefillLocation,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
fields: locationFields,
|
||||
append,
|
||||
remove,
|
||||
update: updateLocationField,
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: "locations",
|
||||
});
|
||||
|
||||
const locationOptions = props.locationOptions.map((locationOption) => {
|
||||
const options = locationOption.options.filter((option) => {
|
||||
// Skip "Organizer's Default App" for non-team members
|
||||
return !team?.id ? option.label !== t("organizer_default_conferencing_app") : true;
|
||||
});
|
||||
|
||||
return {
|
||||
...locationOption,
|
||||
options,
|
||||
};
|
||||
});
|
||||
|
||||
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
||||
const seatsEnabled = !!getValues("seatsPerTimeSlot");
|
||||
|
||||
const validLocations =
|
||||
getValues("locations")?.filter((location) => {
|
||||
const eventLocation = getEventLocationType(location.type);
|
||||
if (!eventLocation) {
|
||||
// It's possible that the location app in use got uninstalled.
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
const defaultValue = isManagedEventType
|
||||
? locationOptions.find((op) => op.label === t("default"))?.options[0]
|
||||
: undefined;
|
||||
|
||||
const { locationDetails, locationAvailable } = getLocationInfo({
|
||||
eventType,
|
||||
locationOptions: props.locationOptions,
|
||||
});
|
||||
|
||||
const LocationInput = (props: {
|
||||
eventLocationType: EventLocationType;
|
||||
defaultValue?: string;
|
||||
index: number;
|
||||
}) => {
|
||||
const { eventLocationType, index, ...remainingProps } = props;
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<Input
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
type="text"
|
||||
required
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
{...(disableLocationProp ? { disabled: true } : {})}
|
||||
className="my-0"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
disabled={disableLocationProp}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
|
||||
const defaultInitialLocation = defaultValue || null;
|
||||
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
|
||||
defaultInitialLocation
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!!prefillLocation) {
|
||||
const newLocationType = prefillLocation.value;
|
||||
|
||||
const canAppendLocation = !validLocations.find((location) => location.type === newLocationType);
|
||||
|
||||
if (canAppendLocation && !seatsEnabled) {
|
||||
append({
|
||||
type: newLocationType,
|
||||
credentialId: prefillLocation?.credentialId,
|
||||
});
|
||||
setSelectedNewOption(prefillLocation);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [prefillLocation, seatsEnabled]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ul ref={animationRef} className="space-y-2">
|
||||
{locationFields.map((field, index) => {
|
||||
const eventLocationType = getEventLocationType(field.type);
|
||||
const defaultLocation = field;
|
||||
|
||||
const option = getLocationFromType(field.type, locationOptions);
|
||||
return (
|
||||
<li key={field.id}>
|
||||
<div className="flex w-full items-center">
|
||||
<LocationSelect
|
||||
name={`locations[${index}].type`}
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
isDisabled={disableLocationProp}
|
||||
defaultValue={option}
|
||||
isSearchable={false}
|
||||
className="block min-w-0 flex-1 rounded-sm text-sm"
|
||||
menuPlacement="auto"
|
||||
onChange={(e: SingleValueLocationOption) => {
|
||||
setShowEmptyLocationSelect(false);
|
||||
if (e?.value) {
|
||||
const newLocationType = e.value;
|
||||
const eventLocationType = getEventLocationType(newLocationType);
|
||||
if (!eventLocationType) {
|
||||
return;
|
||||
}
|
||||
const canAddLocation =
|
||||
eventLocationType.organizerInputType ||
|
||||
!validLocations?.find((location) => location.type === newLocationType);
|
||||
|
||||
if (canAddLocation) {
|
||||
updateLocationField(index, {
|
||||
type: newLocationType,
|
||||
...(e.credentialId && {
|
||||
credentialId: e.credentialId,
|
||||
teamName: e.teamName ?? undefined,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
updateLocationField(index, {
|
||||
type: field.type,
|
||||
...(field.credentialId && {
|
||||
credentialId: field.credentialId,
|
||||
teamName: field.teamName ?? undefined,
|
||||
}),
|
||||
});
|
||||
showToast(t("location_already_exists"), "warning");
|
||||
}
|
||||
// Whenever location changes, we need to reset the locations item in booking questions list else it overflows
|
||||
// previously added values resulting in wrong behaviour
|
||||
const existingBookingFields = getValues("bookingFields");
|
||||
const findLocation = existingBookingFields.findIndex(
|
||||
(field) => field.name === "location"
|
||||
);
|
||||
if (findLocation >= 0) {
|
||||
existingBookingFields[findLocation] = {
|
||||
...existingBookingFields[findLocation],
|
||||
type: "radioInput",
|
||||
label: "",
|
||||
placeholder: "",
|
||||
};
|
||||
setValue("bookingFields", existingBookingFields, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!(disableLocationProp && isChildrenManagedEventType) && (
|
||||
<button
|
||||
data-testid={`delete-locations.${index}.type`}
|
||||
className="min-h-9 block h-9 px-2"
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
aria-label={t("remove")}>
|
||||
<div className="h-4 w-4">
|
||||
<Icon name="x" className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{eventLocationType?.organizerInputType && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="w-full">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<Icon name="corner-down-right" className="h-4 w-4" />
|
||||
</div>
|
||||
<LocationInput
|
||||
data-testid={`${eventLocationType.type}-location-input`}
|
||||
defaultValue={
|
||||
defaultLocation
|
||||
? defaultLocation[eventLocationType.defaultValueVariable]
|
||||
: undefined
|
||||
}
|
||||
eventLocationType={eventLocationType}
|
||||
index={index}
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
errors={formState.errors?.locations?.[index]}
|
||||
name={eventLocationType.defaultValueVariable}
|
||||
className="text-error my-1 ml-6 text-sm"
|
||||
as="div"
|
||||
id="location-error"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-6">
|
||||
<CheckboxField
|
||||
name={`locations[${index}].displayLocationPublicly`}
|
||||
data-testid="display-location"
|
||||
disabled={disableLocationProp}
|
||||
defaultChecked={defaultLocation?.displayLocationPublicly}
|
||||
description={t("display_location_label")}
|
||||
onChange={(e) => {
|
||||
const fieldValues = getValues("locations")[index];
|
||||
updateLocationField(index, {
|
||||
...fieldValues,
|
||||
displayLocationPublicly: e.target.checked,
|
||||
});
|
||||
}}
|
||||
informationIconText={t("display_location_info_badge")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{(validLocations.length === 0 || showEmptyLocationSelect) && (
|
||||
<div className="flex">
|
||||
<LocationSelect
|
||||
defaultMenuIsOpen={showEmptyLocationSelect}
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
value={selectedNewOption}
|
||||
isDisabled={disableLocationProp}
|
||||
defaultValue={defaultValue}
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
menuPlacement="auto"
|
||||
onChange={(e: SingleValueLocationOption) => {
|
||||
setShowEmptyLocationSelect(false);
|
||||
if (e?.value) {
|
||||
const newLocationType = e.value;
|
||||
const eventLocationType = getEventLocationType(newLocationType);
|
||||
if (!eventLocationType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canAppendLocation =
|
||||
eventLocationType.organizerInputType ||
|
||||
!validLocations.find((location) => location.type === newLocationType);
|
||||
|
||||
if (canAppendLocation) {
|
||||
append({
|
||||
type: newLocationType,
|
||||
...(e.credentialId && {
|
||||
credentialId: e.credentialId,
|
||||
teamName: e.teamName ?? undefined,
|
||||
}),
|
||||
});
|
||||
setSelectedNewOption(e);
|
||||
} else {
|
||||
showToast(t("location_already_exists"), "warning");
|
||||
setSelectedNewOption(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{validLocations.some(
|
||||
(location) =>
|
||||
location.type === MeetLocationType && props.destinationCalendar?.integration !== "google_calendar"
|
||||
) && (
|
||||
<div className="text-default flex items-center text-sm">
|
||||
<div className="mr-1.5 h-3 w-3">
|
||||
<Icon name="check" className="h-3 w-3" />
|
||||
</div>
|
||||
<p className="text-default text-sm">
|
||||
<Trans i18nKey="event_type_requires_google_calendar">
|
||||
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
||||
Connect it
|
||||
<Link className="cursor-pointer text-blue-500 underline" href="/apps/google-calendar">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
|
||||
<p className="pl-1 text-sm leading-none text-red-600">
|
||||
{t("app_not_connected", { appName: locationDetails.name })}{" "}
|
||||
<a className="underline" href={`${WEBAPP_URL}/apps/${locationDetails.slug}`}>
|
||||
{t("connect_now")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{validLocations.length > 0 && !disableLocationProp && (
|
||||
// && !isChildrenManagedEventType : Add this to hide add-location button only when location is disabled by Admin
|
||||
<li>
|
||||
<Button
|
||||
data-testid="add-location"
|
||||
StartIcon="plus"
|
||||
color="minimal"
|
||||
disabled={seatsEnabled}
|
||||
tooltip={seatsEnabled ? t("seats_option_doesnt_support_multi_location") : undefined}
|
||||
onClick={() => setShowEmptyLocationSelect(true)}>
|
||||
{t("add_location")}
|
||||
</Button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{props.showAppStoreLink && (
|
||||
<p className="text-default mt-2 text-sm">
|
||||
<Trans i18nKey="cant_find_the_right_conferencing_app_visit_our_app_store">
|
||||
Can't find the right conferencing app? Visit our
|
||||
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/conferencing">
|
||||
App Store
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Locations;
|
||||
52
calcom/apps/web/components/eventtype/ManagedEventDialog.tsx
Normal file
52
calcom/apps/web/components/eventtype/ManagedEventDialog.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Trans } from "next-i18next";
|
||||
|
||||
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ConfirmationDialogContent, Dialog } from "@calcom/ui";
|
||||
|
||||
interface ManagedEventDialogProps {
|
||||
slugExistsChildrenDialogOpen: ChildrenEventType[];
|
||||
slug: string;
|
||||
onOpenChange: () => void;
|
||||
isPending: boolean;
|
||||
onConfirm: (e: { preventDefault: () => void }) => void;
|
||||
}
|
||||
|
||||
export default function ManagedEventDialog(props: ManagedEventDialogProps) {
|
||||
const { t } = useLocale();
|
||||
const { slugExistsChildrenDialogOpen, slug, onOpenChange, isPending, onConfirm } = props;
|
||||
|
||||
return (
|
||||
<Dialog open={slugExistsChildrenDialogOpen.length > 0} onOpenChange={onOpenChange}>
|
||||
<ConfirmationDialogContent
|
||||
isPending={isPending}
|
||||
variety="warning"
|
||||
title={t("managed_event_dialog_title", {
|
||||
slug,
|
||||
count: slugExistsChildrenDialogOpen.length,
|
||||
})}
|
||||
confirmBtnText={t("managed_event_dialog_confirm_button", {
|
||||
count: slugExistsChildrenDialogOpen.length,
|
||||
})}
|
||||
cancelBtnText={t("go_back")}
|
||||
onConfirm={onConfirm}>
|
||||
<p className="mt-5">
|
||||
<Trans
|
||||
i18nKey="managed_event_dialog_information"
|
||||
values={{
|
||||
names: `${slugExistsChildrenDialogOpen
|
||||
.map((ch) => ch.owner.name)
|
||||
.slice(0, -1)
|
||||
.join(", ")} ${
|
||||
slugExistsChildrenDialogOpen.length > 1 ? t("and") : ""
|
||||
} ${slugExistsChildrenDialogOpen.map((ch) => ch.owner.name).slice(-1)}`,
|
||||
slug,
|
||||
}}
|
||||
count={slugExistsChildrenDialogOpen.length}
|
||||
/>
|
||||
</p>{" "}
|
||||
<p className="mt-5">{t("managed_event_dialog_clarification")}</p>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Alert, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||
|
||||
type RecurringEventControllerProps = {
|
||||
eventType: EventTypeSetup;
|
||||
paymentEnabled: boolean;
|
||||
};
|
||||
|
||||
export default function RecurringEventController({
|
||||
eventType,
|
||||
paymentEnabled,
|
||||
}: RecurringEventControllerProps) {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(
|
||||
formMethods.getValues("recurringEvent")
|
||||
);
|
||||
/* Just yearly-0, monthly-1 and weekly-2 */
|
||||
const recurringEventFreqOptions = Object.entries(Frequency)
|
||||
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
|
||||
.map(([key, value]) => ({
|
||||
label: t(`${key.toString().toLowerCase()}`, { count: recurringEventState?.interval }),
|
||||
value: value.toString(),
|
||||
}));
|
||||
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||
|
||||
const recurringLocked = shouldLockDisableProps("recurringEvent");
|
||||
|
||||
return (
|
||||
<div className="block items-start sm:flex">
|
||||
<div className={!paymentEnabled ? "w-full" : ""}>
|
||||
{paymentEnabled ? (
|
||||
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
className="mb-4"
|
||||
severity="warning"
|
||||
title="Experimental: Recurring Events are currently experimental and causes some issues sometimes when checking for availability. We are working on fixing this."
|
||||
/>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||
recurringEventState !== null && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("recurring_event")}
|
||||
{...recurringLocked}
|
||||
description={t("recurring_event_description")}
|
||||
checked={recurringEventState !== null}
|
||||
data-testid="recurring-event-check"
|
||||
onCheckedChange={(e) => {
|
||||
if (!e) {
|
||||
formMethods.setValue("recurringEvent", null, { shouldDirty: true });
|
||||
setRecurringEventState(null);
|
||||
} else {
|
||||
const newVal = eventType.recurringEvent || {
|
||||
interval: 1,
|
||||
count: 12,
|
||||
freq: Frequency.WEEKLY,
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal, { shouldDirty: true });
|
||||
setRecurringEventState(newVal);
|
||||
}
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
{recurringEventState && (
|
||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="mb-0"
|
||||
defaultValue={recurringEventState.interval}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
interval: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal, { shouldDirty: true });
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={recurringEventFreqOptions}
|
||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||
isSearchable={false}
|
||||
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
|
||||
isDisabled={recurringLocked.disabled}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal, { shouldDirty: true });
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
defaultValue={recurringEventState.count}
|
||||
className="mb-0"
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
count: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal, { shouldDirty: true });
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
|
||||
{t("events", {
|
||||
count: recurringEventState.count,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import type { UnitTypeLongPlural } from "dayjs";
|
||||
import { Trans } from "next-i18next";
|
||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Input, SettingsToggle, RadioField, Select } from "@calcom/ui";
|
||||
|
||||
type RequiresConfirmationControllerProps = {
|
||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
requiresConfirmation: boolean;
|
||||
onRequiresConfirmation: Dispatch<SetStateAction<boolean>>;
|
||||
seatsEnabled: boolean;
|
||||
eventType: EventTypeSetup;
|
||||
};
|
||||
|
||||
export default function RequiresConfirmationController({
|
||||
metadata,
|
||||
eventType,
|
||||
requiresConfirmation,
|
||||
onRequiresConfirmation,
|
||||
seatsEnabled,
|
||||
}: RequiresConfirmationControllerProps) {
|
||||
const { t } = useLocale();
|
||||
const [requiresConfirmationSetup, setRequiresConfirmationSetup] = useState(
|
||||
metadata?.requiresConfirmationThreshold
|
||||
);
|
||||
const defaultRequiresConfirmationSetup = { time: 30, unit: "minutes" as UnitTypeLongPlural };
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiresConfirmation) {
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined, { shouldDirty: true });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [requiresConfirmation]);
|
||||
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||
const requiresConfirmationLockedProps = shouldLockDisableProps("requiresConfirmation");
|
||||
|
||||
const options = [
|
||||
{ label: t("minute_timeUnit"), value: "minutes" },
|
||||
{ label: t("hour_timeUnit"), value: "hours" },
|
||||
];
|
||||
|
||||
const defaultValue = options.find(
|
||||
(opt) =>
|
||||
opt.value === (metadata?.requiresConfirmationThreshold?.unit ?? defaultRequiresConfirmationSetup.unit)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="block items-start sm:flex">
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="requiresConfirmation"
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||
requiresConfirmation && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("requires_confirmation")}
|
||||
data-testid="requires-confirmation"
|
||||
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
|
||||
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
|
||||
description={t("requires_confirmation_description")}
|
||||
checked={requiresConfirmation}
|
||||
LockedIcon={requiresConfirmationLockedProps.LockedIcon}
|
||||
onCheckedChange={(val) => {
|
||||
formMethods.setValue("requiresConfirmation", val, { shouldDirty: true });
|
||||
onRequiresConfirmation(val);
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<RadioGroup.Root
|
||||
defaultValue={
|
||||
requiresConfirmation
|
||||
? requiresConfirmationSetup === undefined
|
||||
? "always"
|
||||
: "notice"
|
||||
: undefined
|
||||
}
|
||||
onValueChange={(val) => {
|
||||
if (val === "always") {
|
||||
formMethods.setValue("requiresConfirmation", true, { shouldDirty: true });
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setRequiresConfirmationSetup(undefined);
|
||||
} else if (val === "notice") {
|
||||
formMethods.setValue("requiresConfirmation", true, { shouldDirty: true });
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold",
|
||||
requiresConfirmationSetup || defaultRequiresConfirmationSetup,
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
}}>
|
||||
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
||||
{(requiresConfirmationSetup === undefined ||
|
||||
!requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
label={t("always_requires_confirmation")}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
id="always"
|
||||
value="always"
|
||||
/>
|
||||
)}
|
||||
{(requiresConfirmationSetup !== undefined ||
|
||||
!requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
className="items-center"
|
||||
label={
|
||||
<>
|
||||
<Trans
|
||||
i18nKey="when_booked_with_less_than_notice"
|
||||
defaults="When booked with less than <time></time> notice"
|
||||
components={{
|
||||
time: (
|
||||
<div className="mx-2 inline-flex">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
onChange={(evt) => {
|
||||
const val = Number(evt.target?.value);
|
||||
setRequiresConfirmationSetup({
|
||||
unit:
|
||||
requiresConfirmationSetup?.unit ??
|
||||
defaultRequiresConfirmationSetup.unit,
|
||||
time: val,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.time",
|
||||
val,
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield] focus:z-10 focus:border-r"
|
||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||
/>
|
||||
<label
|
||||
className={classNames(
|
||||
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
||||
)}>
|
||||
<Select
|
||||
inputId="notice"
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
isDisabled={requiresConfirmationLockedProps.disabled}
|
||||
innerClassNames={{ control: "rounded-l-none bg-subtle" }}
|
||||
onChange={(opt) => {
|
||||
setRequiresConfirmationSetup({
|
||||
time:
|
||||
requiresConfirmationSetup?.time ??
|
||||
defaultRequiresConfirmationSetup.time,
|
||||
unit: opt?.value as UnitTypeLongPlural,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.unit",
|
||||
opt?.value as UnitTypeLongPlural,
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
id="notice"
|
||||
value="notice"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
calcom/apps/web/components/eventtype/SkeletonLoader.tsx
Normal file
47
calcom/apps/web/components/eventtype/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="mb-4 flex items-center">
|
||||
<SkeletonAvatar className="h-8 w-8" />
|
||||
<div className="flex flex-col space-y-1">
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
<SkeletonText className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<ul className="border-subtle bg-default divide-subtle divide-y rounded-md border sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div>
|
||||
<SkeletonText className="h-5 w-32" />
|
||||
</div>
|
||||
<div className="">
|
||||
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<Icon name="clock" className="text-subtle mr-1.5 mt-0.5 inline h-4 w-4" />
|
||||
<SkeletonText className="h-4 w-12" />
|
||||
</li>
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<Icon name="user" className="text-subtle mr-1.5 mt-0.5 inline h-4 w-4" />
|
||||
<SkeletonText className="h-4 w-12" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import type { TDependencyData } from "@calcom/app-store/_appRegistry";
|
||||
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/components";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Badge, Button, Icon } from "@calcom/ui";
|
||||
|
||||
interface IAppConnectionItem {
|
||||
title: string;
|
||||
description?: string;
|
||||
logo: string;
|
||||
type: App["type"];
|
||||
installed?: boolean;
|
||||
isDefault?: boolean;
|
||||
defaultInstall?: boolean;
|
||||
slug?: string;
|
||||
dependencyData?: TDependencyData;
|
||||
}
|
||||
|
||||
const AppConnectionItem = (props: IAppConnectionItem) => {
|
||||
const { title, logo, type, installed, isDefault, defaultInstall, slug } = props;
|
||||
const { t } = useLocale();
|
||||
const setDefaultConferencingApp = trpc.viewer.appsRouter.setDefaultConferencingApp.useMutation();
|
||||
const dependency = props.dependencyData?.find((data) => !data.installed);
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-between p-5">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img src={logo} alt={title} className="h-8 w-8" />
|
||||
<p className="text-sm font-bold">{title}</p>
|
||||
{isDefault && <Badge variant="green">{t("default")}</Badge>}
|
||||
</div>
|
||||
<InstallAppButtonWithoutPlanCheck
|
||||
type={type}
|
||||
options={{
|
||||
onSuccess: () => {
|
||||
if (defaultInstall && slug) {
|
||||
setDefaultConferencingApp.mutate({ slug });
|
||||
}
|
||||
},
|
||||
}}
|
||||
render={(buttonProps) => (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
color="secondary"
|
||||
disabled={installed || !!dependency}
|
||||
type="button"
|
||||
loading={buttonProps?.isPending}
|
||||
tooltip={
|
||||
dependency ? (
|
||||
<div className="items-start space-x-2.5">
|
||||
<div className="flex items-start">
|
||||
<div>
|
||||
<Icon name="circle-alert" className="mr-2 mt-1 font-semibold" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-semibold">
|
||||
{t("this_app_requires_connected_account", {
|
||||
appName: title,
|
||||
dependencyName: dependency.name,
|
||||
})}
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<>
|
||||
<Link
|
||||
href={`${WEBAPP_URL}/getting-started/connected-calendar`}
|
||||
className="flex items-center text-xs underline">
|
||||
<span className="mr-1">
|
||||
{t("connect_app", { dependencyName: dependency.name })}
|
||||
</span>
|
||||
<Icon name="arrow-right" className="inline-block h-3 w-3" />
|
||||
</Link>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
// Save cookie key to return url step
|
||||
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
||||
buttonProps && buttonProps.onClick && buttonProps?.onClick(event);
|
||||
}}>
|
||||
{installed ? t("installed") : t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { AppConnectionItem };
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
||||
|
||||
interface IConnectedCalendarItem {
|
||||
name: string;
|
||||
logo: string;
|
||||
externalId?: string;
|
||||
integrationType: string;
|
||||
calendars?: {
|
||||
primary: true | null;
|
||||
isSelected: boolean;
|
||||
credentialId: number;
|
||||
name?: string | undefined;
|
||||
readOnly?: boolean | undefined;
|
||||
userId?: number | undefined;
|
||||
integration?: string | undefined;
|
||||
externalId: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
|
||||
const { name, logo, externalId, calendars, integrationType } = prop;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center p-4">
|
||||
<img src={logo} alt={name} className="m-1 h-8 w-8" />
|
||||
<div className="mx-4">
|
||||
<p className="font-sans text-sm font-bold leading-5">
|
||||
{name}
|
||||
{/* Temporarily removed till we use it on another place */}
|
||||
{/* <span className="mx-1 rounded-[4px] bg-success py-[2px] px-[6px] font-sans text-xs font-medium text-green-600">
|
||||
{t("default")}
|
||||
</span> */}
|
||||
</p>
|
||||
<div className="fle-row flex">
|
||||
<span
|
||||
title={externalId}
|
||||
className="max-w-44 text-subtle mt-1 overflow-hidden text-ellipsis whitespace-nowrap font-sans text-sm">
|
||||
{externalId}{" "}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Temporarily removed */}
|
||||
{/* <Button
|
||||
color="minimal"
|
||||
type="button"
|
||||
className="ml-auto flex rounded-md border border-subtle py-[10x] px-4 font-sans text-sm">
|
||||
{t("edit")}
|
||||
</Button> */}
|
||||
</div>
|
||||
<div className="border-subtle h-[1px] w-full border-b" />
|
||||
<div>
|
||||
<ul className="p-4">
|
||||
{calendars?.map((calendar, i) => (
|
||||
<CalendarSwitch
|
||||
credentialId={calendar.credentialId}
|
||||
key={calendar.externalId}
|
||||
externalId={calendar.externalId}
|
||||
title={calendar.name || "Nameless Calendar"}
|
||||
name={calendar.name || "Nameless Calendar"}
|
||||
type={integrationType}
|
||||
isChecked={calendar.isSelected}
|
||||
isLastItemInList={i === calendars.length - 1}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { ConnectedCalendarItem };
|
||||
@@ -0,0 +1,37 @@
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterInputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
interface ICreateEventsOnCalendarSelectProps {
|
||||
calendar?: RouterInputs["viewer"]["setDestinationCalendar"] | null;
|
||||
}
|
||||
|
||||
const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps) => {
|
||||
const { calendar } = props;
|
||||
const { t } = useLocale();
|
||||
const mutation = trpc.viewer.setDestinationCalendar.useMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6 flex flex-row">
|
||||
<div className="w-full">
|
||||
<label htmlFor="createEventsOn" className="text-default flex text-sm font-medium">
|
||||
{t("create_events_on")}
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<DestinationCalendarSelector
|
||||
value={calendar ? calendar.externalId : undefined}
|
||||
onChange={(calendar) => {
|
||||
mutation.mutate(calendar);
|
||||
}}
|
||||
hidePlaceholder
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { CreateEventsOnCalendarSelect };
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SkeletonAvatar, SkeletonText, SkeletonButton } from "@calcom/ui";
|
||||
|
||||
export function StepConnectionLoader() {
|
||||
return (
|
||||
<ul className="bg-default divide-subtle border-subtle divide-y rounded-md border p-0 dark:bg-black">
|
||||
{Array.from({ length: 4 }).map((_item, index) => {
|
||||
return (
|
||||
<li className="flex w-full flex-row justify-center border-b-0 py-6" key={index}>
|
||||
<SkeletonAvatar className="mx-6 h-8 w-8 px-4" />
|
||||
<SkeletonText className="ml-1 mr-4 mt-3 h-5 w-full" />
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon, List } from "@calcom/ui";
|
||||
|
||||
import { AppConnectionItem } from "../components/AppConnectionItem";
|
||||
import { ConnectedCalendarItem } from "../components/ConnectedCalendarItem";
|
||||
import { CreateEventsOnCalendarSelect } from "../components/CreateEventsOnCalendarSelect";
|
||||
import { StepConnectionLoader } from "../components/StepConnectionLoader";
|
||||
|
||||
interface IConnectCalendarsProps {
|
||||
nextStep: () => void;
|
||||
}
|
||||
|
||||
const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
||||
const { nextStep } = props;
|
||||
const queryConnectedCalendars = trpc.viewer.connectedCalendars.useQuery({ onboarding: true });
|
||||
const { t } = useLocale();
|
||||
const queryIntegrations = trpc.viewer.integrations.useQuery({
|
||||
variant: "calendar",
|
||||
onlyInstalled: false,
|
||||
sortByMostPopular: true,
|
||||
});
|
||||
|
||||
const firstCalendar = queryConnectedCalendars.data?.connectedCalendars.find(
|
||||
(item) => item.calendars && item.calendars?.length > 0
|
||||
);
|
||||
const disabledNextButton = firstCalendar === undefined;
|
||||
const destinationCalendar = queryConnectedCalendars.data?.destinationCalendar;
|
||||
return (
|
||||
<>
|
||||
{/* Already connected calendars */}
|
||||
{!queryConnectedCalendars.isPending &&
|
||||
firstCalendar &&
|
||||
firstCalendar.integration &&
|
||||
firstCalendar.integration.title &&
|
||||
firstCalendar.integration.logo && (
|
||||
<>
|
||||
<List className="bg-default border-subtle rounded-md border p-0 dark:bg-black ">
|
||||
<ConnectedCalendarItem
|
||||
key={firstCalendar.integration.title}
|
||||
name={firstCalendar.integration.title}
|
||||
logo={firstCalendar.integration.logo}
|
||||
externalId={
|
||||
firstCalendar && firstCalendar.calendars && firstCalendar.calendars.length > 0
|
||||
? firstCalendar.calendars[0].externalId
|
||||
: ""
|
||||
}
|
||||
calendars={firstCalendar.calendars}
|
||||
integrationType={firstCalendar.integration.type}
|
||||
/>
|
||||
</List>
|
||||
{/* Create event on selected calendar */}
|
||||
<CreateEventsOnCalendarSelect calendar={destinationCalendar} />
|
||||
<p className="text-subtle mt-4 text-sm">{t("connect_calendars_from_app_store")}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Connect calendars list */}
|
||||
{firstCalendar === undefined && queryIntegrations.data && queryIntegrations.data.items.length > 0 && (
|
||||
<List className="bg-default divide-subtle border-subtle mx-1 divide-y rounded-md border p-0 dark:bg-black sm:mx-0">
|
||||
{queryIntegrations.data &&
|
||||
queryIntegrations.data.items.map((item) => (
|
||||
<li key={item.title}>
|
||||
{item.title && item.logo && (
|
||||
<AppConnectionItem
|
||||
type={item.type}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
logo={item.logo}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{queryIntegrations.isPending && <StepConnectionLoader />}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="save-calendar-button"
|
||||
className={classNames(
|
||||
"text-inverted bg-inverted border-inverted mt-8 flex w-full flex-row justify-center rounded-md border p-2 text-center text-sm",
|
||||
disabledNextButton ? "cursor-not-allowed opacity-20" : ""
|
||||
)}
|
||||
onClick={() => nextStep()}
|
||||
disabled={disabledNextButton}>
|
||||
{firstCalendar ? `${t("continue")}` : `${t("next_step_text")}`}
|
||||
<Icon name="arrow-right" className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { ConnectedCalendars };
|
||||
@@ -0,0 +1,79 @@
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Icon, List } from "@calcom/ui";
|
||||
|
||||
import { AppConnectionItem } from "../components/AppConnectionItem";
|
||||
import { StepConnectionLoader } from "../components/StepConnectionLoader";
|
||||
|
||||
interface ConnectedAppStepProps {
|
||||
nextStep: () => void;
|
||||
}
|
||||
|
||||
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
const { nextStep } = props;
|
||||
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.integrations.useQuery({
|
||||
variant: "conferencing",
|
||||
onlyInstalled: false,
|
||||
sortByMostPopular: true,
|
||||
});
|
||||
const { data } = useMeQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
const metadata = userMetadata.parse(data?.metadata);
|
||||
|
||||
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
|
||||
(item) => item.userCredentialIds.length > 0
|
||||
);
|
||||
|
||||
const defaultConferencingApp = metadata?.defaultConferencingApp?.appSlug;
|
||||
return (
|
||||
<>
|
||||
{!isPending && (
|
||||
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
|
||||
{queryConnectedVideoApps?.items &&
|
||||
queryConnectedVideoApps?.items.map((item) => {
|
||||
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
|
||||
return (
|
||||
<li key={item.name}>
|
||||
{item.name && item.logo && (
|
||||
<AppConnectionItem
|
||||
type={item.type}
|
||||
title={item.name}
|
||||
isDefault={item.slug === defaultConferencingApp}
|
||||
description={item.description}
|
||||
dependencyData={item.dependencyData}
|
||||
logo={item.logo}
|
||||
slug={item.slug}
|
||||
installed={item.userCredentialIds.length > 0}
|
||||
defaultInstall={
|
||||
!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{isPending && <StepConnectionLoader />}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="save-video-button"
|
||||
className={classNames(
|
||||
"text-inverted border-inverted bg-inverted mt-8 flex w-full flex-row justify-center rounded-md border p-2 text-center text-sm",
|
||||
!hasAnyInstalledVideoApps ? "cursor-not-allowed opacity-20" : ""
|
||||
)}
|
||||
disabled={!hasAnyInstalledVideoApps}
|
||||
onClick={() => nextStep()}>
|
||||
{t("next_step_text")}
|
||||
<Icon name="arrow-right" className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { ConnectedVideoStep };
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Schedule } from "@calcom/features/schedules";
|
||||
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Button, Form, Icon } from "@calcom/ui";
|
||||
|
||||
interface ISetupAvailabilityProps {
|
||||
nextStep: () => void;
|
||||
defaultScheduleId?: number | null;
|
||||
}
|
||||
|
||||
const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
||||
const { defaultScheduleId } = props;
|
||||
|
||||
const { t } = useLocale();
|
||||
const { nextStep } = props;
|
||||
|
||||
const scheduleId = defaultScheduleId === null ? undefined : defaultScheduleId;
|
||||
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId: defaultScheduleId ?? undefined },
|
||||
{
|
||||
enabled: !!scheduleId,
|
||||
}
|
||||
);
|
||||
|
||||
const availabilityForm = useForm({
|
||||
defaultValues: {
|
||||
schedule: queryAvailability?.data?.availability || DEFAULT_SCHEDULE,
|
||||
},
|
||||
});
|
||||
|
||||
const mutationOptions = {
|
||||
onError: (error: TRPCClientErrorLike<AppRouter>) => {
|
||||
throw new Error(error.message);
|
||||
},
|
||||
onSuccess: () => {
|
||||
nextStep();
|
||||
},
|
||||
};
|
||||
const createSchedule = trpc.viewer.availability.schedule.create.useMutation(mutationOptions);
|
||||
const updateSchedule = trpc.viewer.availability.schedule.update.useMutation(mutationOptions);
|
||||
return (
|
||||
<Form
|
||||
form={availabilityForm}
|
||||
handleSubmit={async (values) => {
|
||||
try {
|
||||
if (defaultScheduleId) {
|
||||
await updateSchedule.mutate({
|
||||
scheduleId: defaultScheduleId,
|
||||
name: t("default_schedule_name"),
|
||||
...values,
|
||||
});
|
||||
} else {
|
||||
await createSchedule.mutate({
|
||||
name: t("default_schedule_name"),
|
||||
...values,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
// setError(error);
|
||||
// @TODO: log error
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<div className="bg-default dark:text-inverted text-emphasis border-subtle w-full rounded-md border dark:bg-opacity-5">
|
||||
<Schedule control={availabilityForm.control} name="schedule" weekStart={1} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
data-testid="save-availability"
|
||||
type="submit"
|
||||
className="mt-2 w-full justify-center p-2 text-sm sm:mt-8"
|
||||
loading={availabilityForm.formState.isSubmitting}
|
||||
disabled={availabilityForm.formState.isSubmitting}>
|
||||
{t("next_step_text")} <Icon name="arrow-right" className="ml-2 h-4 w-4 self-center" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export { SetupAvailability };
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { FormEvent } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||
import { UserAvatar } from "@calcom/ui";
|
||||
|
||||
type FormData = {
|
||||
bio: string;
|
||||
};
|
||||
|
||||
const UserProfile = () => {
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const { t } = useLocale();
|
||||
const avatarRef = useRef<HTMLInputElement>(null);
|
||||
const { setValue, handleSubmit, getValues } = useForm<FormData>({
|
||||
defaultValues: { bio: user?.bio || "" },
|
||||
});
|
||||
|
||||
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
|
||||
const [imageSrc, setImageSrc] = useState<string>(user?.avatar || "");
|
||||
const utils = trpc.useUtils();
|
||||
const router = useRouter();
|
||||
const createEventType = trpc.viewer.eventTypes.create.useMutation();
|
||||
const telemetry = useTelemetry();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (_data, context) => {
|
||||
if (context.avatarUrl) {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
await utils.viewer.me.refetch();
|
||||
} else
|
||||
try {
|
||||
if (eventTypes?.length === 0) {
|
||||
await Promise.all(
|
||||
DEFAULT_EVENT_TYPES.map(async (event) => {
|
||||
return createEventType.mutate(event);
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
await utils.viewer.me.refetch();
|
||||
const redirectUrl = localStorage.getItem("onBoardingRedirect");
|
||||
localStorage.removeItem("onBoardingRedirect");
|
||||
|
||||
redirectUrl ? router.push(redirectUrl) : router.push("/");
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("problem_saving_user_profile"), "error");
|
||||
},
|
||||
});
|
||||
const onSubmit = handleSubmit((data: { bio: string }) => {
|
||||
const { bio } = data;
|
||||
|
||||
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
|
||||
mutation.mutate({
|
||||
bio,
|
||||
completedOnboarding: true,
|
||||
});
|
||||
});
|
||||
|
||||
async function updateProfileHandler(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const enteredAvatar = avatarRef.current?.value;
|
||||
mutation.mutate({
|
||||
avatarUrl: enteredAvatar,
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_EVENT_TYPES = [
|
||||
{
|
||||
title: t("15min_meeting"),
|
||||
slug: "15min",
|
||||
length: 15,
|
||||
},
|
||||
{
|
||||
title: t("30min_meeting"),
|
||||
slug: "30min",
|
||||
length: 30,
|
||||
},
|
||||
{
|
||||
title: t("secret_meeting"),
|
||||
slug: "secret",
|
||||
length: 15,
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
||||
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} />}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
placeholder="URL"
|
||||
className="border-default focus:ring-empthasis mt-1 block w-full rounded-sm border px-3 py-2 text-sm focus:border-gray-800 focus:outline-none"
|
||||
defaultValue={imageSrc}
|
||||
/>
|
||||
<div className="flex items-center px-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("add_profile_photo")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
if (avatarRef.current) {
|
||||
avatarRef.current.value = newAvatar;
|
||||
}
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
|
||||
const ev2 = new Event("input", { bubbles: true });
|
||||
avatarRef.current?.dispatchEvent(ev2);
|
||||
updateProfileHandler(ev2 as unknown as FormEvent<HTMLFormElement>);
|
||||
setImageSrc(newAvatar);
|
||||
}}
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="mt-8">
|
||||
<Label className="text-default mb-2 block text-sm font-medium">{t("about")}</Label>
|
||||
<Editor
|
||||
getText={() => md.render(getValues("bio") || user?.bio || "")}
|
||||
setText={(value: string) => setValue("bio", turndown(value))}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
<p className="text-default mt-2 font-sans text-sm font-normal">{t("few_sentences_about_yourself")}</p>
|
||||
</fieldset>
|
||||
<Button
|
||||
loading={mutation.isPending}
|
||||
EndIcon="arrow-right"
|
||||
type="submit"
|
||||
className="mt-8 w-full items-center justify-center">
|
||||
{t("finish")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, TimezoneSelect, Icon, Input } from "@calcom/ui";
|
||||
|
||||
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
|
||||
interface IUserSettingsProps {
|
||||
nextStep: () => void;
|
||||
hideUsername?: boolean;
|
||||
}
|
||||
|
||||
const UserSettings = (props: IUserSettingsProps) => {
|
||||
const { nextStep } = props;
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const { t } = useLocale();
|
||||
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
|
||||
const telemetry = useTelemetry();
|
||||
const userSettingsSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
|
||||
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
|
||||
}),
|
||||
});
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<z.infer<typeof userSettingsSchema>>({
|
||||
defaultValues: {
|
||||
name: user?.name || "",
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(userSettingsSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.event(telemetryEventTypes.onboardingStarted);
|
||||
}, [telemetry]);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const onSuccess = async () => {
|
||||
await utils.viewer.me.invalidate();
|
||||
nextStep();
|
||||
};
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: onSuccess,
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
mutation.mutate({
|
||||
name: data.name,
|
||||
timeZone: selectedTimeZone,
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-6">
|
||||
{/* Username textfield: when not coming from signup */}
|
||||
{!props.hideUsername && <UsernameAvailabilityField />}
|
||||
|
||||
{/* Full name textfield */}
|
||||
<div className="w-full">
|
||||
<label htmlFor="name" className="text-default mb-2 block text-sm font-medium">
|
||||
{t("full_name")}
|
||||
</label>
|
||||
<Input
|
||||
{...register("name", {
|
||||
required: true,
|
||||
})}
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p data-testid="required" className="py-2 text-xs text-red-500">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Timezone select field */}
|
||||
<div className="w-full">
|
||||
<label htmlFor="timeZone" className="text-default block text-sm font-medium">
|
||||
{t("timezone")}
|
||||
</label>
|
||||
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={({ value }) => setSelectedTimeZone(value)}
|
||||
className="mt-2 w-full rounded-md text-sm"
|
||||
/>
|
||||
|
||||
<p className="text-subtle mt-3 flex flex-row font-sans text-xs leading-tight">
|
||||
{t("current_time")} {dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-8 flex w-full flex-row justify-center"
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending}>
|
||||
{t("next_step_text")}
|
||||
<Icon name="arrow-right" className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export { UserSettings };
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@calcom/ui";
|
||||
|
||||
function pluralize(opts: { num: number; plural: string; singular: string }) {
|
||||
if (opts.num === 0) {
|
||||
return opts.singular;
|
||||
}
|
||||
return opts.singular;
|
||||
}
|
||||
|
||||
export default function SubHeadingTitleWithConnections(props: { title: ReactNode; numConnections?: number }) {
|
||||
const num = props.numConnections;
|
||||
return (
|
||||
<>
|
||||
<span>{props.title}</span>
|
||||
{num ? (
|
||||
<Badge variant="success">
|
||||
{num}{" "}
|
||||
{pluralize({
|
||||
num,
|
||||
singular: "connection",
|
||||
plural: "connections",
|
||||
})}
|
||||
</Badge>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
4
calcom/apps/web/components/layouts/_README.md
Normal file
4
calcom/apps/web/components/layouts/_README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
We don't have many layouts currently however, if they are relevant to a features - please put them in their relvant component folder
|
||||
in `web/components` if you believe they will be reused in other apps please place them in `packages/features/[feature]`
|
||||
|
||||
Thanks Sean 🔥
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Attendee, Booking, User } from "@prisma/client";
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { JsonLd } from "react-schemaorg";
|
||||
import type { EventReservation, Person, ReservationStatusType } from "schema-dts";
|
||||
|
||||
type EventSchemaUser = Pick<User, "name" | "email">;
|
||||
type EventSchemaAttendee = Pick<Attendee, "name" | "email">;
|
||||
|
||||
interface EventReservationSchemaInterface {
|
||||
reservationId: Booking["uid"];
|
||||
eventName: Booking["title"];
|
||||
startTime: Booking["startTime"];
|
||||
endTime: Booking["endTime"];
|
||||
organizer: EventSchemaUser | null;
|
||||
attendees: EventSchemaAttendee[];
|
||||
location: Booking["location"];
|
||||
description: Booking["description"];
|
||||
status: Booking["status"];
|
||||
}
|
||||
|
||||
const EventReservationSchema: FC<EventReservationSchemaInterface> = ({
|
||||
reservationId,
|
||||
eventName,
|
||||
startTime,
|
||||
endTime,
|
||||
organizer,
|
||||
attendees,
|
||||
location,
|
||||
description,
|
||||
status,
|
||||
}) => {
|
||||
const reservationStatus = useMemo<ReservationStatusType>(() => {
|
||||
switch (status) {
|
||||
case "ACCEPTED":
|
||||
return "ReservationConfirmed";
|
||||
case "REJECTED":
|
||||
case "CANCELLED":
|
||||
return "ReservationCancelled";
|
||||
case "PENDING":
|
||||
return "ReservationPending";
|
||||
default:
|
||||
return "ReservationHold";
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<JsonLd<EventReservation>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "EventReservation",
|
||||
reservationId,
|
||||
reservationStatus,
|
||||
reservationFor: {
|
||||
"@type": "Event",
|
||||
name: eventName,
|
||||
startDate: startTime.toString(),
|
||||
endDate: endTime.toString(),
|
||||
organizer: organizer
|
||||
? ({ "@type": "Person", name: organizer.name, email: organizer.email } as Person)
|
||||
: undefined,
|
||||
attendee: attendees?.map(
|
||||
(person) => ({ "@type": "Person", name: person.name, email: person.email } as Person)
|
||||
),
|
||||
location: location || undefined,
|
||||
description: description || undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventReservationSchema;
|
||||
115
calcom/apps/web/components/security/ChangePasswordSection.tsx
Normal file
115
calcom/apps/web/components/security/ChangePasswordSection.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { SyntheticEvent } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, showToast } from "@calcom/ui";
|
||||
|
||||
const ChangePasswordSection = () => {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
// hold display until the locale is loaded
|
||||
if (!isLocaleReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMessages: { [key: string]: string } = {
|
||||
[ErrorCode.IncorrectPassword]: t("current_incorrect_password"),
|
||||
[ErrorCode.NewPasswordMatchesOld]: t("new_password_matches_old_password"),
|
||||
};
|
||||
|
||||
async function changePasswordHandler(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/changepw", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ oldPassword, newPassword }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
showToast(t("password_has_been_changed"), "success");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
setErrorMessage(errorMessages[body.error] || `${t("something_went_wrong")}${t("please_try_again")}`);
|
||||
} catch (err) {
|
||||
console.error(t("error_changing_password"), err);
|
||||
setErrorMessage(`${t("something_went_wrong")}${t("please_try_again")}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="divide-subtle divide-y lg:col-span-9" onSubmit={changePasswordHandler}>
|
||||
<div className="py-6 lg:pb-5">
|
||||
<div className="my-3">
|
||||
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">{t("change_password")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2 sm:flex-row sm:space-y-0">
|
||||
<div className="w-full ltr:mr-2 rtl:ml-2 sm:w-1/2">
|
||||
<label htmlFor="current_password" className="text-default block text-sm font-medium">
|
||||
{t("current_password")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onInput={(e) => setOldPassword(e.currentTarget.value)}
|
||||
name="current_password"
|
||||
id="current_password"
|
||||
required
|
||||
className="border-default block w-full rounded-sm text-sm"
|
||||
placeholder={t("your_old_password")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full sm:w-1/2">
|
||||
<label htmlFor="new_password" className="text-default block text-sm font-medium">
|
||||
{t("new_password")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="password"
|
||||
name="new_password"
|
||||
id="new_password"
|
||||
value={newPassword}
|
||||
required
|
||||
onInput={(e) => setNewPassword(e.currentTarget.value)}
|
||||
className="border-default block w-full rounded-sm text-sm"
|
||||
placeholder={t("super_secure_new_password")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
<div className="flex py-8 sm:justify-end">
|
||||
<Button color="secondary" type="submit">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordSection;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, Form, PasswordField } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
import TwoFactorModalHeader from "./TwoFactorModalHeader";
|
||||
|
||||
interface DisableTwoFactorAuthModalProps {
|
||||
/** Called when the user closes the modal without disabling two-factor auth */
|
||||
onCancel: () => void;
|
||||
/** Called when the user disables two-factor auth */
|
||||
onDisable: () => void;
|
||||
}
|
||||
|
||||
interface DisableTwoFactorValues {
|
||||
totpCode: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuthModalProps) => {
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const { t } = useLocale();
|
||||
const form = useForm<DisableTwoFactorValues>();
|
||||
async function handleDisable({ totpCode, password }: DisableTwoFactorValues) {
|
||||
if (isDisabling) {
|
||||
return;
|
||||
}
|
||||
setIsDisabling(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.disable(password, totpCode);
|
||||
if (response.status === 200) {
|
||||
onDisable();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
if (body.error === ErrorCode.IncorrectPassword) {
|
||||
setErrorMessage(t("incorrect_password"));
|
||||
}
|
||||
if (body.error === ErrorCode.SecondFactorRequired) {
|
||||
setErrorMessage(t("2fa_required"));
|
||||
}
|
||||
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
setErrorMessage(t("incorrect_2fa"));
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
console.error(t("error_disabling_2fa"), e);
|
||||
} finally {
|
||||
setIsDisabling(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent>
|
||||
<Form form={form} handleSubmit={handleDisable}>
|
||||
<TwoFactorModalHeader title={t("disable_2fa")} description={t("disable_2fa_recommendation")} />
|
||||
|
||||
<div className="mb-4">
|
||||
<PasswordField
|
||||
labelProps={{
|
||||
className: "block text-sm font-medium text-default",
|
||||
}}
|
||||
{...form.register("password")}
|
||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||
/>
|
||||
<TwoFactor center={false} />
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" className="me-2 ms-2" disabled={isDisabling}>
|
||||
{t("disable")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisableTwoFactorAuthModal;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Badge, Button, showToast } from "@calcom/ui";
|
||||
|
||||
const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonation: boolean }) => {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
await utils.viewer.me.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col justify-between pl-2 pt-9 sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
|
||||
{t("user_impersonation_heading")}
|
||||
</h2>
|
||||
<Badge className="ml-2 text-xs" variant={!disableImpersonation ? "success" : "gray"}>
|
||||
{!disableImpersonation ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-subtle mt-1 text-sm">{t("user_impersonation_description")}</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 sm:self-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
!disableImpersonation
|
||||
? mutation.mutate({ disableImpersonation: true })
|
||||
: mutation.mutate({ disableImpersonation: false })
|
||||
}>
|
||||
{!disableImpersonation ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisableUserImpersonation;
|
||||
219
calcom/apps/web/components/security/EnableTwoFactorModal.tsx
Normal file
219
calcom/apps/web/components/security/EnableTwoFactorModal.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { BaseSyntheticEvent } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, Form } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
import TwoFactorModalHeader from "./TwoFactorModalHeader";
|
||||
|
||||
interface EnableTwoFactorModalProps {
|
||||
/**
|
||||
* Called when the user closes the modal without disabling two-factor auth
|
||||
*/
|
||||
onCancel: () => void;
|
||||
|
||||
/**
|
||||
* Called when the user enables two-factor auth
|
||||
*/
|
||||
onEnable: () => void;
|
||||
}
|
||||
|
||||
enum SetupStep {
|
||||
ConfirmPassword,
|
||||
DisplayQrCode,
|
||||
EnterTotpCode,
|
||||
}
|
||||
|
||||
const WithStep = ({
|
||||
step,
|
||||
current,
|
||||
children,
|
||||
}: {
|
||||
step: SetupStep;
|
||||
current: SetupStep;
|
||||
children: JSX.Element;
|
||||
}) => {
|
||||
return step === current ? children : null;
|
||||
};
|
||||
|
||||
interface EnableTwoFactorValues {
|
||||
totpCode: string;
|
||||
}
|
||||
|
||||
const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps) => {
|
||||
const { t } = useLocale();
|
||||
const form = useForm<EnableTwoFactorValues>();
|
||||
|
||||
const setupDescriptions = {
|
||||
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
||||
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
||||
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
||||
};
|
||||
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
||||
const [password, setPassword] = useState("");
|
||||
const [dataUri, setDataUri] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
async function handleSetup(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.setup(password);
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
setDataUri(body.dataUri);
|
||||
setSecret(body.secret);
|
||||
setStep(SetupStep.DisplayQrCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.error === ErrorCode.IncorrectPassword) {
|
||||
setErrorMessage(t("incorrect_password"));
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
console.error(t("error_enabling_2fa"), e);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnable({ totpCode }: EnableTwoFactorValues, e: BaseSyntheticEvent | undefined) {
|
||||
e?.preventDefault();
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.enable(totpCode);
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
onEnable();
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
setErrorMessage(`${t("code_is_incorrect")} ${t("please_try_again")}`);
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
console.error(t("error_enabling_2fa"), e);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnableRef = useCallbackRef(handleEnable);
|
||||
|
||||
const totpCode = form.watch("totpCode");
|
||||
|
||||
// auto submit 2FA if all inputs have a value
|
||||
useEffect(() => {
|
||||
if (totpCode?.trim().length === 6) {
|
||||
form.handleSubmit(handleEnableRef.current)();
|
||||
}
|
||||
}, [form, handleEnableRef, totpCode]);
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent>
|
||||
<TwoFactorModalHeader title={t("enable_2fa")} description={setupDescriptions[step]} />
|
||||
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<form onSubmit={handleSetup}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="text-default mt-4 block text-sm font-medium">
|
||||
{t("password")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
required
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
className="border-default block w-full rounded-sm text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={dataUri} alt="" />
|
||||
}
|
||||
</div>
|
||||
<p className="text-center font-mono text-xs">{secret}</p>
|
||||
</>
|
||||
</WithStep>
|
||||
<Form handleSubmit={handleEnable} form={form}>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<div className="mb-4">
|
||||
<TwoFactor center />
|
||||
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
</WithStep>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="me-2 ms-2"
|
||||
onClick={handleSetup}
|
||||
disabled={password.length === 0 || isSubmitting}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<Button type="submit" className="me-2 ms-2" onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<Button type="submit" className="me-2 ms-2" disabled={isSubmitting}>
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnableTwoFactorModal;
|
||||
33
calcom/apps/web/components/security/TwoFactorAuthAPI.ts
Normal file
33
calcom/apps/web/components/security/TwoFactorAuthAPI.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
const TwoFactorAuthAPI = {
|
||||
async setup(password: string) {
|
||||
return fetch("/api/auth/two-factor/totp/setup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async enable(code: string) {
|
||||
return fetch("/api/auth/two-factor/totp/enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async disable(password: string, code: string) {
|
||||
return fetch("/api/auth/two-factor/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password, code }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default TwoFactorAuthAPI;
|
||||
59
calcom/apps/web/components/security/TwoFactorAuthSection.tsx
Normal file
59
calcom/apps/web/components/security/TwoFactorAuthSection.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Badge, Button } from "@calcom/ui";
|
||||
|
||||
import DisableTwoFactorModal from "./DisableTwoFactorModal";
|
||||
import EnableTwoFactorModal from "./EnableTwoFactorModal";
|
||||
|
||||
const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean }) => {
|
||||
const [enabled, setEnabled] = useState(twoFactorEnabled);
|
||||
const [enableModalOpen, setEnableModalOpen] = useState(false);
|
||||
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col justify-between pl-2 pt-9 sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">{t("2fa")}</h2>
|
||||
<Badge className="ml-2 text-xs" variant={enabled ? "success" : "gray"}>
|
||||
{enabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-subtle mt-1 text-sm">{t("add_an_extra_layer_of_security")}</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 sm:self-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
|
||||
{enabled ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{enableModalOpen && (
|
||||
<EnableTwoFactorModal
|
||||
onEnable={() => {
|
||||
setEnabled(true);
|
||||
setEnableModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setEnableModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{disableModalOpen && (
|
||||
<DisableTwoFactorModal
|
||||
onDisable={() => {
|
||||
setEnabled(false);
|
||||
setDisableModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setDisableModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorAuthSection;
|
||||
19
calcom/apps/web/components/security/TwoFactorModalHeader.tsx
Normal file
19
calcom/apps/web/components/security/TwoFactorModalHeader.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<Icon name="shield" className="text-inverted h-6 w-6" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<h3 className="font-cal text-emphasis text-lg font-medium leading-6" id="modal-title">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-muted text-sm">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorModalHeader;
|
||||
126
calcom/apps/web/components/settings/CustomEmailTextField.tsx
Normal file
126
calcom/apps/web/components/settings/CustomEmailTextField.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { FormValues } from "@pages/settings/my-account/profile";
|
||||
import { useState } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Badge,
|
||||
TextField,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Button,
|
||||
InputError,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type CustomEmailTextFieldProps = {
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
formMethodFieldName: keyof FormValues;
|
||||
errorMessage: string;
|
||||
emailVerified: boolean;
|
||||
emailPrimary: boolean;
|
||||
dataTestId: string;
|
||||
handleChangePrimary: () => void;
|
||||
handleVerifyEmail: () => void;
|
||||
handleItemDelete: () => void;
|
||||
};
|
||||
|
||||
const CustomEmailTextField = ({
|
||||
formMethods,
|
||||
formMethodFieldName,
|
||||
errorMessage,
|
||||
emailVerified,
|
||||
emailPrimary,
|
||||
dataTestId,
|
||||
handleChangePrimary,
|
||||
handleVerifyEmail,
|
||||
handleItemDelete,
|
||||
}: CustomEmailTextFieldProps) => {
|
||||
const { t } = useLocale();
|
||||
const [inputFocus, setInputFocus] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`border-default mt-2 flex items-center rounded-md border ${
|
||||
inputFocus ? "ring-brand-default border-neutral-300 ring-2" : ""
|
||||
}`}>
|
||||
<TextField
|
||||
{...formMethods.register(formMethodFieldName)}
|
||||
label=""
|
||||
containerClassName="flex flex-1 items-center"
|
||||
className="mb-0 border-none outline-none focus:ring-0"
|
||||
data-testid={dataTestId}
|
||||
onFocus={() => setInputFocus(true)}
|
||||
onBlur={() => setInputFocus(false)}
|
||||
/>
|
||||
<div className="flex items-center pr-2">
|
||||
{emailPrimary && (
|
||||
<Badge variant="blue" size="sm" data-testid={`${dataTestId}-primary-badge`}>
|
||||
{t("primary")}
|
||||
</Badge>
|
||||
)}
|
||||
{!emailVerified && (
|
||||
<Badge variant="orange" size="sm" className="ml-2" data-testid={`${dataTestId}-unverified-badge`}>
|
||||
{t("unverified")}
|
||||
</Badge>
|
||||
)}
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
StartIcon="ellipsis"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
className="ml-2 rounded-md"
|
||||
data-testid="secondary-email-action-group-button"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
StartIcon="flag"
|
||||
color="secondary"
|
||||
className="disabled:opacity-40"
|
||||
onClick={handleChangePrimary}
|
||||
disabled={!emailVerified || emailPrimary}
|
||||
data-testid="secondary-email-make-primary-button">
|
||||
{t("make_primary")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{!emailVerified && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
StartIcon="send"
|
||||
color="secondary"
|
||||
className="disabled:opacity-40"
|
||||
onClick={handleVerifyEmail}
|
||||
disabled={emailVerified}
|
||||
data-testid="resend-verify-email-button">
|
||||
{t("resend_email")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
StartIcon="trash"
|
||||
color="destructive"
|
||||
className="disabled:opacity-40"
|
||||
onClick={handleItemDelete}
|
||||
disabled={emailPrimary}
|
||||
data-testid="secondary-email-delete-button">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && <InputError message={errorMessage} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomEmailTextField;
|
||||
140
calcom/apps/web/components/settings/DisableTwoFactorModal.tsx
Normal file
140
calcom/apps/web/components/settings/DisableTwoFactorModal.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
||||
|
||||
import BackupCode from "@components/auth/BackupCode";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
|
||||
interface DisableTwoFactorAuthModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
disablePassword?: boolean;
|
||||
/** Called when the user closes the modal without disabling two-factor auth */
|
||||
onCancel: () => void;
|
||||
/** Called when the user disables two-factor auth */
|
||||
onDisable: () => void;
|
||||
}
|
||||
|
||||
interface DisableTwoFactorValues {
|
||||
backupCode: string;
|
||||
totpCode: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const DisableTwoFactorAuthModal = ({
|
||||
onDisable,
|
||||
onCancel,
|
||||
disablePassword,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DisableTwoFactorAuthModalProps) => {
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||
const { t } = useLocale();
|
||||
|
||||
const form = useForm<DisableTwoFactorValues>();
|
||||
|
||||
const resetForm = (clearPassword = true) => {
|
||||
if (clearPassword) form.setValue("password", "");
|
||||
form.setValue("backupCode", "");
|
||||
form.setValue("totpCode", "");
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
async function handleDisable({ password, totpCode, backupCode }: DisableTwoFactorValues) {
|
||||
if (isDisabling) {
|
||||
return;
|
||||
}
|
||||
setIsDisabling(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
|
||||
if (response.status === 200) {
|
||||
setTwoFactorLostAccess(false);
|
||||
resetForm();
|
||||
onDisable();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
if (body.error === ErrorCode.IncorrectPassword) {
|
||||
setErrorMessage(t("incorrect_password"));
|
||||
} else if (body.error === ErrorCode.SecondFactorRequired) {
|
||||
setErrorMessage(t("2fa_required"));
|
||||
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
setErrorMessage(t("incorrect_2fa"));
|
||||
} else if (body.error === ErrorCode.IncorrectBackupCode) {
|
||||
setErrorMessage(t("incorrect_backup_code"));
|
||||
} else if (body.error === ErrorCode.MissingBackupCodes) {
|
||||
setErrorMessage(t("missing_backup_codes"));
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
console.error(t("error_disabling_2fa"), e);
|
||||
} finally {
|
||||
setIsDisabling(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent title={t("disable_2fa")} description={t("disable_2fa_recommendation")} type="creation">
|
||||
<Form form={form} handleSubmit={handleDisable}>
|
||||
<div className="mb-8">
|
||||
{!disablePassword && (
|
||||
<PasswordField
|
||||
required
|
||||
labelProps={{
|
||||
className: "block text-sm font-medium text-default",
|
||||
}}
|
||||
{...form.register("password")}
|
||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||
/>
|
||||
)}
|
||||
{twoFactorLostAccess ? (
|
||||
<BackupCode center={false} />
|
||||
) : (
|
||||
<TwoFactor center={false} autoFocus={false} />
|
||||
)}
|
||||
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter showDivider className="relative mt-5">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="mr-auto"
|
||||
onClick={() => {
|
||||
setTwoFactorLostAccess(!twoFactorLostAccess);
|
||||
resetForm(false);
|
||||
}}>
|
||||
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="me-2 ms-2"
|
||||
data-testid="disable-2fa"
|
||||
loading={isDisabling}
|
||||
disabled={isDisabling}>
|
||||
{t("disable")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisableTwoFactorAuthModal;
|
||||
296
calcom/apps/web/components/settings/EnableTwoFactorModal.tsx
Normal file
296
calcom/apps/web/components/settings/EnableTwoFactorModal.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import type { BaseSyntheticEvent } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
|
||||
interface EnableTwoFactorModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
|
||||
/**
|
||||
* Called when the user closes the modal without disabling two-factor auth
|
||||
*/
|
||||
onCancel: () => void;
|
||||
|
||||
/**
|
||||
* Called when the user enables two-factor auth
|
||||
*/
|
||||
onEnable: () => void;
|
||||
}
|
||||
|
||||
enum SetupStep {
|
||||
ConfirmPassword,
|
||||
DisplayBackupCodes,
|
||||
DisplayQrCode,
|
||||
EnterTotpCode,
|
||||
}
|
||||
|
||||
const WithStep = ({
|
||||
step,
|
||||
current,
|
||||
children,
|
||||
}: {
|
||||
step: SetupStep;
|
||||
current: SetupStep;
|
||||
children: JSX.Element;
|
||||
}) => {
|
||||
return step === current ? children : null;
|
||||
};
|
||||
|
||||
interface EnableTwoFactorValues {
|
||||
totpCode: string;
|
||||
}
|
||||
|
||||
const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: EnableTwoFactorModalProps) => {
|
||||
const { t } = useLocale();
|
||||
const form = useForm<EnableTwoFactorValues>();
|
||||
|
||||
const setupDescriptions = {
|
||||
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
||||
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
|
||||
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
||||
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
||||
};
|
||||
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
||||
const [password, setPassword] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState([]);
|
||||
const [backupCodesUrl, setBackupCodesUrl] = useState("");
|
||||
const [dataUri, setDataUri] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const resetState = () => {
|
||||
setPassword("");
|
||||
setErrorMessage(null);
|
||||
setStep(SetupStep.ConfirmPassword);
|
||||
};
|
||||
|
||||
async function handleSetup(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.setup(password);
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
setBackupCodes(body.backupCodes);
|
||||
|
||||
// create backup codes download url
|
||||
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
|
||||
type: "text/plain",
|
||||
});
|
||||
if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl);
|
||||
setBackupCodesUrl(URL.createObjectURL(textBlob));
|
||||
|
||||
setDataUri(body.dataUri);
|
||||
setSecret(body.secret);
|
||||
setStep(SetupStep.DisplayQrCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.error === ErrorCode.IncorrectPassword) {
|
||||
setErrorMessage(t("incorrect_password"));
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
console.error(t("error_enabling_2fa"), e);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnable({ totpCode }: EnableTwoFactorValues, e: BaseSyntheticEvent | undefined) {
|
||||
e?.preventDefault();
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.enable(totpCode);
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
setStep(SetupStep.DisplayBackupCodes);
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
setErrorMessage(`${t("code_is_incorrect")} ${t("please_try_again")}`);
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
console.error(t("error_enabling_2fa"), e);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnableRef = useCallbackRef(handleEnable);
|
||||
|
||||
const totpCode = form.watch("totpCode");
|
||||
|
||||
// auto submit 2FA if all inputs have a value
|
||||
useEffect(() => {
|
||||
if (totpCode?.trim().length === 6) {
|
||||
form.handleSubmit(handleEnableRef.current)();
|
||||
}
|
||||
}, [form, handleEnableRef, totpCode]);
|
||||
|
||||
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
title={step === SetupStep.DisplayBackupCodes ? t("backup_codes") : t("enable_2fa")}
|
||||
description={setupDescriptions[step]}
|
||||
type="creation">
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<form onSubmit={handleSetup}>
|
||||
<div className="mb-4">
|
||||
<PasswordField
|
||||
label={t("password")}
|
||||
name="password"
|
||||
id="password"
|
||||
required
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<>
|
||||
<div className="-mt-3 flex justify-center">
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={dataUri} alt="" />
|
||||
}
|
||||
</div>
|
||||
<p data-testid="two-factor-secret" className="mb-4 text-center font-mono text-xs">
|
||||
{secret}
|
||||
</p>
|
||||
</>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||
<>
|
||||
<div className="mt-5 grid grid-cols-2 gap-1 text-center font-mono md:pl-10 md:pr-10">
|
||||
{backupCodes.map((code) => (
|
||||
<div key={code}>{formatBackupCode(code)}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</WithStep>
|
||||
<Form handleSubmit={handleEnable} form={form}>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<div className="-mt-4 pb-2">
|
||||
<TwoFactor center />
|
||||
|
||||
{errorMessage && (
|
||||
<p data-testid="error-submitting-code" className="mt-1 text-sm text-red-700">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</WithStep>
|
||||
<DialogFooter className="mt-8" showDivider>
|
||||
{step !== SetupStep.DisplayBackupCodes ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
resetState();
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="me-2 ms-2"
|
||||
onClick={handleSetup}
|
||||
loading={isSubmitting}
|
||||
disabled={password.length === 0 || isSubmitting}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="goto-otp-screen"
|
||||
className="me-2 ms-2"
|
||||
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="me-2 ms-2"
|
||||
data-testid="enable-2fa"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}>
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="backup-codes-close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetState();
|
||||
onEnable();
|
||||
}}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="backup-codes-copy"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(backupCodes.map(formatBackupCode).join("\n"));
|
||||
showToast(t("backup_codes_copied"), "success");
|
||||
}}>
|
||||
{t("copy")}
|
||||
</Button>
|
||||
<a download="cal-backup-codes.txt" href={backupCodesUrl}>
|
||||
<Button color="primary" data-testid="backup-codes-download">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</a>
|
||||
</>
|
||||
</WithStep>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnableTwoFactorModal;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui";
|
||||
|
||||
interface SecondaryEmailConfirmModalProps {
|
||||
email: string;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const SecondaryEmailConfirmModal = ({ email, onCancel }: SecondaryEmailConfirmModalProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
title={t("confirm_email")}
|
||||
description={<Trans i18nKey="confirm_email_description" values={{ email }} />}
|
||||
type="creation"
|
||||
data-testid="secondary-email-confirm-dialog">
|
||||
<DialogFooter>
|
||||
<DialogClose color="primary" onClick={onCancel} data-testid="secondary-email-confirm-done-button">
|
||||
{t("done")}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecondaryEmailConfirmModal;
|
||||
77
calcom/apps/web/components/settings/SecondaryEmailModal.tsx
Normal file
77
calcom/apps/web/components/settings/SecondaryEmailModal.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
Button,
|
||||
TextField,
|
||||
Form,
|
||||
InputError,
|
||||
} from "@calcom/ui";
|
||||
|
||||
interface SecondaryEmailModalProps {
|
||||
isLoading: boolean;
|
||||
errorMessage?: string;
|
||||
handleAddEmail: (value: { email: string }) => void;
|
||||
onCancel: () => void;
|
||||
clearErrorMessage: () => void;
|
||||
}
|
||||
|
||||
const SecondaryEmailModal = ({
|
||||
isLoading,
|
||||
errorMessage,
|
||||
handleAddEmail,
|
||||
onCancel,
|
||||
clearErrorMessage,
|
||||
}: SecondaryEmailModalProps) => {
|
||||
const { t } = useLocale();
|
||||
type FormValues = {
|
||||
email: string;
|
||||
};
|
||||
const formMethods = useForm<FormValues>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// We will reset the errorMessage once the user starts modifying the email
|
||||
const subscription = formMethods.watch(() => clearErrorMessage());
|
||||
return () => subscription.unsubscribe();
|
||||
}, [formMethods.watch]);
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
title={t("add_email")}
|
||||
description={t("add_email_description")}
|
||||
type="creation"
|
||||
data-testid="secondary-email-add-dialog">
|
||||
<Form form={formMethods} handleSubmit={handleAddEmail}>
|
||||
<TextField
|
||||
label={t("email_address")}
|
||||
data-testid="secondary-email-input"
|
||||
{...formMethods.register("email")}
|
||||
/>
|
||||
{errorMessage && <InputError message={errorMessage} />}
|
||||
<DialogFooter showDivider className="mt-10">
|
||||
<DialogClose onClick={onCancel}>{t("cancel")}</DialogClose>
|
||||
<Button type="submit" data-testid="add-secondary-email-button" disabled={isLoading}>
|
||||
{t("add_email")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecondaryEmailModal;
|
||||
163
calcom/apps/web/components/settings/TravelScheduleModal.tsx
Normal file
163
calcom/apps/web/components/settings/TravelScheduleModal.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { FormValues } from "@pages/settings/my-account/general";
|
||||
import { useState } from "react";
|
||||
import type { UseFormSetValue } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib/timePreferences";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
Button,
|
||||
Label,
|
||||
DateRangePicker,
|
||||
TimezoneSelect,
|
||||
SettingsToggle,
|
||||
DatePicker,
|
||||
} from "@calcom/ui";
|
||||
|
||||
interface TravelScheduleModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
existingSchedules: FormValues["travelSchedules"];
|
||||
}
|
||||
|
||||
const TravelScheduleModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
setValue,
|
||||
existingSchedules,
|
||||
}: TravelScheduleModalProps) => {
|
||||
const { t } = useLocale();
|
||||
const { timezone: preferredTimezone } = useTimePreferences();
|
||||
|
||||
const [startDate, setStartDate] = useState<Date>(new Date());
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(new Date());
|
||||
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState(preferredTimezone);
|
||||
const [isNoEndDate, setIsNoEndDate] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const isOverlapping = (newSchedule: { startDate: Date; endDate?: Date }) => {
|
||||
const newStart = dayjs(newSchedule.startDate);
|
||||
const newEnd = newSchedule.endDate ? dayjs(newSchedule.endDate) : null;
|
||||
|
||||
for (const schedule of existingSchedules) {
|
||||
const start = dayjs(schedule.startDate);
|
||||
const end = schedule.endDate ? dayjs(schedule.endDate) : null;
|
||||
|
||||
if (!newEnd) {
|
||||
// if the start date is after or on the existing schedule's start date and before the existing schedule's end date (if it has one)
|
||||
if (newStart.isSame(start) || newStart.isAfter(start)) {
|
||||
if (!end || newStart.isSame(end) || newStart.isBefore(end)) return true;
|
||||
}
|
||||
} else {
|
||||
// For schedules with an end date, check for any overlap
|
||||
if (newStart.isSame(end) || newStart.isBefore(end) || end === null) {
|
||||
if (newEnd.isSame(start) || newEnd.isAfter(start)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetValues = () => {
|
||||
setStartDate(new Date());
|
||||
setEndDate(new Date());
|
||||
setSelectedTimeZone(preferredTimezone);
|
||||
setIsNoEndDate(false);
|
||||
};
|
||||
|
||||
const createNewSchedule = () => {
|
||||
const newSchedule = {
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone: selectedTimeZone,
|
||||
};
|
||||
|
||||
if (!isOverlapping(newSchedule)) {
|
||||
setValue("travelSchedules", existingSchedules.concat(newSchedule), { shouldDirty: true });
|
||||
onOpenChange();
|
||||
resetValues();
|
||||
} else {
|
||||
setErrorMessage(t("overlaps_with_existing_schedule"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
title={t("travel_schedule")}
|
||||
description={t("travel_schedule_description")}
|
||||
type="creation">
|
||||
<div>
|
||||
{!isNoEndDate ? (
|
||||
<>
|
||||
<Label className="mt-2">{t("time_range")}</Label>
|
||||
<DateRangePicker
|
||||
dates={{
|
||||
startDate,
|
||||
endDate: endDate ?? startDate,
|
||||
}}
|
||||
onDatesChange={({ startDate: newStartDate, endDate: newEndDate }) => {
|
||||
// If newStartDate does become undefined - we resort back to to-todays date
|
||||
setStartDate(newStartDate ?? new Date());
|
||||
setEndDate(newEndDate);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Label className="mt-2">{t("date")}</Label>
|
||||
<DatePicker
|
||||
minDate={new Date()}
|
||||
date={startDate}
|
||||
className="w-56"
|
||||
onDatesChange={(newDate) => {
|
||||
setStartDate(newDate);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="text-error mt-1 text-sm">{errorMessage}</div>
|
||||
<div className="mt-3">
|
||||
<SettingsToggle
|
||||
labelClassName="mt-1 font-normal"
|
||||
title={t("schedule_tz_without_end_date")}
|
||||
checked={isNoEndDate}
|
||||
onCheckedChange={(e) => {
|
||||
setEndDate(!e ? startDate : undefined);
|
||||
setIsNoEndDate(e);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Label className="mt-6">{t("timezone")}</Label>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={({ value }) => setSelectedTimeZone(value)}
|
||||
className="mb-11 mt-2 w-full rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter showDivider className="relative">
|
||||
<DialogClose />
|
||||
<Button
|
||||
onClick={() => {
|
||||
createNewSchedule();
|
||||
}}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TravelScheduleModal;
|
||||
33
calcom/apps/web/components/settings/TwoFactorAuthAPI.ts
Normal file
33
calcom/apps/web/components/settings/TwoFactorAuthAPI.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
const TwoFactorAuthAPI = {
|
||||
async setup(password: string) {
|
||||
return fetch("/api/auth/two-factor/totp/setup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async enable(code: string) {
|
||||
return fetch("/api/auth/two-factor/totp/enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async disable(password: string, code: string, backupCode: string) {
|
||||
return fetch("/api/auth/two-factor/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password, code, backupCode }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default TwoFactorAuthAPI;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Card, Icon } from "@calcom/ui";
|
||||
|
||||
import { helpCards } from "@lib/settings/platform/utils";
|
||||
|
||||
export const HelpCards = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="grid-col-1 mb-4 grid gap-2 md:grid-cols-3">
|
||||
{helpCards.map((card) => {
|
||||
return (
|
||||
<div key={card.title}>
|
||||
<Card
|
||||
icon={<Icon name={card.icon} className="h-5 w-5 text-green-700" />}
|
||||
variant={card.variant}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
actionButton={{
|
||||
href: `${card.actionButton.href}`,
|
||||
child: `${card.actionButton.child}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EmptyScreen, Button } from "@calcom/ui";
|
||||
|
||||
export default function NoPlatformPlan() {
|
||||
return (
|
||||
<EmptyScreen
|
||||
Icon="credit-card"
|
||||
headline="Subscription needed"
|
||||
description="You are not subscribed to a Platform plan."
|
||||
buttonRaw={
|
||||
<div className="flex gap-2">
|
||||
<Button href="https://cal.com/platform/pricing">Go to Pricing</Button>
|
||||
<Button color="secondary" href="https://cal.com/pricing">
|
||||
Contact Sales
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
|
||||
import { OAuthClientsDropdown } from "@components/settings/platform/dashboard/oauth-client-dropdown";
|
||||
|
||||
type ManagedUserHeaderProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
initialClientName: string;
|
||||
handleChange: (clientId: string, clientName: string) => void;
|
||||
};
|
||||
|
||||
export const ManagedUserHeader = ({
|
||||
oauthClients,
|
||||
initialClientName,
|
||||
handleChange,
|
||||
}: ManagedUserHeaderProps) => {
|
||||
return (
|
||||
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
|
||||
Managed Users
|
||||
</h1>
|
||||
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
|
||||
See all the managed users created by your OAuth client.
|
||||
</p>
|
||||
</div>
|
||||
<OAuthClientsDropdown
|
||||
oauthClients={oauthClients}
|
||||
initialClientName={initialClientName}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
|
||||
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
|
||||
|
||||
import { ManagedUserHeader } from "@components/settings/platform/dashboard/managed-user-header";
|
||||
import { ManagedUserTable } from "@components/settings/platform/dashboard/managed-user-table";
|
||||
|
||||
type ManagedUserListProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
managedUsers?: ManagedUser[];
|
||||
initialClientName: string;
|
||||
initialClientId: string;
|
||||
isManagedUserLoading: boolean;
|
||||
handleChange: (clientId: string, clientName: string) => void;
|
||||
};
|
||||
|
||||
export const ManagedUserList = ({
|
||||
initialClientName,
|
||||
initialClientId,
|
||||
oauthClients,
|
||||
managedUsers,
|
||||
isManagedUserLoading,
|
||||
handleChange,
|
||||
}: ManagedUserListProps) => {
|
||||
return (
|
||||
<div>
|
||||
<ManagedUserHeader
|
||||
oauthClients={oauthClients}
|
||||
initialClientName={initialClientName}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<ManagedUserTable
|
||||
managedUsers={managedUsers}
|
||||
isManagedUserLoading={isManagedUserLoading}
|
||||
initialClientId={initialClientId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { EmptyScreen } from "@calcom/ui";
|
||||
|
||||
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
|
||||
|
||||
type ManagedUserTableProps = {
|
||||
managedUsers?: ManagedUser[];
|
||||
isManagedUserLoading: boolean;
|
||||
initialClientId: string;
|
||||
};
|
||||
|
||||
export const ManagedUserTable = ({
|
||||
managedUsers,
|
||||
isManagedUserLoading,
|
||||
initialClientId,
|
||||
}: ManagedUserTableProps) => {
|
||||
const showUsers = !isManagedUserLoading && managedUsers?.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showUsers ? (
|
||||
<>
|
||||
<table className="w-[100%] rounded-lg">
|
||||
<colgroup className="border-subtle overflow-hidden rounded-b-lg border border-b-0" span={3} />
|
||||
<tr>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">Id</td>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">Username</td>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">Email</td>
|
||||
</tr>
|
||||
{managedUsers.map((user) => {
|
||||
return (
|
||||
<tr key={user.id} className="">
|
||||
<td className="border-subtle overflow-hidden border px-4 py-3 md:text-center">{user.id}</td>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">{user.username}</td>
|
||||
<td className="border-subtle overflow-hidden border px-4 py-3 md:overflow-auto md:text-center">
|
||||
{user.email}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
limitWidth={false}
|
||||
headline={
|
||||
initialClientId == undefined
|
||||
? "OAuth client is missing. You need to create an OAuth client first in order to create a managed user."
|
||||
: `OAuth client ${initialClientId} does not have a managed user present.`
|
||||
}
|
||||
description={
|
||||
initialClientId == undefined
|
||||
? "Refer to the Platform Docs from the sidebar in order to create an OAuth client."
|
||||
: "Refer to the Platform Docs from the sidebar in order to create a managed user."
|
||||
}
|
||||
className="items-center border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type OAuthClientsDropdownProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
initialClientName: string;
|
||||
handleChange: (clientId: string, clientName: string) => void;
|
||||
};
|
||||
|
||||
export const OAuthClientsDropdown = ({
|
||||
oauthClients,
|
||||
initialClientName,
|
||||
handleChange,
|
||||
}: OAuthClientsDropdownProps) => {
|
||||
return (
|
||||
<div>
|
||||
{Array.isArray(oauthClients) && oauthClients.length > 0 ? (
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button color="secondary">{initialClientName}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{oauthClients.map((client) => {
|
||||
return (
|
||||
<div key={client.id}>
|
||||
{initialClientName !== client.name ? (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem type="button" onClick={() => handleChange(client.id, client.name)}>
|
||||
{client.name}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
import { EmptyScreen, Button } from "@calcom/ui";
|
||||
|
||||
import { OAuthClientCard } from "@components/settings/platform/oauth-clients/OAuthClientCard";
|
||||
|
||||
type OAuthClientsListProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
isDeleting: boolean;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAuthClientsListProps) => {
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
|
||||
OAuth Clients
|
||||
</h1>
|
||||
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
|
||||
Connect your platform to cal.com with OAuth
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<NewOAuthClientButton redirectLink="/settings/platform/oauth-clients/create" />
|
||||
</div>
|
||||
</div>
|
||||
{Array.isArray(oauthClients) && oauthClients.length ? (
|
||||
<>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0">
|
||||
{oauthClients.map((client, index) => {
|
||||
return (
|
||||
<OAuthClientCard
|
||||
name={client.name}
|
||||
redirectUris={client.redirectUris}
|
||||
bookingRedirectUri={client.bookingRedirectUri}
|
||||
bookingRescheduleRedirectUri={client.bookingRescheduleRedirectUri}
|
||||
bookingCancelRedirectUri={client.bookingCancelRedirectUri}
|
||||
permissions={client.permissions}
|
||||
key={index}
|
||||
lastItem={oauthClients.length === index + 1}
|
||||
id={client.id}
|
||||
secret={client.secret}
|
||||
isLoading={isDeleting}
|
||||
onDelete={handleDelete}
|
||||
areEmailsEnabled={client.areEmailsEnabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
headline="Create your first OAuth client"
|
||||
description="OAuth clients facilitate access to Cal.com on behalf of users"
|
||||
Icon="plus"
|
||||
className=""
|
||||
buttonRaw={<NewOAuthClientButton redirectLink="/settings/platform/oauth-clients/create" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NewOAuthClientButton = ({ redirectLink, label }: { redirectLink: string; label?: string }) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.push(redirectLink);
|
||||
}}
|
||||
color="secondary"
|
||||
StartIcon="plus">
|
||||
{!!label ? label : "Add"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { useCheckTeamBilling } from "@calcom/web/lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
|
||||
|
||||
export const useGetUserAttributes = () => {
|
||||
const { data: user, isLoading: isUserLoading } = useMeQuery();
|
||||
const { data: userBillingData, isFetching: isUserBillingDataLoading } = useCheckTeamBilling(
|
||||
user?.organizationId,
|
||||
user?.organization.isPlatform
|
||||
);
|
||||
const isPlatformUser = user?.organization.isPlatform;
|
||||
const isPaidUser = userBillingData?.valid;
|
||||
const userOrgId = user?.organizationId;
|
||||
|
||||
return { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId };
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants";
|
||||
import type { Avatar } from "@calcom/prisma/client";
|
||||
import { Button, Icon, showToast } from "@calcom/ui";
|
||||
|
||||
import { hasPermission } from "../../../../../../packages/platform/utils/permissions";
|
||||
|
||||
type OAuthClientCardProps = {
|
||||
name: string;
|
||||
logo?: Avatar;
|
||||
redirectUris: string[];
|
||||
bookingRedirectUri: string | null;
|
||||
bookingCancelRedirectUri: string | null;
|
||||
bookingRescheduleRedirectUri: string | null;
|
||||
areEmailsEnabled: boolean;
|
||||
permissions: number;
|
||||
lastItem: boolean;
|
||||
id: string;
|
||||
secret: string;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export const OAuthClientCard = ({
|
||||
name,
|
||||
logo,
|
||||
redirectUris,
|
||||
bookingRedirectUri,
|
||||
bookingCancelRedirectUri,
|
||||
bookingRescheduleRedirectUri,
|
||||
permissions,
|
||||
id,
|
||||
secret,
|
||||
lastItem,
|
||||
onDelete,
|
||||
isLoading,
|
||||
areEmailsEnabled,
|
||||
}: OAuthClientCardProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const clientPermissions = Object.values(PERMISSIONS_GROUPED_MAP).map((value, index) => {
|
||||
let permissionsMessage = "";
|
||||
const hasReadPermission = hasPermission(permissions, value.read);
|
||||
const hasWritePermission = hasPermission(permissions, value.write);
|
||||
|
||||
if (hasReadPermission || hasWritePermission) {
|
||||
permissionsMessage = hasReadPermission ? "read" : "write";
|
||||
}
|
||||
|
||||
if (hasReadPermission && hasWritePermission) {
|
||||
permissionsMessage = "read/write";
|
||||
}
|
||||
|
||||
return (
|
||||
!!permissionsMessage && (
|
||||
<div key={value.read} className="relative text-sm">
|
||||
{permissionsMessage} {`${value.label}s`.toLocaleLowerCase()}
|
||||
{Object.values(PERMISSIONS_GROUPED_MAP).length === index + 1 ? " " : ", "}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex w-full justify-between px-4 py-4 sm:px-6",
|
||||
lastItem ? "" : "border-subtle border-b"
|
||||
)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-1">
|
||||
<p className="font-semibold">
|
||||
Client name: <span className="font-normal">{name}</span>
|
||||
</p>
|
||||
</div>
|
||||
{!!logo && (
|
||||
<div>
|
||||
<>{logo}</>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="font-semibold">Client Id:</div>
|
||||
<div>{id}</div>
|
||||
<Icon
|
||||
name="clipboard"
|
||||
type="button"
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(id);
|
||||
showToast("Client id copied to clipboard.", "success");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-semibold">Client Secret:</div>
|
||||
<div className="flex items-center justify-center rounded-md">
|
||||
{[...new Array(20)].map((_, index) => (
|
||||
<Icon name="asterisk" key={`${index}asterisk`} className="h-2 w-2" />
|
||||
))}
|
||||
<Icon
|
||||
name="clipboard"
|
||||
type="button"
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(secret);
|
||||
showToast("Client secret copied to clipboard.", "success");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle flex text-sm">
|
||||
<span className="font-semibold">Permissions: </span>
|
||||
{permissions ? <div className="flex">{clientPermissions}</div> : <> Disabled</>}
|
||||
</div>
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Redirect uris: </span>
|
||||
{redirectUris.map((item, index) => (redirectUris.length === index + 1 ? `${item}` : `${item}, `))}
|
||||
</div>
|
||||
{bookingRedirectUri && (
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Booking redirect uri: </span> {bookingRedirectUri}
|
||||
</div>
|
||||
)}
|
||||
{bookingRescheduleRedirectUri && (
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Booking reschedule uri: </span> {bookingRescheduleRedirectUri}
|
||||
</div>
|
||||
)}
|
||||
{bookingCancelRedirectUri && (
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Booking cancel uri: </span> {bookingCancelRedirectUri}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="text-sm font-semibold">Emails enabled:</span> {areEmailsEnabled ? "Yes" : "No"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<Button
|
||||
className="bg-subtle hover:bg-emphasis text-white"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-red-500 text-white hover:bg-red-600"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={() => onDelete(id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
|
||||
import { TextField, Tooltip, Button, Label } from "@calcom/ui";
|
||||
|
||||
type OAuthClientFormProps = {
|
||||
defaultValues?: Partial<FormValues>;
|
||||
isPending?: boolean;
|
||||
isFormDisabled?: boolean;
|
||||
onSubmit: (data: FormValues) => void;
|
||||
};
|
||||
|
||||
export type FormValues = {
|
||||
name: string;
|
||||
logo?: string;
|
||||
permissions: number;
|
||||
eventTypeRead: boolean;
|
||||
eventTypeWrite: boolean;
|
||||
bookingRead: boolean;
|
||||
bookingWrite: boolean;
|
||||
scheduleRead: boolean;
|
||||
scheduleWrite: boolean;
|
||||
appsRead: boolean;
|
||||
appsWrite: boolean;
|
||||
profileRead: boolean;
|
||||
profileWrite: boolean;
|
||||
redirectUris: {
|
||||
uri: string;
|
||||
}[];
|
||||
bookingRedirectUri?: string;
|
||||
bookingCancelRedirectUri?: string;
|
||||
bookingRescheduleRedirectUri?: string;
|
||||
areEmailsEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const OAuthClientForm = ({
|
||||
defaultValues,
|
||||
isPending,
|
||||
isFormDisabled,
|
||||
onSubmit,
|
||||
}: OAuthClientFormProps) => {
|
||||
const { t } = useLocale();
|
||||
const { register, control, handleSubmit, setValue } = useForm<FormValues>({
|
||||
defaultValues: { redirectUris: [{ uri: "" }], ...defaultValues },
|
||||
});
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "redirectUris",
|
||||
});
|
||||
|
||||
const [isSelectAllPermissionsChecked, setIsSelectAllPermissionsChecked] = useState(false);
|
||||
|
||||
const selectAllPermissions = useCallback(() => {
|
||||
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
|
||||
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
|
||||
const permissionKey = PERMISSIONS_GROUPED_MAP[entity].key;
|
||||
|
||||
setValue(`${permissionKey}Read`, !isSelectAllPermissionsChecked);
|
||||
setValue(`${permissionKey}Write`, !isSelectAllPermissionsChecked);
|
||||
});
|
||||
|
||||
setIsSelectAllPermissionsChecked((preValue) => !preValue);
|
||||
}, [isSelectAllPermissionsChecked, setValue]);
|
||||
|
||||
const permissionsCheckboxes = Object.keys(PERMISSIONS_GROUPED_MAP).map((key) => {
|
||||
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
|
||||
const permissionKey = PERMISSIONS_GROUPED_MAP[entity].key;
|
||||
const permissionLabel = PERMISSIONS_GROUPED_MAP[entity].label;
|
||||
|
||||
return (
|
||||
<div className="my-3" key={key}>
|
||||
<p className="text-sm font-semibold">{permissionLabel}</p>
|
||||
<div className="mt-1 flex gap-x-5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<input
|
||||
{...register(`${permissionKey}Read`)}
|
||||
id={`${permissionKey}Read`}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={!!defaultValues}
|
||||
/>
|
||||
<label htmlFor={`${permissionKey}Read`} className="cursor-pointer text-sm">
|
||||
Read
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<input
|
||||
{...register(`${permissionKey}Write`)}
|
||||
id={`${permissionKey}Write`}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={!!defaultValues}
|
||||
/>
|
||||
<label htmlFor={`${permissionKey}Write`} className="cursor-pointer text-sm">
|
||||
Write
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form
|
||||
className="border-subtle rounded-b-lg border border-t-0 px-4 pb-8 pt-2"
|
||||
onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mt-6">
|
||||
<TextField disabled={isFormDisabled} required={true} label="Client name" {...register("name")} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Label>Redirect uris</Label>
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<div className="flex items-end" key={field.id}>
|
||||
<div className="w-[80vw]">
|
||||
<TextField
|
||||
type="url"
|
||||
required={index === 0}
|
||||
className="w-[100%]"
|
||||
label=""
|
||||
disabled={isFormDisabled}
|
||||
{...register(`redirectUris.${index}.uri` as const)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Button
|
||||
tooltip="Add url"
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
StartIcon="plus"
|
||||
className="text-default mx-2 mb-2"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => {
|
||||
append({ uri: "" });
|
||||
}}
|
||||
/>
|
||||
{index > 0 && (
|
||||
<Button
|
||||
tooltip="Remove url"
|
||||
type="button"
|
||||
color="destructive"
|
||||
variant="icon"
|
||||
StartIcon="trash"
|
||||
className="text-default mx-2 mb-2"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/** <div className="mt-6">
|
||||
<Controller
|
||||
control={control}
|
||||
name="logo"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label>Client logo</Label>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
alt=""
|
||||
imageSrc={value}
|
||||
fallback={<Icon name="plus" className="text-subtle h-4 w-4" />}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="vatar-upload"
|
||||
buttonMsg="Upload"
|
||||
imageSrc={value}
|
||||
handleAvatarChange={(newAvatar: string) => {
|
||||
setValue("logo", newAvatar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div> */}
|
||||
<div className="mt-6">
|
||||
<Tooltip content="URL of your booking page">
|
||||
<TextField
|
||||
type="url"
|
||||
label="Booking redirect uri"
|
||||
className="w-[100%]"
|
||||
{...register("bookingRedirectUri")}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Tooltip content="URL of the page where your users can cancel their booking">
|
||||
<TextField
|
||||
type="url"
|
||||
label="Booking cancel redirect uri"
|
||||
className="w-[100%]"
|
||||
{...register("bookingCancelRedirectUri")}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Tooltip content="URL of the page where your users can reschedule their booking">
|
||||
<TextField
|
||||
type="url"
|
||||
label="Booking reschedule redirect uri"
|
||||
className="w-[100%]"
|
||||
{...register("bookingRescheduleRedirectUri")}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<input
|
||||
{...register("areEmailsEnabled")}
|
||||
id="areEmailsEnabled"
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
<label htmlFor="areEmailsEnabled" className="cursor-pointer px-2 text-base font-semibold">
|
||||
Enable emails
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="flex justify-between">
|
||||
<h1 className="text-base font-semibold underline">Permissions</h1>
|
||||
<Button type="button" onClick={selectAllPermissions} disabled={!!defaultValues || isFormDisabled}>
|
||||
{!isSelectAllPermissionsChecked ? "Select all" : "Discard all"}
|
||||
</Button>
|
||||
</div>
|
||||
<div>{permissionsCheckboxes}</div>
|
||||
</div>
|
||||
<Button className="mt-6" type="submit" loading={isPending}>
|
||||
{defaultValues ? "Update" : "Submit"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
type IndividualPlatformPlan = {
|
||||
plan: string;
|
||||
description: string;
|
||||
pricing?: number;
|
||||
includes: string[];
|
||||
};
|
||||
|
||||
// if pricing or plans change in future modify this
|
||||
export const platformPlans: IndividualPlatformPlan[] = [
|
||||
{
|
||||
plan: "Starter",
|
||||
description:
|
||||
"Perfect for just getting started with community support and access to hosted platform APIs, Cal.com Atoms (React components) and more.",
|
||||
pricing: 99,
|
||||
includes: [
|
||||
"Up to 100 bookings a month",
|
||||
"Community Support",
|
||||
"Cal Atoms (React Library)",
|
||||
"Platform APIs",
|
||||
"Admin APIs",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Essentials",
|
||||
description:
|
||||
"Your essential package with sophisticated support, hosted platform APIs, Cal.com Atoms (React components) and more.",
|
||||
pricing: 299,
|
||||
includes: [
|
||||
"Up to 500 bookings a month. $0,60 overage beyond",
|
||||
"Everything in Starter",
|
||||
"Cal Atoms (React Library)",
|
||||
"User Management and Analytics",
|
||||
"Technical Account Manager and Onboarding Support",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Scale",
|
||||
description:
|
||||
"The best all-in-one plan to scale your company. Everything you need to provide scheduling for the masses, without breaking things.",
|
||||
pricing: 2499,
|
||||
includes: [
|
||||
"Up to 5000 bookings a month. $0.50 overage beyond",
|
||||
"Everything in Essentials",
|
||||
"Credential import from other platforms",
|
||||
"Compliance Check SOC2, HIPAA",
|
||||
"One-on-one developer calls",
|
||||
"Help with Credentials Verification (Zoom, Google App Store)",
|
||||
"Expedited features and integrations",
|
||||
"SLA (99.999% uptime)",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Enterprise",
|
||||
description: "Everything in Scale with generous volume discounts beyond 50,000 bookings a month.",
|
||||
includes: ["Beyond 50,000 bookings a month", "Everything in Scale", "Up to 50% discount on overages"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
type PlatformBillingCardProps = {
|
||||
plan: string;
|
||||
description: string;
|
||||
pricing?: number;
|
||||
includes: string[];
|
||||
isLoading?: boolean;
|
||||
handleSubscribe?: () => void;
|
||||
};
|
||||
|
||||
export const PlatformBillingCard = ({
|
||||
plan,
|
||||
description,
|
||||
pricing,
|
||||
includes,
|
||||
isLoading,
|
||||
handleSubscribe,
|
||||
}: PlatformBillingCardProps) => {
|
||||
return (
|
||||
<div className="border-subtle mx-4 w-auto rounded-md border p-5 ">
|
||||
<div className="pb-5">
|
||||
<h1 className="pb-3 pt-3 text-xl font-semibold">{plan}</h1>
|
||||
<p className="pb-5 text-base">{description}</p>
|
||||
<h1 className="text-3xl font-semibold">
|
||||
{pricing && (
|
||||
<>
|
||||
US${pricing} <span className="text-sm">per month</span>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
onClick={handleSubscribe}
|
||||
className="flex w-[100%] items-center justify-center">
|
||||
{pricing ? "Subscribe" : "Schedule a time"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<p>This includes:</p>
|
||||
{includes.map((feature) => {
|
||||
return (
|
||||
<div key={feature} className="my-2 flex">
|
||||
<div className="pr-2">•</div>
|
||||
<div>{feature}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import { useSubscribeTeamToStripe } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
|
||||
|
||||
import { platformPlans } from "@components/settings/platform/platformUtils";
|
||||
import { PlatformBillingCard } from "@components/settings/platform/pricing/billing-card";
|
||||
|
||||
type PlatformPricingProps = { teamId?: number | null };
|
||||
|
||||
export const PlatformPricing = ({ teamId }: PlatformPricingProps) => {
|
||||
const router = useRouter();
|
||||
const { mutateAsync, isPending } = useSubscribeTeamToStripe({
|
||||
onSuccess: (redirectUrl: string) => {
|
||||
router.push(redirectUrl);
|
||||
},
|
||||
onError: () => {
|
||||
showToast(ErrorCode.UnableToSubscribeToThePlatform, "error");
|
||||
},
|
||||
teamId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-auto flex-col items-center justify-center px-5 py-10 md:px-10 lg:h-[100%]">
|
||||
<div className="mb-5 text-center text-2xl font-semibold">
|
||||
<h1>Subscribe to Platform</h1>
|
||||
</div>
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-4">
|
||||
{platformPlans.map((plan) => {
|
||||
return (
|
||||
<div key={plan.plan}>
|
||||
<PlatformBillingCard
|
||||
plan={plan.plan}
|
||||
description={plan.description}
|
||||
pricing={plan.pricing}
|
||||
includes={plan.includes}
|
||||
isLoading={isPending}
|
||||
handleSubscribe={() => {
|
||||
!!teamId &&
|
||||
(plan.plan === "Enterprise"
|
||||
? router.push("https://i.cal.com/sales/exploration")
|
||||
: mutateAsync({ plan: plan.plan.toLocaleUpperCase() }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
191
calcom/apps/web/components/setup/AdminUser.tsx
Normal file
191
calcom/apps/web/components/setup/AdminUser.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import classNames from "classnames";
|
||||
import { signIn } from "next-auth/react";
|
||||
import React from "react";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmailField, EmptyScreen, Label, PasswordField, TextField } from "@calcom/ui";
|
||||
|
||||
export const AdminUserContainer = (props: React.ComponentProps<typeof AdminUser> & { userCount: number }) => {
|
||||
const { t } = useLocale();
|
||||
if (props.userCount > 0)
|
||||
return (
|
||||
<form
|
||||
id="wizard-step-1"
|
||||
name="wizard-step-1"
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
props.onSuccess();
|
||||
}}>
|
||||
<EmptyScreen
|
||||
Icon="user-check"
|
||||
headline={t("admin_user_created")}
|
||||
description={t("admin_user_created_description")}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
return <AdminUser {...props} />;
|
||||
};
|
||||
|
||||
export const AdminUser = (props: { onSubmit: () => void; onError: () => void; onSuccess: () => void }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.refine((val) => val.trim().length >= 1, { message: t("at_least_characters", { count: 1 }) }),
|
||||
email_address: z.string().email({ message: t("enter_valid_email") }),
|
||||
full_name: z.string().min(3, t("at_least_characters", { count: 3 })),
|
||||
password: z.string().superRefine((data, ctx) => {
|
||||
const isStrict = true;
|
||||
const result = isPasswordValid(data, true, isStrict);
|
||||
Object.keys(result).map((key: string) => {
|
||||
if (!result[key as keyof typeof result]) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [key],
|
||||
message: key,
|
||||
});
|
||||
}
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
type formSchemaType = z.infer<typeof formSchema>;
|
||||
|
||||
const formMethods = useForm<formSchemaType>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const onError = () => {
|
||||
props.onError();
|
||||
};
|
||||
|
||||
const onSubmit = formMethods.handleSubmit(async (data) => {
|
||||
props.onSubmit();
|
||||
const response = await fetch("/api/auth/setup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: data.username.trim(),
|
||||
full_name: data.full_name,
|
||||
email_address: data.email_address.toLowerCase(),
|
||||
password: data.password,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (response.status === 200) {
|
||||
await signIn("credentials", {
|
||||
redirect: false,
|
||||
callbackUrl: "/",
|
||||
email: data.email_address.toLowerCase(),
|
||||
password: data.password,
|
||||
});
|
||||
props.onSuccess();
|
||||
} else {
|
||||
props.onError();
|
||||
}
|
||||
}, onError);
|
||||
|
||||
const longWebsiteUrl = WEBSITE_URL.length > 30;
|
||||
|
||||
return (
|
||||
<FormProvider {...formMethods}>
|
||||
<form id="wizard-step-1" name="wizard-step-1" className="space-y-4" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<Controller
|
||||
name="username"
|
||||
control={formMethods.control}
|
||||
render={({ field: { onBlur, onChange, value } }) => (
|
||||
<>
|
||||
<Label htmlFor="username" className={classNames(longWebsiteUrl && "mb-0")}>
|
||||
<span className="block">{t("username")}</span>
|
||||
{longWebsiteUrl && (
|
||||
<small className="items-centerpx-3 bg-subtle border-default text-subtle mt-2 inline-flex rounded-t-md border border-b-0 px-3 py-1">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}
|
||||
</small>
|
||||
)}
|
||||
</Label>
|
||||
<TextField
|
||||
addOnLeading={
|
||||
!longWebsiteUrl && (
|
||||
<span className="text-subtle inline-flex items-center rounded-none px-3 text-sm">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
</span>
|
||||
)
|
||||
}
|
||||
id="username"
|
||||
labelSrOnly={true}
|
||||
value={value || ""}
|
||||
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
|
||||
onBlur={onBlur}
|
||||
name="username"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name="full_name"
|
||||
control={formMethods.control}
|
||||
render={({ field: { onBlur, onChange, value } }) => (
|
||||
<TextField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
color={formMethods.formState.errors.full_name ? "warn" : ""}
|
||||
type="text"
|
||||
name="full_name"
|
||||
autoCapitalize="none"
|
||||
autoComplete="name"
|
||||
autoCorrect="off"
|
||||
className="my-0"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name="email_address"
|
||||
control={formMethods.control}
|
||||
render={({ field: { onBlur, onChange, value } }) => (
|
||||
<EmailField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="my-0"
|
||||
name="email_address"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name="password"
|
||||
control={formMethods.control}
|
||||
render={({ field: { onBlur, onChange, value } }) => (
|
||||
<PasswordField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
hintErrors={["caplow", "admin_min", "num"]}
|
||||
name="password"
|
||||
className="my-0"
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
71
calcom/apps/web/components/setup/ChooseLicense.tsx
Normal file
71
calcom/apps/web/components/setup/ChooseLicense.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import classNames from "classnames";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
const ChooseLicense = (
|
||||
props: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
} & Omit<JSX.IntrinsicElements["form"], "onSubmit" | "onChange">
|
||||
) => {
|
||||
const { value: initialValue = "FREE", onChange, onSubmit, ...rest } = props;
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<form
|
||||
{...rest}
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(value);
|
||||
}}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={initialValue}
|
||||
value={value}
|
||||
aria-label={t("choose_a_license")}
|
||||
className="grid grid-rows-2 gap-4 md:grid-cols-2 md:grid-rows-1"
|
||||
onValueChange={(value) => {
|
||||
onChange(value);
|
||||
setValue(value);
|
||||
}}>
|
||||
<RadioGroup.Item value="FREE">
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default cursor-pointer space-y-2 rounded-md border p-4 hover:border-black",
|
||||
value === "FREE" && "ring-2 ring-black"
|
||||
)}>
|
||||
<h2 className="font-cal text-emphasis text-xl">{t("agplv3_license")}</h2>
|
||||
<p className="font-medium text-green-800">{t("free_license_fee")}</p>
|
||||
<p className="text-subtle">{t("forever_open_and_free")}</p>
|
||||
<ul className="text-subtle ml-4 list-disc text-left text-xs">
|
||||
<li>{t("required_to_keep_your_code_open_source")}</li>
|
||||
<li>{t("cannot_repackage_and_resell")}</li>
|
||||
<li>{t("no_enterprise_features")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</RadioGroup.Item>
|
||||
<RadioGroup.Item value="EE" disabled>
|
||||
<Link href="https://cal.com/sales" target="_blank">
|
||||
<div className={classNames("bg-default h-full cursor-pointer space-y-2 rounded-md border p-4")}>
|
||||
<h2 className="font-cal text-emphasis text-xl">{t("custom_plan")}</h2>
|
||||
<p className="font-medium text-green-800">{t("contact_sales")}</p>
|
||||
<p className="text-subtle">Build on top of Cal.com</p>
|
||||
<ul className="text-subtle ml-4 list-disc text-left text-xs">
|
||||
<li>{t("no_need_to_keep_your_code_open_source")}</li>
|
||||
<li>{t("repackage_rebrand_resell")}</li>
|
||||
<li>{t("a_vast_suite_of_enterprise_features")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Link>
|
||||
</RadioGroup.Item>
|
||||
</RadioGroup.Root>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChooseLicense;
|
||||
146
calcom/apps/web/components/setup/EnterpriseLicense.tsx
Normal file
146
calcom/apps/web/components/setup/EnterpriseLicense.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { noop } from "lodash";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Controller, FormProvider, useForm, useFormState } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { CONSOLE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, TextField } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
type EnterpriseLicenseFormValues = {
|
||||
licenseKey: string;
|
||||
};
|
||||
|
||||
const makeSchemaLicenseKey = (args: { callback: (valid: boolean) => void; onSuccessValidate: () => void }) =>
|
||||
z.object({
|
||||
licenseKey: z
|
||||
.string()
|
||||
.uuid({
|
||||
message: "License key must follow UUID format: 8-4-4-4-12",
|
||||
})
|
||||
.superRefine(async (data, ctx) => {
|
||||
const parse = z.string().uuid().safeParse(data);
|
||||
if (parse.success) {
|
||||
args.callback(true);
|
||||
const response = await fetch(`${CONSOLE_URL}/api/license?key=${data}`);
|
||||
args.callback(false);
|
||||
const json = await response.json();
|
||||
if (!json.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `License key ${json.message.toLowerCase()}`,
|
||||
});
|
||||
} else {
|
||||
args.onSuccessValidate();
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
const EnterpriseLicense = (
|
||||
props: {
|
||||
licenseKey?: string;
|
||||
initialValue?: Partial<EnterpriseLicenseFormValues>;
|
||||
onSuccessValidate: () => void;
|
||||
onSubmit: (value: EnterpriseLicenseFormValues) => void;
|
||||
onSuccess?: (
|
||||
data: RouterOutputs["viewer"]["deploymentSetup"]["update"],
|
||||
variables: RouterInputs["viewer"]["deploymentSetup"]["update"]
|
||||
) => void;
|
||||
} & Omit<JSX.IntrinsicElements["form"], "onSubmit">
|
||||
) => {
|
||||
const { onSubmit, onSuccess = noop, onSuccessValidate = noop, ...rest } = props;
|
||||
const { t } = useLocale();
|
||||
const [checkLicenseLoading, setCheckLicenseLoading] = useState(false);
|
||||
const mutation = trpc.viewer.deploymentSetup.update.useMutation({
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const schemaLicenseKey = useCallback(
|
||||
() =>
|
||||
makeSchemaLicenseKey({
|
||||
callback: setCheckLicenseLoading,
|
||||
onSuccessValidate,
|
||||
}),
|
||||
[setCheckLicenseLoading, onSuccessValidate]
|
||||
);
|
||||
|
||||
const formMethods = useForm<EnterpriseLicenseFormValues>({
|
||||
defaultValues: {
|
||||
licenseKey: props.licenseKey || "",
|
||||
},
|
||||
resolver: zodResolver(schemaLicenseKey()),
|
||||
});
|
||||
|
||||
const handleSubmit = formMethods.handleSubmit((values) => {
|
||||
onSubmit(values);
|
||||
setCheckLicenseLoading(false);
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
const { isDirty, errors } = useFormState(formMethods);
|
||||
|
||||
return (
|
||||
<FormProvider {...formMethods}>
|
||||
<form {...rest} className="bg-default space-y-4 rounded-md px-8 py-10" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<Button
|
||||
className="w-full justify-center text-lg"
|
||||
EndIcon="external-link"
|
||||
href="https://console.cal.com"
|
||||
target="_blank">
|
||||
{t("purchase_license")}
|
||||
</Button>
|
||||
<div className="relative flex justify-center">
|
||||
<hr className="border-subtle my-8 w-full border-[1.5px]" />
|
||||
<span className="bg-default absolute mt-[22px] px-3.5 text-sm">OR</span>
|
||||
</div>
|
||||
{t("already_have_key")}
|
||||
<Controller
|
||||
name="licenseKey"
|
||||
control={formMethods.control}
|
||||
render={({ field: { onBlur, onChange, value } }) => (
|
||||
<TextField
|
||||
{...formMethods.register("licenseKey")}
|
||||
className={classNames(
|
||||
"group-hover:border-emphasis mb-0",
|
||||
(checkLicenseLoading || (errors.licenseKey === undefined && isDirty)) && "border-r-0"
|
||||
)}
|
||||
placeholder="xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx"
|
||||
labelSrOnly={true}
|
||||
value={value}
|
||||
addOnFilled={false}
|
||||
addOnClassname={classNames(
|
||||
"hover:border-default",
|
||||
errors.licenseKey === undefined && isDirty && "group-hover:border-emphasis"
|
||||
)}
|
||||
addOnSuffix={
|
||||
checkLicenseLoading ? (
|
||||
<Icon name="loader" className="h-5 w-5 animate-spin" />
|
||||
) : errors.licenseKey === undefined && isDirty ? (
|
||||
<Icon name="check" className="h-5 w-5 text-green-700" />
|
||||
) : undefined
|
||||
}
|
||||
color={errors.licenseKey ? "warn" : ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("licenseKey", e.target.value);
|
||||
await formMethods.trigger("licenseKey");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnterpriseLicense;
|
||||
40
calcom/apps/web/components/setup/StepDone.tsx
Normal file
40
calcom/apps/web/components/setup/StepDone.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
const StepDone = (props: {
|
||||
currentStep: number;
|
||||
nextStepPath: string;
|
||||
setIsPending: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<form
|
||||
id={`wizard-step-${props.currentStep}`}
|
||||
name={`wizard-step-${props.currentStep}`}
|
||||
className="flex justify-center space-y-4"
|
||||
onSubmit={(e) => {
|
||||
props.setIsPending(true);
|
||||
e.preventDefault();
|
||||
router.replace(props.nextStepPath);
|
||||
}}>
|
||||
<div className="min-h-36 my-6 flex flex-col items-center justify-center">
|
||||
<div className="dark:bg-default flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-600">
|
||||
<Icon
|
||||
name="check"
|
||||
className="text-inverted dark:bg-default dark:text-default inline-block h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-[420px] text-center">
|
||||
<h2 className="mb-1 mt-6 text-lg font-medium dark:text-gray-300">{t("all_done")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepDone;
|
||||
80
calcom/apps/web/components/team/screens/Team.tsx
Normal file
80
calcom/apps/web/components/team/screens/Team.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
import { UserAvatar } from "@calcom/ui";
|
||||
|
||||
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = Pick<
|
||||
MembersType[number],
|
||||
"id" | "name" | "bio" | "username" | "organizationId" | "avatarUrl"
|
||||
> & {
|
||||
profile: Omit<UserProfile, "upId">;
|
||||
safeBio: string | null;
|
||||
bookerUrl: string;
|
||||
};
|
||||
|
||||
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
||||
const routerQuery = useRouterQuery();
|
||||
const { t } = useLocale();
|
||||
const isBioEmpty = !member.bio || !member.bio.replace("<p><br></p>", "").length;
|
||||
|
||||
// We don't want to forward orgSlug and user which are route params to the next route
|
||||
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = routerQuery;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={member.id}
|
||||
href={{ pathname: `${member.bookerUrl}/${member.username}`, query: queryParamsToForward }}>
|
||||
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 hover:cursor-pointer">
|
||||
<UserAvatar noOrganizationIndicator size="md" user={member} />
|
||||
<section className="mt-2 line-clamp-4 w-full space-y-1">
|
||||
<p className="text-default font-medium">{member.name}</p>
|
||||
<div className="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">
|
||||
{!isBioEmpty ? (
|
||||
<>
|
||||
<div
|
||||
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||
dangerouslySetInnerHTML={{ __html: md.render(markdownToSafeHTML(member.bio)) }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
t("user_from_team", { user: member.name, team: teamName })
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const Members = ({ members, teamName }: { members: MemberType[]; teamName: string | null }) => {
|
||||
if (!members || members.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="team-members-container"
|
||||
className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
|
||||
{members.map((member) => {
|
||||
return member.username !== null && <Member key={member.id} member={member} teamName={teamName} />;
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Team = ({ members, teamName }: { members: MemberType[]; teamName: string | null }) => {
|
||||
return (
|
||||
<div>
|
||||
<Members members={members} teamName={teamName} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Team;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user