2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View 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;
}

View 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>
);
}

View 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>
);
}

View 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 },
},
}
);
}

View File

@@ -0,0 +1,4 @@
/**
* @deprecated Use custom Skeletons instead
**/
export { Loader as default } from "@calcom/ui";

View 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;

View 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;

View File

@@ -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;

View 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>
);
}

View 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}
/>
);
}

View 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>
);
};

View 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>
}
/>
)}
</>
);
}}
/>
);
}

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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
)
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
};

View 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>
);
};

View 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>
);
}

View File

@@ -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>;

View 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>
);
}

View 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" />;
}

View 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>
);
}

View 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>;

View File

@@ -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>;

View 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>
);
};

View 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}>
&quot;{booking.description}&quot;
</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>,&nbsp;</span> : <span>&nbsp;{t("and")}&nbsp;</span>}
<Attendee {...attendees[0]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
{attendees.length > 1 && (
<>
<div className="text-emphasis inline-block text-sm">&nbsp;{t("and")}&nbsp;</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;

View 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>
)}
</>
);
}

View 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>
);
}

View 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>
);
};

View 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&apos;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>
);
};

View 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>
);
};

View 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>
</>
);
};

View 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>
);
};

View File

@@ -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;

View 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;

View 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} />;
};

View 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>
);
};

View 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 &nbsp;
<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>
)}
</>
);
};

View 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} />
);
};

View 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} />
);
};

View 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")}&nbsp;</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">&nbsp;{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>
);
}}
/>
);
};

View 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} />;
};

View 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>
);
};

View 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>
);
};

View File

@@ -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;

View 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&apos;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 };

View 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 &nbsp;
<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>
);
};

View File

@@ -0,0 +1 @@
export { default } from "@calcom/features/ee/workflows/components/EventWorkflowsTab";

View 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>
);
};

View 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&apos;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;

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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>
);
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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}
</>
);
}

View 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 🔥

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;

View 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;

View 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;

View 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;

View File

@@ -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>
</>
);
};

View File

@@ -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>
}
/>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 };
};

View File

@@ -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">
&nbsp;{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> : <>&nbsp;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>
);
};

View File

@@ -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>
);
};

View File

@@ -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"],
},
];

View File

@@ -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">&bull;</div>
<div>{feature}</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View 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;

View 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;

View 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;

View 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