first commit
This commit is contained in:
17
calcom/packages/features/MainLayout.tsx
Normal file
17
calcom/packages/features/MainLayout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
|
||||
export default function MainLayout({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
return (
|
||||
<Shell withoutMain={true} {...rest}>
|
||||
{children}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export const getLayout = (page: React.ReactElement) => <MainLayout>{page}</MainLayout>;
|
||||
19
calcom/packages/features/MainLayoutAppDir.tsx
Normal file
19
calcom/packages/features/MainLayoutAppDir.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
|
||||
export default function MainLayout({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
return (
|
||||
<Shell withoutMain={true} {...rest}>
|
||||
{children}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export const getLayout = (page: React.ReactElement) => <MainLayout>{page}</MainLayout>;
|
||||
347
calcom/packages/features/apps/AdminAppsList.tsx
Normal file
347
calcom/packages/features/apps/AdminAppsList.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { noop } from "lodash";
|
||||
import type { FC } from "react";
|
||||
import { useReducer, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import AppCategoryNavigation from "@calcom/app-store/_components/AppCategoryNavigation";
|
||||
import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated";
|
||||
import { classNames as cs } from "@calcom/lib";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
EmptyScreen,
|
||||
Form,
|
||||
Icon,
|
||||
List,
|
||||
showToast,
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
Switch,
|
||||
TextField,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import AppListCard from "../../../apps/web/components/AppListCard";
|
||||
|
||||
type App = RouterOutputs["viewer"]["appsRouter"]["listLocal"][number];
|
||||
|
||||
const IntegrationContainer = ({
|
||||
app,
|
||||
category,
|
||||
handleModelOpen,
|
||||
}: {
|
||||
app: App;
|
||||
category: string;
|
||||
handleModelOpen: (data: EditModalState) => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [disableDialog, setDisableDialog] = useState(false);
|
||||
|
||||
const showKeyModal = (fromEnabled?: boolean) => {
|
||||
// FIXME: This is preventing the modal from opening for apps that has null keys
|
||||
if (app.keys) {
|
||||
handleModelOpen({
|
||||
dirName: app.dirName,
|
||||
keys: app.keys,
|
||||
slug: app.slug,
|
||||
type: app.type,
|
||||
isOpen: "editKeys",
|
||||
fromEnabled,
|
||||
appName: app.name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const enableAppMutation = trpc.viewer.appsRouter.toggle.useMutation({
|
||||
onSuccess: (enabled) => {
|
||||
utils.viewer.appsRouter.listLocal.invalidate({ category });
|
||||
setDisableDialog(false);
|
||||
showToast(
|
||||
enabled ? t("app_is_enabled", { appName: app.name }) : t("app_is_disabled", { appName: app.name }),
|
||||
"success"
|
||||
);
|
||||
if (enabled) {
|
||||
showKeyModal();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<li>
|
||||
<AppListCard
|
||||
logo={app.logo}
|
||||
description={app.description}
|
||||
title={app.name}
|
||||
isTemplate={app.isTemplate}
|
||||
actions={
|
||||
<div className="flex items-center justify-self-end">
|
||||
{app.keys && (
|
||||
<Button color="secondary" className="mr-2" onClick={() => showKeyModal()}>
|
||||
<Icon name="pencil" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Switch
|
||||
checked={app.enabled}
|
||||
onClick={() => {
|
||||
if (app.enabled) {
|
||||
setDisableDialog(true);
|
||||
} else if (app.keys) {
|
||||
showKeyModal(true);
|
||||
} else {
|
||||
enableAppMutation.mutate({ slug: app.slug, enabled: !app.enabled });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Dialog open={disableDialog} onOpenChange={setDisableDialog}>
|
||||
<ConfirmationDialogContent
|
||||
title={t("disable_app")}
|
||||
variety="danger"
|
||||
onConfirm={() => {
|
||||
enableAppMutation.mutate({ slug: app.slug, enabled: !app.enabled });
|
||||
}}>
|
||||
{t("disable_app_description")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const querySchema = z.object({
|
||||
category: z
|
||||
.nativeEnum({ ...AppCategories, conferencing: "conferencing" })
|
||||
.optional()
|
||||
.default(AppCategories.calendar),
|
||||
});
|
||||
|
||||
const AdminAppsList = ({
|
||||
baseURL,
|
||||
className,
|
||||
useQueryParam = false,
|
||||
classNames,
|
||||
onSubmit = noop,
|
||||
...rest
|
||||
}: {
|
||||
baseURL: string;
|
||||
classNames?: {
|
||||
form?: string;
|
||||
appCategoryNavigationRoot?: string;
|
||||
appCategoryNavigationContainer?: string;
|
||||
verticalTabsItem?: string;
|
||||
};
|
||||
className?: string;
|
||||
useQueryParam?: boolean;
|
||||
onSubmit?: () => void;
|
||||
} & Omit<JSX.IntrinsicElements["form"], "onSubmit">) => {
|
||||
return (
|
||||
<form
|
||||
{...rest}
|
||||
className={
|
||||
classNames?.form ?? "max-w-80 bg-default mb-4 rounded-md px-0 pt-0 md:max-w-full md:px-8 md:pt-10"
|
||||
}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}>
|
||||
<AppCategoryNavigation
|
||||
baseURL={baseURL}
|
||||
useQueryParam={useQueryParam}
|
||||
classNames={{
|
||||
root: className,
|
||||
verticalTabsItem: classNames?.verticalTabsItem,
|
||||
container: cs("min-w-0 w-full", classNames?.appCategoryNavigationContainer ?? "max-w-[500px]"),
|
||||
}}>
|
||||
<AdminAppsListContainer />
|
||||
</AppCategoryNavigation>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const EditKeysModal: FC<{
|
||||
dirName: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
isOpen: boolean;
|
||||
keys: App["keys"];
|
||||
handleModelClose: () => void;
|
||||
fromEnabled?: boolean;
|
||||
appName?: string;
|
||||
}> = (props) => {
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useLocale();
|
||||
const { dirName, slug, type, isOpen, keys, handleModelClose, fromEnabled, appName } = props;
|
||||
const appKeySchema = appKeysSchemas[dirName as keyof typeof appKeysSchemas];
|
||||
|
||||
const formMethods = useForm({
|
||||
resolver: zodResolver(appKeySchema),
|
||||
});
|
||||
|
||||
const saveKeysMutation = trpc.viewer.appsRouter.saveKeys.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(fromEnabled ? t("app_is_enabled", { appName }) : t("keys_have_been_saved"), "success");
|
||||
utils.viewer.appsRouter.listLocal.invalidate();
|
||||
handleModelClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleModelClose}>
|
||||
<DialogContent title={t("edit_keys")} type="creation">
|
||||
{!!keys && typeof keys === "object" && (
|
||||
<Form
|
||||
id="edit-keys"
|
||||
form={formMethods}
|
||||
handleSubmit={(values) =>
|
||||
saveKeysMutation.mutate({
|
||||
slug,
|
||||
type,
|
||||
keys: values,
|
||||
dirName,
|
||||
fromEnabled,
|
||||
})
|
||||
}
|
||||
className="px-4 pb-4">
|
||||
{Object.keys(keys).map((key) => (
|
||||
<Controller
|
||||
name={key}
|
||||
key={key}
|
||||
control={formMethods.control}
|
||||
defaultValue={keys && keys[key] ? keys?.[key] : ""}
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
label={key}
|
||||
key={key}
|
||||
name={key}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue(key, e?.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Form>
|
||||
)}
|
||||
<DialogFooter showDivider className="mt-8">
|
||||
<DialogClose onClick={handleModelClose} />
|
||||
<Button form="edit-keys" type="submit">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface EditModalState extends Pick<App, "keys"> {
|
||||
isOpen: "none" | "editKeys" | "disableKeys";
|
||||
dirName: string;
|
||||
type: string;
|
||||
slug: string;
|
||||
fromEnabled?: boolean;
|
||||
appName?: string;
|
||||
}
|
||||
|
||||
const AdminAppsListContainer = () => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { t } = useLocale();
|
||||
const category = searchParams?.get("category") || AppCategories.calendar;
|
||||
|
||||
const { data: apps, isPending } = trpc.viewer.appsRouter.listLocal.useQuery(
|
||||
{ category },
|
||||
{ enabled: searchParams !== null }
|
||||
);
|
||||
|
||||
const [modalState, setModalState] = useReducer(
|
||||
(data: EditModalState, partialData: Partial<EditModalState>) => ({ ...data, ...partialData }),
|
||||
{
|
||||
keys: null,
|
||||
isOpen: "none",
|
||||
dirName: "",
|
||||
type: "",
|
||||
slug: "",
|
||||
}
|
||||
);
|
||||
|
||||
const handleModelClose = () =>
|
||||
setModalState({ keys: null, isOpen: "none", dirName: "", slug: "", type: "" });
|
||||
|
||||
const handleModelOpen = (data: EditModalState) => setModalState({ ...data });
|
||||
|
||||
if (isPending) return <SkeletonLoader />;
|
||||
|
||||
if (!apps || apps.length === 0) {
|
||||
return (
|
||||
<EmptyScreen
|
||||
Icon="circle-alert"
|
||||
headline={t("no_available_apps")}
|
||||
description={t("no_available_apps_description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{apps.map((app) => (
|
||||
<IntegrationContainer
|
||||
handleModelOpen={handleModelOpen}
|
||||
app={app}
|
||||
key={app.name}
|
||||
category={category}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<EditKeysModal
|
||||
keys={modalState.keys}
|
||||
dirName={modalState.dirName}
|
||||
handleModelClose={handleModelClose}
|
||||
isOpen={modalState.isOpen === "editKeys"}
|
||||
slug={modalState.slug}
|
||||
type={modalState.type}
|
||||
fromEnabled={modalState.fromEnabled}
|
||||
appName={modalState.appName}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminAppsList;
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
<SkeletonContainer className="w-[30rem] pr-10">
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType } from "@calcom/app-store/locations";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Form,
|
||||
showToast,
|
||||
TextField,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type LocationTypeSetLinkDialogFormProps = {
|
||||
link?: string;
|
||||
type: EventLocationType["type"];
|
||||
};
|
||||
|
||||
export function AppSetDefaultLinkDialog({
|
||||
locationType,
|
||||
setLocationType,
|
||||
onSuccess,
|
||||
}: {
|
||||
locationType: EventLocationType & { slug: string };
|
||||
setLocationType: Dispatch<SetStateAction<(EventLocationType & { slug: string }) | undefined>>;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const eventLocationTypeOptions = getEventLocationType(locationType.type);
|
||||
|
||||
const form = useForm<LocationTypeSetLinkDialogFormProps>({
|
||||
resolver: zodResolver(
|
||||
z.object({ link: z.string().regex(new RegExp(eventLocationTypeOptions?.urlRegExp ?? "")) })
|
||||
),
|
||||
});
|
||||
|
||||
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(`Invalid App Link Format`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={!!locationType} onOpenChange={() => setLocationType(undefined)}>
|
||||
<DialogContent
|
||||
title={t("default_app_link_title")}
|
||||
description={t("default_app_link_description")}
|
||||
type="creation"
|
||||
Icon="circle-alert">
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
updateDefaultAppMutation.mutate({
|
||||
appSlug: locationType.slug,
|
||||
appLink: values.link,
|
||||
});
|
||||
setLocationType(undefined);
|
||||
}}>
|
||||
<>
|
||||
<TextField
|
||||
type="text"
|
||||
required
|
||||
{...form.register("link")}
|
||||
placeholder={locationType.organizerInputPlaceholder ?? ""}
|
||||
label={locationType.label ?? ""}
|
||||
/>
|
||||
|
||||
<DialogFooter showDivider className="mt-8">
|
||||
<DialogClose />
|
||||
<Button color="primary" type="submit">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
import { Button, ConfirmationDialogContent, Dialog, DialogTrigger, showToast } from "@calcom/ui";
|
||||
|
||||
export default function DisconnectIntegration({
|
||||
credentialId,
|
||||
label,
|
||||
trashIcon,
|
||||
isGlobal,
|
||||
onSuccess,
|
||||
buttonProps,
|
||||
}: {
|
||||
credentialId: number;
|
||||
label?: string;
|
||||
trashIcon?: boolean;
|
||||
isGlobal?: boolean;
|
||||
onSuccess?: () => void;
|
||||
buttonProps?: ButtonProps;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const mutation = trpc.viewer.deleteCredential.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("app_removed_successfully"), "success");
|
||||
setModalOpen(false);
|
||||
onSuccess && onSuccess();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("error_removing_app"), "error");
|
||||
setModalOpen(false);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.connectedCalendars.invalidate();
|
||||
await utils.viewer.integrations.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
color={buttonProps?.color || "destructive"}
|
||||
StartIcon={!trashIcon ? undefined : "trash"}
|
||||
size="base"
|
||||
variant={trashIcon && !label ? "icon" : "button"}
|
||||
disabled={isGlobal}
|
||||
{...buttonProps}>
|
||||
{label && label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_app")}
|
||||
confirmBtnText={t("yes_remove_app")}
|
||||
onConfirm={() => {
|
||||
mutation.mutate({ id: credentialId });
|
||||
}}>
|
||||
<p className="mt-5">{t("are_you_sure_you_want_to_remove_this_app")}</p>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Dialog, showToast, ConfirmationDialogContent } from "@calcom/ui";
|
||||
|
||||
interface DisconnectIntegrationModalProps {
|
||||
credentialId: number | null;
|
||||
isOpen: boolean;
|
||||
handleModelClose: () => void;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export default function DisconnectIntegrationModal({
|
||||
credentialId,
|
||||
isOpen,
|
||||
handleModelClose,
|
||||
teamId,
|
||||
}: DisconnectIntegrationModalProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const mutation = trpc.viewer.deleteCredential.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("app_removed_successfully"), "success");
|
||||
handleModelClose();
|
||||
utils.viewer.integrations.invalidate();
|
||||
utils.viewer.connectedCalendars.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("error_removing_app"), "error");
|
||||
handleModelClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleModelClose}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_app")}
|
||||
confirmBtnText={t("yes_remove_app")}
|
||||
onConfirm={() => {
|
||||
if (credentialId) {
|
||||
mutation.mutate({ id: credentialId, teamId });
|
||||
}
|
||||
}}>
|
||||
<p className="mt-5">{t("are_you_sure_you_want_to_remove_this_app")}</p>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
27
calcom/packages/features/auth/PermissionContainer.tsx
Normal file
27
calcom/packages/features/auth/PermissionContainer.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { FC } from "react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
type AdminRequiredProps = {
|
||||
as?: keyof JSX.IntrinsicElements;
|
||||
children?: React.ReactNode;
|
||||
/**Not needed right now but will be useful if we ever expand our permission roles */
|
||||
roleRequired?: UserPermissionRole;
|
||||
};
|
||||
|
||||
export const PermissionContainer: FC<AdminRequiredProps> = ({
|
||||
children,
|
||||
as,
|
||||
roleRequired = "ADMIN",
|
||||
...rest
|
||||
}) => {
|
||||
const session = useSession();
|
||||
|
||||
// Admin can do everything
|
||||
if (session.data?.user.role !== roleRequired && session.data?.user.role != UserPermissionRole.ADMIN)
|
||||
return null;
|
||||
const Component = as ?? Fragment;
|
||||
return <Component {...rest}>{children}</Component>;
|
||||
};
|
||||
1
calcom/packages/features/auth/README.md
Normal file
1
calcom/packages/features/auth/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# Auth-related code will live here
|
||||
68
calcom/packages/features/auth/SAMLLogin.tsx
Normal file
68
calcom/packages/features/auth/SAMLLogin.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import z from "zod";
|
||||
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
interface Props {
|
||||
samlTenantID: string;
|
||||
samlProductID: string;
|
||||
setErrorMessage: Dispatch<SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email({ message: "Please enter a valid email" }),
|
||||
});
|
||||
|
||||
export function SAMLLogin({ samlTenantID, samlProductID, setErrorMessage }: Props) {
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
|
||||
const mutation = trpc.viewer.public.samlTenantProduct.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
await signIn("saml", {}, { tenant: data.tenant, product: data.product });
|
||||
},
|
||||
onError: (err) => {
|
||||
setErrorMessage(t(err.message));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
StartIcon="lock"
|
||||
color="secondary"
|
||||
data-testid="saml"
|
||||
className="flex w-full justify-center"
|
||||
onClick={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!HOSTED_CAL_FEATURES) {
|
||||
await signIn("saml", {}, { tenant: samlTenantID, product: samlProductID });
|
||||
return;
|
||||
}
|
||||
|
||||
// Hosted solution, fetch tenant and product from the backend
|
||||
const email = methods.getValues("email");
|
||||
const parsed = schema.safeParse({ email });
|
||||
|
||||
if (!parsed.success) {
|
||||
const {
|
||||
fieldErrors: { email },
|
||||
} = parsed.error.flatten();
|
||||
|
||||
setErrorMessage(email ? email[0] : null);
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
email,
|
||||
});
|
||||
}}>
|
||||
{t("signin_with_saml_oidc")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
20
calcom/packages/features/auth/lib/ErrorCode.ts
Normal file
20
calcom/packages/features/auth/lib/ErrorCode.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export enum ErrorCode {
|
||||
IncorrectEmailPassword = "incorrect-email-password",
|
||||
UserNotFound = "user-not-found",
|
||||
IncorrectPassword = "incorrect-password",
|
||||
UserMissingPassword = "missing-password",
|
||||
TwoFactorDisabled = "two-factor-disabled",
|
||||
TwoFactorAlreadyEnabled = "two-factor-already-enabled",
|
||||
TwoFactorSetupRequired = "two-factor-setup-required",
|
||||
SecondFactorRequired = "second-factor-required",
|
||||
IncorrectTwoFactorCode = "incorrect-two-factor-code",
|
||||
IncorrectBackupCode = "incorrect-backup-code",
|
||||
MissingBackupCodes = "missing-backup-codes",
|
||||
IncorrectEmailVerificationCode = "incorrect_email_verification_code",
|
||||
InternalServerError = "internal-server-error",
|
||||
NewPasswordMatchesOld = "new-password-matches-old",
|
||||
ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled",
|
||||
RateLimitExceeded = "rate-limit-exceeded",
|
||||
SocialIdentityProviderRequired = "social-identity-provider-required",
|
||||
UserAccountLocked = "user-account-locked",
|
||||
}
|
||||
13
calcom/packages/features/auth/lib/ensureSession.ts
Normal file
13
calcom/packages/features/auth/lib/ensureSession.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
import { getSession } from "./getSession";
|
||||
|
||||
type CtxOrReq = { req: NextApiRequest; ctx?: never } | { ctx: { req: NextApiRequest }; req?: never };
|
||||
|
||||
export const ensureSession = async (ctxOrReq: CtxOrReq) => {
|
||||
const session = await getSession(ctxOrReq);
|
||||
if (!session?.user.id) throw new HttpError({ statusCode: 401, message: "Unauthorized" });
|
||||
return session;
|
||||
};
|
||||
61
calcom/packages/features/auth/lib/getLocale.ts
Normal file
61
calcom/packages/features/auth/lib/getLocale.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { parse } from "accept-language-parser";
|
||||
import { lookup } from "bcp-47-match";
|
||||
import type { GetTokenParams } from "next-auth/jwt";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
import { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
|
||||
|
||||
//@ts-expect-error no type definitions
|
||||
import { i18n } from "@calcom/config/next-i18next.config";
|
||||
|
||||
/**
|
||||
* This is a slimmed down version of the `getServerSession` function from
|
||||
* `next-auth`.
|
||||
*
|
||||
* Instead of requiring the entire options object for NextAuth, we create
|
||||
* a compatible session using information from the incoming token.
|
||||
*
|
||||
* The downside to this is that we won't refresh sessions if the users
|
||||
* token has expired (30 days). This should be fine as we call `/auth/session`
|
||||
* frequently enough on the client-side to keep the session alive.
|
||||
*/
|
||||
export const getLocale = async (
|
||||
req:
|
||||
| GetTokenParams["req"]
|
||||
| {
|
||||
cookies: ReadonlyRequestCookies;
|
||||
headers: ReadonlyHeaders;
|
||||
}
|
||||
): Promise<string> => {
|
||||
const token = await getToken({
|
||||
req: req as GetTokenParams["req"],
|
||||
});
|
||||
|
||||
const tokenLocale = token?.["locale"];
|
||||
|
||||
if (tokenLocale) {
|
||||
return tokenLocale;
|
||||
}
|
||||
|
||||
const acceptLanguage =
|
||||
req.headers instanceof Headers ? req.headers.get("accept-language") : req.headers["accept-language"];
|
||||
|
||||
const languages = acceptLanguage ? parse(acceptLanguage) : [];
|
||||
|
||||
const code: string = languages[0]?.code ?? "";
|
||||
const region: string = languages[0]?.region ?? "";
|
||||
|
||||
// the code should consist of 2 or 3 lowercase letters
|
||||
// the regex underneath is more permissive
|
||||
const testedCode = /^[a-zA-Z]+$/.test(code) ? code : "en";
|
||||
|
||||
// the code should consist of either 2 uppercase letters or 3 digits
|
||||
// the regex underneath is more permissive
|
||||
const testedRegion = /^[a-zA-Z0-9]+$/.test(region) ? region : "";
|
||||
|
||||
const requestedLocale = `${testedCode}${testedRegion !== "" ? "-" : ""}${testedRegion}`;
|
||||
|
||||
// use fallback to closest supported locale.
|
||||
// for instance, es-419 will be transformed to es
|
||||
return lookup(i18n.locales, requestedLocale) ?? requestedLocale;
|
||||
};
|
||||
131
calcom/packages/features/auth/lib/getServerSession.ts
Normal file
131
calcom/packages/features/auth/lib/getServerSession.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { LRUCache } from "lru-cache";
|
||||
import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from "next";
|
||||
import type { AuthOptions, Session } from "next-auth";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["getServerSession"] });
|
||||
/**
|
||||
* Stores the session in memory using the stringified token as the key.
|
||||
*
|
||||
*/
|
||||
const CACHE = new LRUCache<string, Session>({ max: 1000 });
|
||||
|
||||
/**
|
||||
* This is a slimmed down version of the `getServerSession` function from
|
||||
* `next-auth`.
|
||||
*
|
||||
* Instead of requiring the entire options object for NextAuth, we create
|
||||
* a compatible session using information from the incoming token.
|
||||
*
|
||||
* The downside to this is that we won't refresh sessions if the users
|
||||
* token has expired (30 days). This should be fine as we call `/auth/session`
|
||||
* frequently enough on the client-side to keep the session alive.
|
||||
*/
|
||||
export async function getServerSession(options: {
|
||||
req: NextApiRequest | GetServerSidePropsContext["req"];
|
||||
res?: NextApiResponse | GetServerSidePropsContext["res"];
|
||||
authOptions?: AuthOptions;
|
||||
}) {
|
||||
const { req, authOptions: { secret } = {} } = options;
|
||||
|
||||
const token = await getToken({
|
||||
req,
|
||||
secret,
|
||||
});
|
||||
|
||||
log.debug("Getting server session", safeStringify({ token }));
|
||||
|
||||
if (!token || !token.email || !token.sub) {
|
||||
log.debug("Couldnt get token");
|
||||
return null;
|
||||
}
|
||||
|
||||
const cachedSession = CACHE.get(JSON.stringify(token));
|
||||
|
||||
if (cachedSession) {
|
||||
log.debug("Returning cached session", safeStringify(cachedSession));
|
||||
return cachedSession;
|
||||
}
|
||||
|
||||
const userFromDb = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: token.email.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!userFromDb) {
|
||||
log.debug("No user found");
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasValidLicense = await checkLicense(prisma);
|
||||
|
||||
let upId = token.upId;
|
||||
|
||||
if (!upId) {
|
||||
upId = `usr-${userFromDb.id}`;
|
||||
}
|
||||
|
||||
if (!upId) {
|
||||
log.error("No upId found for session", { userId: userFromDb.id });
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await UserRepository.enrichUserWithTheProfile({
|
||||
user: userFromDb,
|
||||
upId,
|
||||
});
|
||||
|
||||
const session: Session = {
|
||||
hasValidLicense,
|
||||
expires: new Date(typeof token.exp === "number" ? token.exp * 1000 : Date.now()).toISOString(),
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
email_verified: user.emailVerified !== null,
|
||||
role: user.role,
|
||||
image: getUserAvatarUrl({
|
||||
avatarUrl: user.avatarUrl,
|
||||
}),
|
||||
belongsToActiveTeam: token.belongsToActiveTeam,
|
||||
org: token.org,
|
||||
locale: user.locale ?? undefined,
|
||||
profile: user.profile,
|
||||
},
|
||||
profileId: token.profileId,
|
||||
upId,
|
||||
};
|
||||
|
||||
if (token?.impersonatedBy?.id) {
|
||||
const impersonatedByUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: token.impersonatedBy.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
if (impersonatedByUser) {
|
||||
session.user.impersonatedBy = {
|
||||
id: impersonatedByUser?.id,
|
||||
role: impersonatedByUser.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
CACHE.set(JSON.stringify(token), session);
|
||||
|
||||
log.debug("Returned session", safeStringify(session));
|
||||
return session;
|
||||
}
|
||||
10
calcom/packages/features/auth/lib/getSession.ts
Normal file
10
calcom/packages/features/auth/lib/getSession.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Session } from "next-auth";
|
||||
import type { GetSessionParams } from "next-auth/react";
|
||||
import { getSession as getSessionInner } from "next-auth/react";
|
||||
|
||||
export async function getSession(options: GetSessionParams): Promise<Session | null> {
|
||||
const session = await getSessionInner(options);
|
||||
|
||||
// that these are equal are ensured in `[...nextauth]`'s callback
|
||||
return session as Session | null;
|
||||
}
|
||||
6
calcom/packages/features/auth/lib/hashPassword.ts
Normal file
6
calcom/packages/features/auth/lib/hashPassword.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { hash } from "bcryptjs";
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
const hashedPassword = await hash(password, 12);
|
||||
return hashedPassword;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
|
||||
export const identityProviderNameMap: { [key in IdentityProvider]: string } = {
|
||||
[IdentityProvider.CAL]: "Cal",
|
||||
[IdentityProvider.GOOGLE]: "Google",
|
||||
[IdentityProvider.SAML]: "SAML",
|
||||
};
|
||||
26
calcom/packages/features/auth/lib/isPasswordValid.ts
Normal file
26
calcom/packages/features/auth/lib/isPasswordValid.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export function isPasswordValid(password: string): boolean;
|
||||
export function isPasswordValid(
|
||||
password: string,
|
||||
breakdown: boolean,
|
||||
strict?: boolean
|
||||
): { caplow: boolean; num: boolean; min: boolean; admin_min: boolean };
|
||||
export function isPasswordValid(password: string, breakdown?: boolean, strict?: boolean) {
|
||||
let cap = false, // Has uppercase characters
|
||||
low = false, // Has lowercase characters
|
||||
num = false, // At least one number
|
||||
min = false, // Eight characters, or fifteen in strict mode.
|
||||
admin_min = false;
|
||||
if (password.length >= 7 && (!strict || password.length > 14)) min = true;
|
||||
if (strict && password.length > 14) admin_min = true;
|
||||
if (password.match(/\d/)) num = true;
|
||||
if (password.match(/[a-z]/)) low = true;
|
||||
if (password.match(/[A-Z]/)) cap = true;
|
||||
|
||||
if (!breakdown) return cap && low && num && min && (strict ? admin_min : true);
|
||||
|
||||
let errors: Record<string, boolean> = { caplow: cap && low, num, min };
|
||||
// Only return the admin key if strict mode is enabled.
|
||||
if (strict) errors = { ...errors, admin_min };
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Account, IdentityProvider, Prisma, User, VerificationToken } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import { identityProviderNameMap } from "./identityProviderNameMap";
|
||||
|
||||
/** @return { import("next-auth/adapters").Adapter } */
|
||||
export default function CalComAdapter(prismaClient: PrismaClient) {
|
||||
return {
|
||||
createUser: (data: Prisma.UserCreateInput) => prismaClient.user.create({ data }),
|
||||
getUser: (id: string | number) =>
|
||||
prismaClient.user.findUnique({ where: { id: typeof id === "string" ? parseInt(id) : id } }),
|
||||
getUserByEmail: (email: User["email"]) => prismaClient.user.findUnique({ where: { email } }),
|
||||
async getUserByAccount(provider_providerAccountId: {
|
||||
providerAccountId: Account["providerAccountId"];
|
||||
provider: User["identityProvider"];
|
||||
}) {
|
||||
let _account;
|
||||
const account = await prismaClient.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId,
|
||||
},
|
||||
select: { user: true },
|
||||
});
|
||||
if (account) {
|
||||
return (_account = account === null || account === void 0 ? void 0 : account.user) !== null &&
|
||||
_account !== void 0
|
||||
? _account
|
||||
: null;
|
||||
}
|
||||
|
||||
// NOTE: this code it's our fallback to users without Account but credentials in User Table
|
||||
// We should remove this code after all googles tokens have expired
|
||||
const provider = provider_providerAccountId?.provider.toUpperCase() as IdentityProvider;
|
||||
if (["GOOGLE", "SAML"].indexOf(provider) < 0) {
|
||||
return null;
|
||||
}
|
||||
const obtainProvider = identityProviderNameMap[provider].toUpperCase() as IdentityProvider;
|
||||
const user = await prismaClient.user.findFirst({
|
||||
where: {
|
||||
identityProviderId: provider_providerAccountId?.providerAccountId,
|
||||
identityProvider: obtainProvider,
|
||||
},
|
||||
});
|
||||
return user || null;
|
||||
},
|
||||
updateUser: ({ id, ...data }: Prisma.UserUncheckedCreateInput) =>
|
||||
prismaClient.user.update({ where: { id }, data }),
|
||||
deleteUser: (id: User["id"]) => prismaClient.user.delete({ where: { id } }),
|
||||
async createVerificationToken(data: VerificationToken) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { id: _, ...verificationToken } = await prismaClient.verificationToken.create({
|
||||
data,
|
||||
});
|
||||
return verificationToken;
|
||||
},
|
||||
async useVerificationToken(identifier_token: Prisma.VerificationTokenIdentifierTokenCompoundUniqueInput) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { id: _, ...verificationToken } = await prismaClient.verificationToken.delete({
|
||||
where: { identifier_token },
|
||||
});
|
||||
return verificationToken;
|
||||
} catch (error) {
|
||||
// If token already used/deleted, just return null
|
||||
// https://www.prisma.io/docs/reference/api-reference/error-reference#p2025
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2025") return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
linkAccount: (data: Prisma.AccountCreateInput) => prismaClient.account.create({ data }),
|
||||
unlinkAccount: (provider_providerAccountId: Prisma.AccountProviderProviderAccountIdCompoundUniqueInput) =>
|
||||
prismaClient.account.delete({ where: { provider_providerAccountId } }),
|
||||
};
|
||||
}
|
||||
947
calcom/packages/features/auth/lib/next-auth-options.ts
Normal file
947
calcom/packages/features/auth/lib/next-auth-options.ts
Normal file
@@ -0,0 +1,947 @@
|
||||
import type { Membership, Team, UserPermissionRole } from "@prisma/client";
|
||||
import type { AuthOptions, Session } from "next-auth";
|
||||
import type { JWT } from "next-auth/jwt";
|
||||
import { encode } from "next-auth/jwt";
|
||||
import type { Provider } from "next-auth/providers";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import EmailProvider from "next-auth/providers/email";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import createUsersAndConnectToOrg from "@calcom/features/ee/dsync/lib/users/createUsersAndConnectToOrg";
|
||||
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
|
||||
import { getOrgFullOrigin, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { ENABLE_PROFILE_SWITCHER, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto";
|
||||
import { defaultCookies } from "@calcom/lib/default-cookies";
|
||||
import { isENVDev } from "@calcom/lib/env";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { ErrorCode } from "./ErrorCode";
|
||||
import { isPasswordValid } from "./isPasswordValid";
|
||||
import CalComAdapter from "./next-auth-custom-adapter";
|
||||
import { verifyPassword } from "./verifyPassword";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["next-auth-options"] });
|
||||
const GOOGLE_API_CREDENTIALS = process.env.GOOGLE_API_CREDENTIALS || "{}";
|
||||
const { client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET } =
|
||||
JSON.parse(GOOGLE_API_CREDENTIALS)?.web || {};
|
||||
const GOOGLE_LOGIN_ENABLED = process.env.GOOGLE_LOGIN_ENABLED === "true";
|
||||
const IS_GOOGLE_LOGIN_ENABLED = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET && GOOGLE_LOGIN_ENABLED);
|
||||
const ORGANIZATIONS_AUTOLINK =
|
||||
process.env.ORGANIZATIONS_AUTOLINK === "1" || process.env.ORGANIZATIONS_AUTOLINK === "true";
|
||||
|
||||
const usernameSlug = (username: string) => `${slugify(username)}-${randomString(6).toLowerCase()}`;
|
||||
const getDomainFromEmail = (email: string): string => email.split("@")[1];
|
||||
const getVerifiedOrganizationByAutoAcceptEmailDomain = async (domain: string) => {
|
||||
const existingOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
organizationSettings: {
|
||||
isOrganizationVerified: true,
|
||||
orgAutoAcceptEmail: domain,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
return existingOrg?.id;
|
||||
};
|
||||
const loginWithTotp = async (email: string) =>
|
||||
`/auth/login?totp=${await (await import("./signJwt")).default({ email })}`;
|
||||
|
||||
type UserTeams = {
|
||||
teams: (Membership & {
|
||||
team: Pick<Team, "metadata">;
|
||||
})[];
|
||||
};
|
||||
|
||||
export const checkIfUserBelongsToActiveTeam = <T extends UserTeams>(user: T) =>
|
||||
user.teams.some((m: { team: { metadata: unknown } }) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
|
||||
|
||||
return metadata.success && metadata.data?.subscriptionId;
|
||||
});
|
||||
|
||||
const checkIfUserShouldBelongToOrg = async (idP: IdentityProvider, email: string) => {
|
||||
const [orgUsername, apexDomain] = email.split("@");
|
||||
if (!ORGANIZATIONS_AUTOLINK || idP !== "GOOGLE") return { orgUsername, orgId: undefined };
|
||||
const existingOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
organizationSettings: {
|
||||
isOrganizationVerified: true,
|
||||
orgAutoAcceptEmail: apexDomain,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
return { orgUsername, orgId: existingOrg?.id };
|
||||
};
|
||||
|
||||
const providers: Provider[] = [
|
||||
CredentialsProvider({
|
||||
id: "credentials",
|
||||
name: "Cal.com",
|
||||
type: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
|
||||
password: { label: "Password", type: "password", placeholder: "Your super secure password" },
|
||||
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
|
||||
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials) {
|
||||
console.error(`For some reason credentials are missing`);
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
const user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({
|
||||
email: credentials.email,
|
||||
});
|
||||
// Don't leak information about it being username or password that is invalid
|
||||
if (!user) {
|
||||
throw new Error(ErrorCode.IncorrectEmailPassword);
|
||||
}
|
||||
|
||||
// Locked users cannot login
|
||||
if (user.locked) {
|
||||
throw new Error(ErrorCode.UserAccountLocked);
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
identifier: user.email,
|
||||
});
|
||||
|
||||
if (!user.password?.hash && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
|
||||
throw new Error(ErrorCode.IncorrectEmailPassword);
|
||||
}
|
||||
if (!user.password?.hash && user.identityProvider == IdentityProvider.CAL) {
|
||||
throw new Error(ErrorCode.IncorrectEmailPassword);
|
||||
}
|
||||
|
||||
if (user.password?.hash && !credentials.totpCode) {
|
||||
if (!user.password?.hash) {
|
||||
throw new Error(ErrorCode.IncorrectEmailPassword);
|
||||
}
|
||||
const isCorrectPassword = await verifyPassword(credentials.password, user.password.hash);
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.IncorrectEmailPassword);
|
||||
}
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled && credentials.backupCode) {
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
|
||||
console.error("Missing encryption key; cannot proceed with backup code login.");
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
if (!user.backupCodes) throw new Error(ErrorCode.MissingBackupCodes);
|
||||
|
||||
const backupCodes = JSON.parse(
|
||||
symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY)
|
||||
);
|
||||
|
||||
// check if user-supplied code matches one
|
||||
const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", ""));
|
||||
if (index === -1) throw new Error(ErrorCode.IncorrectBackupCode);
|
||||
|
||||
// delete verified backup code and re-encrypt remaining
|
||||
backupCodes[index] = null;
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
|
||||
},
|
||||
});
|
||||
} else if (user.twoFactorEnabled) {
|
||||
if (!credentials.totpCode) {
|
||||
throw new Error(ErrorCode.SecondFactorRequired);
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
|
||||
console.error(`"Missing encryption key; cannot proceed with two factor login."`);
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
|
||||
if (secret.length !== 32) {
|
||||
console.error(
|
||||
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
|
||||
);
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
const isValidToken = (await import("@calcom/lib/totp")).totpAuthenticatorCheck(
|
||||
credentials.totpCode,
|
||||
secret
|
||||
);
|
||||
if (!isValidToken) {
|
||||
throw new Error(ErrorCode.IncorrectTwoFactorCode);
|
||||
}
|
||||
}
|
||||
// Check if the user you are logging into has any active teams
|
||||
const hasActiveTeams = checkIfUserBelongsToActiveTeam(user);
|
||||
|
||||
// authentication success- but does it meet the minimum password requirements?
|
||||
const validateRole = (role: UserPermissionRole) => {
|
||||
// User's role is not "ADMIN"
|
||||
if (role !== "ADMIN") return role;
|
||||
// User's identity provider is not "CAL"
|
||||
if (user.identityProvider !== IdentityProvider.CAL) return role;
|
||||
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
console.warn("E2E testing is enabled, skipping password and 2FA requirements for Admin");
|
||||
return role;
|
||||
}
|
||||
|
||||
// User's password is valid and two-factor authentication is enabled
|
||||
if (isPasswordValid(credentials.password, false, true) && user.twoFactorEnabled) return role;
|
||||
// Code is running in a development environment
|
||||
if (isENVDev) return role;
|
||||
// By this point it is an ADMIN without valid security conditions
|
||||
return "INACTIVE_ADMIN";
|
||||
};
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: validateRole(user.role),
|
||||
belongsToActiveTeam: hasActiveTeams,
|
||||
locale: user.locale,
|
||||
profile: user.allProfiles[0],
|
||||
};
|
||||
},
|
||||
}),
|
||||
ImpersonationProvider,
|
||||
];
|
||||
|
||||
if (IS_GOOGLE_LOGIN_ENABLED) {
|
||||
providers.push(
|
||||
GoogleProvider({
|
||||
clientId: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (isSAMLLoginEnabled) {
|
||||
providers.push({
|
||||
id: "saml",
|
||||
name: "BoxyHQ",
|
||||
type: "oauth",
|
||||
version: "2.0",
|
||||
checks: ["pkce", "state"],
|
||||
authorization: {
|
||||
url: `${WEBAPP_URL}/api/auth/saml/authorize`,
|
||||
params: {
|
||||
scope: "",
|
||||
response_type: "code",
|
||||
provider: "saml",
|
||||
},
|
||||
},
|
||||
token: {
|
||||
url: `${WEBAPP_URL}/api/auth/saml/token`,
|
||||
params: { grant_type: "authorization_code" },
|
||||
},
|
||||
userinfo: `${WEBAPP_URL}/api/auth/saml/userinfo`,
|
||||
profile: async (profile: {
|
||||
id?: number;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
locale?: string;
|
||||
}) => {
|
||||
const user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({
|
||||
email: profile.email || "",
|
||||
});
|
||||
return {
|
||||
id: profile.id || 0,
|
||||
firstName: profile.firstName || "",
|
||||
lastName: profile.lastName || "",
|
||||
email: profile.email || "",
|
||||
name: `${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
|
||||
email_verified: true,
|
||||
locale: profile.locale,
|
||||
...(user ? { profile: user.allProfiles[0] } : {}),
|
||||
};
|
||||
},
|
||||
options: {
|
||||
clientId: "dummy",
|
||||
clientSecret: clientSecretVerifier,
|
||||
},
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
});
|
||||
|
||||
// Idp initiated login
|
||||
providers.push(
|
||||
CredentialsProvider({
|
||||
id: "saml-idp",
|
||||
name: "IdP Login",
|
||||
credentials: {
|
||||
code: {},
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { code } = credentials;
|
||||
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { oauthController } = await (await import("@calcom/features/ee/sso/lib/jackson")).default();
|
||||
|
||||
// Fetch access token
|
||||
const { access_token } = await oauthController.token({
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: `${process.env.NEXTAUTH_URL}`,
|
||||
client_id: "dummy",
|
||||
client_secret: clientSecretVerifier,
|
||||
});
|
||||
|
||||
if (!access_token) {
|
||||
return null;
|
||||
}
|
||||
// Fetch user info
|
||||
const userInfo = await oauthController.userInfo(access_token);
|
||||
|
||||
if (!userInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id, firstName, lastName } = userInfo;
|
||||
const email = userInfo.email.toLowerCase();
|
||||
let user = !email
|
||||
? undefined
|
||||
: await UserRepository.findByEmailAndIncludeProfilesAndPassword({ email });
|
||||
if (!user) {
|
||||
const hostedCal = Boolean(HOSTED_CAL_FEATURES);
|
||||
if (hostedCal && email) {
|
||||
const domain = getDomainFromEmail(email);
|
||||
const organizationId = await getVerifiedOrganizationByAutoAcceptEmailDomain(domain);
|
||||
if (organizationId) {
|
||||
const createUsersAndConnectToOrgProps = {
|
||||
emailsToCreate: [email],
|
||||
organizationId,
|
||||
identityProvider: IdentityProvider.SAML,
|
||||
identityProviderId: email,
|
||||
};
|
||||
await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps);
|
||||
user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({
|
||||
email: email,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!user) throw new Error(ErrorCode.UserNotFound);
|
||||
}
|
||||
const [userProfile] = user?.allProfiles;
|
||||
return {
|
||||
id: id as unknown as number,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
name: `${firstName} ${lastName}`.trim(),
|
||||
email_verified: true,
|
||||
profile: userProfile,
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
providers.push(
|
||||
EmailProvider({
|
||||
type: "email",
|
||||
maxAge: 10 * 60 * 60, // Magic links are valid for 10 min only
|
||||
// Here we setup the sendVerificationRequest that calls the email template with the identifier (email) and token to verify.
|
||||
sendVerificationRequest: async (props) => (await import("./sendVerificationRequest")).default(props),
|
||||
})
|
||||
);
|
||||
|
||||
function isNumber(n: string) {
|
||||
return !isNaN(parseFloat(n)) && !isNaN(+n);
|
||||
}
|
||||
|
||||
const calcomAdapter = CalComAdapter(prisma);
|
||||
|
||||
const mapIdentityProvider = (providerName: string) => {
|
||||
switch (providerName) {
|
||||
case "saml-idp":
|
||||
case "saml":
|
||||
return IdentityProvider.SAML;
|
||||
default:
|
||||
return IdentityProvider.GOOGLE;
|
||||
}
|
||||
};
|
||||
|
||||
export const AUTH_OPTIONS: AuthOptions = {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
adapter: calcomAdapter,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
jwt: {
|
||||
// decorate the native JWT encode function
|
||||
// Impl. detail: We don't pass through as this function is called with encode/decode functions.
|
||||
encode: async ({ token, maxAge, secret }) => {
|
||||
if (token?.sub && isNumber(token.sub)) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: Number(token.sub) },
|
||||
select: { metadata: true },
|
||||
});
|
||||
// if no user is found, we still don't want to crash here.
|
||||
if (user) {
|
||||
const metadata = userMetadata.parse(user.metadata);
|
||||
if (metadata?.sessionTimeout) {
|
||||
maxAge = metadata.sessionTimeout * 60;
|
||||
}
|
||||
}
|
||||
}
|
||||
return encode({ secret, token, maxAge });
|
||||
},
|
||||
},
|
||||
cookies: defaultCookies(WEBAPP_URL?.startsWith("https://")),
|
||||
pages: {
|
||||
signIn: "/auth/login",
|
||||
signOut: "/auth/logout",
|
||||
error: "/auth/error", // Error code passed in query string as ?error=
|
||||
verifyRequest: "/auth/verify",
|
||||
// newUser: "/auth/new", // New users will be directed here on first sign in (leave the property out if not of interest)
|
||||
},
|
||||
providers,
|
||||
callbacks: {
|
||||
async jwt({
|
||||
// Always available but with a little difference in value
|
||||
token,
|
||||
// Available only in case of signIn, signUp or useSession().update call.
|
||||
trigger,
|
||||
// Available when useSession().update is called. The value will be the POST data
|
||||
session,
|
||||
// Available only in the first call once the user signs in. Not available in subsequent calls
|
||||
user,
|
||||
// Available only in the first call once the user signs in. Not available in subsequent calls
|
||||
account,
|
||||
}) {
|
||||
log.debug("callbacks:jwt", safeStringify({ token, user, account, trigger, session }));
|
||||
// The data available in 'session' depends on what data was supplied in update method call of session
|
||||
if (trigger === "update") {
|
||||
return {
|
||||
...token,
|
||||
profileId: session?.profileId ?? token.profileId ?? null,
|
||||
upId: session?.upId ?? token.upId ?? null,
|
||||
locale: session?.locale ?? token.locale ?? "en",
|
||||
name: session?.name ?? token.name,
|
||||
username: session?.username ?? token.username,
|
||||
email: session?.email ?? token.email,
|
||||
} as JWT;
|
||||
}
|
||||
const autoMergeIdentities = async () => {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
where: { email: token.email! },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatarUrl: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
locale: true,
|
||||
movedToProfileId: true,
|
||||
teams: {
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
return token;
|
||||
}
|
||||
|
||||
// Check if the existingUser has any active teams
|
||||
const belongsToActiveTeam = checkIfUserBelongsToActiveTeam(existingUser);
|
||||
const { teams: _teams, ...existingUserWithoutTeamsField } = existingUser;
|
||||
const allProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser(existingUser);
|
||||
log.debug(
|
||||
"callbacks:jwt:autoMergeIdentities",
|
||||
safeStringify({
|
||||
allProfiles,
|
||||
})
|
||||
);
|
||||
const { upId } = determineProfile({ profiles: allProfiles, token });
|
||||
|
||||
const profile = await ProfileRepository.findByUpId(upId);
|
||||
if (!profile) {
|
||||
throw new Error("Profile not found");
|
||||
}
|
||||
|
||||
const profileOrg = profile?.organization;
|
||||
let orgRole: MembershipRole | undefined;
|
||||
// Get users role of org
|
||||
if (profileOrg) {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
teamId: profileOrg.id,
|
||||
userId: existingUser.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
orgRole = membership?.role;
|
||||
}
|
||||
|
||||
return {
|
||||
...existingUserWithoutTeamsField,
|
||||
...token,
|
||||
profileId: profile.id,
|
||||
upId,
|
||||
belongsToActiveTeam,
|
||||
// All organizations in the token would be too big to store. It breaks the sessions request.
|
||||
// So, we just set the currently switched organization only here.
|
||||
org: profileOrg
|
||||
? {
|
||||
id: profileOrg.id,
|
||||
name: profileOrg.name,
|
||||
slug: profileOrg.slug ?? profileOrg.requestedSlug ?? "",
|
||||
logoUrl: profileOrg.logoUrl,
|
||||
fullDomain: getOrgFullOrigin(profileOrg.slug ?? profileOrg.requestedSlug ?? ""),
|
||||
domainSuffix: subdomainSuffix(),
|
||||
role: orgRole as MembershipRole, // It can't be undefined if we have a profileOrg
|
||||
}
|
||||
: null,
|
||||
} as JWT;
|
||||
};
|
||||
if (!user) {
|
||||
return await autoMergeIdentities();
|
||||
}
|
||||
if (!account) {
|
||||
return token;
|
||||
}
|
||||
if (account.type === "credentials") {
|
||||
// return token if credentials,saml-idp
|
||||
if (account.provider === "saml-idp") {
|
||||
return token;
|
||||
}
|
||||
// any other credentials, add user info
|
||||
return {
|
||||
...token,
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
impersonatedBy: user.impersonatedBy,
|
||||
belongsToActiveTeam: user?.belongsToActiveTeam,
|
||||
org: user?.org,
|
||||
locale: user?.locale,
|
||||
profileId: user.profile?.id ?? token.profileId ?? null,
|
||||
upId: user.profile?.upId ?? token.upId ?? null,
|
||||
} as JWT;
|
||||
}
|
||||
|
||||
// The arguments above are from the provider so we need to look up the
|
||||
// user based on those values in order to construct a JWT.
|
||||
if (account.type === "oauth") {
|
||||
if (!account.provider || !account.providerAccountId) {
|
||||
return token;
|
||||
}
|
||||
const idP = account.provider === "saml" ? IdentityProvider.SAML : IdentityProvider.GOOGLE;
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
identityProvider: idP,
|
||||
},
|
||||
{
|
||||
identityProviderId: account.providerAccountId,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
return await autoMergeIdentities();
|
||||
}
|
||||
|
||||
return {
|
||||
...token,
|
||||
id: existingUser.id,
|
||||
name: existingUser.name,
|
||||
username: existingUser.username,
|
||||
email: existingUser.email,
|
||||
role: existingUser.role,
|
||||
impersonatedBy: token.impersonatedBy,
|
||||
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||
org: token?.org,
|
||||
locale: existingUser.locale,
|
||||
} as JWT;
|
||||
}
|
||||
|
||||
if (account.type === "email") {
|
||||
return await autoMergeIdentities();
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token, user }) {
|
||||
log.debug("callbacks:session - Session callback called", safeStringify({ session, token, user }));
|
||||
const hasValidLicense = await checkLicense(prisma);
|
||||
const profileId = token.profileId;
|
||||
const calendsoSession: Session = {
|
||||
...session,
|
||||
profileId,
|
||||
upId: token.upId || session.upId,
|
||||
hasValidLicense,
|
||||
user: {
|
||||
...session.user,
|
||||
id: token.id as number,
|
||||
name: token.name,
|
||||
username: token.username as string,
|
||||
role: token.role as UserPermissionRole,
|
||||
impersonatedBy: token.impersonatedBy,
|
||||
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||
org: token?.org,
|
||||
locale: token.locale,
|
||||
},
|
||||
};
|
||||
return calendsoSession;
|
||||
},
|
||||
async signIn(params) {
|
||||
const {
|
||||
/**
|
||||
* Available when Credentials provider is used - Has the value returned by authorize callback
|
||||
*/
|
||||
user,
|
||||
/**
|
||||
* Available when Credentials provider is used - Has the value submitted as the body of the HTTP POST submission
|
||||
*/
|
||||
profile,
|
||||
account,
|
||||
} = params;
|
||||
|
||||
log.debug("callbacks:signin", safeStringify(params));
|
||||
|
||||
if (account?.provider === "email") {
|
||||
return true;
|
||||
}
|
||||
// In this case we've already verified the credentials in the authorize
|
||||
// callback so we can sign the user in.
|
||||
// Only if provider is not saml-idp
|
||||
if (account?.provider !== "saml-idp") {
|
||||
if (account?.type === "credentials") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (account?.type !== "oauth") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!user.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.name) {
|
||||
return false;
|
||||
}
|
||||
if (account?.provider) {
|
||||
const idP: IdentityProvider = mapIdentityProvider(account.provider);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error TODO validate email_verified key on profile
|
||||
user.email_verified = user.email_verified || !!user.emailVerified || profile.email_verified;
|
||||
|
||||
if (!user.email_verified) {
|
||||
return "/auth/error?error=unverified-email";
|
||||
}
|
||||
|
||||
let existingUser = await prisma.user.findFirst({
|
||||
include: {
|
||||
accounts: {
|
||||
where: {
|
||||
provider: account.provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
identityProvider: idP,
|
||||
identityProviderId: account.providerAccountId,
|
||||
},
|
||||
});
|
||||
|
||||
/* --- START FIX LEGACY ISSUE WHERE 'identityProviderId' was accidentally set to userId --- */
|
||||
if (!existingUser) {
|
||||
existingUser = await prisma.user.findFirst({
|
||||
include: {
|
||||
accounts: {
|
||||
where: {
|
||||
provider: account.provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
identityProvider: idP,
|
||||
identityProviderId: String(user.id),
|
||||
},
|
||||
});
|
||||
if (existingUser) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: existingUser?.id,
|
||||
},
|
||||
data: {
|
||||
identityProviderId: account.providerAccountId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
/* --- END FIXES LEGACY ISSUE WHERE 'identityProviderId' was accidentally set to userId --- */
|
||||
if (existingUser) {
|
||||
// In this case there's an existing user and their email address
|
||||
// hasn't changed since they last logged in.
|
||||
if (existingUser.email === user.email) {
|
||||
try {
|
||||
// If old user without Account entry we link their google account
|
||||
if (existingUser.accounts.length === 0) {
|
||||
const linkAccountWithUserData = {
|
||||
...account,
|
||||
userId: existingUser.id,
|
||||
providerEmail: user.email,
|
||||
};
|
||||
await calcomAdapter.linkAccount(linkAccountWithUserData);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error("Error while linking account of already existing user");
|
||||
}
|
||||
}
|
||||
if (existingUser.twoFactorEnabled && existingUser.identityProvider === idP) {
|
||||
return loginWithTotp(existingUser.email);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If the email address doesn't match, check if an account already exists
|
||||
// with the new email address. If it does, for now we return an error. If
|
||||
// not, update the email of their account and log them in.
|
||||
const userWithNewEmail = await prisma.user.findFirst({
|
||||
where: { email: user.email },
|
||||
});
|
||||
|
||||
if (!userWithNewEmail) {
|
||||
await prisma.user.update({ where: { id: existingUser.id }, data: { email: user.email } });
|
||||
if (existingUser.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUser.email);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return "/auth/error?error=new-email-conflict";
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no existing user for this identity provider and id, create
|
||||
// a new account. If an account already exists with the incoming email
|
||||
// address return an error for now.
|
||||
|
||||
const existingUserWithEmail = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: user.email,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
include: {
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUserWithEmail) {
|
||||
// if self-hosted then we can allow auto-merge of identity providers if email is verified
|
||||
if (
|
||||
!hostedCal &&
|
||||
existingUserWithEmail.emailVerified &&
|
||||
existingUserWithEmail.identityProvider !== IdentityProvider.CAL
|
||||
) {
|
||||
if (existingUserWithEmail.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUserWithEmail.email);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// check if user was invited
|
||||
if (
|
||||
!existingUserWithEmail.password?.hash &&
|
||||
!existingUserWithEmail.emailVerified &&
|
||||
!existingUserWithEmail.username
|
||||
) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
email: existingUserWithEmail.email,
|
||||
},
|
||||
data: {
|
||||
// update the email to the IdP email
|
||||
email: user.email,
|
||||
// Slugify the incoming name and append a few random characters to
|
||||
// prevent conflicts for users with the same name.
|
||||
username: usernameSlug(user.name),
|
||||
emailVerified: new Date(Date.now()),
|
||||
name: user.name,
|
||||
identityProvider: idP,
|
||||
identityProviderId: account.providerAccountId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUserWithEmail.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUserWithEmail.email);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// User signs up with email/password and then tries to login with Google/SAML using the same email
|
||||
if (
|
||||
existingUserWithEmail.identityProvider === IdentityProvider.CAL &&
|
||||
(idP === IdentityProvider.GOOGLE || idP === IdentityProvider.SAML)
|
||||
) {
|
||||
await prisma.user.update({
|
||||
where: { email: existingUserWithEmail.email },
|
||||
// also update email to the IdP email
|
||||
data: {
|
||||
email: user.email.toLowerCase(),
|
||||
identityProvider: idP,
|
||||
identityProviderId: account.providerAccountId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUserWithEmail.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUserWithEmail.email);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
|
||||
return "/auth/error?error=use-password-login";
|
||||
} else if (
|
||||
existingUserWithEmail.identityProvider === IdentityProvider.GOOGLE &&
|
||||
idP === IdentityProvider.SAML
|
||||
) {
|
||||
await prisma.user.update({
|
||||
where: { email: existingUserWithEmail.email },
|
||||
// also update email to the IdP email
|
||||
data: {
|
||||
email: user.email.toLowerCase(),
|
||||
identityProvider: idP,
|
||||
identityProviderId: account.providerAccountId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return "/auth/error?error=use-identity-login";
|
||||
}
|
||||
|
||||
// Associate with organization if enabled by flag and idP is Google (for now)
|
||||
const { orgUsername, orgId } = await checkIfUserShouldBelongToOrg(idP, user.email);
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
// Slugify the incoming name and append a few random characters to
|
||||
// prevent conflicts for users with the same name.
|
||||
username: orgId ? slugify(orgUsername) : usernameSlug(user.name),
|
||||
emailVerified: new Date(Date.now()),
|
||||
name: user.name,
|
||||
...(user.image && { avatarUrl: user.image }),
|
||||
email: user.email,
|
||||
identityProvider: idP,
|
||||
identityProviderId: account.providerAccountId,
|
||||
...(orgId && {
|
||||
verified: true,
|
||||
organization: { connect: { id: orgId } },
|
||||
teams: {
|
||||
create: { role: MembershipRole.MEMBER, accepted: true, team: { connect: { id: orgId } } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const linkAccountNewUserData = { ...account, userId: newUser.id, providerEmail: user.email };
|
||||
await calcomAdapter.linkAccount(linkAccountNewUserData);
|
||||
|
||||
if (account.twoFactorEnabled) {
|
||||
return loginWithTotp(newUser.email);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* Used to handle the navigation right after successful login or logout
|
||||
*/
|
||||
async redirect({ url, baseUrl }) {
|
||||
// Allows relative callback URLs
|
||||
if (url.startsWith("/")) return `${baseUrl}${url}`;
|
||||
// Allows callback URLs on the same domain
|
||||
else if (new URL(url).hostname === new URL(WEBAPP_URL).hostname) return url;
|
||||
return baseUrl;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Identifies the profile the user should be logged into.
|
||||
*/
|
||||
const determineProfile = ({
|
||||
token,
|
||||
profiles,
|
||||
}: {
|
||||
token: JWT;
|
||||
profiles: { id: number | null; upId: string }[];
|
||||
}) => {
|
||||
// If profile switcher is disabled, we can only show the first profile.
|
||||
if (!ENABLE_PROFILE_SWITCHER) {
|
||||
return profiles[0];
|
||||
}
|
||||
|
||||
if (token.upId) {
|
||||
// Otherwise use what's in the token
|
||||
return { profileId: token.profileId, upId: token.upId as string };
|
||||
}
|
||||
|
||||
// If there is just one profile it has to be the one we want to log into.
|
||||
return profiles[0];
|
||||
};
|
||||
55
calcom/packages/features/auth/lib/oAuthAuthorization.ts
Normal file
55
calcom/packages/features/auth/lib/oAuthAuthorization.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { OAuthTokenPayload } from "@calcom/types/oauth";
|
||||
|
||||
export default async function isAuthorized(req: NextApiRequest, requiredScopes: string[] = []) {
|
||||
const token = req.headers.authorization?.split(" ")[1] || "";
|
||||
let decodedToken: OAuthTokenPayload;
|
||||
try {
|
||||
decodedToken = jwt.verify(token, process.env.CALENDSO_ENCRYPTION_KEY || "") as OAuthTokenPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!decodedToken) return null;
|
||||
const hasAllRequiredScopes = requiredScopes.every((scope) => decodedToken.scope.includes(scope));
|
||||
|
||||
if (!hasAllRequiredScopes || decodedToken.token_type !== "Access Token") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (decodedToken.userId) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: decodedToken.userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return { id: user.id, name: user.username, isTeam: false };
|
||||
}
|
||||
|
||||
if (decodedToken.teamId) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: decodedToken.teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) return null;
|
||||
return { ...team, isTeam: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
51
calcom/packages/features/auth/lib/passwordResetRequest.ts
Normal file
51
calcom/packages/features/auth/lib/passwordResetRequest.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { User } from "@prisma/client";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendPasswordResetEmail } from "@calcom/emails";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export const PASSWORD_RESET_EXPIRY_HOURS = 6;
|
||||
|
||||
const RECENT_MAX_ATTEMPTS = 3;
|
||||
const RECENT_PERIOD_IN_MINUTES = 5;
|
||||
|
||||
const createPasswordReset = async (email: string): Promise<string> => {
|
||||
const expiry = dayjs().add(PASSWORD_RESET_EXPIRY_HOURS, "hours").toDate();
|
||||
const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({
|
||||
data: {
|
||||
email,
|
||||
expires: expiry,
|
||||
},
|
||||
});
|
||||
|
||||
return `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/forgot-password/${createdResetPasswordRequest.id}`;
|
||||
};
|
||||
|
||||
const guardAgainstTooManyPasswordResets = async (email: string) => {
|
||||
const recentPasswordRequestsCount = await prisma.resetPasswordRequest.count({
|
||||
where: {
|
||||
email,
|
||||
createdAt: {
|
||||
gt: dayjs().subtract(RECENT_PERIOD_IN_MINUTES, "minutes").toDate(),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (recentPasswordRequestsCount >= RECENT_MAX_ATTEMPTS) {
|
||||
throw new Error("Too many password reset attempts. Please try again later.");
|
||||
}
|
||||
};
|
||||
const passwordResetRequest = async (user: Pick<User, "email" | "name" | "locale">) => {
|
||||
const { email } = user;
|
||||
const t = await getTranslation(user.locale ?? "en", "common");
|
||||
await guardAgainstTooManyPasswordResets(email);
|
||||
const resetLink = await createPasswordReset(email);
|
||||
// send email in user language
|
||||
await sendPasswordResetEmail({
|
||||
language: t,
|
||||
user,
|
||||
resetLink,
|
||||
});
|
||||
};
|
||||
|
||||
export { passwordResetRequest };
|
||||
39
calcom/packages/features/auth/lib/sendVerificationRequest.ts
Normal file
39
calcom/packages/features/auth/lib/sendVerificationRequest.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from "fs";
|
||||
import Handlebars from "handlebars";
|
||||
import type { SendVerificationRequestParams } from "next-auth/providers/email";
|
||||
import type { TransportOptions } from "nodemailer";
|
||||
import nodemailer from "nodemailer";
|
||||
import path from "path";
|
||||
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
|
||||
const transporter = nodemailer.createTransport<TransportOptions>({
|
||||
...(serverConfig.transport as TransportOptions),
|
||||
} as TransportOptions);
|
||||
|
||||
const sendVerificationRequest = async ({ identifier, url }: SendVerificationRequestParams) => {
|
||||
const emailsDir = path.resolve(process.cwd(), "..", "..", "packages/emails", "templates");
|
||||
const originalUrl = new URL(url);
|
||||
const webappUrl = new URL(process.env.NEXTAUTH_URL || WEBAPP_URL);
|
||||
if (originalUrl.origin !== webappUrl.origin) {
|
||||
url = url.replace(originalUrl.origin, webappUrl.origin);
|
||||
}
|
||||
const emailFile = readFileSync(path.join(emailsDir, "confirm-email.html"), {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const emailTemplate = Handlebars.compile(emailFile);
|
||||
// async transporter
|
||||
transporter.sendMail({
|
||||
from: `${process.env.EMAIL_FROM}` || APP_NAME,
|
||||
to: identifier,
|
||||
subject: `Your sign-in link for ${APP_NAME}`,
|
||||
html: emailTemplate({
|
||||
base_url: WEBAPP_URL,
|
||||
signin_url: url,
|
||||
email: identifier,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default sendVerificationRequest;
|
||||
17
calcom/packages/features/auth/lib/signJwt.ts
Normal file
17
calcom/packages/features/auth/lib/signJwt.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { SignJWT } from "jose";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
|
||||
const signJwt = async (payload: { email: string }) => {
|
||||
const secret = new TextEncoder().encode(process.env.CALENDSO_ENCRYPTION_KEY);
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(payload.email)
|
||||
.setIssuedAt()
|
||||
.setIssuer(WEBSITE_URL)
|
||||
.setAudience(`${WEBSITE_URL}/auth/login`)
|
||||
.setExpirationTime("2m")
|
||||
.sign(secret);
|
||||
};
|
||||
|
||||
export default signJwt;
|
||||
9
calcom/packages/features/auth/lib/validPassword.ts
Normal file
9
calcom/packages/features/auth/lib/validPassword.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function validPassword(password: string) {
|
||||
if (password.length < 7) return false;
|
||||
|
||||
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password)) return false;
|
||||
|
||||
if (!/\d+/.test(password)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
153
calcom/packages/features/auth/lib/verifyEmail.ts
Normal file
153
calcom/packages/features/auth/lib/verifyEmail.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { randomBytes, createHash } from "crypto";
|
||||
import { totp } from "otplib";
|
||||
|
||||
import {
|
||||
sendEmailVerificationCode,
|
||||
sendEmailVerificationLink,
|
||||
sendChangeOfEmailVerificationLink,
|
||||
} from "@calcom/emails/email-manager";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: [`[[Auth] `] });
|
||||
|
||||
interface VerifyEmailType {
|
||||
username?: string;
|
||||
email: string;
|
||||
language?: string;
|
||||
secondaryEmailId?: number;
|
||||
isVerifyingEmail?: boolean;
|
||||
isPlatform?: boolean;
|
||||
}
|
||||
|
||||
export const sendEmailVerification = async ({
|
||||
email,
|
||||
language,
|
||||
username,
|
||||
secondaryEmailId,
|
||||
isPlatform = false,
|
||||
}: VerifyEmailType) => {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const translation = await getTranslation(language ?? "en", "common");
|
||||
const emailVerification = await getFeatureFlag(prisma, "email-verification");
|
||||
|
||||
if (!emailVerification) {
|
||||
log.warn("Email verification is disabled - Skipping");
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
|
||||
if (isPlatform) {
|
||||
log.warn("Skipping Email verification");
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: email,
|
||||
});
|
||||
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: email,
|
||||
token,
|
||||
expires: new Date(Date.now() + 24 * 3600 * 1000), // +1 day
|
||||
secondaryEmailId: secondaryEmailId || null,
|
||||
},
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
});
|
||||
|
||||
await sendEmailVerificationLink({
|
||||
language: translation,
|
||||
verificationEmailLink: `${WEBAPP_URL}/api/auth/verify-email?${params.toString()}`,
|
||||
user: {
|
||||
email,
|
||||
name: username,
|
||||
},
|
||||
isSecondaryEmailVerification: !!secondaryEmailId,
|
||||
});
|
||||
|
||||
return { ok: true, skipped: false };
|
||||
};
|
||||
|
||||
export const sendEmailVerificationByCode = async ({
|
||||
email,
|
||||
language,
|
||||
username,
|
||||
isVerifyingEmail,
|
||||
}: VerifyEmailType) => {
|
||||
const translation = await getTranslation(language ?? "en", "common");
|
||||
const secret = createHash("md5")
|
||||
.update(email + process.env.CALENDSO_ENCRYPTION_KEY)
|
||||
.digest("hex");
|
||||
|
||||
totp.options = { step: 900 };
|
||||
const code = totp.generate(secret);
|
||||
|
||||
await sendEmailVerificationCode({
|
||||
language: translation,
|
||||
verificationEmailCode: code,
|
||||
user: {
|
||||
email,
|
||||
name: username,
|
||||
},
|
||||
isVerifyingEmail,
|
||||
});
|
||||
|
||||
return { ok: true, skipped: false };
|
||||
};
|
||||
|
||||
interface ChangeOfEmail {
|
||||
user: {
|
||||
username: string;
|
||||
emailFrom: string;
|
||||
emailTo: string;
|
||||
};
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOfEmail) => {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const translation = await getTranslation(language ?? "en", "common");
|
||||
const emailVerification = await getFeatureFlag(prisma, "email-verification");
|
||||
|
||||
if (!emailVerification) {
|
||||
log.warn("Email verification is disabled - Skipping");
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: user.emailFrom,
|
||||
});
|
||||
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: user.emailFrom, // We use from as this is the email use to get the metadata from
|
||||
token,
|
||||
expires: new Date(Date.now() + 24 * 3600 * 1000), // +1 day
|
||||
},
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
});
|
||||
|
||||
await sendChangeOfEmailVerificationLink({
|
||||
language: translation,
|
||||
verificationEmailLink: `${WEBAPP_URL}/auth/verify-email-change?${params.toString()}`,
|
||||
user: {
|
||||
emailFrom: user.emailFrom,
|
||||
emailTo: user.emailTo,
|
||||
name: user.username,
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true, skipped: false };
|
||||
};
|
||||
6
calcom/packages/features/auth/lib/verifyPassword.ts
Normal file
6
calcom/packages/features/auth/lib/verifyPassword.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { compare } from "bcryptjs";
|
||||
|
||||
export async function verifyPassword(password: string, hashedPassword: string) {
|
||||
const isValid = await compare(password, hashedPassword);
|
||||
return isValid;
|
||||
}
|
||||
23
calcom/packages/features/auth/package.json
Normal file
23
calcom/packages/features/auth/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@calcom/feature-auth",
|
||||
"sideEffects": false,
|
||||
"private": true,
|
||||
"description": "Cal.com's main auth code",
|
||||
"authors": "Cal.com, Inc.",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/dayjs": "*",
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@calcom/trpc": "*",
|
||||
"@calcom/ui": "*",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"handlebars": "^4.7.7",
|
||||
"jose": "^4.13.1",
|
||||
"lru-cache": "^9.0.3",
|
||||
"next-auth": "^4.22.1",
|
||||
"nodemailer": "^6.7.8",
|
||||
"otplib": "^12.0.1"
|
||||
}
|
||||
}
|
||||
228
calcom/packages/features/auth/signup/handlers/calcomHandler.ts
Normal file
228
calcom/packages/features/auth/signup/handlers/calcomHandler.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { NextApiResponse } from "next";
|
||||
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
|
||||
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { usernameHandler, type RequestWithUsernameStatus } from "@calcom/lib/server/username";
|
||||
import { createWebUser as syncServicesCreateWebUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
|
||||
import {
|
||||
findTokenByToken,
|
||||
throwIfTokenExpired,
|
||||
validateAndGetCorrectedUsernameForTeam,
|
||||
} from "../utils/token";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["signupCalcomHandler"] });
|
||||
|
||||
async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
|
||||
const {
|
||||
email: _email,
|
||||
password,
|
||||
token,
|
||||
} = signupSchema
|
||||
.pick({
|
||||
email: true,
|
||||
password: true,
|
||||
token: true,
|
||||
})
|
||||
.parse(req.body);
|
||||
|
||||
log.debug("handler", { email: _email });
|
||||
|
||||
let username: string | null = req.usernameStatus.requestedUserName;
|
||||
let checkoutSessionId: string | null = null;
|
||||
|
||||
// Check for premium username
|
||||
if (req.usernameStatus.statusCode === 418) {
|
||||
return res.status(req.usernameStatus.statusCode).json(req.usernameStatus.json);
|
||||
}
|
||||
|
||||
// Validate the user
|
||||
if (!username) {
|
||||
throw new HttpError({
|
||||
statusCode: 422,
|
||||
message: "Invalid username",
|
||||
});
|
||||
}
|
||||
|
||||
const email = _email.toLowerCase();
|
||||
|
||||
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
|
||||
if (token) {
|
||||
foundToken = await findTokenByToken({ token });
|
||||
throwIfTokenExpired(foundToken?.expires);
|
||||
username = await validateAndGetCorrectedUsernameForTeam({
|
||||
username,
|
||||
email,
|
||||
teamId: foundToken?.teamId ?? null,
|
||||
isSignup: true,
|
||||
});
|
||||
} else {
|
||||
const usernameAndEmailValidation = await validateAndGetCorrectedUsernameAndEmail({
|
||||
username,
|
||||
email,
|
||||
isSignup: true,
|
||||
});
|
||||
if (!usernameAndEmailValidation.isValid) {
|
||||
throw new HttpError({
|
||||
statusCode: 409,
|
||||
message: "Username or email is already taken",
|
||||
});
|
||||
}
|
||||
|
||||
if (!usernameAndEmailValidation.username) {
|
||||
throw new HttpError({
|
||||
statusCode: 422,
|
||||
message: "Invalid username",
|
||||
});
|
||||
}
|
||||
|
||||
username = usernameAndEmailValidation.username;
|
||||
}
|
||||
|
||||
// Create the customer in Stripe
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
metadata: {
|
||||
email /* Stripe customer email can be changed, so we add this to keep track of which email was used to signup */,
|
||||
username,
|
||||
},
|
||||
});
|
||||
|
||||
const returnUrl = `${WEBAPP_URL}/api/integrations/stripepayment/paymentCallback?checkoutSessionId={CHECKOUT_SESSION_ID}&callbackUrl=/auth/verify?sessionId={CHECKOUT_SESSION_ID}`;
|
||||
|
||||
// Pro username, must be purchased
|
||||
if (req.usernameStatus.statusCode === 402) {
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
customer: customer.id,
|
||||
line_items: [
|
||||
{
|
||||
price: getPremiumMonthlyPlanPriceId(),
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: returnUrl,
|
||||
cancel_url: returnUrl,
|
||||
allow_promotion_codes: true,
|
||||
});
|
||||
|
||||
/** We create a username-less user until he pays */
|
||||
checkoutSessionId = checkoutSession.id;
|
||||
username = null;
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
if (foundToken && foundToken?.teamId) {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: foundToken.teamId,
|
||||
},
|
||||
include: {
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
organizationSettings: true,
|
||||
},
|
||||
},
|
||||
organizationSettings: true,
|
||||
},
|
||||
});
|
||||
if (team) {
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {
|
||||
username,
|
||||
emailVerified: new Date(Date.now()),
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
password: {
|
||||
upsert: {
|
||||
create: { hash: hashedPassword },
|
||||
update: { hash: hashedPassword },
|
||||
},
|
||||
},
|
||||
},
|
||||
create: {
|
||||
username,
|
||||
email,
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
password: { create: { hash: hashedPassword } },
|
||||
},
|
||||
});
|
||||
// Wrapping in a transaction as if one fails we want to rollback the whole thing to preventa any data inconsistencies
|
||||
const { membership } = await createOrUpdateMemberships({
|
||||
user,
|
||||
team,
|
||||
});
|
||||
|
||||
closeComUpsertTeamUser(team, user, membership.role);
|
||||
|
||||
// Accept any child team invites for orgs.
|
||||
if (team.parent) {
|
||||
await joinAnyChildTeamOnOrgInvite({
|
||||
userId: user.id,
|
||||
org: team.parent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup token after use
|
||||
await prisma.verificationToken.delete({
|
||||
where: {
|
||||
id: foundToken.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create the user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
password: { create: { hash: hashedPassword } },
|
||||
metadata: {
|
||||
stripeCustomerId: customer.id,
|
||||
checkoutSessionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) {
|
||||
await prefillAvatar({ email });
|
||||
}
|
||||
sendEmailVerification({
|
||||
email,
|
||||
language: await getLocaleFromRequest(req),
|
||||
username: username || "",
|
||||
});
|
||||
// Sync Services
|
||||
await syncServicesCreateWebUser(user);
|
||||
}
|
||||
|
||||
if (checkoutSessionId) {
|
||||
console.log("Created user but missing payment", checkoutSessionId);
|
||||
return res.status(402).json({
|
||||
message: "Created user but missing payment",
|
||||
checkoutSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({ message: "Created user", stripeCustomerId: customer.id });
|
||||
}
|
||||
|
||||
export default usernameHandler(handler);
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
|
||||
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { isUsernameReservedDueToMigration } from "@calcom/lib/server/username";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
|
||||
import { prefillAvatar } from "../utils/prefillAvatar";
|
||||
import {
|
||||
findTokenByToken,
|
||||
throwIfTokenExpired,
|
||||
validateAndGetCorrectedUsernameForTeam,
|
||||
} from "../utils/token";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const data = req.body;
|
||||
const { email, password, language, token } = signupSchema.parse(data);
|
||||
|
||||
const username = slugify(data.username);
|
||||
const userEmail = email.toLowerCase();
|
||||
|
||||
if (!username) {
|
||||
res.status(422).json({ message: "Invalid username" });
|
||||
return;
|
||||
}
|
||||
|
||||
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
|
||||
let correctedUsername = username;
|
||||
if (token) {
|
||||
foundToken = await findTokenByToken({ token });
|
||||
throwIfTokenExpired(foundToken?.expires);
|
||||
correctedUsername = await validateAndGetCorrectedUsernameForTeam({
|
||||
username,
|
||||
email: userEmail,
|
||||
teamId: foundToken?.teamId,
|
||||
isSignup: true,
|
||||
});
|
||||
} else {
|
||||
const userValidation = await validateAndGetCorrectedUsernameAndEmail({
|
||||
username,
|
||||
email: userEmail,
|
||||
isSignup: true,
|
||||
});
|
||||
if (!userValidation.isValid) {
|
||||
logger.error("User validation failed", { userValidation });
|
||||
return res.status(409).json({ message: "Username or email is already taken" });
|
||||
}
|
||||
if (!userValidation.username) {
|
||||
return res.status(422).json({ message: "Invalid username" });
|
||||
}
|
||||
correctedUsername = userValidation.username;
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
if (foundToken && foundToken?.teamId) {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: foundToken.teamId,
|
||||
},
|
||||
include: {
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
organizationSettings: true,
|
||||
},
|
||||
},
|
||||
organizationSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (team) {
|
||||
const isInviteForATeamInOrganization = !!team.parent;
|
||||
const isCheckingUsernameInGlobalNamespace = !team.isOrganization && !isInviteForATeamInOrganization;
|
||||
|
||||
if (isCheckingUsernameInGlobalNamespace) {
|
||||
const isUsernameAvailable = !(await isUsernameReservedDueToMigration(correctedUsername));
|
||||
if (!isUsernameAvailable) {
|
||||
res.status(409).json({ message: "A user exists with that username" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: userEmail },
|
||||
update: {
|
||||
username: correctedUsername,
|
||||
password: {
|
||||
upsert: {
|
||||
create: { hash: hashedPassword },
|
||||
update: { hash: hashedPassword },
|
||||
},
|
||||
},
|
||||
emailVerified: new Date(Date.now()),
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
},
|
||||
create: {
|
||||
username: correctedUsername,
|
||||
email: userEmail,
|
||||
password: { create: { hash: hashedPassword } },
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
},
|
||||
});
|
||||
|
||||
const { membership } = await createOrUpdateMemberships({
|
||||
user,
|
||||
team,
|
||||
});
|
||||
|
||||
closeComUpsertTeamUser(team, user, membership.role);
|
||||
|
||||
// Accept any child team invites for orgs.
|
||||
if (team.parent) {
|
||||
await joinAnyChildTeamOnOrgInvite({
|
||||
userId: user.id,
|
||||
org: team.parent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup token after use
|
||||
await prisma.verificationToken.delete({
|
||||
where: {
|
||||
id: foundToken.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const isUsernameAvailable = !(await isUsernameReservedDueToMigration(correctedUsername));
|
||||
if (!isUsernameAvailable) {
|
||||
res.status(409).json({ message: "A user exists with that username" });
|
||||
return;
|
||||
}
|
||||
if (IS_PREMIUM_USERNAME_ENABLED) {
|
||||
const checkUsername = await checkPremiumUsername(correctedUsername);
|
||||
if (checkUsername.premium) {
|
||||
res.status(422).json({
|
||||
message: "Sign up from https://cal.com/signup to claim your premium username",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await prisma.user.upsert({
|
||||
where: { email: userEmail },
|
||||
update: {
|
||||
username: correctedUsername,
|
||||
password: {
|
||||
upsert: {
|
||||
create: { hash: hashedPassword },
|
||||
update: { hash: hashedPassword },
|
||||
},
|
||||
},
|
||||
emailVerified: new Date(Date.now()),
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
},
|
||||
create: {
|
||||
username: correctedUsername,
|
||||
email: userEmail,
|
||||
password: { create: { hash: hashedPassword } },
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) {
|
||||
await prefillAvatar({ email: userEmail });
|
||||
}
|
||||
|
||||
await sendEmailVerification({
|
||||
email: userEmail,
|
||||
username: correctedUsername,
|
||||
language,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({ message: "Created user" });
|
||||
}
|
||||
128
calcom/packages/features/auth/signup/username.ts
Normal file
128
calcom/packages/features/auth/signup/username.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
import { isPremiumUserName, generateUsernameSuggestion } from "@calcom/lib/server/username";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export type RequestWithUsernameStatus = NextApiRequest & {
|
||||
usernameStatus: {
|
||||
/**
|
||||
* ```text
|
||||
* 200: Username is available
|
||||
* 402: Pro username, must be purchased
|
||||
* 418: A user exists with that username
|
||||
* ```
|
||||
*/
|
||||
statusCode: 200 | 402 | 418;
|
||||
requestedUserName: string;
|
||||
json: {
|
||||
available: boolean;
|
||||
premium: boolean;
|
||||
message?: string;
|
||||
suggestion?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
export const usernameStatusSchema = z.object({
|
||||
statusCode: z.union([z.literal(200), z.literal(402), z.literal(418)]),
|
||||
requestedUserName: z.string(),
|
||||
json: z.object({
|
||||
available: z.boolean(),
|
||||
premium: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
suggestion: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
type CustomNextApiHandler<T = unknown> = (
|
||||
req: RequestWithUsernameStatus,
|
||||
res: NextApiResponse<T>
|
||||
) => void | Promise<void>;
|
||||
|
||||
const usernameHandler =
|
||||
(handler: CustomNextApiHandler) =>
|
||||
async (req: RequestWithUsernameStatus, res: NextApiResponse): Promise<void> => {
|
||||
const username = slugify(req.body.username);
|
||||
const check = await usernameCheck(username);
|
||||
|
||||
req.usernameStatus = {
|
||||
statusCode: 200,
|
||||
requestedUserName: username,
|
||||
json: {
|
||||
available: true,
|
||||
premium: false,
|
||||
message: "Username is available",
|
||||
},
|
||||
};
|
||||
|
||||
if (check.premium) {
|
||||
req.usernameStatus.statusCode = 402;
|
||||
req.usernameStatus.json.premium = true;
|
||||
req.usernameStatus.json.message = "This is a premium username.";
|
||||
}
|
||||
|
||||
if (!check.available) {
|
||||
req.usernameStatus.statusCode = 418;
|
||||
req.usernameStatus.json.available = false;
|
||||
req.usernameStatus.json.message = "A user exists with that username";
|
||||
}
|
||||
|
||||
req.usernameStatus.json.suggestion = check.suggestedUsername;
|
||||
|
||||
return handler(req, res);
|
||||
};
|
||||
|
||||
const usernameCheck = async (usernameRaw: string) => {
|
||||
const response = {
|
||||
available: true,
|
||||
premium: false,
|
||||
suggestedUsername: "",
|
||||
};
|
||||
|
||||
const username = slugify(usernameRaw);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
// Simply remove it when we drop organizationId column
|
||||
organizationId: null,
|
||||
},
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
response.available = false;
|
||||
}
|
||||
|
||||
if (await isPremiumUserName(username)) {
|
||||
response.premium = true;
|
||||
}
|
||||
|
||||
// get list of similar usernames in the db
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
contains: username,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
|
||||
// We only need suggestedUsername if the username is not available
|
||||
if (!response.available) {
|
||||
response.suggestedUsername = await generateUsernameSuggestion(
|
||||
users.map((user) => user.username).filter(notEmpty),
|
||||
username
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export { usernameHandler, usernameCheck };
|
||||
@@ -0,0 +1,90 @@
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
|
||||
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Team, User, OrganizationSettings } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { getOrgUsernameFromEmail } from "./getOrgUsernameFromEmail";
|
||||
|
||||
export const createOrUpdateMemberships = async ({
|
||||
user,
|
||||
team,
|
||||
}: {
|
||||
user: Pick<User, "id">;
|
||||
team: Pick<Team, "id" | "parentId" | "isOrganization"> & {
|
||||
organizationSettings: OrganizationSettings | null;
|
||||
};
|
||||
}) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
if (team.isOrganization) {
|
||||
const dbUser = await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
organizationId: team.id,
|
||||
},
|
||||
select: {
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Ideally dbUser.username should never be null, but just in case.
|
||||
// This method being called only during signup means that dbUser.username should be the correct org username
|
||||
const orgUsername =
|
||||
dbUser.username ||
|
||||
getOrgUsernameFromEmail(dbUser.email, team.organizationSettings?.orgAutoAcceptEmail ?? null);
|
||||
await tx.profile.upsert({
|
||||
create: {
|
||||
uid: ProfileRepository.generateProfileUid(),
|
||||
userId: user.id,
|
||||
organizationId: team.id,
|
||||
username: orgUsername,
|
||||
},
|
||||
update: {
|
||||
username: orgUsername,
|
||||
},
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: user.id,
|
||||
organizationId: team.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
const membership = await tx.membership.upsert({
|
||||
where: {
|
||||
userId_teamId: { userId: user.id, teamId: team.id },
|
||||
},
|
||||
update: {
|
||||
accepted: true,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
const orgMembership = null;
|
||||
if (team.parentId) {
|
||||
await tx.membership.upsert({
|
||||
where: {
|
||||
userId_teamId: { userId: user.id, teamId: team.parentId },
|
||||
},
|
||||
update: {
|
||||
accepted: true,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
teamId: team.parentId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
await updateNewTeamMemberEventTypes(user.id, team.id);
|
||||
return { membership, orgMembership };
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
|
||||
export const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string | null) => {
|
||||
const [emailUser, emailDomain = ""] = email.split("@");
|
||||
const username =
|
||||
emailDomain === autoAcceptEmailDomain
|
||||
? slugify(emailUser)
|
||||
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
|
||||
|
||||
return username;
|
||||
};
|
||||
84
calcom/packages/features/auth/signup/utils/organization.ts
Normal file
84
calcom/packages/features/auth/signup/utils/organization.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
|
||||
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Team, OrganizationSettings } from "@calcom/prisma/client";
|
||||
|
||||
import { getOrgUsernameFromEmail } from "./getOrgUsernameFromEmail";
|
||||
|
||||
export async function joinAnyChildTeamOnOrgInvite({
|
||||
userId,
|
||||
org,
|
||||
}: {
|
||||
userId: number;
|
||||
org: Pick<Team, "id"> & {
|
||||
organizationSettings: OrganizationSettings | null;
|
||||
};
|
||||
}) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const orgUsername =
|
||||
user.username ||
|
||||
getOrgUsernameFromEmail(user.email, org.organizationSettings?.orgAutoAcceptEmail ?? null);
|
||||
|
||||
await prisma.$transaction([
|
||||
// Simply remove this update when we remove the `organizationId` field from the user table
|
||||
prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
organizationId: org.id,
|
||||
},
|
||||
}),
|
||||
prisma.profile.upsert({
|
||||
create: {
|
||||
uid: ProfileRepository.generateProfileUid(),
|
||||
userId: userId,
|
||||
organizationId: org.id,
|
||||
username: orgUsername,
|
||||
},
|
||||
update: {
|
||||
username: orgUsername,
|
||||
},
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: user.id,
|
||||
organizationId: org.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.membership.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
team: {
|
||||
id: org.id,
|
||||
},
|
||||
accepted: false,
|
||||
},
|
||||
data: {
|
||||
accepted: true,
|
||||
},
|
||||
}),
|
||||
prisma.membership.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
team: {
|
||||
parentId: org.id,
|
||||
},
|
||||
accepted: false,
|
||||
},
|
||||
data: {
|
||||
accepted: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await updateNewTeamMemberEventTypes(userId, org.id);
|
||||
}
|
||||
83
calcom/packages/features/auth/signup/utils/prefillAvatar.ts
Normal file
83
calcom/packages/features/auth/signup/utils/prefillAvatar.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import fetch from "node-fetch";
|
||||
|
||||
import { uploadAvatar } from "@calcom/lib/server/avatar";
|
||||
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
interface IPrefillAvatar {
|
||||
email: string;
|
||||
}
|
||||
|
||||
async function downloadImageDataFromUrl(url: string) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("Error fetching image from: ", url);
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageBuffer = await response.buffer();
|
||||
const base64Image = `data:image/png;base64,${imageBuffer.toString("base64")}`;
|
||||
|
||||
return base64Image;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const prefillAvatar = async ({ email }: IPrefillAvatar) => {
|
||||
const imageUrl = await getImageUrlAvatarAPI(email);
|
||||
if (!imageUrl) return;
|
||||
|
||||
const base64Image = await downloadImageDataFromUrl(imageUrl);
|
||||
if (!base64Image) return;
|
||||
|
||||
const avatar = await resizeBase64Image(base64Image);
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { email: email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const avatarUrl = await uploadAvatar({ userId: user.id, avatar });
|
||||
|
||||
const data: Prisma.UserUpdateInput = {};
|
||||
data.avatarUrl = avatarUrl;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { email: email },
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
const getImageUrlAvatarAPI = async (email: string) => {
|
||||
if (!process.env.AVATARAPI_USERNAME || !process.env.AVATARAPI_PASSWORD) {
|
||||
console.info("No avatar api credentials found");
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch("https://avatarapi.com/v2/api.aspx", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: process.env.AVATARAPI_USERNAME,
|
||||
password: process.env.AVATARAPI_PASSWORD,
|
||||
email: email,
|
||||
}),
|
||||
});
|
||||
|
||||
const info = await response.json();
|
||||
|
||||
if (!info.Success) {
|
||||
console.log("Error from avatar api: ", info.Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return info.Image as string;
|
||||
};
|
||||
65
calcom/packages/features/auth/signup/utils/token.ts
Normal file
65
calcom/packages/features/auth/signup/utils/token.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { validateAndGetCorrectedUsernameInTeam } from "@calcom/lib/validateUsername";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
export async function findTokenByToken({ token }: { token: string }) {
|
||||
const foundToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
expires: true,
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundToken) {
|
||||
throw new HttpError({
|
||||
statusCode: 401,
|
||||
message: "Invalid Token",
|
||||
});
|
||||
}
|
||||
|
||||
return foundToken;
|
||||
}
|
||||
|
||||
export function throwIfTokenExpired(expires?: Date) {
|
||||
if (!expires) return;
|
||||
if (dayjs(expires).isBefore(dayjs())) {
|
||||
throw new HttpError({
|
||||
statusCode: 401,
|
||||
message: "Token expired",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateAndGetCorrectedUsernameForTeam({
|
||||
username,
|
||||
email,
|
||||
teamId,
|
||||
isSignup,
|
||||
}: {
|
||||
username: string;
|
||||
email: string;
|
||||
teamId: number | null;
|
||||
isSignup: boolean;
|
||||
}) {
|
||||
if (!teamId) return username;
|
||||
|
||||
const teamUserValidation = await validateAndGetCorrectedUsernameInTeam(username, email, teamId, isSignup);
|
||||
if (!teamUserValidation.isValid) {
|
||||
throw new HttpError({
|
||||
statusCode: 409,
|
||||
message: "Username or email is already taken",
|
||||
});
|
||||
}
|
||||
if (!teamUserValidation.username) {
|
||||
throw new HttpError({
|
||||
statusCode: 422,
|
||||
message: "Invalid username",
|
||||
});
|
||||
}
|
||||
return teamUserValidation.username;
|
||||
}
|
||||
486
calcom/packages/features/bookings/Booker/Booker.tsx
Normal file
486
calcom/packages/features/bookings/Booker/Booker.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import { AnimatePresence, LazyMotion, m } from "framer-motion";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import StickyBox from "react-sticky-box";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
|
||||
import { useNonEmptyScheduleDays } from "@calcom/features/schedules";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { VerifyCodeDialog } from "../components/VerifyCodeDialog";
|
||||
import { AvailableTimeSlots } from "./components/AvailableTimeSlots";
|
||||
import { BookEventForm } from "./components/BookEventForm";
|
||||
import { BookFormAsModal } from "./components/BookEventForm/BookFormAsModal";
|
||||
import { EventMeta } from "./components/EventMeta";
|
||||
import { HavingTroubleFindingTime } from "./components/HavingTroubleFindingTime";
|
||||
import { Header } from "./components/Header";
|
||||
import { InstantBooking } from "./components/InstantBooking";
|
||||
import { LargeCalendar } from "./components/LargeCalendar";
|
||||
import { OverlayCalendar } from "./components/OverlayCalendar/OverlayCalendar";
|
||||
import { RedirectToInstantMeetingModal } from "./components/RedirectToInstantMeetingModal";
|
||||
import { BookerSection } from "./components/Section";
|
||||
import { NotFound } from "./components/Unavailable";
|
||||
import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config";
|
||||
import { useBookerStore } from "./store";
|
||||
import type { BookerProps, WrappedBookerProps } from "./types";
|
||||
|
||||
const loadFramerFeatures = () => import("./framer-features").then((res) => res.default);
|
||||
const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy"));
|
||||
const UnpublishedEntity = dynamic(() =>
|
||||
import("@calcom/ui/components/unpublished-entity/UnpublishedEntity").then((mod) => mod.UnpublishedEntity)
|
||||
);
|
||||
const DatePicker = dynamic(() => import("./components/DatePicker").then((mod) => mod.DatePicker), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const BookerComponent = ({
|
||||
username,
|
||||
eventSlug,
|
||||
hideBranding = false,
|
||||
entity,
|
||||
isInstantMeeting = false,
|
||||
onGoBackInstantMeeting,
|
||||
onConnectNowInstantMeeting,
|
||||
onOverlayClickNoCalendar,
|
||||
onClickOverlayContinue,
|
||||
onOverlaySwitchStateChange,
|
||||
sessionUsername,
|
||||
rescheduleUid,
|
||||
hasSession,
|
||||
extraOptions,
|
||||
bookings,
|
||||
verifyEmail,
|
||||
slots,
|
||||
calendars,
|
||||
bookerForm,
|
||||
event,
|
||||
bookerLayout,
|
||||
schedule,
|
||||
verifyCode,
|
||||
isPlatform,
|
||||
orgBannerUrl,
|
||||
customClassNames,
|
||||
}: BookerProps & WrappedBookerProps) => {
|
||||
const { t } = useLocale();
|
||||
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
|
||||
const selectedDate = useBookerStore((state) => state.selectedDate);
|
||||
const {
|
||||
shouldShowFormInDialog,
|
||||
hasDarkBackground,
|
||||
extraDays,
|
||||
columnViewExtraDays,
|
||||
isMobile,
|
||||
layout,
|
||||
hideEventTypeDetails,
|
||||
isEmbed,
|
||||
bookerLayouts,
|
||||
} = bookerLayout;
|
||||
|
||||
const [seatedEventData, setSeatedEventData] = useBookerStore(
|
||||
(state) => [state.seatedEventData, state.setSeatedEventData],
|
||||
shallow
|
||||
);
|
||||
const { selectedTimeslot, setSelectedTimeslot } = slots;
|
||||
|
||||
const [dayCount, setDayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow);
|
||||
|
||||
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots).filter(
|
||||
(slot) => dayjs(selectedDate).diff(slot, "day") <= 0
|
||||
);
|
||||
|
||||
const totalWeekDays = 7;
|
||||
const addonDays =
|
||||
nonEmptyScheduleDays.length < extraDays
|
||||
? (extraDays - nonEmptyScheduleDays.length + 1) * totalWeekDays
|
||||
: nonEmptyScheduleDays.length === extraDays
|
||||
? totalWeekDays
|
||||
: 0;
|
||||
// Taking one more available slot(extraDays + 1) to calculate the no of days in between, that next and prev button need to shift.
|
||||
const availableSlots = nonEmptyScheduleDays.slice(0, extraDays + 1);
|
||||
if (nonEmptyScheduleDays.length !== 0)
|
||||
columnViewExtraDays.current =
|
||||
Math.abs(dayjs(selectedDate).diff(availableSlots[availableSlots.length - 2], "day")) + addonDays;
|
||||
|
||||
const nextSlots =
|
||||
Math.abs(dayjs(selectedDate).diff(availableSlots[availableSlots.length - 1], "day")) + addonDays;
|
||||
|
||||
const animationScope = useBookerResizeAnimation(layout, bookerState);
|
||||
|
||||
const timeslotsRef = useRef<HTMLDivElement>(null);
|
||||
const StickyOnDesktop = isMobile ? "div" : StickyBox;
|
||||
|
||||
const { bookerFormErrorRef, key, formEmail, bookingForm, errors: formErrors } = bookerForm;
|
||||
|
||||
const { handleBookEvent, errors, loadingStates, expiryTime, instantVideoMeetingUrl } = bookings;
|
||||
|
||||
const {
|
||||
isEmailVerificationModalVisible,
|
||||
setEmailVerificationModalVisible,
|
||||
handleVerifyEmail,
|
||||
renderConfirmNotVerifyEmailButtonCond,
|
||||
isVerificationCodeSending,
|
||||
} = verifyEmail;
|
||||
|
||||
const {
|
||||
overlayBusyDates,
|
||||
isOverlayCalendarEnabled,
|
||||
connectedCalendars,
|
||||
loadingConnectedCalendar,
|
||||
onToggleCalendar,
|
||||
} = calendars;
|
||||
|
||||
const scrolledToTimeslotsOnce = useRef(false);
|
||||
const scrollToTimeSlots = () => {
|
||||
if (isMobile && !isEmbed && !scrolledToTimeslotsOnce.current) {
|
||||
timeslotsRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
scrolledToTimeslotsOnce.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (event.isPending) return setBookerState("loading");
|
||||
if (!selectedDate) return setBookerState("selecting_date");
|
||||
if (!selectedTimeslot) return setBookerState("selecting_time");
|
||||
return setBookerState("booking");
|
||||
}, [event, selectedDate, selectedTimeslot, setBookerState]);
|
||||
|
||||
const slot = getQueryParam("slot");
|
||||
useEffect(() => {
|
||||
setSelectedTimeslot(slot || null);
|
||||
}, [slot, setSelectedTimeslot]);
|
||||
const EventBooker = useMemo(() => {
|
||||
return bookerState === "booking" ? (
|
||||
<BookEventForm
|
||||
key={key}
|
||||
onCancel={() => {
|
||||
setSelectedTimeslot(null);
|
||||
if (seatedEventData.bookingUid) {
|
||||
setSeatedEventData({ ...seatedEventData, bookingUid: undefined, attendees: undefined });
|
||||
}
|
||||
}}
|
||||
onSubmit={renderConfirmNotVerifyEmailButtonCond ? handleBookEvent : handleVerifyEmail}
|
||||
errorRef={bookerFormErrorRef}
|
||||
errors={{ ...formErrors, ...errors }}
|
||||
loadingStates={loadingStates}
|
||||
renderConfirmNotVerifyEmailButtonCond={renderConfirmNotVerifyEmailButtonCond}
|
||||
bookingForm={bookingForm}
|
||||
eventQuery={event}
|
||||
extraOptions={extraOptions}
|
||||
rescheduleUid={rescheduleUid}
|
||||
isVerificationCodeSending={isVerificationCodeSending}
|
||||
isPlatform={isPlatform}>
|
||||
<>
|
||||
{verifyCode ? (
|
||||
<VerifyCodeDialog
|
||||
isOpenDialog={isEmailVerificationModalVisible}
|
||||
setIsOpenDialog={setEmailVerificationModalVisible}
|
||||
email={formEmail}
|
||||
isUserSessionRequiredToVerify={false}
|
||||
verifyCodeWithSessionNotRequired={verifyCode.verifyCodeWithSessionNotRequired}
|
||||
verifyCodeWithSessionRequired={verifyCode.verifyCodeWithSessionRequired}
|
||||
error={verifyCode.error}
|
||||
resetErrors={verifyCode.resetErrors}
|
||||
isPending={verifyCode.isPending}
|
||||
setIsPending={verifyCode.setIsPending}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{!isPlatform && (
|
||||
<RedirectToInstantMeetingModal
|
||||
expiryTime={expiryTime}
|
||||
bookingId={parseInt(getQueryParam("bookingId") || "0")}
|
||||
instantVideoMeetingUrl={instantVideoMeetingUrl}
|
||||
onGoBack={() => {
|
||||
onGoBackInstantMeeting();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</BookEventForm>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}, [
|
||||
bookerFormErrorRef,
|
||||
instantVideoMeetingUrl,
|
||||
bookerState,
|
||||
bookingForm,
|
||||
errors,
|
||||
event,
|
||||
expiryTime,
|
||||
extraOptions,
|
||||
formEmail,
|
||||
formErrors,
|
||||
handleBookEvent,
|
||||
handleVerifyEmail,
|
||||
isEmailVerificationModalVisible,
|
||||
key,
|
||||
loadingStates,
|
||||
onGoBackInstantMeeting,
|
||||
renderConfirmNotVerifyEmailButtonCond,
|
||||
rescheduleUid,
|
||||
seatedEventData,
|
||||
setEmailVerificationModalVisible,
|
||||
setSeatedEventData,
|
||||
setSelectedTimeslot,
|
||||
verifyCode?.error,
|
||||
verifyCode?.isPending,
|
||||
verifyCode?.resetErrors,
|
||||
verifyCode?.setIsPending,
|
||||
verifyCode?.verifyCodeWithSessionNotRequired,
|
||||
verifyCode?.verifyCodeWithSessionRequired,
|
||||
isPlatform,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Unpublished organization handling - Below
|
||||
* - Reschedule links in email are of the organization event for an unpublished org, so we need to allow rescheduling unpublished event
|
||||
* - Ideally, we should allow rescheduling only for the event that has an old link(non-org link) but that's a bit complex and we are fine showing all reschedule links on unpublished organization
|
||||
*/
|
||||
const considerUnpublished = entity.considerUnpublished && !rescheduleUid;
|
||||
|
||||
if (considerUnpublished) {
|
||||
return <UnpublishedEntity {...entity} />;
|
||||
}
|
||||
|
||||
if (event.isSuccess && !event.data) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
if (bookerState === "loading") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{event.data && !isPlatform ? <BookingPageTagManager eventType={event.data} /> : <></>}
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
// In a popup embed, if someone clicks outside the main(having main class or main tag), it closes the embed
|
||||
"main",
|
||||
"text-default flex min-h-full w-full flex-col items-center",
|
||||
layout === BookerLayouts.MONTH_VIEW ? "overflow-visible" : "overflow-clip"
|
||||
)}>
|
||||
<div
|
||||
ref={animationScope}
|
||||
data-testid="booker-container"
|
||||
className={classNames(
|
||||
...getBookerSizeClassNames(layout, bookerState, hideEventTypeDetails),
|
||||
`bg-default dark:bg-muted grid max-w-full items-start dark:[color-scheme:dark] sm:transition-[width] sm:duration-300 sm:motion-reduce:transition-none md:flex-row`,
|
||||
// We remove border only when the content covers entire viewport. Because in embed, it can almost never be the case that it covers entire viewport, we show the border there
|
||||
(layout === BookerLayouts.MONTH_VIEW || isEmbed) && "border-subtle rounded-md",
|
||||
!isEmbed && "sm:transition-[width] sm:duration-300",
|
||||
isEmbed && layout === BookerLayouts.MONTH_VIEW && "border-booker sm:border-booker-width",
|
||||
!isEmbed && layout === BookerLayouts.MONTH_VIEW && `border-subtle border`,
|
||||
`${customClassNames?.bookerContainer}`
|
||||
)}>
|
||||
<AnimatePresence>
|
||||
{!isInstantMeeting && (
|
||||
<BookerSection
|
||||
area="header"
|
||||
className={classNames(
|
||||
layout === BookerLayouts.MONTH_VIEW && "fixed top-4 z-10 ltr:right-4 rtl:left-4",
|
||||
(layout === BookerLayouts.COLUMN_VIEW || layout === BookerLayouts.WEEK_VIEW) &&
|
||||
"bg-default dark:bg-muted sticky top-0 z-10"
|
||||
)}>
|
||||
{!isPlatform ? (
|
||||
<Header
|
||||
isMyLink={Boolean(username === sessionUsername)}
|
||||
eventSlug={eventSlug}
|
||||
enabledLayouts={bookerLayouts.enabledLayouts}
|
||||
extraDays={layout === BookerLayouts.COLUMN_VIEW ? columnViewExtraDays.current : extraDays}
|
||||
isMobile={isMobile}
|
||||
nextSlots={nextSlots}
|
||||
renderOverlay={() =>
|
||||
isEmbed ? (
|
||||
<></>
|
||||
) : (
|
||||
<>
|
||||
<OverlayCalendar
|
||||
isOverlayCalendarEnabled={isOverlayCalendarEnabled}
|
||||
connectedCalendars={connectedCalendars}
|
||||
loadingConnectedCalendar={loadingConnectedCalendar}
|
||||
overlayBusyDates={overlayBusyDates}
|
||||
onToggleCalendar={onToggleCalendar}
|
||||
hasSession={hasSession}
|
||||
handleClickContinue={onClickOverlayContinue}
|
||||
handleSwitchStateChange={onOverlaySwitchStateChange}
|
||||
handleClickNoCalendar={() => {
|
||||
onOverlayClickNoCalendar();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</BookerSection>
|
||||
)}
|
||||
<StickyOnDesktop key="meta" className={classNames("relative z-10 flex [grid-area:meta]")}>
|
||||
<BookerSection
|
||||
area="meta"
|
||||
className="max-w-screen flex w-full flex-col md:w-[var(--booker-meta-width)]">
|
||||
{!hideEventTypeDetails && orgBannerUrl && !isPlatform && (
|
||||
<img
|
||||
loading="eager"
|
||||
className="-mb-9 ltr:rounded-tl-md rtl:rounded-tr-md"
|
||||
alt="org banner"
|
||||
src={orgBannerUrl}
|
||||
/>
|
||||
)}
|
||||
<EventMeta
|
||||
classNames={{
|
||||
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
|
||||
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
|
||||
eventMetaTimezoneSelect:
|
||||
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
|
||||
}}
|
||||
event={event.data}
|
||||
isPending={event.isPending}
|
||||
isPlatform={isPlatform}
|
||||
/>
|
||||
{layout !== BookerLayouts.MONTH_VIEW &&
|
||||
!(layout === "mobile" && bookerState === "booking") && (
|
||||
<div className="mt-auto px-5 py-3">
|
||||
<DatePicker event={event} schedule={schedule} scrollToTimeSlots={scrollToTimeSlots} />
|
||||
</div>
|
||||
)}
|
||||
</BookerSection>
|
||||
</StickyOnDesktop>
|
||||
|
||||
<BookerSection
|
||||
key="book-event-form"
|
||||
area="main"
|
||||
className="sticky top-0 ml-[-1px] h-full p-6 md:w-[var(--booker-main-width)] md:border-l"
|
||||
{...fadeInLeft}
|
||||
visible={bookerState === "booking" && !shouldShowFormInDialog}>
|
||||
{EventBooker}
|
||||
</BookerSection>
|
||||
|
||||
<BookerSection
|
||||
key="datepicker"
|
||||
area="main"
|
||||
visible={bookerState !== "booking" && layout === BookerLayouts.MONTH_VIEW}
|
||||
{...fadeInLeft}
|
||||
initial="visible"
|
||||
className="md:border-subtle ml-[-1px] h-full flex-shrink px-5 py-3 md:border-l lg:w-[var(--booker-main-width)]">
|
||||
<DatePicker
|
||||
classNames={{
|
||||
datePickerContainer: customClassNames?.datePickerCustomClassNames?.datePickerContainer,
|
||||
datePickerTitle: customClassNames?.datePickerCustomClassNames?.datePickerTitle,
|
||||
datePickerDays: customClassNames?.datePickerCustomClassNames?.datePickerDays,
|
||||
datePickerDate: customClassNames?.datePickerCustomClassNames?.datePickerDate,
|
||||
datePickerDatesActive: customClassNames?.datePickerCustomClassNames?.datePickerDatesActive,
|
||||
datePickerToggle: customClassNames?.datePickerCustomClassNames?.datePickerToggle,
|
||||
}}
|
||||
event={event}
|
||||
schedule={schedule}
|
||||
scrollToTimeSlots={scrollToTimeSlots}
|
||||
/>
|
||||
</BookerSection>
|
||||
|
||||
<BookerSection
|
||||
key="large-calendar"
|
||||
area="main"
|
||||
visible={layout === BookerLayouts.WEEK_VIEW}
|
||||
className="border-subtle sticky top-0 ml-[-1px] h-full md:border-l"
|
||||
{...fadeInLeft}>
|
||||
<LargeCalendar
|
||||
extraDays={extraDays}
|
||||
schedule={schedule.data}
|
||||
isLoading={schedule.isPending}
|
||||
event={event}
|
||||
/>
|
||||
</BookerSection>
|
||||
<BookerSection
|
||||
key="timeslots"
|
||||
area={{ default: "main", month_view: "timeslots" }}
|
||||
visible={
|
||||
(layout !== BookerLayouts.WEEK_VIEW && bookerState === "selecting_time") ||
|
||||
layout === BookerLayouts.COLUMN_VIEW
|
||||
}
|
||||
className={classNames(
|
||||
"border-subtle rtl:border-default flex h-full w-full flex-col overflow-x-auto px-5 py-3 pb-0 rtl:border-r ltr:md:border-l",
|
||||
layout === BookerLayouts.MONTH_VIEW &&
|
||||
"h-full overflow-hidden md:w-[var(--booker-timeslots-width)]",
|
||||
layout !== BookerLayouts.MONTH_VIEW && "sticky top-0"
|
||||
)}
|
||||
ref={timeslotsRef}
|
||||
{...fadeInLeft}>
|
||||
<AvailableTimeSlots
|
||||
customClassNames={customClassNames?.availableTimeSlotsCustomClassNames}
|
||||
extraDays={extraDays}
|
||||
limitHeight={layout === BookerLayouts.MONTH_VIEW}
|
||||
schedule={schedule?.data}
|
||||
isLoading={schedule.isPending}
|
||||
seatsPerTimeSlot={event.data?.seatsPerTimeSlot}
|
||||
showAvailableSeatsCount={event.data?.seatsShowAvailabilityCount}
|
||||
event={event}
|
||||
/>
|
||||
</BookerSection>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<HavingTroubleFindingTime
|
||||
visible={bookerState !== "booking" && layout === BookerLayouts.MONTH_VIEW && !isMobile}
|
||||
dayCount={dayCount}
|
||||
isScheduleLoading={schedule.isLoading}
|
||||
onButtonClick={() => {
|
||||
setDayCount(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{bookerState !== "booking" && event.data?.isInstantEvent && (
|
||||
<div
|
||||
className={classNames(
|
||||
"animate-fade-in-up z-40 my-2 opacity-0",
|
||||
layout === BookerLayouts.MONTH_VIEW && isEmbed ? "" : "fixed bottom-2"
|
||||
)}
|
||||
style={{ animationDelay: "1s" }}>
|
||||
<InstantBooking
|
||||
event={event.data}
|
||||
onConnectNow={() => {
|
||||
onConnectNowInstantMeeting();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!hideBranding && !isPlatform && (
|
||||
<m.span
|
||||
key="logo"
|
||||
className={classNames(
|
||||
"-z-10 mb-6 mt-auto pt-6 [&_img]:h-[15px]",
|
||||
hasDarkBackground ? "dark" : "",
|
||||
layout === BookerLayouts.MONTH_VIEW ? "block" : "hidden"
|
||||
)}>
|
||||
<PoweredBy logoOnly />
|
||||
</m.span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BookFormAsModal
|
||||
onCancel={() => setSelectedTimeslot(null)}
|
||||
visible={bookerState === "booking" && shouldShowFormInDialog}>
|
||||
{EventBooker}
|
||||
</BookFormAsModal>
|
||||
<Toaster position="bottom-right" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Booker = (props: BookerProps & WrappedBookerProps) => {
|
||||
return (
|
||||
<LazyMotion strict features={loadFramerFeatures}>
|
||||
<BookerComponent {...props} />
|
||||
</LazyMotion>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { AvailableTimes, AvailableTimesSkeleton } from "@calcom/features/bookings";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { useNonEmptyScheduleDays } from "@calcom/features/schedules";
|
||||
import { useSlotsForAvailableDates } from "@calcom/features/schedules/lib/use-schedule/useSlotsForDate";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { AvailableTimesHeader } from "../../components/AvailableTimesHeader";
|
||||
import { useBookerStore } from "../store";
|
||||
import type { useScheduleForEventReturnType } from "../utils/event";
|
||||
|
||||
type AvailableTimeSlotsProps = {
|
||||
extraDays?: number;
|
||||
limitHeight?: boolean;
|
||||
schedule?: useScheduleForEventReturnType["data"];
|
||||
isLoading: boolean;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
showAvailableSeatsCount?: boolean | null;
|
||||
event: {
|
||||
data?: Pick<BookerEvent, "length"> | null;
|
||||
};
|
||||
customClassNames?: {
|
||||
availableTimeSlotsContainer?: string;
|
||||
availableTimeSlotsTitle?: string;
|
||||
availableTimeSlotsHeaderContainer?: string;
|
||||
availableTimeSlotsTimeFormatToggle?: string;
|
||||
availableTimes?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders available time slots for a given date.
|
||||
* It will extract the date from the booker store.
|
||||
* Next to that you can also pass in the `extraDays` prop, this
|
||||
* will also fetch the next `extraDays` days and show multiple days
|
||||
* in columns next to each other.
|
||||
*/
|
||||
export const AvailableTimeSlots = ({
|
||||
extraDays,
|
||||
limitHeight,
|
||||
seatsPerTimeSlot,
|
||||
showAvailableSeatsCount,
|
||||
schedule,
|
||||
isLoading,
|
||||
event,
|
||||
customClassNames,
|
||||
}: AvailableTimeSlotsProps) => {
|
||||
const selectedDate = useBookerStore((state) => state.selectedDate);
|
||||
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
|
||||
const setSeatedEventData = useBookerStore((state) => state.setSeatedEventData);
|
||||
const date = selectedDate || dayjs().format("YYYY-MM-DD");
|
||||
const [layout] = useBookerStore((state) => [state.layout]);
|
||||
const isColumnView = layout === BookerLayouts.COLUMN_VIEW;
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const onTimeSelect = (
|
||||
time: string,
|
||||
attendees: number,
|
||||
seatsPerTimeSlot?: number | null,
|
||||
bookingUid?: string
|
||||
) => {
|
||||
setSelectedTimeslot(time);
|
||||
if (seatsPerTimeSlot) {
|
||||
setSeatedEventData({
|
||||
seatsPerTimeSlot,
|
||||
attendees,
|
||||
bookingUid,
|
||||
showAvailableSeatsCount,
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.slots);
|
||||
const nonEmptyScheduleDaysFromSelectedDate = nonEmptyScheduleDays.filter(
|
||||
(slot) => dayjs(selectedDate).diff(slot, "day") <= 0
|
||||
);
|
||||
|
||||
// Creates an array of dates to fetch slots for.
|
||||
// If `extraDays` is passed in, we will extend the array with the next `extraDays` days.
|
||||
const dates = !extraDays
|
||||
? [date]
|
||||
: nonEmptyScheduleDaysFromSelectedDate.length > 0
|
||||
? nonEmptyScheduleDaysFromSelectedDate.slice(0, extraDays)
|
||||
: [];
|
||||
|
||||
const slotsPerDay = useSlotsForAvailableDates(dates, schedule?.slots);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames(`flex`, `${customClassNames?.availableTimeSlotsContainer}`)}>
|
||||
{isLoading ? (
|
||||
<div className="mb-3 h-8" />
|
||||
) : (
|
||||
slotsPerDay.length > 0 &&
|
||||
slotsPerDay.map((slots) => (
|
||||
<AvailableTimesHeader
|
||||
customClassNames={{
|
||||
availableTimeSlotsHeaderContainer: customClassNames?.availableTimeSlotsHeaderContainer,
|
||||
availableTimeSlotsTitle: customClassNames?.availableTimeSlotsTitle,
|
||||
availableTimeSlotsTimeFormatToggle: customClassNames?.availableTimeSlotsTimeFormatToggle,
|
||||
}}
|
||||
key={slots.date}
|
||||
date={dayjs(slots.date)}
|
||||
showTimeFormatToggle={!isColumnView}
|
||||
availableMonth={
|
||||
dayjs(selectedDate).format("MM") !== dayjs(slots.date).format("MM")
|
||||
? dayjs(slots.date).format("MMM")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classNames(
|
||||
limitHeight && "scroll-bar flex-grow overflow-auto md:h-[400px]",
|
||||
!limitHeight && "flex h-full w-full flex-row gap-4",
|
||||
`${customClassNames?.availableTimeSlotsContainer}`
|
||||
)}>
|
||||
{isLoading && // Shows exact amount of days as skeleton.
|
||||
Array.from({ length: 1 + (extraDays ?? 0) }).map((_, i) => <AvailableTimesSkeleton key={i} />)}
|
||||
{!isLoading &&
|
||||
slotsPerDay.length > 0 &&
|
||||
slotsPerDay.map((slots) => (
|
||||
<div key={slots.date} className="scroll-bar h-full w-full overflow-y-auto overflow-x-hidden">
|
||||
<AvailableTimes
|
||||
className={customClassNames?.availableTimeSlotsContainer}
|
||||
customClassNames={customClassNames?.availableTimes}
|
||||
showTimeFormatToggle={!isColumnView}
|
||||
onTimeSelect={onTimeSelect}
|
||||
slots={slots.slots}
|
||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||
showAvailableSeatsCount={showAvailableSeatsCount}
|
||||
event={event}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { FieldError } from "react-hook-form";
|
||||
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { IS_CALCOM, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert, Button, EmptyScreen, Form } from "@calcom/ui";
|
||||
|
||||
import { useBookerStore } from "../../store";
|
||||
import type { UseBookingFormReturnType } from "../hooks/useBookingForm";
|
||||
import type { IUseBookingErrors, IUseBookingLoadingStates } from "../hooks/useBookings";
|
||||
import { BookingFields } from "./BookingFields";
|
||||
import { FormSkeleton } from "./Skeleton";
|
||||
|
||||
type BookEventFormProps = {
|
||||
onCancel?: () => void;
|
||||
onSubmit: () => void;
|
||||
errorRef: React.RefObject<HTMLDivElement>;
|
||||
errors: UseBookingFormReturnType["errors"] & IUseBookingErrors;
|
||||
loadingStates: IUseBookingLoadingStates;
|
||||
children?: React.ReactNode;
|
||||
bookingForm: UseBookingFormReturnType["bookingForm"];
|
||||
renderConfirmNotVerifyEmailButtonCond: boolean;
|
||||
extraOptions: Record<string, string | string[]>;
|
||||
isPlatform?: boolean;
|
||||
isVerificationCodeSending: boolean;
|
||||
};
|
||||
|
||||
export const BookEventForm = ({
|
||||
onCancel,
|
||||
eventQuery,
|
||||
rescheduleUid,
|
||||
onSubmit,
|
||||
errorRef,
|
||||
errors,
|
||||
loadingStates,
|
||||
renderConfirmNotVerifyEmailButtonCond,
|
||||
bookingForm,
|
||||
children,
|
||||
extraOptions,
|
||||
isVerificationCodeSending,
|
||||
isPlatform = false,
|
||||
}: Omit<BookEventFormProps, "event"> & {
|
||||
eventQuery: {
|
||||
isError: boolean;
|
||||
isPending: boolean;
|
||||
data?: Pick<BookerEvent, "price" | "currency" | "metadata" | "bookingFields" | "locations"> | null;
|
||||
};
|
||||
rescheduleUid: string | null;
|
||||
}) => {
|
||||
const eventType = eventQuery.data;
|
||||
const setFormValues = useBookerStore((state) => state.setFormValues);
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
const timeslot = useBookerStore((state) => state.selectedTimeslot);
|
||||
const username = useBookerStore((state) => state.username);
|
||||
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
|
||||
|
||||
const [responseVercelIdHeader] = useState<string | null>(null);
|
||||
const { t } = useLocale();
|
||||
|
||||
const isPaidEvent = useMemo(() => {
|
||||
if (!eventType?.price) return false;
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
return eventType?.price > 0 && !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0;
|
||||
}, [eventType]);
|
||||
|
||||
if (eventQuery.isError) return <Alert severity="warning" message={t("error_booking_event")} />;
|
||||
if (eventQuery.isPending || !eventQuery.data) return <FormSkeleton />;
|
||||
if (!timeslot)
|
||||
return (
|
||||
<EmptyScreen
|
||||
headline={t("timeslot_missing_title")}
|
||||
description={t("timeslot_missing_description")}
|
||||
Icon="calendar"
|
||||
buttonText={t("timeslot_missing_cta")}
|
||||
buttonOnClick={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!eventType) {
|
||||
console.warn("No event type found for event", extraOptions);
|
||||
return <Alert severity="warning" message={t("error_booking_event")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<Form
|
||||
className="flex h-full flex-col"
|
||||
onChange={() => {
|
||||
// Form data is saved in store. This way when user navigates back to
|
||||
// still change the timeslot, and comes back to the form, all their values
|
||||
// still exist. This gets cleared when the form is submitted.
|
||||
const values = bookingForm.getValues();
|
||||
setFormValues(values);
|
||||
}}
|
||||
form={bookingForm}
|
||||
handleSubmit={onSubmit}
|
||||
noValidate>
|
||||
<BookingFields
|
||||
isDynamicGroupBooking={!!(username && username.indexOf("+") > -1)}
|
||||
fields={eventType.bookingFields}
|
||||
locations={eventType.locations}
|
||||
rescheduleUid={rescheduleUid || undefined}
|
||||
bookingData={bookingData}
|
||||
/>
|
||||
{(errors.hasFormErrors || errors.hasDataErrors) && (
|
||||
<div data-testid="booking-fail">
|
||||
<Alert
|
||||
ref={errorRef}
|
||||
className="my-2"
|
||||
severity="info"
|
||||
title={rescheduleUid ? t("reschedule_fail") : t("booking_fail")}
|
||||
message={getError(errors.formErrors, errors.dataErrors, t, responseVercelIdHeader)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isPlatform && IS_CALCOM && (
|
||||
<div className="text-subtle my-3 w-full text-xs opacity-80">
|
||||
<Trans
|
||||
i18nKey="signing_up_terms"
|
||||
components={[
|
||||
<Link
|
||||
className="text-emphasis hover:underline"
|
||||
key="terms"
|
||||
href={`${WEBSITE_URL}/terms`}
|
||||
target="_blank">
|
||||
Terms
|
||||
</Link>,
|
||||
<Link
|
||||
className="text-emphasis hover:underline"
|
||||
key="privacy"
|
||||
href={`${WEBSITE_URL}/privacy`}
|
||||
target="_blank">
|
||||
Privacy Policy.
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="modalsticky mt-auto flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
{isInstantMeeting ? (
|
||||
<Button type="submit" color="primary" loading={loadingStates.creatingInstantBooking}>
|
||||
{isPaidEvent ? t("pay_and_book") : t("confirm")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{!!onCancel && (
|
||||
<Button color="minimal" type="button" onClick={onCancel} data-testid="back">
|
||||
{t("back")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
loading={
|
||||
loadingStates.creatingBooking ||
|
||||
loadingStates.creatingRecurringBooking ||
|
||||
isVerificationCodeSending
|
||||
}
|
||||
data-testid={
|
||||
rescheduleUid && bookingData ? "confirm-reschedule-button" : "confirm-book-button"
|
||||
}>
|
||||
{rescheduleUid && bookingData
|
||||
? t("reschedule")
|
||||
: renderConfirmNotVerifyEmailButtonCond
|
||||
? isPaidEvent
|
||||
? t("pay_and_book")
|
||||
: t("confirm")
|
||||
: t("verify_email_email_button")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getError = (
|
||||
globalError: FieldError | undefined,
|
||||
// It feels like an implementation detail to reimplement the types of useMutation here.
|
||||
// Since they don't matter for this function, I'd rather disable them then giving you
|
||||
// the cognitive overload of thinking to update them here when anything changes.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dataError: any,
|
||||
t: TFunction,
|
||||
responseVercelIdHeader: string | null
|
||||
) => {
|
||||
if (globalError) return globalError?.message;
|
||||
|
||||
const error = dataError;
|
||||
|
||||
return error?.message ? (
|
||||
<>
|
||||
{responseVercelIdHeader ?? ""} {t(error.message)}
|
||||
</>
|
||||
) : (
|
||||
<>{t("can_you_try_again")}</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { useEventTypeById, useIsPlatform } from "@calcom/atoms/monorepo";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Badge, Dialog, DialogContent } from "@calcom/ui";
|
||||
|
||||
import { getDurationFormatted } from "../../../components/event-meta/Duration";
|
||||
import { useTimePreferences } from "../../../lib";
|
||||
import { useBookerStore } from "../../store";
|
||||
import { FromTime } from "../../utils/dates";
|
||||
import { useEvent } from "../../utils/event";
|
||||
|
||||
const BookEventFormWrapper = ({ children, onCancel }: { onCancel: () => void; children: ReactNode }) => {
|
||||
const { data } = useEvent();
|
||||
|
||||
return <BookEventFormWrapperComponent child={children} eventLength={data?.length} onCancel={onCancel} />;
|
||||
};
|
||||
|
||||
const PlatformBookEventFormWrapper = ({
|
||||
children,
|
||||
onCancel,
|
||||
}: {
|
||||
onCancel: () => void;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const eventId = useBookerStore((state) => state.eventId);
|
||||
const { data } = useEventTypeById(eventId);
|
||||
|
||||
return (
|
||||
<BookEventFormWrapperComponent child={children} eventLength={data?.lengthInMinutes} onCancel={onCancel} />
|
||||
);
|
||||
};
|
||||
|
||||
export const BookEventFormWrapperComponent = ({
|
||||
child,
|
||||
eventLength,
|
||||
}: {
|
||||
onCancel: () => void;
|
||||
child: ReactNode;
|
||||
eventLength?: number;
|
||||
}) => {
|
||||
const { i18n, t } = useLocale();
|
||||
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
|
||||
const selectedDuration = useBookerStore((state) => state.selectedDuration);
|
||||
const { timeFormat, timezone } = useTimePreferences();
|
||||
if (!selectedTimeslot) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h1 className="font-cal text-emphasis text-xl leading-5">{t("confirm_your_details")} </h1>
|
||||
<div className="my-4 flex flex-wrap gap-2 rounded-md leading-none">
|
||||
<Badge variant="grayWithoutHover" startIcon="calendar" size="lg">
|
||||
<FromTime
|
||||
date={selectedTimeslot}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timezone}
|
||||
language={i18n.language}
|
||||
/>
|
||||
</Badge>
|
||||
{(selectedDuration || eventLength) && (
|
||||
<Badge variant="grayWithoutHover" startIcon="clock" size="lg">
|
||||
<span>{getDurationFormatted(selectedDuration || eventLength, t)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{child}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const BookFormAsModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={onCancel}>
|
||||
<DialogContent
|
||||
type={undefined}
|
||||
enableOverflow
|
||||
className="[&_.modalsticky]:border-t-subtle [&_.modalsticky]:bg-default max-h-[80vh] pb-0 [&_.modalsticky]:sticky [&_.modalsticky]:bottom-0 [&_.modalsticky]:left-0 [&_.modalsticky]:right-0 [&_.modalsticky]:-mx-8 [&_.modalsticky]:border-t [&_.modalsticky]:px-8 [&_.modalsticky]:py-4">
|
||||
{!isPlatform ? (
|
||||
<BookEventFormWrapper onCancel={onCancel}>{children}</BookEventFormWrapper>
|
||||
) : (
|
||||
<PlatformBookEventFormWrapper onCancel={onCancel}>{children}</PlatformBookEventFormWrapper>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { getOrganizerInputLocationTypes } from "@calcom/app-store/locations";
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import { SystemField } from "../../../lib/SystemField";
|
||||
|
||||
export const BookingFields = ({
|
||||
fields,
|
||||
locations,
|
||||
rescheduleUid,
|
||||
isDynamicGroupBooking,
|
||||
bookingData,
|
||||
}: {
|
||||
fields: NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"];
|
||||
locations: LocationObject[];
|
||||
rescheduleUid?: string;
|
||||
bookingData?: GetBookingType | null;
|
||||
isDynamicGroupBooking: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { watch, setValue } = useFormContext();
|
||||
const locationResponse = watch("responses.location");
|
||||
const currentView = rescheduleUid ? "reschedule" : "";
|
||||
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
|
||||
|
||||
return (
|
||||
// TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view
|
||||
// The logic here intends to make modifications to booking fields based on the way we want to specifically show Booking Form
|
||||
<div>
|
||||
{fields.map((field, index) => {
|
||||
// Don't Display Location field in case of instant meeting as only Cal Video is supported
|
||||
if (isInstantMeeting && field.name === "location") return null;
|
||||
|
||||
// During reschedule by default all system fields are readOnly. Make them editable on case by case basis.
|
||||
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
|
||||
let readOnly =
|
||||
(field.editable === "system" || field.editable === "system-but-optional") &&
|
||||
!!rescheduleUid &&
|
||||
bookingData !== null;
|
||||
|
||||
let hidden = !!field.hidden;
|
||||
const fieldViews = field.views;
|
||||
|
||||
if (fieldViews && !fieldViews.find((view) => view.id === currentView)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (field.name === SystemField.Enum.rescheduleReason) {
|
||||
if (bookingData === null) {
|
||||
return null;
|
||||
}
|
||||
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule
|
||||
readOnly = false;
|
||||
}
|
||||
|
||||
if (field.name === SystemField.Enum.smsReminderNumber) {
|
||||
// `smsReminderNumber` and location.optionValue when location.value===phone are the same data point. We should solve it in a better way in the Form Builder itself.
|
||||
// I think we should have a way to connect 2 fields together and have them share the same value in Form Builder
|
||||
if (locationResponse?.value === "phone") {
|
||||
setValue(`responses.${SystemField.Enum.smsReminderNumber}`, locationResponse?.optionValue);
|
||||
// Just don't render the field now, as the value is already connected to attendee phone location
|
||||
return null;
|
||||
}
|
||||
// `smsReminderNumber` can be edited during reschedule even though it's a system field
|
||||
readOnly = false;
|
||||
}
|
||||
|
||||
if (field.name === SystemField.Enum.guests) {
|
||||
readOnly = false;
|
||||
// No matter what user configured for Guests field, we don't show it for dynamic group booking as that doesn't support guests
|
||||
hidden = isDynamicGroupBooking ? true : !!field.hidden;
|
||||
}
|
||||
|
||||
// We don't show `notes` field during reschedule but since it's a query param we better valid if rescheduleUid brought any bookingData
|
||||
if (field.name === SystemField.Enum.notes && bookingData !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Attendee location field can be edited during reschedule
|
||||
if (field.name === SystemField.Enum.location) {
|
||||
if (locationResponse?.value === "attendeeInPerson" || "phone") {
|
||||
readOnly = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamically populate location field options
|
||||
if (field.name === SystemField.Enum.location && field.type === "radioInput") {
|
||||
if (!field.optionsInputs) {
|
||||
throw new Error("radioInput must have optionsInputs");
|
||||
}
|
||||
const optionsInputs = field.optionsInputs;
|
||||
|
||||
// TODO: Instead of `getLocationOptionsForSelect` options should be retrieved from dataStore[field.getOptionsAt]. It would make it agnostic of the `name` of the field.
|
||||
const options = getLocationOptionsForSelect(locations, t);
|
||||
options.forEach((option) => {
|
||||
const optionInput = optionsInputs[option.value as keyof typeof optionsInputs];
|
||||
if (optionInput) {
|
||||
optionInput.placeholder = option.inputPlaceholder;
|
||||
}
|
||||
});
|
||||
field.options = options.filter(
|
||||
(location): location is NonNullable<(typeof options)[number]> => !!location
|
||||
);
|
||||
}
|
||||
|
||||
if (field?.options) {
|
||||
const organizerInputTypes = getOrganizerInputLocationTypes();
|
||||
const organizerInputObj: Record<string, number> = {};
|
||||
|
||||
field.options.forEach((f) => {
|
||||
if (f.value in organizerInputObj) {
|
||||
organizerInputObj[f.value]++;
|
||||
} else {
|
||||
organizerInputObj[f.value] = 1;
|
||||
}
|
||||
});
|
||||
|
||||
field.options = field.options.map((field) => {
|
||||
return {
|
||||
...field,
|
||||
value:
|
||||
organizerInputTypes.includes(field.value) && organizerInputObj[field.value] > 1
|
||||
? field.label
|
||||
: field.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormBuilderField className="mb-4" field={{ ...field, hidden }} readOnly={readOnly} key={index} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
export const FormSkeleton = () => (
|
||||
<div className="flex flex-col">
|
||||
<SkeletonText className="h-7 w-32" />
|
||||
<SkeletonText className="mt-2 h-7 w-full" />
|
||||
<SkeletonText className="mt-4 h-7 w-28" />
|
||||
<SkeletonText className="mt-2 h-7 w-full" />
|
||||
|
||||
<div className="mt-12 flex h-7 w-full flex-row items-center gap-4">
|
||||
<SkeletonText className="inline h-4 w-4 rounded-full" />
|
||||
<SkeletonText className="inline h-7 w-32" />
|
||||
</div>
|
||||
<div className="mt-2 flex h-7 w-full flex-row items-center gap-4">
|
||||
<SkeletonText className="inline h-4 w-4 rounded-full" />
|
||||
<SkeletonText className="inline h-7 w-28" />
|
||||
</div>
|
||||
|
||||
<SkeletonText className="mt-8 h-7 w-32" />
|
||||
<SkeletonText className="mt-2 h-7 w-full" />
|
||||
<SkeletonText className="mt-4 h-7 w-28" />
|
||||
<SkeletonText className="mt-2 h-7 w-full" />
|
||||
|
||||
<div className="mt-6 flex flex-row gap-3">
|
||||
<SkeletonText className="ml-auto h-8 w-20" />
|
||||
<SkeletonText className="h-8 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { BookEventForm } from "./BookEventForm";
|
||||
@@ -0,0 +1,70 @@
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { default as DatePickerComponent } from "@calcom/features/calendars/DatePicker";
|
||||
import { useNonEmptyScheduleDays } from "@calcom/features/schedules";
|
||||
import { weekdayToWeekIndex } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
|
||||
import { useBookerStore } from "../store";
|
||||
import type { useScheduleForEventReturnType } from "../utils/event";
|
||||
|
||||
export const DatePicker = ({
|
||||
event,
|
||||
schedule,
|
||||
classNames,
|
||||
scrollToTimeSlots,
|
||||
}: {
|
||||
event: {
|
||||
data?: { users: Pick<User, "weekStart">[] } | null;
|
||||
};
|
||||
schedule: useScheduleForEventReturnType;
|
||||
classNames?: {
|
||||
datePickerContainer?: string;
|
||||
datePickerTitle?: string;
|
||||
datePickerDays?: string;
|
||||
datePickerDate?: string;
|
||||
datePickerDatesActive?: string;
|
||||
datePickerToggle?: string;
|
||||
};
|
||||
scrollToTimeSlots?: () => void;
|
||||
}) => {
|
||||
const { i18n } = useLocale();
|
||||
const [month, selectedDate] = useBookerStore((state) => [state.month, state.selectedDate], shallow);
|
||||
const [setSelectedDate, setMonth, setDayCount] = useBookerStore(
|
||||
(state) => [state.setSelectedDate, state.setMonth, state.setDayCount],
|
||||
shallow
|
||||
);
|
||||
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots);
|
||||
|
||||
return (
|
||||
<DatePickerComponent
|
||||
customClassNames={{
|
||||
datePickerTitle: classNames?.datePickerTitle,
|
||||
datePickerDays: classNames?.datePickerDays,
|
||||
datePickersDates: classNames?.datePickerDate,
|
||||
datePickerDatesActive: classNames?.datePickerDatesActive,
|
||||
datePickerToggle: classNames?.datePickerToggle,
|
||||
}}
|
||||
className={classNames?.datePickerContainer}
|
||||
isPending={schedule.isPending}
|
||||
onChange={(date: Dayjs | null) => {
|
||||
setSelectedDate(date === null ? date : date.format("YYYY-MM-DD"));
|
||||
}}
|
||||
onMonthChange={(date: Dayjs) => {
|
||||
setMonth(date.format("YYYY-MM"));
|
||||
setSelectedDate(date.format("YYYY-MM-DD"));
|
||||
setDayCount(null); // Whenever the month is changed, we nullify getting X days
|
||||
}}
|
||||
includedDates={nonEmptyScheduleDays}
|
||||
locale={i18n.language}
|
||||
browsingDate={month ? dayjs(month) : undefined}
|
||||
selected={dayjs(selectedDate)}
|
||||
weekStart={weekdayToWeekIndex(event?.data?.users?.[0]?.weekStart)}
|
||||
slots={schedule?.data?.slots}
|
||||
scrollToTimeSlots={scrollToTimeSlots}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { m } from "framer-motion";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import { Timezone as PlatformTimezoneSelect } from "@calcom/atoms/monorepo";
|
||||
import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { EventDetails, EventMembers, EventMetaSkeleton, EventTitle } from "@calcom/features/bookings";
|
||||
import { SeatsAvailabilityText } from "@calcom/features/bookings/components/SeatsAvailabilityText";
|
||||
import { EventMetaBlock } from "@calcom/features/bookings/components/event-meta/Details";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { fadeInUp } from "../config";
|
||||
import { useBookerStore } from "../store";
|
||||
import { FromToTime } from "../utils/dates";
|
||||
|
||||
const WebTimezoneSelect = dynamic(
|
||||
() => import("@calcom/ui/components/form/timezone-select/TimezoneSelect").then((mod) => mod.TimezoneSelect),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
export const EventMeta = ({
|
||||
event,
|
||||
isPending,
|
||||
isPlatform = true,
|
||||
classNames,
|
||||
}: {
|
||||
event?: Pick<
|
||||
BookerEvent,
|
||||
| "lockTimeZoneToggleOnBookingPage"
|
||||
| "schedule"
|
||||
| "seatsPerTimeSlot"
|
||||
| "users"
|
||||
| "length"
|
||||
| "schedulingType"
|
||||
| "profile"
|
||||
| "entity"
|
||||
| "description"
|
||||
| "title"
|
||||
| "metadata"
|
||||
| "locations"
|
||||
| "currency"
|
||||
| "requiresConfirmation"
|
||||
| "recurringEvent"
|
||||
| "price"
|
||||
| "isDynamic"
|
||||
> | null;
|
||||
isPending: boolean;
|
||||
isPlatform?: boolean;
|
||||
classNames?: {
|
||||
eventMetaContainer?: string;
|
||||
eventMetaTitle?: string;
|
||||
eventMetaTimezoneSelect?: string;
|
||||
};
|
||||
}) => {
|
||||
const { setTimezone, timeFormat, timezone } = useTimePreferences();
|
||||
const selectedDuration = useBookerStore((state) => state.selectedDuration);
|
||||
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
|
||||
const bookerState = useBookerStore((state) => state.state);
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
|
||||
const [seatedEventData, setSeatedEventData] = useBookerStore(
|
||||
(state) => [state.seatedEventData, state.setSeatedEventData],
|
||||
shallow
|
||||
);
|
||||
const { i18n, t } = useLocale();
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
const isEmbed = useIsEmbed();
|
||||
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
|
||||
const [TimezoneSelect] = useMemo(
|
||||
() => (isPlatform ? [PlatformTimezoneSelect] : [WebTimezoneSelect]),
|
||||
[isPlatform]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
//In case the event has lockTimeZone enabled ,set the timezone to event's attached availability timezone
|
||||
if (event && event?.lockTimeZoneToggleOnBookingPage && event?.schedule?.timeZone) {
|
||||
setTimezone(event.schedule?.timeZone);
|
||||
}
|
||||
}, [event, setTimezone]);
|
||||
|
||||
if (hideEventTypeDetails) {
|
||||
return null;
|
||||
}
|
||||
// If we didn't pick a time slot yet, we load bookingData via SSR so bookingData should be set
|
||||
// Otherwise we load seatedEventData from useBookerStore
|
||||
const bookingSeatAttendeesQty = seatedEventData?.attendees || bookingData?.attendees.length;
|
||||
const eventTotalSeats = seatedEventData?.seatsPerTimeSlot || event?.seatsPerTimeSlot;
|
||||
|
||||
const isHalfFull =
|
||||
bookingSeatAttendeesQty && eventTotalSeats && bookingSeatAttendeesQty / eventTotalSeats >= 0.5;
|
||||
const isNearlyFull =
|
||||
bookingSeatAttendeesQty && eventTotalSeats && bookingSeatAttendeesQty / eventTotalSeats >= 0.83;
|
||||
|
||||
const colorClass = isNearlyFull
|
||||
? "text-rose-600"
|
||||
: isHalfFull
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight";
|
||||
|
||||
return (
|
||||
<div className={`${classNames?.eventMetaContainer || ""} relative z-10 p-6`} data-testid="event-meta">
|
||||
{isPending && (
|
||||
<m.div {...fadeInUp} initial="visible" layout>
|
||||
<EventMetaSkeleton />
|
||||
</m.div>
|
||||
)}
|
||||
{!isPending && !!event && (
|
||||
<m.div {...fadeInUp} layout transition={{ ...fadeInUp.transition, delay: 0.3 }}>
|
||||
{!isPlatform && (
|
||||
<EventMembers
|
||||
schedulingType={event.schedulingType}
|
||||
users={event.users}
|
||||
profile={event.profile}
|
||||
entity={event.entity}
|
||||
/>
|
||||
)}
|
||||
<EventTitle className={`${classNames?.eventMetaTitle} my-2`}>{event?.title}</EventTitle>
|
||||
{event.description && (
|
||||
<EventMetaBlock contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
|
||||
<div dangerouslySetInnerHTML={{ __html: event.description }} />
|
||||
</EventMetaBlock>
|
||||
)}
|
||||
<div className="space-y-4 font-medium rtl:-mr-2">
|
||||
{rescheduleUid && bookingData && (
|
||||
<EventMetaBlock icon="calendar">
|
||||
{t("former_time")}
|
||||
<br />
|
||||
<span className="line-through" data-testid="former_time_p">
|
||||
<FromToTime
|
||||
date={bookingData.startTime.toString()}
|
||||
duration={null}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timezone}
|
||||
language={i18n.language}
|
||||
/>
|
||||
</span>
|
||||
</EventMetaBlock>
|
||||
)}
|
||||
{selectedTimeslot && (
|
||||
<EventMetaBlock icon="calendar">
|
||||
<FromToTime
|
||||
date={selectedTimeslot}
|
||||
duration={selectedDuration || event.length}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timezone}
|
||||
language={i18n.language}
|
||||
/>
|
||||
</EventMetaBlock>
|
||||
)}
|
||||
<EventDetails event={event} />
|
||||
<EventMetaBlock
|
||||
className="cursor-pointer [&_.current-timezone:before]:focus-within:opacity-100 [&_.current-timezone:before]:hover:opacity-100"
|
||||
contentClassName="relative max-w-[90%]"
|
||||
icon="globe">
|
||||
{bookerState === "booking" ? (
|
||||
<>{timezone}</>
|
||||
) : (
|
||||
<span
|
||||
className={`current-timezone before:bg-subtle min-w-32 -mt-[2px] flex h-6 max-w-full items-center justify-start before:absolute before:inset-0 before:bottom-[-3px] before:left-[-30px] before:top-[-3px] before:w-[calc(100%_+_35px)] before:rounded-md before:py-3 before:opacity-0 before:transition-opacity ${
|
||||
event.lockTimeZoneToggleOnBookingPage ? "cursor-not-allowed" : ""
|
||||
}`}>
|
||||
<TimezoneSelect
|
||||
menuPosition="absolute"
|
||||
timezoneSelectCustomClassname={classNames?.eventMetaTimezoneSelect}
|
||||
classNames={{
|
||||
control: () => "!min-h-0 p-0 w-full border-0 bg-transparent focus-within:ring-0",
|
||||
menu: () => "!w-64 max-w-[90vw]",
|
||||
singleValue: () => "text-text py-1",
|
||||
indicatorsContainer: () => "ml-auto",
|
||||
container: () => "max-w-full",
|
||||
}}
|
||||
value={timezone}
|
||||
onChange={(tz) => setTimezone(tz.value)}
|
||||
isDisabled={event.lockTimeZoneToggleOnBookingPage}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</EventMetaBlock>
|
||||
{bookerState === "booking" && eventTotalSeats && bookingSeatAttendeesQty ? (
|
||||
<EventMetaBlock icon="user" className={`${colorClass}`}>
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<p>
|
||||
<SeatsAvailabilityText
|
||||
showExact={!!seatedEventData.showAvailableSeatsCount}
|
||||
totalSeats={eventTotalSeats}
|
||||
bookedSeats={bookingSeatAttendeesQty || 0}
|
||||
variant="fraction"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</EventMetaBlock>
|
||||
) : null}
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { BOOKER_NUMBER_OF_DAYS_TO_LOAD } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
type Props = {
|
||||
onButtonClick: () => void;
|
||||
dayCount: number | null;
|
||||
visible: boolean;
|
||||
isScheduleLoading: boolean;
|
||||
};
|
||||
export function HavingTroubleFindingTime(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const [internalClick, setInternalClick] = useState(false);
|
||||
|
||||
if (!props.visible) return null;
|
||||
|
||||
// Easiest way to detect if its not enabled
|
||||
if (
|
||||
(process.env.NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD == "0" && BOOKER_NUMBER_OF_DAYS_TO_LOAD == 0) ||
|
||||
!process.env.NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD
|
||||
)
|
||||
return null;
|
||||
|
||||
// If we have clicked this internally - and the schedule above is not loading - hide this banner as there is no use of being able to go backwards
|
||||
if (internalClick && !props.isScheduleLoading) return null;
|
||||
if (props.isScheduleLoading || !props.dayCount) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle mt-6 flex w-1/2 min-w-0 items-center justify-between rounded-[32px] border p-3 text-sm leading-none shadow-sm lg:w-1/3">
|
||||
<div className="flex items-center gap-2 overflow-x-hidden">
|
||||
<Icon name="info" className="text-default h-4 w-4" />
|
||||
<p className="w-full leading-none">{t("having_trouble_finding_time")}</p>
|
||||
</div>
|
||||
{/* TODO: we should give this more of a touch target on mobile */}
|
||||
<button
|
||||
className="inline-flex items-center gap-2 font-medium"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
props.onButtonClick();
|
||||
setInternalClick(true);
|
||||
}}>
|
||||
{t("show_more")} <Icon name="arrow-right" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
calcom/packages/features/bookings/Booker/components/Header.tsx
Normal file
217
calcom/packages/features/bookings/Booker/components/Header.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
import { Button, ButtonGroup, Icon, ToggleGroup, Tooltip } from "@calcom/ui";
|
||||
|
||||
import { TimeFormatToggle } from "../../components/TimeFormatToggle";
|
||||
import { useBookerStore } from "../store";
|
||||
import type { BookerLayout } from "../types";
|
||||
|
||||
export function Header({
|
||||
extraDays,
|
||||
isMobile,
|
||||
enabledLayouts,
|
||||
nextSlots,
|
||||
eventSlug,
|
||||
isMyLink,
|
||||
renderOverlay,
|
||||
}: {
|
||||
extraDays: number;
|
||||
isMobile: boolean;
|
||||
enabledLayouts: BookerLayouts[];
|
||||
nextSlots: number;
|
||||
eventSlug: string;
|
||||
isMyLink: boolean;
|
||||
renderOverlay?: () => JSX.Element | null;
|
||||
}) {
|
||||
const { t, i18n } = useLocale();
|
||||
const isEmbed = useIsEmbed();
|
||||
const [layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
|
||||
const selectedDateString = useBookerStore((state) => state.selectedDate);
|
||||
const setSelectedDate = useBookerStore((state) => state.setSelectedDate);
|
||||
const addToSelectedDate = useBookerStore((state) => state.addToSelectedDate);
|
||||
const isMonthView = layout === BookerLayouts.MONTH_VIEW;
|
||||
const selectedDate = dayjs(selectedDateString);
|
||||
const today = dayjs();
|
||||
const selectedDateMin3DaysDifference = useMemo(() => {
|
||||
const diff = today.diff(selectedDate, "days");
|
||||
return diff > 3 || diff < -3;
|
||||
}, [today, selectedDate]);
|
||||
|
||||
const onLayoutToggle = useCallback(
|
||||
(newLayout: string) => {
|
||||
if (layout === newLayout || !newLayout) return;
|
||||
setLayout(newLayout as BookerLayout);
|
||||
},
|
||||
[setLayout, layout]
|
||||
);
|
||||
|
||||
if (isMobile || !enabledLayouts) return null;
|
||||
|
||||
// In month view we only show the layout toggle.
|
||||
if (isMonthView) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{isMyLink && !isEmbed ? (
|
||||
<Tooltip content={t("troubleshooter_tooltip")} side="bottom">
|
||||
<Button
|
||||
color="primary"
|
||||
target="_blank"
|
||||
href={`${WEBAPP_URL}/availability/troubleshoot?eventType=${eventSlug}`}>
|
||||
{t("need_help")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
renderOverlay?.()
|
||||
)}
|
||||
<LayoutToggleWithData
|
||||
layout={layout}
|
||||
enabledLayouts={enabledLayouts}
|
||||
onLayoutToggle={onLayoutToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const endDate = selectedDate.add(layout === BookerLayouts.COLUMN_VIEW ? extraDays : extraDays - 1, "days");
|
||||
|
||||
const isSameMonth = () => {
|
||||
return selectedDate.format("MMM") === endDate.format("MMM");
|
||||
};
|
||||
|
||||
const isSameYear = () => {
|
||||
return selectedDate.format("YYYY") === endDate.format("YYYY");
|
||||
};
|
||||
const formattedMonth = new Intl.DateTimeFormat(i18n.language ?? "en", { month: "short" });
|
||||
const FormattedSelectedDateRange = () => {
|
||||
return (
|
||||
<h3 className="min-w-[150px] text-base font-semibold leading-4">
|
||||
{formattedMonth.format(selectedDate.toDate())} {selectedDate.format("D")}
|
||||
{!isSameYear() && <span className="text-subtle">, {selectedDate.format("YYYY")} </span>}-{" "}
|
||||
{!isSameMonth() && formattedMonth.format(endDate.toDate())} {endDate.format("D")},{" "}
|
||||
<span className="text-subtle">
|
||||
{isSameYear() ? selectedDate.format("YYYY") : endDate.format("YYYY")}
|
||||
</span>
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-default relative z-10 flex border-b px-5 py-4 ltr:border-l rtl:border-r">
|
||||
<div className="flex items-center gap-5 rtl:flex-grow">
|
||||
<FormattedSelectedDateRange />
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
className="group rtl:ml-1 rtl:rotate-180"
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
StartIcon="chevron-left"
|
||||
aria-label="Previous Day"
|
||||
onClick={() => addToSelectedDate(layout === BookerLayouts.COLUMN_VIEW ? -nextSlots : -extraDays)}
|
||||
/>
|
||||
<Button
|
||||
className="group rtl:mr-1 rtl:rotate-180"
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
StartIcon="chevron-right"
|
||||
aria-label="Next Day"
|
||||
onClick={() => addToSelectedDate(layout === BookerLayouts.COLUMN_VIEW ? nextSlots : extraDays)}
|
||||
/>
|
||||
{selectedDateMin3DaysDifference && (
|
||||
<Button
|
||||
className="capitalize ltr:ml-2 rtl:mr-2"
|
||||
color="secondary"
|
||||
onClick={() => setSelectedDate(today.format("YYYY-MM-DD"))}>
|
||||
{t("today")}
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{renderOverlay?.()}
|
||||
<TimeFormatToggle />
|
||||
<div className="fixed top-4 ltr:right-4 rtl:left-4">
|
||||
<LayoutToggleWithData
|
||||
layout={layout}
|
||||
enabledLayouts={enabledLayouts}
|
||||
onLayoutToggle={onLayoutToggle}
|
||||
/>
|
||||
</div>
|
||||
{/*
|
||||
This second layout toggle is hidden, but needed to reserve the correct spot in the DIV
|
||||
for the fixed toggle above to fit into. If we wouldn't make it fixed in this view, the transition
|
||||
would be really weird, because the element is positioned fixed in the month view, and then
|
||||
when switching layouts wouldn't anymore, causing it to animate from the center to the top right,
|
||||
while it actually already was on place. That's why we have this element twice.
|
||||
*/}
|
||||
<div className="pointer-events-none opacity-0" aria-hidden>
|
||||
<LayoutToggleWithData
|
||||
layout={layout}
|
||||
enabledLayouts={enabledLayouts}
|
||||
onLayoutToggle={onLayoutToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LayoutToggle = ({
|
||||
onLayoutToggle,
|
||||
layout,
|
||||
enabledLayouts,
|
||||
}: {
|
||||
onLayoutToggle: (layout: string) => void;
|
||||
layout: string;
|
||||
enabledLayouts?: BookerLayouts[];
|
||||
}) => {
|
||||
const isEmbed = useIsEmbed();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const layoutOptions = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: BookerLayouts.MONTH_VIEW,
|
||||
label: <Icon name="calendar" width="16" height="16" />,
|
||||
tooltip: t("switch_monthly"),
|
||||
},
|
||||
{
|
||||
value: BookerLayouts.WEEK_VIEW,
|
||||
label: <Icon name="grid-3x3" width="16" height="16" />,
|
||||
tooltip: t("switch_weekly"),
|
||||
},
|
||||
{
|
||||
value: BookerLayouts.COLUMN_VIEW,
|
||||
label: <Icon name="columns-3" width="16" height="16" />,
|
||||
tooltip: t("switch_columnview"),
|
||||
},
|
||||
].filter((layout) => enabledLayouts?.includes(layout.value as BookerLayouts));
|
||||
}, [t, enabledLayouts]);
|
||||
|
||||
// We don't want to show the layout toggle in embed mode as of now as it doesn't look rightly placed when embedded.
|
||||
// There is a Embed API to control the layout toggle from outside of the iframe.
|
||||
if (isEmbed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ToggleGroup onValueChange={onLayoutToggle} defaultValue={layout} options={layoutOptions} />;
|
||||
};
|
||||
|
||||
const LayoutToggleWithData = ({
|
||||
enabledLayouts,
|
||||
onLayoutToggle,
|
||||
layout,
|
||||
}: {
|
||||
enabledLayouts: BookerLayouts[];
|
||||
onLayoutToggle: (layout: string) => void;
|
||||
layout: string;
|
||||
}) => {
|
||||
return enabledLayouts.length <= 1 ? null : (
|
||||
<LayoutToggle onLayoutToggle={onLayoutToggle} layout={layout} enabledLayouts={enabledLayouts} />
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Button, UserAvatarGroupWithOrg } from "@calcom/ui";
|
||||
|
||||
interface IInstantBookingProps {
|
||||
onConnectNow: () => void;
|
||||
event: Pick<BookerEvent, "entity" | "schedulingType"> & {
|
||||
users: (Pick<User, "name" | "username" | "avatarUrl"> & { bookerUrl: string })[];
|
||||
};
|
||||
}
|
||||
|
||||
export const InstantBooking = ({ onConnectNow, event }: IInstantBookingProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className=" bg-default border-subtle mx-2 block items-center gap-3 rounded-xl border p-[6px] text-sm shadow-sm delay-1000 sm:flex">
|
||||
<div className="flex items-center gap-3 ps-1">
|
||||
<div className="relative">
|
||||
<UserAvatarGroupWithOrg
|
||||
size="sm"
|
||||
className="border-muted"
|
||||
organization={{
|
||||
slug: event.entity.orgSlug,
|
||||
name: event.entity.name || "",
|
||||
}}
|
||||
users={event.schedulingType !== SchedulingType.ROUND_ROBIN ? event.users : []}
|
||||
/>
|
||||
<div className="border-muted absolute -bottom-0.5 -right-1 h-2 w-2 rounded-full border bg-green-500" />
|
||||
</div>
|
||||
<div>{t("dont_want_to_wait")}</div>
|
||||
</div>
|
||||
<div className="mt-2 sm:mt-0">
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
onConnectNow();
|
||||
}}
|
||||
size="sm"
|
||||
className="w-full justify-center rounded-lg sm:w-auto">
|
||||
{t("connect_now")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useMemo, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { Calendar } from "@calcom/features/calendars/weeklyview";
|
||||
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
import { useBookerStore } from "../store";
|
||||
import type { useScheduleForEventReturnType } from "../utils/event";
|
||||
import { getQueryParam } from "../utils/query-param";
|
||||
import { useOverlayCalendarStore } from "./OverlayCalendar/store";
|
||||
|
||||
export const LargeCalendar = ({
|
||||
extraDays,
|
||||
schedule,
|
||||
isLoading,
|
||||
event,
|
||||
}: {
|
||||
extraDays: number;
|
||||
schedule?: useScheduleForEventReturnType["data"];
|
||||
isLoading: boolean;
|
||||
event: {
|
||||
data?: Pick<BookerEvent, "length"> | null;
|
||||
};
|
||||
}) => {
|
||||
const selectedDate = useBookerStore((state) => state.selectedDate);
|
||||
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
|
||||
const selectedEventDuration = useBookerStore((state) => state.selectedDuration);
|
||||
const overlayEvents = useOverlayCalendarStore((state) => state.overlayBusyDates);
|
||||
const displayOverlay =
|
||||
getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault");
|
||||
|
||||
const eventDuration = selectedEventDuration || event?.data?.length || 30;
|
||||
|
||||
const availableSlots = useMemo(() => {
|
||||
const availableTimeslots: CalendarAvailableTimeslots = {};
|
||||
if (!schedule) return availableTimeslots;
|
||||
if (!schedule.slots) return availableTimeslots;
|
||||
|
||||
for (const day in schedule.slots) {
|
||||
availableTimeslots[day] = schedule.slots[day].map((slot) => {
|
||||
const { time, ...rest } = slot;
|
||||
return {
|
||||
start: dayjs(time).toDate(),
|
||||
end: dayjs(time).add(eventDuration, "minutes").toDate(),
|
||||
...rest,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return availableTimeslots;
|
||||
}, [schedule, eventDuration]);
|
||||
|
||||
const startDate = selectedDate ? dayjs(selectedDate).toDate() : dayjs().toDate();
|
||||
const endDate = dayjs(startDate)
|
||||
.add(extraDays - 1, "day")
|
||||
.toDate();
|
||||
|
||||
// HACK: force rerender when overlay events change
|
||||
// Sine we dont use react router here we need to force rerender (ATOM SUPPORT)
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
useEffect(() => {}, [displayOverlay]);
|
||||
|
||||
const overlayEventsForDate = useMemo(() => {
|
||||
if (!overlayEvents || !displayOverlay) return [];
|
||||
return overlayEvents.map((event, id) => {
|
||||
return {
|
||||
id,
|
||||
start: dayjs(event.start).toDate(),
|
||||
end: dayjs(event.end).toDate(),
|
||||
title: "Busy",
|
||||
options: {
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
} as CalendarEvent;
|
||||
});
|
||||
}, [overlayEvents, displayOverlay]);
|
||||
|
||||
return (
|
||||
<div className="h-full [--calendar-dates-sticky-offset:66px]">
|
||||
<Calendar
|
||||
isPending={isLoading}
|
||||
availableTimeslots={availableSlots}
|
||||
startHour={0}
|
||||
endHour={23}
|
||||
events={overlayEventsForDate}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onEmptyCellClick={(date) => setSelectedTimeslot(date.toISOString())}
|
||||
gridCellsPerHour={60 / eventDuration}
|
||||
hoverEventDuration={eventDuration}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { IOutOfOfficeData } from "@calcom/core/getUserAvailability";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
interface IOutOfOfficeInSlotsProps {
|
||||
date: string;
|
||||
fromUser?: IOutOfOfficeData["anyDate"]["fromUser"];
|
||||
toUser?: IOutOfOfficeData["anyDate"]["toUser"];
|
||||
emoji?: string;
|
||||
reason?: string;
|
||||
borderDashed?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => {
|
||||
const { t } = useLocale();
|
||||
const { fromUser, toUser, emoji = "🏝️", borderDashed = true, date, className } = props;
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
if (!fromUser || !toUser) return null;
|
||||
return (
|
||||
<div className={classNames("relative h-full pb-5", className)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"flex h-full flex-col items-center justify-start rounded-md border bg-white px-4 py-4 font-normal dark:bg-transparent",
|
||||
borderDashed && "border-dashed"
|
||||
)}>
|
||||
<div className="bg-emphasis flex h-14 w-14 flex-col items-center justify-center rounded-full">
|
||||
<span className="m-auto text-center text-lg">{emoji}</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-center">
|
||||
<p className="mt-2 text-base font-bold">
|
||||
{t("ooo_user_is_ooo", { displayName: fromUser.displayName })}
|
||||
</p>
|
||||
|
||||
{fromUser?.displayName && toUser?.displayName && (
|
||||
<p className="text-center text-sm">
|
||||
<Trans
|
||||
i18nKey="ooo_slots_returning"
|
||||
values={{ displayName: toUser.displayName }}
|
||||
default="<1>{{ displayName }}</1> can take their meetings while they are away."
|
||||
components={[<strong key="username">username</strong>]}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{toUser?.id && (
|
||||
<Button
|
||||
className="mt-8 max-w-[90%]"
|
||||
variant="button"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
// grab current dates and query params from URL
|
||||
|
||||
const month = searchParams.get("month");
|
||||
const layout = searchParams.get("layout");
|
||||
const targetDate = searchParams.get("date") || date;
|
||||
// go to the booking page with the selected user and correct search param
|
||||
// While being an org push will maintain the org context and just change the user in params
|
||||
router.push(
|
||||
`/${toUser.username}?${month ? `month=${month}&` : ""}date=${targetDate}${
|
||||
layout ? `&layout=${layout}` : ""
|
||||
}`
|
||||
);
|
||||
}}>
|
||||
<span className="block overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{t("ooo_slots_book_with", { displayName: toUser.displayName })}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { UseCalendarsReturnType } from "../hooks/useCalendars";
|
||||
import { useOverlayCalendar } from "../hooks/useOverlayCalendar";
|
||||
import { OverlayCalendarContinueModal } from "./OverlayCalendarContinueModal";
|
||||
import { OverlayCalendarSettingsModal } from "./OverlayCalendarSettingsModal";
|
||||
import { OverlayCalendarSwitch } from "./OverlayCalendarSwitch";
|
||||
|
||||
type OverlayCalendarProps = Pick<
|
||||
UseCalendarsReturnType,
|
||||
| "connectedCalendars"
|
||||
| "overlayBusyDates"
|
||||
| "onToggleCalendar"
|
||||
| "loadingConnectedCalendar"
|
||||
| "isOverlayCalendarEnabled"
|
||||
> & {
|
||||
handleClickNoCalendar: () => void;
|
||||
hasSession: boolean;
|
||||
handleClickContinue: () => void;
|
||||
handleSwitchStateChange: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export const OverlayCalendar = ({
|
||||
connectedCalendars,
|
||||
overlayBusyDates,
|
||||
onToggleCalendar,
|
||||
isOverlayCalendarEnabled,
|
||||
loadingConnectedCalendar,
|
||||
handleClickNoCalendar,
|
||||
handleSwitchStateChange,
|
||||
handleClickContinue,
|
||||
hasSession,
|
||||
}: OverlayCalendarProps) => {
|
||||
const {
|
||||
handleCloseContinueModal,
|
||||
handleCloseSettingsModal,
|
||||
isOpenOverlayContinueModal,
|
||||
isOpenOverlaySettingsModal,
|
||||
handleToggleConnectedCalendar,
|
||||
checkIsCalendarToggled,
|
||||
} = useOverlayCalendar({ connectedCalendars, overlayBusyDates, onToggleCalendar });
|
||||
return (
|
||||
<>
|
||||
<OverlayCalendarSwitch
|
||||
enabled={isOverlayCalendarEnabled}
|
||||
hasSession={hasSession}
|
||||
onStateChange={handleSwitchStateChange}
|
||||
/>
|
||||
<OverlayCalendarContinueModal
|
||||
open={isOpenOverlayContinueModal}
|
||||
onClose={handleCloseContinueModal}
|
||||
onContinue={handleClickContinue}
|
||||
/>
|
||||
<OverlayCalendarSettingsModal
|
||||
connectedCalendars={connectedCalendars}
|
||||
open={isOpenOverlaySettingsModal}
|
||||
onClose={handleCloseSettingsModal}
|
||||
isLoading={loadingConnectedCalendar}
|
||||
onToggleConnectedCalendar={handleToggleConnectedCalendar}
|
||||
onClickNoCalendar={() => {
|
||||
handleClickNoCalendar();
|
||||
}}
|
||||
checkIsCalendarToggled={checkIsCalendarToggled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter } from "@calcom/ui";
|
||||
|
||||
interface IOverlayCalendarContinueModalProps {
|
||||
open?: boolean;
|
||||
onClose?: (state: boolean) => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function OverlayCalendarContinueModal(props: IOverlayCalendarContinueModalProps) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<Dialog open={props.open} onOpenChange={props.onClose}>
|
||||
<DialogContent
|
||||
type="creation"
|
||||
title={t("overlay_my_calendar")}
|
||||
description={t("overlay_my_calendar_toc")}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
data-testid="overlay-calendar-continue-button"
|
||||
onClick={() => {
|
||||
props.onContinue();
|
||||
}}
|
||||
className="gap w-full items-center justify-center font-semibold"
|
||||
StartIcon="calendar-search">
|
||||
{t("continue_with", { appName: APP_NAME })}
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{/* Agh modal hacks */}
|
||||
<></>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
EmptyScreen,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemTitle,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
Switch,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { UseCalendarsReturnType } from "../hooks/useCalendars";
|
||||
|
||||
interface IOverlayCalendarSettingsModalProps {
|
||||
open?: boolean;
|
||||
onClose?: (state: boolean) => void;
|
||||
onClickNoCalendar?: () => void;
|
||||
isLoading: boolean;
|
||||
connectedCalendars: UseCalendarsReturnType["connectedCalendars"];
|
||||
onToggleConnectedCalendar: (externalCalendarId: string, credentialId: number) => void;
|
||||
checkIsCalendarToggled: (externalCalendarId: string, credentialId: number) => boolean;
|
||||
}
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="border-subtle mt-3 space-y-4 rounded-xl border px-4 py-4 ">
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export function OverlayCalendarSettingsModal({
|
||||
connectedCalendars,
|
||||
isLoading,
|
||||
open,
|
||||
onClose,
|
||||
onClickNoCalendar,
|
||||
onToggleConnectedCalendar,
|
||||
checkIsCalendarToggled,
|
||||
}: IOverlayCalendarSettingsModalProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
enableOverflow
|
||||
type="creation"
|
||||
title="Calendar Settings"
|
||||
className="pb-4"
|
||||
description={t("view_overlay_calendar_events")}>
|
||||
<div className="no-scrollbar max-h-full overflow-y-scroll ">
|
||||
{isLoading ? (
|
||||
<SkeletonLoader />
|
||||
) : (
|
||||
<>
|
||||
{connectedCalendars.length === 0 ? (
|
||||
<EmptyScreen
|
||||
Icon="calendar"
|
||||
headline={t("no_calendar_installed")}
|
||||
description={t("no_calendar_installed_description")}
|
||||
buttonText={t("add_a_calendar")}
|
||||
buttonOnClick={onClickNoCalendar}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.error && !item.calendars && (
|
||||
<Alert severity="error" title={item.error.message} />
|
||||
)}
|
||||
{item?.error === undefined && item.calendars && (
|
||||
<ListItem className="flex-col rounded-md">
|
||||
<div className="flex w-full flex-1 items-center space-x-3 pb-4 rtl:space-x-reverse">
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
item.integration.logo && (
|
||||
<img
|
||||
className={classNames(
|
||||
"h-10 w-10",
|
||||
item.integration.logo.includes("-dark") && "dark:invert"
|
||||
)}
|
||||
src={item.integration.logo}
|
||||
alt={item.integration.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3" className="space-x-2 rtl:space-x-reverse">
|
||||
<Link href={`/apps/${item.integration.slug}`}>
|
||||
{item.integration.name || item.integration.title}
|
||||
</Link>
|
||||
</ListItemTitle>
|
||||
<ListItemText component="p">{item.primary.email}</ListItemText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle w-full border-t pt-4">
|
||||
<ul className="space-y-4">
|
||||
{item.calendars.map((cal, index) => {
|
||||
const id = cal.integrationTitle ?? `calendar-switch-${index}`;
|
||||
return (
|
||||
<li className="flex gap-3" key={id}>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checkIsCalendarToggled(cal.externalId, item.credentialId)}
|
||||
onCheckedChange={() => {
|
||||
onToggleConnectedCalendar(cal.externalId, item.credentialId);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={id}>{cal.name}</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</ListItem>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2 self-end">
|
||||
<DialogClose>{t("done")}</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Switch } from "@calcom/ui";
|
||||
|
||||
import { useBookerStore } from "../../store";
|
||||
import { useOverlayCalendarStore } from "./store";
|
||||
|
||||
interface OverlayCalendarSwitchProps {
|
||||
enabled?: boolean;
|
||||
hasSession: boolean;
|
||||
onStateChange: (state: boolean) => void;
|
||||
}
|
||||
|
||||
export function OverlayCalendarSwitch({ enabled, hasSession, onStateChange }: OverlayCalendarSwitchProps) {
|
||||
const { t } = useLocale();
|
||||
const setContinueWithProvider = useOverlayCalendarStore((state) => state.setContinueWithProviderModal);
|
||||
const setCalendarSettingsOverlay = useOverlayCalendarStore(
|
||||
(state) => state.setCalendarSettingsOverlayModal
|
||||
);
|
||||
const layout = useBookerStore((state) => state.layout);
|
||||
const switchEnabled = enabled;
|
||||
|
||||
/**
|
||||
* If a user is not logged in and the overlay calendar query param is true,
|
||||
* show the continue modal so they can login / create an account
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!hasSession && switchEnabled) {
|
||||
onStateChange(false);
|
||||
setContinueWithProvider(true);
|
||||
}
|
||||
}, [hasSession, switchEnabled, setContinueWithProvider, onStateChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"hidden gap-2",
|
||||
layout === "week_view" || layout === "column_view" ? "xl:flex" : "md:flex"
|
||||
)}>
|
||||
<div className="flex items-center gap-2 pr-2">
|
||||
<Switch
|
||||
data-testid="overlay-calendar-switch"
|
||||
checked={switchEnabled}
|
||||
id="overlayCalendar"
|
||||
onCheckedChange={(state) => {
|
||||
if (!hasSession) {
|
||||
setContinueWithProvider(state);
|
||||
} else {
|
||||
onStateChange(state);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="overlayCalendar"
|
||||
className="text-emphasis text-sm font-medium leading-none hover:cursor-pointer">
|
||||
{t("overlay_my_calendar")}
|
||||
</label>
|
||||
</div>
|
||||
{hasSession && (
|
||||
<Button
|
||||
size="base"
|
||||
data-testid="overlay-calendar-settings-button"
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
StartIcon="settings"
|
||||
onClick={() => {
|
||||
setCalendarSettingsOverlay(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import type { EventBusyDate } from "@calcom/types/Calendar";
|
||||
|
||||
interface IOverlayCalendarStore {
|
||||
overlayBusyDates: EventBusyDate[] | undefined;
|
||||
setOverlayBusyDates: (busyDates: EventBusyDate[]) => void;
|
||||
continueWithProviderModal: boolean;
|
||||
setContinueWithProviderModal: (value: boolean) => void;
|
||||
calendarSettingsOverlayModal: boolean;
|
||||
setCalendarSettingsOverlayModal: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useOverlayCalendarStore = create<IOverlayCalendarStore>((set) => ({
|
||||
overlayBusyDates: undefined,
|
||||
setOverlayBusyDates: (busyDates: EventBusyDate[]) => {
|
||||
set({ overlayBusyDates: busyDates });
|
||||
},
|
||||
calendarSettingsOverlayModal: false,
|
||||
setCalendarSettingsOverlayModal: (value: boolean) => {
|
||||
set({ calendarSettingsOverlayModal: value });
|
||||
},
|
||||
continueWithProviderModal: false,
|
||||
setContinueWithProviderModal: (value: boolean) => {
|
||||
set({ continueWithProviderModal: value });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Dialog, DialogContent } from "@calcom/ui";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
const message = "/o";
|
||||
event.returnValue = message; // Standard for most browsers
|
||||
return message; // For some older browsers
|
||||
};
|
||||
|
||||
export const RedirectToInstantMeetingModal = ({
|
||||
bookingId,
|
||||
onGoBack,
|
||||
expiryTime,
|
||||
instantVideoMeetingUrl,
|
||||
}: {
|
||||
bookingId: number;
|
||||
onGoBack: () => void;
|
||||
expiryTime?: Date;
|
||||
instantVideoMeetingUrl?: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [timeRemaining, setTimeRemaining] = useState(calculateTimeRemaining());
|
||||
const [hasInstantMeetingTokenExpired, setHasInstantMeetingTokenExpired] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
function calculateTimeRemaining() {
|
||||
const now = dayjs();
|
||||
const expiration = dayjs(expiryTime);
|
||||
const duration = expiration.diff(now);
|
||||
return Math.max(duration, 0);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!expiryTime) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeRemaining(calculateTimeRemaining());
|
||||
setHasInstantMeetingTokenExpired(expiryTime && new Date(expiryTime) < new Date());
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [expiryTime]);
|
||||
|
||||
const formatTime = (milliseconds: number) => {
|
||||
const duration = dayjs.duration(milliseconds);
|
||||
const seconds = duration.seconds();
|
||||
const minutes = duration.minutes();
|
||||
|
||||
return `${minutes}m ${seconds}s`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!expiryTime || hasInstantMeetingTokenExpired || !!instantVideoMeetingUrl) {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [expiryTime, hasInstantMeetingTokenExpired, instantVideoMeetingUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!!instantVideoMeetingUrl) {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
router.push(instantVideoMeetingUrl);
|
||||
}
|
||||
}, [instantVideoMeetingUrl]);
|
||||
|
||||
return (
|
||||
<Dialog open={!!bookingId && !!expiryTime}>
|
||||
<DialogContent enableOverflow className="py-8">
|
||||
<div>
|
||||
{hasInstantMeetingTokenExpired ? (
|
||||
<div>
|
||||
<p className="font-medium">{t("please_book_a_time_sometime_later")}</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
onGoBack();
|
||||
}}
|
||||
color="primary">
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<p className="font-medium">{t("connecting_you_to_someone")}</p>
|
||||
<p className="font-medium">{t("please_do_not_close_this_tab")}</p>
|
||||
<p className="mt-2 font-medium">
|
||||
{t("please_schedule_future_call", {
|
||||
seconds: formatTime(timeRemaining),
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 h-[414px]">
|
||||
<iframe className="mx-auto h-full w-[276px] rounded-lg" src="https://cal.games/" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { MotionProps } from "framer-motion";
|
||||
import { m } from "framer-motion";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
import { useBookerStore } from "../store";
|
||||
import type { BookerAreas, BookerLayout } from "../types";
|
||||
|
||||
/**
|
||||
* Define what grid area a section should be in.
|
||||
* Value is either a string (in case it's always the same area), or an object
|
||||
* looking like:
|
||||
* {
|
||||
* // Where default is the required default area.
|
||||
* default: "calendar",
|
||||
* // Any optional overrides for different layouts by their layout name.
|
||||
* week_view: "main",
|
||||
* }
|
||||
*/
|
||||
type GridArea = BookerAreas | ({ [key in BookerLayout]?: BookerAreas } & { default: BookerAreas });
|
||||
|
||||
type BookerSectionProps = {
|
||||
children: React.ReactNode;
|
||||
area: GridArea;
|
||||
visible?: boolean;
|
||||
className?: string;
|
||||
} & MotionProps;
|
||||
|
||||
// This map with strings is needed so Tailwind generates all classnames,
|
||||
// If we would concatenate them with JS, Tailwind would not generate them.
|
||||
const gridAreaClassNameMap: { [key in BookerAreas]: string } = {
|
||||
calendar: "[grid-area:calendar]",
|
||||
main: "[grid-area:main]",
|
||||
meta: "[grid-area:meta]",
|
||||
timeslots: "[grid-area:timeslots]",
|
||||
header: "[grid-area:header]",
|
||||
};
|
||||
|
||||
/**
|
||||
* Small helper compnent that renders a booker section in a specific grid area.
|
||||
*/
|
||||
export const BookerSection = forwardRef<HTMLDivElement, BookerSectionProps>(function BookerSection(
|
||||
{ children, area, visible, className, ...props },
|
||||
ref
|
||||
) {
|
||||
const layout = useBookerStore((state) => state.layout);
|
||||
let gridClassName: string;
|
||||
|
||||
if (typeof area === "string") {
|
||||
gridClassName = gridAreaClassNameMap[area];
|
||||
} else {
|
||||
gridClassName = gridAreaClassNameMap[area[layout] || area.default];
|
||||
}
|
||||
|
||||
if (!visible && typeof visible !== "undefined") return null;
|
||||
|
||||
return (
|
||||
<m.div ref={ref} className={classNames(gridClassName, className)} layout {...props}>
|
||||
{children}
|
||||
</m.div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
const UnAvailableMessage = ({ children, title }: { children: React.ReactNode; title: string }) => (
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
<div className="border-subtle bg-default dark:bg-muted overflow-hidden rounded-lg border p-10">
|
||||
<h2 className="font-cal mb-4 text-3xl">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Away = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<UnAvailableMessage title={`😴 ${t("user_away")}`}>
|
||||
<p className="max-w-[50ch]">{t("user_away_description")}</p>
|
||||
</UnAvailableMessage>
|
||||
);
|
||||
};
|
||||
|
||||
export const NotFound = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<UnAvailableMessage title={t("404_page_not_found")}>
|
||||
<p className="max-w-[50ch]">{t("booker_event_not_found")}</p>
|
||||
</UnAvailableMessage>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import { useEmbedType, useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import type { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
import { defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { extraDaysConfig } from "../../config";
|
||||
import { useBookerStore } from "../../store";
|
||||
import type { BookerLayout } from "../../types";
|
||||
import { validateLayout } from "../../utils/layout";
|
||||
import { getQueryParam } from "../../utils/query-param";
|
||||
|
||||
export type UseBookerLayoutType = ReturnType<typeof useBookerLayout>;
|
||||
|
||||
export const useBookerLayout = (event: Pick<BookerEvent, "profile"> | undefined | null) => {
|
||||
const [_layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
|
||||
const isEmbed = useIsEmbed();
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const isTablet = useMediaQuery("(max-width: 1024px)");
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
// In Embed we give preference to embed configuration for the layout.If that's not set, we use the App configuration for the event layout
|
||||
// But if it's mobile view, there is only one layout supported which is 'mobile'
|
||||
const layout = isEmbed ? (isMobile ? "mobile" : validateLayout(embedUiConfig.layout) || _layout) : _layout;
|
||||
const extraDays = isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop;
|
||||
const embedType = useEmbedType();
|
||||
// Floating Button and Element Click both are modal and thus have dark background
|
||||
const hasDarkBackground = isEmbed && embedType !== "inline";
|
||||
const columnViewExtraDays = useRef<number>(
|
||||
isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop
|
||||
);
|
||||
const bookerLayouts = event?.profile?.bookerLayouts || defaultBookerLayoutSettings;
|
||||
const defaultLayout = isEmbed
|
||||
? validateLayout(embedUiConfig.layout) || bookerLayouts.defaultLayout
|
||||
: bookerLayouts.defaultLayout;
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && layout !== "mobile") {
|
||||
setLayout("mobile");
|
||||
} else if (!isMobile && layout === "mobile") {
|
||||
setLayout(defaultLayout);
|
||||
}
|
||||
}, [isMobile, setLayout, layout, defaultLayout]);
|
||||
//setting layout from query param
|
||||
useEffect(() => {
|
||||
const layout = getQueryParam("layout") as BookerLayouts;
|
||||
if (
|
||||
!isMobile &&
|
||||
!isEmbed &&
|
||||
validateLayout(layout) &&
|
||||
bookerLayouts?.enabledLayouts?.length &&
|
||||
layout !== _layout
|
||||
) {
|
||||
const validLayout = bookerLayouts.enabledLayouts.find((userLayout) => userLayout === layout);
|
||||
validLayout && setLayout(validLayout);
|
||||
}
|
||||
}, [bookerLayouts, setLayout, _layout, isEmbed, isMobile]);
|
||||
|
||||
// In Embed, a Dialog doesn't look good, we disable it intentionally for the layouts that support showing Form without Dialog(i.e. no-dialog Form)
|
||||
const shouldShowFormInDialogMap: Record<BookerLayout, boolean> = {
|
||||
// mobile supports showing the Form without Dialog
|
||||
mobile: !isEmbed,
|
||||
// We don't show Dialog in month_view currently. Can be easily toggled though as it supports no-dialog Form
|
||||
month_view: false,
|
||||
// week_view doesn't support no-dialog Form
|
||||
// When it's supported, disable it for embed
|
||||
week_view: true,
|
||||
// column_view doesn't support no-dialog Form
|
||||
// When it's supported, disable it for embed
|
||||
column_view: true,
|
||||
};
|
||||
|
||||
const shouldShowFormInDialog = shouldShowFormInDialogMap[layout];
|
||||
|
||||
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
|
||||
|
||||
return {
|
||||
shouldShowFormInDialog,
|
||||
hasDarkBackground,
|
||||
extraDays,
|
||||
columnViewExtraDays,
|
||||
isMobile,
|
||||
isEmbed,
|
||||
isTablet,
|
||||
layout,
|
||||
defaultLayout,
|
||||
hideEventTypeDetails,
|
||||
bookerLayouts,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { useInitialFormValues } from "./useInitialFormValues";
|
||||
|
||||
export interface IUseBookingForm {
|
||||
event?: Pick<BookerEvent, "bookingFields"> | null;
|
||||
sessionEmail?: string | null;
|
||||
sessionName?: string | null;
|
||||
sessionUsername?: string | null;
|
||||
hasSession: boolean;
|
||||
extraOptions: Record<string, string | string[]>;
|
||||
prefillFormParams: {
|
||||
guests: string[];
|
||||
name: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type UseBookingFormReturnType = ReturnType<typeof useBookingForm>;
|
||||
|
||||
export const useBookingForm = ({
|
||||
event,
|
||||
sessionEmail,
|
||||
sessionName,
|
||||
sessionUsername,
|
||||
hasSession,
|
||||
extraOptions,
|
||||
prefillFormParams,
|
||||
}: IUseBookingForm) => {
|
||||
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
const { t } = useLocale();
|
||||
const bookerFormErrorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const bookingFormSchema = z
|
||||
.object({
|
||||
responses: event
|
||||
? getBookingResponsesSchema({
|
||||
bookingFields: event.bookingFields,
|
||||
view: rescheduleUid ? "reschedule" : "booking",
|
||||
})
|
||||
: // Fallback until event is loaded.
|
||||
z.object({}),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type BookingFormValues = {
|
||||
locationType?: EventLocationType["type"];
|
||||
responses: z.infer<typeof bookingFormSchema>["responses"] | null;
|
||||
// Key is not really part of form values, but only used to have a key
|
||||
// to set generic error messages on. Needed until RHF has implemented root error keys.
|
||||
globalError: undefined;
|
||||
};
|
||||
const isRescheduling = !!rescheduleUid && !!bookingData;
|
||||
|
||||
const { initialValues, key } = useInitialFormValues({
|
||||
eventType: event,
|
||||
rescheduleUid,
|
||||
isRescheduling,
|
||||
email: sessionEmail,
|
||||
name: sessionName,
|
||||
username: sessionUsername,
|
||||
hasSession,
|
||||
extraOptions,
|
||||
prefillFormParams,
|
||||
});
|
||||
|
||||
const bookingForm = useForm<BookingFormValues>({
|
||||
defaultValues: initialValues,
|
||||
resolver: zodResolver(
|
||||
// Since this isn't set to strict we only validate the fields in the schema
|
||||
bookingFormSchema,
|
||||
{},
|
||||
{
|
||||
// bookingFormSchema is an async schema, so inform RHF to do async validation.
|
||||
mode: "async",
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// initialValues would be null initially as the async schema parsing is happening. Let's show the form in first render without any prefill values
|
||||
// But ensure that when initialValues is available, the form is reset and rerendered with the prefill values
|
||||
bookingForm.reset(initialValues);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
const email = bookingForm.watch("responses.email");
|
||||
const name = bookingForm.watch("responses.name");
|
||||
|
||||
const beforeVerifyEmail = () => {
|
||||
bookingForm.clearErrors();
|
||||
|
||||
// It shouldn't be possible that this method is fired without having event data,
|
||||
// but since in theory (looking at the types) it is possible, we still handle that case.
|
||||
if (!event) {
|
||||
bookingForm.setError("globalError", { message: t("error_booking_event") });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const errors = {
|
||||
hasFormErrors: Boolean(bookingForm.formState.errors["globalError"]),
|
||||
formErrors: bookingForm.formState.errors["globalError"],
|
||||
};
|
||||
|
||||
return {
|
||||
bookingForm,
|
||||
bookerFormErrorRef,
|
||||
key,
|
||||
formEmail: email,
|
||||
formName: name,
|
||||
beforeVerifyEmail,
|
||||
formErrors: errors,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
|
||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||
import { useHandleBookEvent } from "@calcom/atoms/monorepo";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
|
||||
import { createBooking, createRecurringBooking, createInstantBooking } from "@calcom/features/bookings/lib";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { getFullName } from "@calcom/features/form-builder/utils";
|
||||
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import type { UseBookingFormReturnType } from "./useBookingForm";
|
||||
|
||||
export interface IUseBookings {
|
||||
event: {
|
||||
data?:
|
||||
| (Pick<
|
||||
BookerEvent,
|
||||
| "id"
|
||||
| "slug"
|
||||
| "hosts"
|
||||
| "requiresConfirmation"
|
||||
| "isDynamic"
|
||||
| "metadata"
|
||||
| "forwardParamsSuccessRedirect"
|
||||
| "successRedirectUrl"
|
||||
| "length"
|
||||
| "recurringEvent"
|
||||
| "schedulingType"
|
||||
> & {
|
||||
users: Pick<
|
||||
BookerEvent["users"][number],
|
||||
"name" | "username" | "avatarUrl" | "weekStart" | "profile" | "bookerUrl"
|
||||
>[];
|
||||
})
|
||||
| null;
|
||||
};
|
||||
hashedLink?: string | null;
|
||||
bookingForm: UseBookingFormReturnType["bookingForm"];
|
||||
metadata: Record<string, string>;
|
||||
teamMemberEmail?: string;
|
||||
}
|
||||
|
||||
export interface IUseBookingLoadingStates {
|
||||
creatingBooking: boolean;
|
||||
creatingRecurringBooking: boolean;
|
||||
creatingInstantBooking: boolean;
|
||||
}
|
||||
|
||||
export interface IUseBookingErrors {
|
||||
hasDataErrors: boolean;
|
||||
dataErrors: unknown;
|
||||
}
|
||||
export type UseBookingsReturnType = ReturnType<typeof useBookings>;
|
||||
|
||||
export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemberEmail }: IUseBookings) => {
|
||||
const router = useRouter();
|
||||
const eventSlug = useBookerStore((state) => state.eventSlug);
|
||||
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
const timeslot = useBookerStore((state) => state.selectedTimeslot);
|
||||
const { t } = useLocale();
|
||||
const bookingSuccessRedirect = useBookingSuccessRedirect();
|
||||
const bookerFormErrorRef = useRef<HTMLDivElement>(null);
|
||||
const [instantMeetingTokenExpiryTime, setExpiryTime] = useState<Date | undefined>();
|
||||
const [instantVideoMeetingUrl, setInstantVideoMeetingUrl] = useState<string | undefined>();
|
||||
const duration = useBookerStore((state) => state.selectedDuration);
|
||||
|
||||
const isRescheduling = !!rescheduleUid && !!bookingData;
|
||||
|
||||
const bookingId = parseInt(getQueryParam("bookingId") || "0");
|
||||
|
||||
const _instantBooking = trpc.viewer.bookings.getInstantBookingLocation.useQuery(
|
||||
{
|
||||
bookingId: bookingId,
|
||||
},
|
||||
{
|
||||
enabled: !!bookingId,
|
||||
refetchInterval: 2000,
|
||||
refetchIntervalInBackground: true,
|
||||
}
|
||||
);
|
||||
useEffect(
|
||||
function refactorMeWithoutEffect() {
|
||||
const data = _instantBooking.data;
|
||||
|
||||
if (!data || !data.booking) return;
|
||||
try {
|
||||
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
|
||||
data.booking?.metadata || {}
|
||||
)?.videoCallUrl;
|
||||
|
||||
if (locationVideoCallUrl) {
|
||||
setInstantVideoMeetingUrl(locationVideoCallUrl);
|
||||
} else {
|
||||
showToast(t("something_went_wrong_on_our_end"), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t("something_went_wrong_on_our_end"), "error");
|
||||
}
|
||||
},
|
||||
[_instantBooking.data]
|
||||
);
|
||||
|
||||
const createBookingMutation = useMutation({
|
||||
mutationFn: createBooking,
|
||||
onSuccess: (responseData) => {
|
||||
const { uid, paymentUid } = responseData;
|
||||
const fullName = getFullName(bookingForm.getValues("responses.name"));
|
||||
|
||||
const users = !!event.data?.hosts?.length
|
||||
? event.data?.hosts.map((host) => host.user)
|
||||
: event.data?.users;
|
||||
|
||||
const validDuration = event.data?.isDynamic
|
||||
? duration || event.data?.length
|
||||
: duration && event.data?.metadata?.multipleDuration?.includes(duration)
|
||||
? duration
|
||||
: event.data?.length;
|
||||
const eventPayload = {
|
||||
uid: responseData.uid,
|
||||
title: responseData.title,
|
||||
startTime: responseData.startTime,
|
||||
endTime: responseData.endTime,
|
||||
eventTypeId: responseData.eventTypeId,
|
||||
status: responseData.status,
|
||||
paymentRequired: responseData.paymentRequired,
|
||||
};
|
||||
if (isRescheduling) {
|
||||
sdkActionManager?.fire("rescheduleBookingSuccessful", {
|
||||
booking: responseData,
|
||||
eventType: event.data,
|
||||
date: responseData?.startTime?.toString() || "",
|
||||
duration: validDuration,
|
||||
organizer: {
|
||||
name: users?.[0]?.name || "Nameless",
|
||||
email: responseData?.userPrimaryEmail || responseData.user?.email || "Email-less",
|
||||
timeZone: responseData.user?.timeZone || "Europe/London",
|
||||
},
|
||||
confirmed: !(responseData.status === BookingStatus.PENDING && event.data?.requiresConfirmation),
|
||||
});
|
||||
sdkActionManager?.fire("rescheduleBookingSuccessfulV2", eventPayload);
|
||||
} else {
|
||||
sdkActionManager?.fire("bookingSuccessful", {
|
||||
booking: responseData,
|
||||
eventType: event.data,
|
||||
date: responseData?.startTime?.toString() || "",
|
||||
duration: validDuration,
|
||||
organizer: {
|
||||
name: users?.[0]?.name || "Nameless",
|
||||
email: responseData?.userPrimaryEmail || responseData.user?.email || "Email-less",
|
||||
timeZone: responseData.user?.timeZone || "Europe/London",
|
||||
},
|
||||
confirmed: !(responseData.status === BookingStatus.PENDING && event.data?.requiresConfirmation),
|
||||
});
|
||||
|
||||
sdkActionManager?.fire("bookingSuccessfulV2", eventPayload);
|
||||
}
|
||||
|
||||
if (paymentUid) {
|
||||
router.push(
|
||||
createPaymentLink({
|
||||
paymentUid,
|
||||
date: timeslot,
|
||||
name: fullName,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
absolute: false,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!uid) {
|
||||
console.error("No uid returned from createBookingMutation");
|
||||
return;
|
||||
}
|
||||
|
||||
const query = {
|
||||
isSuccessBookingPage: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventSlug,
|
||||
seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null,
|
||||
formerTime:
|
||||
isRescheduling && bookingData?.startTime ? dayjs(bookingData.startTime).toString() : undefined,
|
||||
};
|
||||
|
||||
bookingSuccessRedirect({
|
||||
successRedirectUrl: event?.data?.successRedirectUrl || "",
|
||||
query,
|
||||
booking: responseData,
|
||||
forwardParamsSuccessRedirect:
|
||||
event?.data?.forwardParamsSuccessRedirect === undefined
|
||||
? true
|
||||
: event?.data?.forwardParamsSuccessRedirect,
|
||||
});
|
||||
},
|
||||
onError: (err, _, ctx) => {
|
||||
bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
},
|
||||
});
|
||||
|
||||
const createInstantBookingMutation = useMutation({
|
||||
mutationFn: createInstantBooking,
|
||||
onSuccess: (responseData) => {
|
||||
updateQueryParam("bookingId", responseData.bookingId);
|
||||
setExpiryTime(responseData.expires);
|
||||
},
|
||||
onError: (err, _, ctx) => {
|
||||
console.error("Error creating instant booking", err);
|
||||
|
||||
bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
},
|
||||
});
|
||||
|
||||
const createRecurringBookingMutation = useMutation({
|
||||
mutationFn: createRecurringBooking,
|
||||
onSuccess: async (responseData) => {
|
||||
const booking = responseData[0] || {};
|
||||
const { uid } = booking;
|
||||
|
||||
if (!uid) {
|
||||
console.error("No uid returned from createRecurringBookingMutation");
|
||||
return;
|
||||
}
|
||||
|
||||
const query = {
|
||||
isSuccessBookingPage: true,
|
||||
allRemainingBookings: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventSlug,
|
||||
formerTime:
|
||||
isRescheduling && bookingData?.startTime ? dayjs(bookingData.startTime).toString() : undefined,
|
||||
};
|
||||
|
||||
bookingSuccessRedirect({
|
||||
successRedirectUrl: event?.data?.successRedirectUrl || "",
|
||||
query,
|
||||
booking,
|
||||
forwardParamsSuccessRedirect:
|
||||
event?.data?.forwardParamsSuccessRedirect === undefined
|
||||
? true
|
||||
: event?.data?.forwardParamsSuccessRedirect,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleBookEvent = useHandleBookEvent({
|
||||
event,
|
||||
bookingForm,
|
||||
hashedLink,
|
||||
metadata,
|
||||
teamMemberEmail,
|
||||
handleInstantBooking: createInstantBookingMutation.mutate,
|
||||
handleRecBooking: createRecurringBookingMutation.mutate,
|
||||
handleBooking: createBookingMutation.mutate,
|
||||
});
|
||||
|
||||
const errors = {
|
||||
hasDataErrors: Boolean(
|
||||
createBookingMutation.isError ||
|
||||
createRecurringBookingMutation.isError ||
|
||||
createInstantBookingMutation.isError
|
||||
),
|
||||
dataErrors:
|
||||
createBookingMutation.error ||
|
||||
createRecurringBookingMutation.error ||
|
||||
createInstantBookingMutation.error,
|
||||
};
|
||||
|
||||
// A redirect is triggered on mutation success, so keep the loading state while it is happening.
|
||||
const loadingStates = {
|
||||
creatingBooking: createBookingMutation.isPending || createBookingMutation.isSuccess,
|
||||
creatingRecurringBooking:
|
||||
createRecurringBookingMutation.isPending || createRecurringBookingMutation.isSuccess,
|
||||
creatingInstantBooking: createInstantBookingMutation.isPending,
|
||||
};
|
||||
|
||||
return {
|
||||
handleBookEvent,
|
||||
expiryTime: instantMeetingTokenExpiryTime,
|
||||
bookingForm,
|
||||
bookerFormErrorRef,
|
||||
errors,
|
||||
loadingStates,
|
||||
instantVideoMeetingUrl,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { useBookerStore } from "../../store";
|
||||
import { useOverlayCalendarStore } from "../OverlayCalendar/store";
|
||||
import { useLocalSet } from "./useLocalSet";
|
||||
|
||||
export type UseCalendarsReturnType = ReturnType<typeof useCalendars>;
|
||||
type UseCalendarsProps = {
|
||||
hasSession: boolean;
|
||||
};
|
||||
export const useCalendars = ({ hasSession }: UseCalendarsProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const selectedDate = useBookerStore((state) => state.selectedDate);
|
||||
const { timezone } = useTimePreferences();
|
||||
const switchEnabled =
|
||||
searchParams?.get("overlayCalendar") === "true" ||
|
||||
localStorage?.getItem("overlayCalendarSwitchDefault") === "true";
|
||||
const { set, clearSet } = useLocalSet<{
|
||||
credentialId: number;
|
||||
externalId: string;
|
||||
}>("toggledConnectedCalendars", []);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [calendarSettingsOverlay] = useOverlayCalendarStore(
|
||||
(state) => [state.calendarSettingsOverlayModal, state.setCalendarSettingsOverlayModal],
|
||||
shallow
|
||||
);
|
||||
|
||||
const { data: overlayBusyDates, isError } = trpc.viewer.availability.calendarOverlay.useQuery(
|
||||
{
|
||||
loggedInUsersTz: timezone || "Europe/London",
|
||||
dateFrom: selectedDate,
|
||||
dateTo: selectedDate,
|
||||
calendarsToLoad: Array.from(set).map((item) => ({
|
||||
credentialId: item.credentialId,
|
||||
externalId: item.externalId,
|
||||
})),
|
||||
},
|
||||
{
|
||||
enabled: hasSession && set.size > 0 && switchEnabled,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function refactorMeWithoutEffect() {
|
||||
if (!isError) return;
|
||||
clearSet();
|
||||
},
|
||||
[isError]
|
||||
);
|
||||
|
||||
const { data, isPending } = trpc.viewer.connectedCalendars.useQuery(undefined, {
|
||||
enabled: !!calendarSettingsOverlay || Boolean(searchParams?.get("overlayCalendar")),
|
||||
});
|
||||
|
||||
return {
|
||||
overlayBusyDates,
|
||||
isOverlayCalendarEnabled: switchEnabled,
|
||||
connectedCalendars: data?.connectedCalendars || [],
|
||||
loadingConnectedCalendar: isPending,
|
||||
onToggleCalendar: () => {
|
||||
utils.viewer.availability.calendarOverlay.reset();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import type getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
|
||||
export type useInitialFormValuesReturnType = ReturnType<typeof useInitialFormValues>;
|
||||
|
||||
type UseInitialFormValuesProps = {
|
||||
eventType?: Pick<BookerEvent, "bookingFields"> | null;
|
||||
rescheduleUid: string | null;
|
||||
isRescheduling: boolean;
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
username?: string | null;
|
||||
hasSession: boolean;
|
||||
extraOptions: Record<string, string | string[]>;
|
||||
prefillFormParams: {
|
||||
guests: string[];
|
||||
name: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export function useInitialFormValues({
|
||||
eventType,
|
||||
rescheduleUid,
|
||||
isRescheduling,
|
||||
email,
|
||||
name,
|
||||
username,
|
||||
hasSession,
|
||||
extraOptions,
|
||||
prefillFormParams,
|
||||
}: UseInitialFormValuesProps) {
|
||||
const [initialValues, setDefaultValues] = useState<{
|
||||
responses?: Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>;
|
||||
bookingId?: number;
|
||||
}>({});
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
const formValues = useBookerStore((state) => state.formValues);
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
if (Object.keys(formValues).length) {
|
||||
setDefaultValues(formValues);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventType?.bookingFields) {
|
||||
return {};
|
||||
}
|
||||
const querySchema = getBookingResponsesPartialSchema({
|
||||
bookingFields: eventType.bookingFields,
|
||||
view: rescheduleUid ? "reschedule" : "booking",
|
||||
});
|
||||
|
||||
const parsedQuery = await querySchema.parseAsync({
|
||||
...extraOptions,
|
||||
name: prefillFormParams.name,
|
||||
// `guest` because we need to support legacy URL with `guest` query param support
|
||||
// `guests` because the `name` of the corresponding bookingField is `guests`
|
||||
guests: prefillFormParams.guests,
|
||||
});
|
||||
|
||||
const defaultUserValues = {
|
||||
email:
|
||||
rescheduleUid && bookingData && bookingData.attendees.length > 0
|
||||
? bookingData?.attendees[0].email
|
||||
: !!parsedQuery["email"]
|
||||
? parsedQuery["email"]
|
||||
: email ?? "",
|
||||
name:
|
||||
rescheduleUid && bookingData && bookingData.attendees.length > 0
|
||||
? bookingData?.attendees[0].name
|
||||
: !!parsedQuery["name"]
|
||||
? parsedQuery["name"]
|
||||
: name ?? username ?? "",
|
||||
};
|
||||
|
||||
if (!isRescheduling) {
|
||||
const defaults = {
|
||||
responses: {} as Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
|
||||
};
|
||||
|
||||
const responses = eventType.bookingFields.reduce((responses, field) => {
|
||||
return {
|
||||
...responses,
|
||||
[field.name]: parsedQuery[field.name] || undefined,
|
||||
};
|
||||
}, {});
|
||||
|
||||
defaults.responses = {
|
||||
...responses,
|
||||
name: defaultUserValues.name,
|
||||
email: defaultUserValues.email,
|
||||
};
|
||||
|
||||
setDefaultValues(defaults);
|
||||
}
|
||||
|
||||
if (!rescheduleUid && !bookingData) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// We should allow current session user as default values for booking form
|
||||
|
||||
const defaults = {
|
||||
responses: {} as Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
|
||||
bookingId: bookingData?.id,
|
||||
};
|
||||
|
||||
const responses = eventType.bookingFields.reduce((responses, field) => {
|
||||
return {
|
||||
...responses,
|
||||
[field.name]: bookingData?.responses[field.name],
|
||||
};
|
||||
}, {});
|
||||
defaults.responses = {
|
||||
...responses,
|
||||
name: defaultUserValues.name,
|
||||
email: defaultUserValues.email,
|
||||
};
|
||||
setDefaultValues(defaults);
|
||||
})();
|
||||
// do not add extraOptions as a dependency, it will cause infinite loop
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
eventType?.bookingFields,
|
||||
formValues,
|
||||
isRescheduling,
|
||||
bookingData,
|
||||
bookingData?.id,
|
||||
rescheduleUid,
|
||||
email,
|
||||
name,
|
||||
username,
|
||||
prefillFormParams,
|
||||
]);
|
||||
|
||||
// When initialValues is available(after doing async schema parsing) or session is available(so that we can prefill logged-in user email and name), we need to reset the form with the initialValues
|
||||
// We also need the key to change if the bookingId changes, so that the form is reset and rerendered with the new initialValues
|
||||
const key = `${Object.keys(initialValues).length}_${hasSession ? 1 : 0}_${initialValues?.bookingId ?? 0}`;
|
||||
|
||||
return { initialValues, key };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
export interface HasExternalId {
|
||||
externalId: string;
|
||||
}
|
||||
|
||||
export function useLocalSet<T extends HasExternalId>(key: string, initialValue: T[]) {
|
||||
const [set, setSet] = useState<Set<T>>(() => {
|
||||
const storedValue = localStorage.getItem(key);
|
||||
return storedValue ? new Set(JSON.parse(storedValue)) : new Set(initialValue);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(key, JSON.stringify(Array.from(set)));
|
||||
}, [key, set]);
|
||||
|
||||
const addValue = (value: T) => {
|
||||
setSet((prevSet) => new Set(prevSet).add(value));
|
||||
};
|
||||
|
||||
const removeById = (id: string) => {
|
||||
setSet((prevSet) => {
|
||||
const updatedSet = new Set(prevSet);
|
||||
updatedSet.forEach((item) => {
|
||||
if (item.externalId === id) {
|
||||
updatedSet.delete(item);
|
||||
}
|
||||
});
|
||||
return updatedSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleValue = (value: T) => {
|
||||
setSet((prevSet) => {
|
||||
const updatedSet = new Set(prevSet);
|
||||
let itemFound = false;
|
||||
|
||||
updatedSet.forEach((item) => {
|
||||
if (item.externalId === value.externalId) {
|
||||
itemFound = true;
|
||||
updatedSet.delete(item);
|
||||
}
|
||||
});
|
||||
|
||||
if (!itemFound) {
|
||||
updatedSet.add(value);
|
||||
}
|
||||
|
||||
return updatedSet;
|
||||
});
|
||||
};
|
||||
|
||||
const hasItem = (value: T) => {
|
||||
return Array.from(set).some((item) => item.externalId === value.externalId);
|
||||
};
|
||||
|
||||
const clearSet = () => {
|
||||
setSet(() => new Set());
|
||||
// clear local storage too
|
||||
localStorage.removeItem(key);
|
||||
};
|
||||
|
||||
return { set, addValue, removeById, toggleValue, hasItem, clearSet };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
|
||||
import { useOverlayCalendarStore } from "../OverlayCalendar/store";
|
||||
import type { UseCalendarsReturnType } from "./useCalendars";
|
||||
import { useLocalSet } from "./useLocalSet";
|
||||
|
||||
export type UseOverlayCalendarReturnType = ReturnType<typeof useOverlayCalendar>;
|
||||
|
||||
export const useOverlayCalendar = ({
|
||||
connectedCalendars,
|
||||
overlayBusyDates,
|
||||
onToggleCalendar,
|
||||
}: Pick<UseCalendarsReturnType, "connectedCalendars" | "overlayBusyDates" | "onToggleCalendar">) => {
|
||||
const { set, toggleValue, hasItem } = useLocalSet<{
|
||||
credentialId: number;
|
||||
externalId: string;
|
||||
}>("toggledConnectedCalendars", []);
|
||||
const [initalised, setInitalised] = useState(false);
|
||||
const [continueWithProvider, setContinueWithProvider] = useOverlayCalendarStore(
|
||||
(state) => [state.continueWithProviderModal, state.setContinueWithProviderModal],
|
||||
shallow
|
||||
);
|
||||
const [calendarSettingsOverlay, setCalendarSettingsOverlay] = useOverlayCalendarStore(
|
||||
(state) => [state.calendarSettingsOverlayModal, state.setCalendarSettingsOverlayModal],
|
||||
shallow
|
||||
);
|
||||
const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates);
|
||||
const { timezone } = useTimePreferences();
|
||||
|
||||
useEffect(() => {
|
||||
if (overlayBusyDates) {
|
||||
const nowDate = dayjs();
|
||||
const usersTimezoneDate = nowDate.tz(timezone);
|
||||
|
||||
const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60;
|
||||
|
||||
const offsettedArray = overlayBusyDates.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
start: dayjs(item.start).add(offset, "hours").toDate(),
|
||||
end: dayjs(item.end).add(offset, "hours").toDate(),
|
||||
};
|
||||
});
|
||||
setOverlayBusyDates(offsettedArray);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overlayBusyDates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectedCalendars && set.size === 0 && !initalised) {
|
||||
connectedCalendars.forEach((item) => {
|
||||
item.calendars?.forEach((cal) => {
|
||||
const id = { credentialId: item.credentialId, externalId: cal.externalId };
|
||||
if (cal.primary) {
|
||||
toggleValue(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
setInitalised(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasItem, set, initalised]);
|
||||
|
||||
const handleToggleConnectedCalendar = (externalCalendarId: string, credentialId: number) => {
|
||||
toggleValue({
|
||||
credentialId: credentialId,
|
||||
externalId: externalCalendarId,
|
||||
});
|
||||
setOverlayBusyDates([]);
|
||||
onToggleCalendar();
|
||||
};
|
||||
|
||||
return {
|
||||
isOpenOverlayContinueModal: continueWithProvider,
|
||||
isOpenOverlaySettingsModal: calendarSettingsOverlay,
|
||||
handleCloseContinueModal: (val: boolean) => setContinueWithProvider(val),
|
||||
handleCloseSettingsModal: (val: boolean) => setCalendarSettingsOverlay(val),
|
||||
handleToggleConnectedCalendar,
|
||||
checkIsCalendarToggled: (externalId: string, credentialId: number) => {
|
||||
return hasItem({
|
||||
credentialId: credentialId,
|
||||
externalId: externalId,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import { useSlotReservationId } from "@calcom/features/bookings/Booker/useSlotReservationId";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { MINUTES_TO_BOOK } from "@calcom/lib/constants";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
export type UseSlotsReturnType = ReturnType<typeof useSlots>;
|
||||
|
||||
export const useSlots = (event: { data?: Pick<BookerEvent, "id" | "length"> | null }) => {
|
||||
const selectedDuration = useBookerStore((state) => state.selectedDuration);
|
||||
const [selectedTimeslot, setSelectedTimeslot] = useBookerStore(
|
||||
(state) => [state.selectedTimeslot, state.setSelectedTimeslot],
|
||||
shallow
|
||||
);
|
||||
const [slotReservationId, setSlotReservationId] = useSlotReservationId();
|
||||
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation({
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSlotReservationId(data.uid);
|
||||
},
|
||||
});
|
||||
const removeSelectedSlot = trpc.viewer.public.slots.removeSelectedSlotMark.useMutation({
|
||||
trpc: { context: { skipBatch: true } },
|
||||
});
|
||||
|
||||
const handleRemoveSlot = () => {
|
||||
if (event?.data) {
|
||||
removeSelectedSlot.mutate({ uid: slotReservationId });
|
||||
}
|
||||
};
|
||||
const handleReserveSlot = () => {
|
||||
if (event?.data?.id && selectedTimeslot && (selectedDuration || event?.data?.length)) {
|
||||
reserveSlotMutation.mutate({
|
||||
slotUtcStartDate: dayjs(selectedTimeslot).utc().format(),
|
||||
eventTypeId: event.data.id,
|
||||
slotUtcEndDate: dayjs(selectedTimeslot)
|
||||
.utc()
|
||||
.add(selectedDuration || event.data.length, "minutes")
|
||||
.format(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const timeslot = useBookerStore((state) => state.selectedTimeslot);
|
||||
|
||||
useEffect(() => {
|
||||
handleReserveSlot();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
handleReserveSlot();
|
||||
}, parseInt(MINUTES_TO_BOOK) * 60 * 1000 - 2000);
|
||||
|
||||
return () => {
|
||||
handleRemoveSlot();
|
||||
clearInterval(interval);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [event?.data?.id, timeslot]);
|
||||
|
||||
return {
|
||||
selectedTimeslot,
|
||||
setSelectedTimeslot,
|
||||
setSlotReservationId,
|
||||
slotReservationId,
|
||||
handleReserveSlot,
|
||||
handleRemoveSlot,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
export type UseVerifyCodeReturnType = ReturnType<typeof useVerifyCode>;
|
||||
|
||||
type UseVerifyCodeProps = {
|
||||
onSuccess: (isVerified: boolean) => void;
|
||||
};
|
||||
|
||||
export const useVerifyCode = ({ onSuccess }: UseVerifyCodeProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [value, setValue] = useState("");
|
||||
const [hasVerified, setHasVerified] = useState(false);
|
||||
|
||||
const verifyCodeMutationUserSessionRequired = trpc.viewer.organizations.verifyCode.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setIsPending(false);
|
||||
onSuccess(data);
|
||||
},
|
||||
onError: (err) => {
|
||||
setIsPending(false);
|
||||
setHasVerified(false);
|
||||
if (err.message === "invalid_code") {
|
||||
setError(t("code_provided_invalid"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const verifyCodeMutationUserSessionNotRequired = trpc.viewer.auth.verifyCodeUnAuthenticated.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setIsPending(false);
|
||||
onSuccess(data);
|
||||
},
|
||||
onError: (err) => {
|
||||
setIsPending(false);
|
||||
setHasVerified(false);
|
||||
if (err.message === "invalid_code") {
|
||||
setError(t("code_provided_invalid"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const verifyCodeWithSessionRequired = (code: string, email: string) => {
|
||||
verifyCodeMutationUserSessionRequired.mutate({
|
||||
code,
|
||||
email,
|
||||
});
|
||||
};
|
||||
|
||||
const verifyCodeWithSessionNotRequired = (code: string, email: string) => {
|
||||
verifyCodeMutationUserSessionNotRequired.mutate({
|
||||
code,
|
||||
email,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
verifyCodeWithSessionRequired,
|
||||
verifyCodeWithSessionNotRequired,
|
||||
isPending,
|
||||
setIsPending,
|
||||
error,
|
||||
value,
|
||||
hasVerified,
|
||||
setValue,
|
||||
setHasVerified,
|
||||
resetErrors: () => setError(""),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
export interface IUseVerifyEmailProps {
|
||||
email: string;
|
||||
onVerifyEmail?: () => void;
|
||||
name?: string | { firstName: string; lastname?: string };
|
||||
requiresBookerEmailVerification?: boolean;
|
||||
}
|
||||
export type UseVerifyEmailReturnType = ReturnType<typeof useVerifyEmail>;
|
||||
export const useVerifyEmail = ({
|
||||
email,
|
||||
name,
|
||||
requiresBookerEmailVerification,
|
||||
onVerifyEmail,
|
||||
}: IUseVerifyEmailProps) => {
|
||||
const [isEmailVerificationModalVisible, setEmailVerificationModalVisible] = useState(false);
|
||||
const verifiedEmail = useBookerStore((state) => state.verifiedEmail);
|
||||
const setVerifiedEmail = useBookerStore((state) => state.setVerifiedEmail);
|
||||
const debouncedEmail = useDebounce(email, 600);
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { t } = useLocale();
|
||||
const sendEmailVerificationByCodeMutation = trpc.viewer.auth.sendVerifyEmailCode.useMutation({
|
||||
onSuccess: () => {
|
||||
setEmailVerificationModalVisible(true);
|
||||
showToast(t("email_sent"), "success");
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("email_not_sent"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const { data: isEmailVerificationRequired } =
|
||||
trpc.viewer.public.checkIfUserEmailVerificationRequired.useQuery(
|
||||
{
|
||||
userSessionEmail: session?.user.email || "",
|
||||
email: debouncedEmail,
|
||||
},
|
||||
{
|
||||
enabled: !!debouncedEmail,
|
||||
}
|
||||
);
|
||||
|
||||
const handleVerifyEmail = () => {
|
||||
onVerifyEmail?.();
|
||||
|
||||
sendEmailVerificationByCodeMutation.mutate({
|
||||
email,
|
||||
username: typeof name === "string" ? name : name?.firstName,
|
||||
});
|
||||
};
|
||||
|
||||
const isVerificationCodeSending = sendEmailVerificationByCodeMutation.isPending;
|
||||
|
||||
const renderConfirmNotVerifyEmailButtonCond =
|
||||
(!requiresBookerEmailVerification && !isEmailVerificationRequired) ||
|
||||
(email && verifiedEmail && verifiedEmail === email);
|
||||
|
||||
return {
|
||||
handleVerifyEmail,
|
||||
isEmailVerificationModalVisible,
|
||||
setEmailVerificationModalVisible,
|
||||
setVerifiedEmail,
|
||||
renderConfirmNotVerifyEmailButtonCond: Boolean(renderConfirmNotVerifyEmailButtonCond),
|
||||
isVerificationCodeSending,
|
||||
};
|
||||
};
|
||||
241
calcom/packages/features/bookings/Booker/config.ts
Normal file
241
calcom/packages/features/bookings/Booker/config.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { cubicBezier, useAnimate } from "framer-motion";
|
||||
import { useReducedMotion } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import type { BookerLayout, BookerState } from "./types";
|
||||
|
||||
// Framer motion fade in animation configs.
|
||||
export const fadeInLeft = {
|
||||
variants: {
|
||||
visible: { opacity: 1, x: 0 },
|
||||
hidden: { opacity: 0, x: 20 },
|
||||
},
|
||||
initial: "hidden",
|
||||
exit: "hidden",
|
||||
animate: "visible",
|
||||
transition: { ease: "easeInOut", delay: 0.1 },
|
||||
};
|
||||
export const fadeInUp = {
|
||||
variants: {
|
||||
visible: { opacity: 1, y: 0 },
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
},
|
||||
initial: "hidden",
|
||||
exit: "hidden",
|
||||
animate: "visible",
|
||||
transition: { ease: "easeInOut", delay: 0.1 },
|
||||
};
|
||||
|
||||
export const fadeInRight = {
|
||||
variants: {
|
||||
visible: { opacity: 1, x: 0 },
|
||||
hidden: { opacity: 0, x: -20 },
|
||||
},
|
||||
initial: "hidden",
|
||||
exit: "hidden",
|
||||
animate: "visible",
|
||||
transition: { ease: "easeInOut", delay: 0.1 },
|
||||
};
|
||||
|
||||
type ResizeAnimationConfig = {
|
||||
[key in BookerLayout]: {
|
||||
[key in BookerState | "default"]?: React.CSSProperties;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* This configuration is used to animate the grid container for the booker.
|
||||
* The object is structured as following:
|
||||
*
|
||||
* The root property of the object: is the name of the layout
|
||||
* (mobile, month_view, week_view, column_view)
|
||||
*
|
||||
* The values of these properties are objects that define the animation for each state of the booker.
|
||||
* The animation have the same properties as you could pass to the animate prop of framer-motion:
|
||||
* @see: https://www.framer.com/motion/animation/
|
||||
*/
|
||||
export const resizeAnimationConfig: ResizeAnimationConfig = {
|
||||
mobile: {
|
||||
default: {
|
||||
width: "100%",
|
||||
minHeight: "0px",
|
||||
gridTemplateAreas: `
|
||||
"meta"
|
||||
"header"
|
||||
"main"
|
||||
"timeslots"
|
||||
`,
|
||||
gridTemplateColumns: "100%",
|
||||
gridTemplateRows: "minmax(min-content,max-content) 1fr",
|
||||
},
|
||||
},
|
||||
month_view: {
|
||||
default: {
|
||||
width: "calc(var(--booker-meta-width) + var(--booker-main-width))",
|
||||
minHeight: "450px",
|
||||
height: "auto",
|
||||
gridTemplateAreas: `
|
||||
"meta main main"
|
||||
"meta main main"
|
||||
`,
|
||||
gridTemplateColumns: "var(--booker-meta-width) var(--booker-main-width)",
|
||||
gridTemplateRows: "1fr 0fr",
|
||||
},
|
||||
selecting_time: {
|
||||
width: "calc(var(--booker-meta-width) + var(--booker-main-width) + var(--booker-timeslots-width))",
|
||||
minHeight: "450px",
|
||||
height: "auto",
|
||||
gridTemplateAreas: `
|
||||
"meta main timeslots"
|
||||
"meta main timeslots"
|
||||
`,
|
||||
gridTemplateColumns: "var(--booker-meta-width) 1fr var(--booker-timeslots-width)",
|
||||
gridTemplateRows: "1fr 0fr",
|
||||
},
|
||||
},
|
||||
week_view: {
|
||||
default: {
|
||||
width: "100vw",
|
||||
minHeight: "100vh",
|
||||
height: "auto",
|
||||
gridTemplateAreas: `
|
||||
"meta header header"
|
||||
"meta main main"
|
||||
`,
|
||||
gridTemplateColumns: "var(--booker-meta-width) 1fr",
|
||||
gridTemplateRows: "70px auto",
|
||||
},
|
||||
},
|
||||
column_view: {
|
||||
default: {
|
||||
width: "100vw",
|
||||
minHeight: "100vh",
|
||||
height: "auto",
|
||||
gridTemplateAreas: `
|
||||
"meta header header"
|
||||
"meta main main"
|
||||
`,
|
||||
gridTemplateColumns: "var(--booker-meta-width) 1fr",
|
||||
gridTemplateRows: "70px auto",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const getBookerSizeClassNames = (
|
||||
layout: BookerLayout,
|
||||
bookerState: BookerState,
|
||||
hideEventTypeDetails = false
|
||||
) => {
|
||||
const getBookerMetaClass = (className: string) => {
|
||||
if (hideEventTypeDetails) {
|
||||
return "";
|
||||
}
|
||||
return className;
|
||||
};
|
||||
|
||||
return [
|
||||
// Size settings are abstracted on their own lines purely for readability.
|
||||
// General sizes, used always
|
||||
"[--booker-timeslots-width:240px] lg:[--booker-timeslots-width:280px]",
|
||||
// Small calendar defaults
|
||||
layout === BookerLayouts.MONTH_VIEW && getBookerMetaClass("[--booker-meta-width:240px]"),
|
||||
// Meta column get's wider in booking view to fit the full date on a single row in case
|
||||
// of a multi occurance event. Also makes form less wide, which also looks better.
|
||||
layout === BookerLayouts.MONTH_VIEW &&
|
||||
bookerState === "booking" &&
|
||||
`[--booker-main-width:420px] ${getBookerMetaClass("lg:[--booker-meta-width:340px]")}`,
|
||||
// Smaller meta when not in booking view.
|
||||
layout === BookerLayouts.MONTH_VIEW &&
|
||||
bookerState !== "booking" &&
|
||||
`[--booker-main-width:480px] ${getBookerMetaClass("lg:[--booker-meta-width:280px]")}`,
|
||||
// Fullscreen view settings.
|
||||
layout !== BookerLayouts.MONTH_VIEW &&
|
||||
`[--booker-main-width:480px] [--booker-meta-width:340px] ${getBookerMetaClass(
|
||||
"lg:[--booker-meta-width:424px]"
|
||||
)}`,
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook returns a ref that should be set on the booker element.
|
||||
* Based on that ref this hook animates the size of the booker element with framer motion.
|
||||
* It also takes into account the prefers-reduced-motion setting, to not animate when that's set.
|
||||
*/
|
||||
export const useBookerResizeAnimation = (layout: BookerLayout, state: BookerState) => {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const [animationScope, animate] = useAnimate();
|
||||
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
|
||||
``;
|
||||
useEffect(() => {
|
||||
const animationConfig = resizeAnimationConfig[layout][state] || resizeAnimationConfig[layout].default;
|
||||
|
||||
if (!animationScope.current) return;
|
||||
|
||||
const animatedProperties = {
|
||||
height: animationConfig?.height || "auto",
|
||||
};
|
||||
|
||||
const nonAnimatedProperties = {
|
||||
// Width is animated by the css class instead of via framer motion,
|
||||
// because css is better at animating the calcs, framer motion might
|
||||
// make some mistakes in that.
|
||||
gridTemplateAreas: animationConfig?.gridTemplateAreas,
|
||||
width: animationConfig?.width || "auto",
|
||||
gridTemplateColumns: animationConfig?.gridTemplateColumns,
|
||||
gridTemplateRows: animationConfig?.gridTemplateRows,
|
||||
minHeight: animationConfig?.minHeight,
|
||||
};
|
||||
|
||||
// In this cases we don't animate the booker at all.
|
||||
if (prefersReducedMotion || layout === "mobile" || isEmbed) {
|
||||
const styles = { ...nonAnimatedProperties, ...animatedProperties };
|
||||
Object.keys(styles).forEach((property) => {
|
||||
if (property === "height") {
|
||||
// Change 100vh to 100% in embed, since 100vh in iframe will behave weird, because
|
||||
// the iframe will constantly grow. 100% will simply make sure it grows with the iframe.
|
||||
animationScope.current.style.height =
|
||||
animatedProperties.height === "100vh" && isEmbed ? "100%" : animatedProperties.height;
|
||||
} else {
|
||||
animationScope.current.style[property] = styles[property as keyof typeof styles];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Object.keys(nonAnimatedProperties).forEach((property) => {
|
||||
animationScope.current.style[property] =
|
||||
nonAnimatedProperties[property as keyof typeof nonAnimatedProperties];
|
||||
});
|
||||
animate(animationScope.current, animatedProperties, {
|
||||
duration: 0.5,
|
||||
ease: cubicBezier(0.4, 0, 0.2, 1),
|
||||
});
|
||||
}
|
||||
}, [animate, isEmbed, animationScope, layout, prefersReducedMotion, state]);
|
||||
|
||||
return animationScope;
|
||||
};
|
||||
|
||||
/**
|
||||
* These configures the amount of days that are shown on top of the selected date.
|
||||
*/
|
||||
export const extraDaysConfig = {
|
||||
mobile: {
|
||||
// Desktop tablet feels weird on mobile layout,
|
||||
// but this is simply here to make the types a lot easier..
|
||||
desktop: 0,
|
||||
tablet: 0,
|
||||
},
|
||||
[BookerLayouts.MONTH_VIEW]: {
|
||||
desktop: 0,
|
||||
tablet: 0,
|
||||
},
|
||||
[BookerLayouts.WEEK_VIEW]: {
|
||||
desktop: 7,
|
||||
tablet: 4,
|
||||
},
|
||||
[BookerLayouts.COLUMN_VIEW]: {
|
||||
desktop: 6,
|
||||
tablet: 2,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { domAnimation } from "framer-motion";
|
||||
|
||||
export default domAnimation;
|
||||
2
calcom/packages/features/bookings/Booker/index.ts
Normal file
2
calcom/packages/features/bookings/Booker/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Booker } from "./Booker";
|
||||
export type { BookerProps } from "./types";
|
||||
398
calcom/packages/features/bookings/Booker/store.ts
Normal file
398
calcom/packages/features/bookings/Booker/store.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { BOOKER_NUMBER_OF_DAYS_TO_LOAD } from "@calcom/lib/constants";
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import type { GetBookingType } from "../lib/get-booking";
|
||||
import type { BookerState, BookerLayout } from "./types";
|
||||
import { updateQueryParam, getQueryParam, removeQueryParam } from "./utils/query-param";
|
||||
|
||||
/**
|
||||
* Arguments passed into store initializer, containing
|
||||
* the event data.
|
||||
*/
|
||||
type StoreInitializeType = {
|
||||
username: string;
|
||||
eventSlug: string;
|
||||
// Month can be undefined if it's not passed in as a prop.
|
||||
eventId: number | undefined;
|
||||
layout: BookerLayout;
|
||||
month?: string;
|
||||
bookingUid?: string | null;
|
||||
isTeamEvent?: boolean;
|
||||
bookingData?: GetBookingType | null | undefined;
|
||||
verifiedEmail?: string | null;
|
||||
rescheduleUid?: string | null;
|
||||
seatReferenceUid?: string;
|
||||
durationConfig?: number[] | null;
|
||||
org?: string | null;
|
||||
isInstantMeeting?: boolean;
|
||||
};
|
||||
|
||||
type SeatedEventData = {
|
||||
seatsPerTimeSlot?: number | null;
|
||||
attendees?: number;
|
||||
bookingUid?: string;
|
||||
showAvailableSeatsCount?: boolean | null;
|
||||
};
|
||||
|
||||
export type BookerStore = {
|
||||
/**
|
||||
* Event details. These are stored in store for easier
|
||||
* access in child components.
|
||||
*/
|
||||
username: string | null;
|
||||
eventSlug: string | null;
|
||||
eventId: number | null;
|
||||
/**
|
||||
* Verified booker email.
|
||||
* Needed in case user turns on Requires Booker Email Verification for an event
|
||||
*/
|
||||
verifiedEmail: string | null;
|
||||
setVerifiedEmail: (email: string | null) => void;
|
||||
/**
|
||||
* Current month being viewed. Format is YYYY-MM.
|
||||
*/
|
||||
month: string | null;
|
||||
setMonth: (month: string | null) => void;
|
||||
/**
|
||||
* Current state of the booking process
|
||||
* the user is currently in. See enum for possible values.
|
||||
*/
|
||||
state: BookerState;
|
||||
setState: (state: BookerState) => void;
|
||||
/**
|
||||
* The booker component supports different layouts,
|
||||
* this value tracks the current layout.
|
||||
*/
|
||||
layout: BookerLayout;
|
||||
setLayout: (layout: BookerLayout) => void;
|
||||
/**
|
||||
* Date selected by user (exact day). Format is YYYY-MM-DD.
|
||||
*/
|
||||
selectedDate: string | null;
|
||||
setSelectedDate: (date: string | null) => void;
|
||||
addToSelectedDate: (days: number) => void;
|
||||
/**
|
||||
* Multiple Selected Dates and Times
|
||||
*/
|
||||
selectedDatesAndTimes: { [key: string]: { [key: string]: string[] } } | null;
|
||||
setSelectedDatesAndTimes: (selectedDatesAndTimes: { [key: string]: { [key: string]: string[] } }) => void;
|
||||
/**
|
||||
* Multiple duration configuration
|
||||
*/
|
||||
durationConfig: number[] | null;
|
||||
/**
|
||||
* Selected event duration in minutes.
|
||||
*/
|
||||
selectedDuration: number | null;
|
||||
setSelectedDuration: (duration: number | null) => void;
|
||||
/**
|
||||
* Selected timeslot user has chosen. This is a date string
|
||||
* containing both the date + time.
|
||||
*/
|
||||
selectedTimeslot: string | null;
|
||||
setSelectedTimeslot: (timeslot: string | null) => void;
|
||||
/**
|
||||
* Number of recurring events to create.
|
||||
*/
|
||||
recurringEventCount: number | null;
|
||||
setRecurringEventCount(count: number | null): void;
|
||||
/**
|
||||
* Input occurrence count.
|
||||
*/
|
||||
occurenceCount: number | null;
|
||||
setOccurenceCount(count: number | null): void;
|
||||
/**
|
||||
* The number of days worth of schedules to load.
|
||||
*/
|
||||
dayCount: number | null;
|
||||
setDayCount: (dayCount: number | null) => void;
|
||||
/**
|
||||
* If booking is being rescheduled or it has seats, it receives a rescheduleUid or bookingUid
|
||||
* the current booking details are passed in. The `bookingData`
|
||||
* object is something that's fetched server side.
|
||||
*/
|
||||
rescheduleUid: string | null;
|
||||
bookingUid: string | null;
|
||||
bookingData: GetBookingType | null;
|
||||
setBookingData: (bookingData: GetBookingType | null | undefined) => void;
|
||||
|
||||
/**
|
||||
* Method called by booker component to set initial data.
|
||||
*/
|
||||
initialize: (data: StoreInitializeType) => void;
|
||||
/**
|
||||
* Stored form state, used when user navigates back and
|
||||
* forth between timeslots and form. Get's cleared on submit
|
||||
* to prevent sticky data.
|
||||
*/
|
||||
formValues: Record<string, any>;
|
||||
setFormValues: (values: Record<string, any>) => void;
|
||||
/**
|
||||
* Force event being a team event, so we only query for team events instead
|
||||
* of also include 'user' events and return the first event that matches with
|
||||
* both the slug and the event slug.
|
||||
*/
|
||||
isTeamEvent: boolean;
|
||||
seatedEventData: SeatedEventData;
|
||||
setSeatedEventData: (seatedEventData: SeatedEventData) => void;
|
||||
|
||||
isInstantMeeting?: boolean;
|
||||
|
||||
org?: string | null;
|
||||
setOrg: (org: string | null | undefined) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The booker store contains the data of the component's
|
||||
* current state. This data can be reused within child components
|
||||
* by importing this hook.
|
||||
*
|
||||
* See comments in interface above for more information on it's specific values.
|
||||
*/
|
||||
export const useBookerStore = create<BookerStore>((set, get) => ({
|
||||
state: "loading",
|
||||
setState: (state: BookerState) => set({ state }),
|
||||
layout: BookerLayouts.MONTH_VIEW,
|
||||
setLayout: (layout: BookerLayout) => {
|
||||
// If we switch to a large layout and don't have a date selected yet,
|
||||
// we selected it here, so week title is rendered properly.
|
||||
if (["week_view", "column_view"].includes(layout) && !get().selectedDate) {
|
||||
set({ selectedDate: dayjs().format("YYYY-MM-DD") });
|
||||
}
|
||||
updateQueryParam("layout", layout);
|
||||
return set({ layout });
|
||||
},
|
||||
selectedDate: getQueryParam("date") || null,
|
||||
setSelectedDate: (selectedDate: string | null) => {
|
||||
// unset selected date
|
||||
if (!selectedDate) {
|
||||
removeQueryParam("date");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSelection = dayjs(get().selectedDate);
|
||||
const newSelection = dayjs(selectedDate);
|
||||
set({ selectedDate });
|
||||
updateQueryParam("date", selectedDate ?? "");
|
||||
|
||||
// Setting month make sure small calendar in fullscreen layouts also updates.
|
||||
if (newSelection.month() !== currentSelection.month()) {
|
||||
set({ month: newSelection.format("YYYY-MM") });
|
||||
updateQueryParam("month", newSelection.format("YYYY-MM"));
|
||||
}
|
||||
},
|
||||
selectedDatesAndTimes: null,
|
||||
setSelectedDatesAndTimes: (selectedDatesAndTimes) => {
|
||||
set({ selectedDatesAndTimes });
|
||||
},
|
||||
addToSelectedDate: (days: number) => {
|
||||
const currentSelection = dayjs(get().selectedDate);
|
||||
const newSelection = currentSelection.add(days, "day");
|
||||
const newSelectionFormatted = newSelection.format("YYYY-MM-DD");
|
||||
|
||||
if (newSelection.month() !== currentSelection.month()) {
|
||||
set({ month: newSelection.format("YYYY-MM") });
|
||||
updateQueryParam("month", newSelection.format("YYYY-MM"));
|
||||
}
|
||||
|
||||
set({ selectedDate: newSelectionFormatted });
|
||||
updateQueryParam("date", newSelectionFormatted);
|
||||
},
|
||||
username: null,
|
||||
eventSlug: null,
|
||||
eventId: null,
|
||||
verifiedEmail: null,
|
||||
setVerifiedEmail: (email: string | null) => {
|
||||
set({ verifiedEmail: email });
|
||||
},
|
||||
month: getQueryParam("month") || getQueryParam("date") || dayjs().format("YYYY-MM"),
|
||||
setMonth: (month: string | null) => {
|
||||
if (!month) {
|
||||
removeQueryParam("month");
|
||||
return;
|
||||
}
|
||||
set({ month, selectedTimeslot: null });
|
||||
updateQueryParam("month", month ?? "");
|
||||
get().setSelectedDate(null);
|
||||
},
|
||||
dayCount: BOOKER_NUMBER_OF_DAYS_TO_LOAD > 0 ? BOOKER_NUMBER_OF_DAYS_TO_LOAD : null,
|
||||
setDayCount: (dayCount: number | null) => {
|
||||
set({ dayCount });
|
||||
},
|
||||
isTeamEvent: false,
|
||||
seatedEventData: {
|
||||
seatsPerTimeSlot: undefined,
|
||||
attendees: undefined,
|
||||
bookingUid: undefined,
|
||||
showAvailableSeatsCount: true,
|
||||
},
|
||||
setSeatedEventData: (seatedEventData: SeatedEventData) => {
|
||||
set({ seatedEventData });
|
||||
updateQueryParam("bookingUid", seatedEventData.bookingUid ?? "null");
|
||||
},
|
||||
initialize: ({
|
||||
username,
|
||||
eventSlug,
|
||||
month,
|
||||
eventId,
|
||||
rescheduleUid = null,
|
||||
bookingUid = null,
|
||||
bookingData = null,
|
||||
layout,
|
||||
isTeamEvent,
|
||||
durationConfig,
|
||||
org,
|
||||
isInstantMeeting,
|
||||
}: StoreInitializeType) => {
|
||||
const selectedDateInStore = get().selectedDate;
|
||||
|
||||
if (
|
||||
get().username === username &&
|
||||
get().eventSlug === eventSlug &&
|
||||
get().month === month &&
|
||||
get().eventId === eventId &&
|
||||
get().rescheduleUid === rescheduleUid &&
|
||||
get().bookingUid === bookingUid &&
|
||||
get().bookingData?.responses.email === bookingData?.responses.email &&
|
||||
get().layout === layout
|
||||
)
|
||||
return;
|
||||
set({
|
||||
username,
|
||||
eventSlug,
|
||||
eventId,
|
||||
org,
|
||||
rescheduleUid,
|
||||
bookingUid,
|
||||
bookingData,
|
||||
layout: layout || BookerLayouts.MONTH_VIEW,
|
||||
isTeamEvent: isTeamEvent || false,
|
||||
durationConfig,
|
||||
// Preselect today's date in week / column view, since they use this to show the week title.
|
||||
selectedDate:
|
||||
selectedDateInStore ||
|
||||
(["week_view", "column_view"].includes(layout) ? dayjs().format("YYYY-MM-DD") : null),
|
||||
});
|
||||
|
||||
if (durationConfig?.includes(Number(getQueryParam("duration")))) {
|
||||
set({
|
||||
selectedDuration: Number(getQueryParam("duration")),
|
||||
});
|
||||
} else {
|
||||
removeQueryParam("duration");
|
||||
}
|
||||
|
||||
// Unset selected timeslot if user is rescheduling. This could happen
|
||||
// if the user reschedules a booking right after the confirmation page.
|
||||
// In that case the time would still be store in the store, this way we
|
||||
// force clear this.
|
||||
// Also, fetch the original booking duration if user is rescheduling and
|
||||
// update the selectedDuration
|
||||
if (rescheduleUid && bookingData) {
|
||||
set({ selectedTimeslot: null });
|
||||
const originalBookingLength = dayjs(bookingData?.endTime).diff(
|
||||
dayjs(bookingData?.startTime),
|
||||
"minutes"
|
||||
);
|
||||
set({ selectedDuration: originalBookingLength });
|
||||
updateQueryParam("duration", originalBookingLength ?? "");
|
||||
}
|
||||
if (month) set({ month });
|
||||
|
||||
if (isInstantMeeting) {
|
||||
const month = dayjs().format("YYYY-MM");
|
||||
const selectedDate = dayjs().format("YYYY-MM-DD");
|
||||
const selectedTimeslot = new Date().toISOString();
|
||||
set({
|
||||
month,
|
||||
selectedDate,
|
||||
selectedTimeslot,
|
||||
isInstantMeeting,
|
||||
});
|
||||
updateQueryParam("month", month);
|
||||
updateQueryParam("date", selectedDate ?? "");
|
||||
updateQueryParam("slot", selectedTimeslot ?? "", false);
|
||||
}
|
||||
//removeQueryParam("layout");
|
||||
},
|
||||
durationConfig: null,
|
||||
selectedDuration: null,
|
||||
setSelectedDuration: (selectedDuration: number | null) => {
|
||||
set({ selectedDuration });
|
||||
updateQueryParam("duration", selectedDuration ?? "");
|
||||
},
|
||||
setBookingData: (bookingData: GetBookingType | null | undefined) => {
|
||||
set({ bookingData: bookingData ?? null });
|
||||
},
|
||||
recurringEventCount: null,
|
||||
setRecurringEventCount: (recurringEventCount: number | null) => set({ recurringEventCount }),
|
||||
occurenceCount: null,
|
||||
setOccurenceCount: (occurenceCount: number | null) => set({ occurenceCount }),
|
||||
rescheduleUid: null,
|
||||
bookingData: null,
|
||||
bookingUid: null,
|
||||
selectedTimeslot: getQueryParam("slot") || null,
|
||||
setSelectedTimeslot: (selectedTimeslot: string | null) => {
|
||||
set({ selectedTimeslot });
|
||||
updateQueryParam("slot", selectedTimeslot ?? "", false);
|
||||
},
|
||||
formValues: {},
|
||||
setFormValues: (formValues: Record<string, any>) => {
|
||||
set({ formValues });
|
||||
},
|
||||
org: null,
|
||||
setOrg: (org: string | null | undefined) => {
|
||||
set({ org });
|
||||
},
|
||||
}));
|
||||
|
||||
export const useInitializeBookerStore = ({
|
||||
username,
|
||||
eventSlug,
|
||||
month,
|
||||
eventId,
|
||||
rescheduleUid = null,
|
||||
bookingData = null,
|
||||
verifiedEmail = null,
|
||||
layout,
|
||||
isTeamEvent,
|
||||
durationConfig,
|
||||
org,
|
||||
isInstantMeeting,
|
||||
}: StoreInitializeType) => {
|
||||
const initializeStore = useBookerStore((state) => state.initialize);
|
||||
useEffect(() => {
|
||||
initializeStore({
|
||||
username,
|
||||
eventSlug,
|
||||
month,
|
||||
eventId,
|
||||
rescheduleUid,
|
||||
bookingData,
|
||||
layout,
|
||||
isTeamEvent,
|
||||
org,
|
||||
verifiedEmail,
|
||||
durationConfig,
|
||||
isInstantMeeting,
|
||||
});
|
||||
}, [
|
||||
initializeStore,
|
||||
org,
|
||||
username,
|
||||
eventSlug,
|
||||
month,
|
||||
eventId,
|
||||
rescheduleUid,
|
||||
bookingData,
|
||||
layout,
|
||||
isTeamEvent,
|
||||
verifiedEmail,
|
||||
durationConfig,
|
||||
isInstantMeeting,
|
||||
]);
|
||||
};
|
||||
152
calcom/packages/features/bookings/Booker/types.ts
Normal file
152
calcom/packages/features/bookings/Booker/types.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { UseBookerLayoutType } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
|
||||
import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
|
||||
import type { UseBookingsReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookings";
|
||||
import type { UseCalendarsReturnType } from "@calcom/features/bookings/Booker/components/hooks/useCalendars";
|
||||
import type { UseSlotsReturnType } from "@calcom/features/bookings/Booker/components/hooks/useSlots";
|
||||
import type { UseVerifyCodeReturnType } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode";
|
||||
import type { UseVerifyEmailReturnType } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail";
|
||||
import type { useScheduleForEventReturnType } from "@calcom/features/bookings/Booker/utils/event";
|
||||
import type { BookerEventQuery } from "@calcom/features/bookings/types";
|
||||
import type { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import type { GetBookingType } from "../lib/get-booking";
|
||||
|
||||
export interface BookerProps {
|
||||
eventSlug: string;
|
||||
username: string;
|
||||
orgBannerUrl?: string | null;
|
||||
|
||||
/*
|
||||
all custom classnames related to booker styling go here
|
||||
*/
|
||||
customClassNames?: CustomClassNames;
|
||||
|
||||
/**
|
||||
* Whether is a team or org, we gather basic info from both
|
||||
*/
|
||||
entity: {
|
||||
considerUnpublished: boolean;
|
||||
isUnpublished?: boolean;
|
||||
orgSlug?: string | null;
|
||||
teamSlug?: string | null;
|
||||
name?: string | null;
|
||||
logoUrl?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* If month is NOT set as a prop on the component, we expect a query parameter
|
||||
* called `month` to be present on the url. If that is missing, the component will
|
||||
* default to the current month.
|
||||
* @note In case you're using a client side router, please pass the value in as a prop,
|
||||
* since the component will leverage window.location, which might not have the query param yet.
|
||||
* @format YYYY-MM.
|
||||
* @optional
|
||||
*/
|
||||
month?: string;
|
||||
/**
|
||||
* Default selected date for with the slotpicker will already open.
|
||||
* @optional
|
||||
*/
|
||||
selectedDate?: Date;
|
||||
|
||||
hideBranding?: boolean;
|
||||
/**
|
||||
* If false and the current username indicates a dynamic booking,
|
||||
* the Booker will immediately show an error.
|
||||
* This is NOT revalidated by calling the API.
|
||||
*/
|
||||
allowsDynamicBooking?: boolean;
|
||||
/**
|
||||
* When rescheduling a booking, the current' bookings data is passed in via this prop.
|
||||
* The component itself won't fetch booking data based on the ID, since there is not public
|
||||
* api to fetch this data. Therefore rescheduling a booking currently is not possible
|
||||
* within the atom (i.e. without a server side component).
|
||||
*/
|
||||
bookingData?: GetBookingType;
|
||||
/**
|
||||
* If this boolean is passed, we will only check team events with this slug and event slug.
|
||||
* If it's not passed, we will first query a generic user event, and only if that doesn't exist
|
||||
* fetch the team event. In case there's both a team + user with the same slug AND same event slug,
|
||||
* that will always result in the user event being returned.
|
||||
*/
|
||||
isTeamEvent?: boolean;
|
||||
/**
|
||||
* Refers to a multiple-duration event-type
|
||||
* It will correspond to selected time from duration query param if exists and if it is allowed as an option,
|
||||
* otherwise, the default value is selected
|
||||
*/
|
||||
duration?: number | null;
|
||||
/**
|
||||
* Configures the selectable options for a multiDuration event type.
|
||||
*/
|
||||
durationConfig?: number[];
|
||||
/**
|
||||
* Refers to the private link from event types page.
|
||||
*/
|
||||
hashedLink?: string | null;
|
||||
isInstantMeeting?: boolean;
|
||||
}
|
||||
|
||||
export type WrappedBookerPropsMain = {
|
||||
sessionUsername?: string | null;
|
||||
rescheduleUid: string | null;
|
||||
bookingUid: string | null;
|
||||
isRedirect: boolean;
|
||||
fromUserNameRedirected: string;
|
||||
hasSession: boolean;
|
||||
onGoBackInstantMeeting: () => void;
|
||||
onConnectNowInstantMeeting: () => void;
|
||||
onOverlayClickNoCalendar: () => void;
|
||||
onClickOverlayContinue: () => void;
|
||||
onOverlaySwitchStateChange: (state: boolean) => void;
|
||||
extraOptions: Record<string, string | string[]>;
|
||||
bookings: UseBookingsReturnType;
|
||||
slots: UseSlotsReturnType;
|
||||
calendars: UseCalendarsReturnType;
|
||||
bookerForm: UseBookingFormReturnType;
|
||||
event: BookerEventQuery;
|
||||
schedule: useScheduleForEventReturnType;
|
||||
bookerLayout: UseBookerLayoutType;
|
||||
verifyEmail: UseVerifyEmailReturnType;
|
||||
customClassNames?: CustomClassNames;
|
||||
};
|
||||
|
||||
export type WrappedBookerPropsForPlatform = WrappedBookerPropsMain & {
|
||||
isPlatform: true;
|
||||
verifyCode: undefined;
|
||||
customClassNames?: CustomClassNames;
|
||||
};
|
||||
export type WrappedBookerPropsForWeb = WrappedBookerPropsMain & {
|
||||
isPlatform: false;
|
||||
verifyCode: UseVerifyCodeReturnType;
|
||||
};
|
||||
|
||||
export type WrappedBookerProps = WrappedBookerPropsForPlatform | WrappedBookerPropsForWeb;
|
||||
|
||||
export type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking";
|
||||
export type BookerLayout = BookerLayouts | "mobile";
|
||||
export type BookerAreas = "calendar" | "timeslots" | "main" | "meta" | "header";
|
||||
|
||||
export type CustomClassNames = {
|
||||
bookerContainer?: string;
|
||||
eventMetaCustomClassNames?: {
|
||||
eventMetaContainer?: string;
|
||||
eventMetaTitle?: string;
|
||||
eventMetaTimezoneSelect?: string;
|
||||
};
|
||||
datePickerCustomClassNames?: {
|
||||
datePickerContainer?: string;
|
||||
datePickerTitle?: string;
|
||||
datePickerDays?: string;
|
||||
datePickerDate?: string;
|
||||
datePickerDatesActive?: string;
|
||||
datePickerToggle?: string;
|
||||
};
|
||||
availableTimeSlotsCustomClassNames?: {
|
||||
availableTimeSlotsContainer?: string;
|
||||
availableTimeSlotsHeaderContainer?: string;
|
||||
availableTimeSlotsTitle?: string;
|
||||
availableTimeSlotsTimeFormatToggle?: string;
|
||||
availableTimes?: string;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// TODO: It would be lost on refresh, so we need to persist it.
|
||||
// Though, we are persisting it in a cookie(`uid` cookie is set through reserveSlot call)
|
||||
// but that becomes a third party cookie in context of embed and thus isn't accessible inside embed
|
||||
// So, we need to persist it in top window as first party cookie in that case.
|
||||
let slotReservationId: null | string = null;
|
||||
|
||||
export const useSlotReservationId = () => {
|
||||
function set(uid: string) {
|
||||
slotReservationId = uid;
|
||||
}
|
||||
function get() {
|
||||
return slotReservationId;
|
||||
}
|
||||
return [get(), set] as const;
|
||||
};
|
||||
82
calcom/packages/features/bookings/Booker/utils/dates.tsx
Normal file
82
calcom/packages/features/bookings/Booker/utils/dates.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
|
||||
interface EventFromToTime {
|
||||
date: string;
|
||||
duration: number | null;
|
||||
timeFormat: TimeFormat;
|
||||
timeZone: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
interface EventFromTime {
|
||||
date: string;
|
||||
timeFormat: TimeFormat;
|
||||
timeZone: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export const formatEventFromTime = ({ date, timeFormat, timeZone, language }: EventFromTime) => {
|
||||
const startDate = new Date(date);
|
||||
const formattedDate = new Intl.DateTimeFormat(language, {
|
||||
timeZone,
|
||||
dateStyle: "full",
|
||||
}).format(startDate);
|
||||
|
||||
const formattedTime = new Intl.DateTimeFormat(language, {
|
||||
timeZone,
|
||||
timeStyle: "short",
|
||||
hour12: timeFormat === TimeFormat.TWELVE_HOUR ? true : false,
|
||||
})
|
||||
.format(startDate)
|
||||
.toLowerCase();
|
||||
|
||||
return { date: formattedDate, time: formattedTime };
|
||||
};
|
||||
|
||||
export const formatEventFromToTime = ({
|
||||
date,
|
||||
duration,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
language,
|
||||
}: EventFromToTime) => {
|
||||
const startDate = new Date(date);
|
||||
const endDate = duration
|
||||
? new Date(new Date(date).setMinutes(startDate.getMinutes() + duration))
|
||||
: startDate;
|
||||
|
||||
const formattedDate = new Intl.DateTimeFormat(language, {
|
||||
timeZone,
|
||||
dateStyle: "full",
|
||||
}).formatRange(startDate, endDate);
|
||||
|
||||
const formattedTime = new Intl.DateTimeFormat(language, {
|
||||
timeZone,
|
||||
timeStyle: "short",
|
||||
hour12: timeFormat === TimeFormat.TWELVE_HOUR ? true : false,
|
||||
})
|
||||
.formatRange(startDate, endDate)
|
||||
.toLowerCase();
|
||||
|
||||
return { date: formattedDate, time: formattedTime };
|
||||
};
|
||||
|
||||
export const FromToTime = (props: EventFromToTime) => {
|
||||
const formatted = formatEventFromToTime(props);
|
||||
return (
|
||||
<>
|
||||
{formatted.date}
|
||||
<br />
|
||||
{formatted.time}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const FromTime = (props: EventFromTime) => {
|
||||
const formatted = formatEventFromTime(props);
|
||||
return (
|
||||
<>
|
||||
{formatted.date}, {formatted.time}
|
||||
</>
|
||||
);
|
||||
};
|
||||
120
calcom/packages/features/bookings/Booker/utils/event.ts
Normal file
120
calcom/packages/features/bookings/Booker/utils/event.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import { useSchedule } from "@calcom/features/schedules";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { useTimePreferences } from "../../lib/timePreferences";
|
||||
import { useBookerStore } from "../store";
|
||||
|
||||
export type useEventReturnType = ReturnType<typeof useEvent>;
|
||||
export type useScheduleForEventReturnType = ReturnType<typeof useScheduleForEvent>;
|
||||
|
||||
/**
|
||||
* Wrapper hook around the trpc query that fetches
|
||||
* the event currently viewed in the booker. It will get
|
||||
* the current event slug and username from the booker store.
|
||||
*
|
||||
* Using this hook means you only need to use one hook, instead
|
||||
* of combining multiple conditional hooks.
|
||||
*/
|
||||
export const useEvent = () => {
|
||||
const [username, eventSlug] = useBookerStore((state) => [state.username, state.eventSlug], shallow);
|
||||
const isTeamEvent = useBookerStore((state) => state.isTeamEvent);
|
||||
const org = useBookerStore((state) => state.org);
|
||||
|
||||
const event = trpc.viewer.public.event.useQuery(
|
||||
{
|
||||
username: username ?? "",
|
||||
eventSlug: eventSlug ?? "",
|
||||
isTeamEvent,
|
||||
org: org ?? null,
|
||||
},
|
||||
{ refetchOnWindowFocus: false, enabled: Boolean(username) && Boolean(eventSlug) }
|
||||
);
|
||||
|
||||
return {
|
||||
data: event?.data,
|
||||
isSuccess: event?.isSuccess,
|
||||
isError: event?.isError,
|
||||
isPending: event?.isPending,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets schedule for the current event and current month.
|
||||
* Gets all values right away and not the store because it increases network timing, only for the first render.
|
||||
* We can read from the store if we want to get the latest values.
|
||||
*
|
||||
* Using this hook means you only need to use one hook, instead
|
||||
* of combining multiple conditional hooks.
|
||||
*
|
||||
* The prefetchNextMonth argument can be used to prefetch two months at once,
|
||||
* useful when the user is viewing dates near the end of the month,
|
||||
* this way the multi day view will show data of both months.
|
||||
*/
|
||||
export const useScheduleForEvent = ({
|
||||
prefetchNextMonth,
|
||||
username,
|
||||
eventSlug,
|
||||
eventId,
|
||||
month,
|
||||
duration,
|
||||
monthCount,
|
||||
dayCount,
|
||||
selectedDate,
|
||||
orgSlug,
|
||||
bookerEmail,
|
||||
}: {
|
||||
prefetchNextMonth?: boolean;
|
||||
username?: string | null;
|
||||
eventSlug?: string | null;
|
||||
eventId?: number | null;
|
||||
month?: string | null;
|
||||
duration?: number | null;
|
||||
monthCount?: number;
|
||||
dayCount?: number | null;
|
||||
selectedDate?: string | null;
|
||||
orgSlug?: string;
|
||||
bookerEmail?: string;
|
||||
} = {}) => {
|
||||
const { timezone } = useTimePreferences();
|
||||
const event = useEvent();
|
||||
const [usernameFromStore, eventSlugFromStore, monthFromStore, durationFromStore] = useBookerStore(
|
||||
(state) => [state.username, state.eventSlug, state.month, state.selectedDuration],
|
||||
shallow
|
||||
);
|
||||
|
||||
const searchParams = useCompatSearchParams();
|
||||
const rescheduleUid = searchParams?.get("rescheduleUid");
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
const isTeam = !!event.data?.team?.parentId;
|
||||
|
||||
const schedule = useSchedule({
|
||||
username: usernameFromStore ?? username,
|
||||
eventSlug: eventSlugFromStore ?? eventSlug,
|
||||
eventId: event.data?.id ?? eventId,
|
||||
timezone,
|
||||
selectedDate,
|
||||
prefetchNextMonth,
|
||||
monthCount,
|
||||
dayCount,
|
||||
rescheduleUid,
|
||||
month: monthFromStore ?? month,
|
||||
duration: durationFromStore ?? duration,
|
||||
isTeamEvent: pathname?.indexOf("/team/") !== -1 || isTeam,
|
||||
orgSlug,
|
||||
bookerEmail,
|
||||
});
|
||||
|
||||
return {
|
||||
data: schedule?.data,
|
||||
isPending: schedule?.isPending,
|
||||
isError: schedule?.isError,
|
||||
isSuccess: schedule?.isSuccess,
|
||||
isLoading: schedule?.isLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
export function getBookerWrapperClasses({ isEmbed }: { isEmbed: boolean }) {
|
||||
// We don't want any margins for Embed. Any margin needed should be added by Embed user.
|
||||
return classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[calc(100dvh)]");
|
||||
}
|
||||
5
calcom/packages/features/bookings/Booker/utils/layout.ts
Normal file
5
calcom/packages/features/bookings/Booker/utils/layout.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { bookerLayoutOptions } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const validateLayout = (layout?: "week_view" | "month_view" | "column_view" | null) => {
|
||||
return bookerLayoutOptions.find((validLayout) => validLayout === layout);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
export const updateQueryParam = (param: string, value: string | number, shouldReplace = true) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get(param) === value) return;
|
||||
|
||||
if (value === "" || value === "null") {
|
||||
removeQueryParam(param, shouldReplace);
|
||||
return;
|
||||
} else {
|
||||
url.searchParams.set(param, `${value}`);
|
||||
}
|
||||
if (shouldReplace) {
|
||||
window.history.replaceState({ ...window.history.state, as: url.href }, "", url.href);
|
||||
} else {
|
||||
window.history.pushState({ ...window.history.state, as: url.href }, "", url.href);
|
||||
}
|
||||
};
|
||||
|
||||
export const getQueryParam = (param: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
return new URLSearchParams(window.location.search).get(param);
|
||||
};
|
||||
|
||||
export const removeQueryParam = (param: string, shouldReplace = true) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.searchParams.get(param)) return;
|
||||
|
||||
url.searchParams.delete(param);
|
||||
if (shouldReplace) {
|
||||
window.history.replaceState({ ...window.history.state, as: url.href }, "", url.href);
|
||||
} else {
|
||||
window.history.pushState({ ...window.history.state, as: url.href }, "", url.href);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { useCalcomTheme } from "@calcom/ui";
|
||||
|
||||
export const useBrandColors = ({
|
||||
brandColor,
|
||||
darkBrandColor,
|
||||
theme,
|
||||
}: {
|
||||
brandColor?: string;
|
||||
darkBrandColor?: string;
|
||||
theme?: string | null;
|
||||
}) => {
|
||||
const brandTheme = useGetBrandingColours({
|
||||
lightVal: brandColor,
|
||||
darkVal: darkBrandColor,
|
||||
});
|
||||
|
||||
useCalcomTheme(brandTheme);
|
||||
useTheme(theme);
|
||||
};
|
||||
1
calcom/packages/features/bookings/README.md
Normal file
1
calcom/packages/features/bookings/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# Bookings related code will live here
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Badge } from "@calcom/ui";
|
||||
|
||||
export default function UnconfirmedBookingBadge() {
|
||||
const { t } = useLocale();
|
||||
const { data: unconfirmedBookingCount } = trpc.viewer.bookingUnconfirmedCount.useQuery();
|
||||
if (!unconfirmedBookingCount) return null;
|
||||
return (
|
||||
<Link href="/bookings/unconfirmed">
|
||||
<Badge
|
||||
rounded
|
||||
title={t("unconfirmed_bookings_tooltip")}
|
||||
variant="orange"
|
||||
className="cursor-pointer hover:bg-orange-800 hover:text-orange-100">
|
||||
{unconfirmedBookingCount}
|
||||
</Badge>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
287
calcom/packages/features/bookings/components/AvailableTimes.tsx
Normal file
287
calcom/packages/features/bookings/components/AvailableTimes.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
// We do not need to worry about importing framer-motion here as it is lazy imported in Booker.
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { AnimatePresence, m } from "framer-motion";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useIsPlatform } from "@calcom/atoms/monorepo";
|
||||
import type { IOutOfOfficeData } from "@calcom/core/getUserAvailability";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { OutOfOfficeInSlots } from "@calcom/features/bookings/Booker/components/OutOfOfficeInSlots";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import type { Slots } from "@calcom/features/schedules";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import type { IGetAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
import { Button, Icon, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { useBookerStore } from "../Booker/store";
|
||||
import { getQueryParam } from "../Booker/utils/query-param";
|
||||
import { useTimePreferences } from "../lib";
|
||||
import { useCheckOverlapWithOverlay } from "../lib/useCheckOverlapWithOverlay";
|
||||
import { SeatsAvailabilityText } from "./SeatsAvailabilityText";
|
||||
|
||||
type TOnTimeSelect = (
|
||||
time: string,
|
||||
attendees: number,
|
||||
seatsPerTimeSlot?: number | null,
|
||||
bookingUid?: string
|
||||
) => void;
|
||||
|
||||
type AvailableTimesProps = {
|
||||
slots: IGetAvailableSlots["slots"][string];
|
||||
onTimeSelect: TOnTimeSelect;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
showAvailableSeatsCount?: boolean | null;
|
||||
showTimeFormatToggle?: boolean;
|
||||
className?: string;
|
||||
selectedSlots?: string[];
|
||||
event: {
|
||||
data?: Pick<BookerEvent, "length"> | null;
|
||||
};
|
||||
customClassNames?: string;
|
||||
};
|
||||
|
||||
const SlotItem = ({
|
||||
slot,
|
||||
seatsPerTimeSlot,
|
||||
selectedSlots,
|
||||
onTimeSelect,
|
||||
showAvailableSeatsCount,
|
||||
event,
|
||||
customClassNames,
|
||||
}: {
|
||||
slot: Slots[string][number];
|
||||
seatsPerTimeSlot?: number | null;
|
||||
selectedSlots?: string[];
|
||||
onTimeSelect: TOnTimeSelect;
|
||||
showAvailableSeatsCount?: boolean | null;
|
||||
event: {
|
||||
data?: Pick<BookerEvent, "length"> | null;
|
||||
};
|
||||
customClassNames?: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const overlayCalendarToggled =
|
||||
getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault");
|
||||
const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]);
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
const layout = useBookerStore((state) => state.layout);
|
||||
const { data: eventData } = event;
|
||||
const hasTimeSlots = !!seatsPerTimeSlot;
|
||||
const computedDateWithUsersTimezone = dayjs.utc(slot.time).tz(timezone);
|
||||
|
||||
const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeSlot);
|
||||
const isHalfFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5;
|
||||
const isNearlyFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83;
|
||||
const colorClass = isNearlyFull ? "bg-rose-600" : isHalfFull ? "bg-yellow-500" : "bg-emerald-400";
|
||||
|
||||
const nowDate = dayjs();
|
||||
const usersTimezoneDate = nowDate.tz(timezone);
|
||||
|
||||
const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60;
|
||||
|
||||
const { isOverlapping, overlappingTimeEnd, overlappingTimeStart } = useCheckOverlapWithOverlay({
|
||||
start: computedDateWithUsersTimezone,
|
||||
selectedDuration: eventData?.length ?? 0,
|
||||
offset,
|
||||
});
|
||||
|
||||
const [overlapConfirm, setOverlapConfirm] = useState(false);
|
||||
|
||||
const onButtonClick = useCallback(() => {
|
||||
if (!overlayCalendarToggled) {
|
||||
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid);
|
||||
return;
|
||||
}
|
||||
if (isOverlapping && overlapConfirm) {
|
||||
setOverlapConfirm(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOverlapping && !overlapConfirm) {
|
||||
setOverlapConfirm(true);
|
||||
return;
|
||||
}
|
||||
if (!overlapConfirm) {
|
||||
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid);
|
||||
}
|
||||
}, [
|
||||
overlayCalendarToggled,
|
||||
isOverlapping,
|
||||
overlapConfirm,
|
||||
onTimeSelect,
|
||||
slot.time,
|
||||
slot?.attendees,
|
||||
slot.bookingUid,
|
||||
seatsPerTimeSlot,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
key={slot.time}
|
||||
disabled={bookingFull || !!(slot.bookingUid && slot.bookingUid === bookingData?.uid)}
|
||||
data-testid="time"
|
||||
data-disabled={bookingFull}
|
||||
data-time={slot.time}
|
||||
onClick={onButtonClick}
|
||||
className={classNames(
|
||||
`hover:border-brand-default min-h-9 mb-2 flex h-auto w-full flex-grow flex-col justify-center py-2`,
|
||||
selectedSlots?.includes(slot.time) && "border-brand-default",
|
||||
`${customClassNames}`
|
||||
)}
|
||||
color="secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
{!hasTimeSlots && overlayCalendarToggled && (
|
||||
<span
|
||||
className={classNames(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
isOverlapping ? "bg-rose-600" : "bg-emerald-400"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{computedDateWithUsersTimezone.format(timeFormat)}
|
||||
</div>
|
||||
{bookingFull && <p className="text-sm">{t("booking_full")}</p>}
|
||||
{hasTimeSlots && !bookingFull && (
|
||||
<p className="flex items-center text-sm">
|
||||
<span
|
||||
className={classNames(colorClass, "mr-1 inline-block h-2 w-2 rounded-full")}
|
||||
aria-hidden
|
||||
/>
|
||||
<SeatsAvailabilityText
|
||||
showExact={!!showAvailableSeatsCount}
|
||||
totalSeats={seatsPerTimeSlot}
|
||||
bookedSeats={slot.attendees || 0}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</Button>
|
||||
{overlapConfirm && isOverlapping && (
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger asChild>
|
||||
<m.div initial={{ width: 0 }} animate={{ width: "auto" }} exit={{ width: 0 }}>
|
||||
<Button
|
||||
variant={layout === "column_view" ? "icon" : "button"}
|
||||
StartIcon={layout === "column_view" ? "chevron-right" : undefined}
|
||||
onClick={() =>
|
||||
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)
|
||||
}>
|
||||
{layout !== "column_view" && t("confirm")}
|
||||
</Button>
|
||||
</m.div>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content side="top" align="end" sideOffset={2}>
|
||||
<div className="text-emphasis bg-inverted w-[var(--booker-timeslots-width)] rounded-md p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p>Busy</p>
|
||||
</div>
|
||||
<p className="text-muted">
|
||||
{overlappingTimeStart} - {overlappingTimeEnd}
|
||||
</p>
|
||||
</div>
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
)}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export const AvailableTimes = ({
|
||||
slots,
|
||||
onTimeSelect,
|
||||
seatsPerTimeSlot,
|
||||
showAvailableSeatsCount,
|
||||
showTimeFormatToggle = true,
|
||||
className,
|
||||
selectedSlots,
|
||||
event,
|
||||
customClassNames,
|
||||
}: AvailableTimesProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const oooAllDay = slots.every((slot) => slot.away);
|
||||
if (oooAllDay) {
|
||||
return <OOOSlot {...slots[0]} />;
|
||||
}
|
||||
|
||||
// Display ooo in slots once but after or before slots
|
||||
const oooBeforeSlots = slots[0] && slots[0].away;
|
||||
const oooAfterSlots = slots[slots.length - 1] && slots[slots.length - 1].away;
|
||||
|
||||
return (
|
||||
<div className={classNames("text-default flex flex-col", className)}>
|
||||
<div className="h-full pb-4">
|
||||
{!slots.length && (
|
||||
<div
|
||||
data-testId="no-slots-available"
|
||||
className="bg-subtle border-subtle flex h-full flex-col items-center rounded-md border p-6 dark:bg-transparent">
|
||||
<Icon name="calendar-x-2" className="text-muted mb-2 h-4 w-4" />
|
||||
<p className={classNames("text-muted", showTimeFormatToggle ? "-mt-1 text-lg" : "text-sm")}>
|
||||
{t("all_booked_today")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{oooBeforeSlots && !oooAfterSlots && <OOOSlot {...slots[0]} />}
|
||||
{slots.map((slot) => {
|
||||
if (slot.away) return null;
|
||||
return (
|
||||
<SlotItem
|
||||
customClassNames={customClassNames}
|
||||
key={slot.time}
|
||||
onTimeSelect={onTimeSelect}
|
||||
slot={slot}
|
||||
selectedSlots={selectedSlots}
|
||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||
showAvailableSeatsCount={showAvailableSeatsCount}
|
||||
event={event}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{oooAfterSlots && !oooBeforeSlots && <OOOSlot {...slots[slots.length - 1]} className="pb-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IOOOSlotProps {
|
||||
fromUser?: IOutOfOfficeData["anyDate"]["fromUser"];
|
||||
toUser?: IOutOfOfficeData["anyDate"]["toUser"];
|
||||
reason?: string;
|
||||
emoji?: string;
|
||||
time?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const OOOSlot: React.FC<IOOOSlotProps> = (props) => {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { fromUser, toUser, reason, emoji, time, className = "" } = props;
|
||||
|
||||
if (isPlatform) return <></>;
|
||||
return (
|
||||
<OutOfOfficeInSlots
|
||||
fromUser={fromUser}
|
||||
toUser={toUser}
|
||||
date={dayjs(time).format("YYYY-MM-DD")}
|
||||
reason={reason}
|
||||
emoji={emoji}
|
||||
borderDashed
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AvailableTimesSkeleton = () => (
|
||||
<div className="flex w-[20%] flex-col only:w-full">
|
||||
{/* Random number of elements between 1 and 6. */}
|
||||
{Array.from({ length: Math.floor(Math.random() * 6) + 1 }).map((_, i) => (
|
||||
<SkeletonText className="mb-4 h-6 w-full" key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { useBookerStore } from "../Booker/store";
|
||||
import { TimeFormatToggle } from "./TimeFormatToggle";
|
||||
|
||||
type AvailableTimesHeaderProps = {
|
||||
date: Dayjs;
|
||||
showTimeFormatToggle?: boolean;
|
||||
availableMonth?: string | undefined;
|
||||
customClassNames?: {
|
||||
availableTimeSlotsHeaderContainer?: string;
|
||||
availableTimeSlotsTitle?: string;
|
||||
availableTimeSlotsTimeFormatToggle?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const AvailableTimesHeader = ({
|
||||
date,
|
||||
showTimeFormatToggle = true,
|
||||
availableMonth,
|
||||
customClassNames,
|
||||
}: AvailableTimesHeaderProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const [layout] = useBookerStore((state) => [state.layout], shallow);
|
||||
const isColumnView = layout === BookerLayouts.COLUMN_VIEW;
|
||||
const isMonthView = layout === BookerLayouts.MONTH_VIEW;
|
||||
const isToday = dayjs().isSame(date, "day");
|
||||
|
||||
return (
|
||||
<header
|
||||
className={classNames(
|
||||
`dark:bg-muted dark:before:bg-muted mb-3 flex w-full flex-row items-center font-medium`,
|
||||
"bg-default before:bg-default",
|
||||
customClassNames?.availableTimeSlotsHeaderContainer
|
||||
)}>
|
||||
<span
|
||||
className={classNames(
|
||||
isColumnView && "w-full text-center",
|
||||
isColumnView ? "text-subtle text-xs uppercase" : "text-emphasis font-semibold"
|
||||
)}>
|
||||
<span
|
||||
className={classNames(
|
||||
isToday && !customClassNames?.availableTimeSlotsTitle && "!text-default",
|
||||
customClassNames?.availableTimeSlotsTitle
|
||||
)}>
|
||||
{nameOfDay(i18n.language, Number(date.format("d")), "short")}
|
||||
</span>
|
||||
<span
|
||||
className={classNames(
|
||||
isColumnView && isToday && "bg-brand-default text-brand ml-2",
|
||||
"inline-flex items-center justify-center rounded-3xl px-1 pt-0.5 font-medium",
|
||||
isMonthView
|
||||
? `text-default text-sm ${customClassNames?.availableTimeSlotsTitle}`
|
||||
: `text-xs ${customClassNames?.availableTimeSlotsTitle}`
|
||||
)}>
|
||||
{date.format("DD")}
|
||||
{availableMonth && `, ${availableMonth}`}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{showTimeFormatToggle && (
|
||||
<div className="ml-auto rtl:mr-auto">
|
||||
<TimeFormatToggle customClassName={customClassNames?.availableTimeSlotsTimeFormatToggle} />
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
||||
import { BookerSeo } from "./BookerSeo";
|
||||
|
||||
// Mocking necessary modules and hooks
|
||||
vi.mock("@calcom/trpc/react", () => ({
|
||||
trpc: {
|
||||
viewer: {
|
||||
public: {
|
||||
event: {
|
||||
useQuery: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/hooks/useLocale", () => ({
|
||||
useLocale: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/ee/organizations/lib/orgDomains", () => ({
|
||||
getOrgFullOrigin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/ui", () => ({
|
||||
HeadSeo: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("BookerSeo Component", () => {
|
||||
it("renders HeadSeo with correct props", () => {
|
||||
const mockData = {
|
||||
event: {
|
||||
slug: "event-slug",
|
||||
profile: { name: "John Doe", image: "image-url", username: "john" },
|
||||
title: "30min",
|
||||
hidden: false,
|
||||
users: [{ name: "Jane Doe", username: "jane" }],
|
||||
},
|
||||
entity: { fromRedirectOfNonOrgLink: false, orgSlug: "org1" },
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
trpc.viewer.public.event.useQuery.mockReturnValueOnce({
|
||||
data: mockData.event,
|
||||
});
|
||||
|
||||
vi.mocked(getOrgFullOrigin).mockImplementation((text: string | null) => `${text}.cal.local`);
|
||||
|
||||
render(
|
||||
<BookerSeo
|
||||
username={mockData.event.profile.username}
|
||||
eventSlug={mockData.event.slug}
|
||||
rescheduleUid={undefined}
|
||||
entity={mockData.entity}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(HeadSeo).toHaveBeenCalledWith(
|
||||
{
|
||||
origin: `${mockData.entity.orgSlug}.cal.local`,
|
||||
isBrandingHidden: undefined,
|
||||
// Don't know why we are adding space in the beginning
|
||||
title: ` ${mockData.event.title} | ${mockData.event.profile.name}`,
|
||||
description: ` ${mockData.event.title}`,
|
||||
meeting: {
|
||||
profile: {
|
||||
name: mockData.event.profile.name,
|
||||
image: mockData.event.profile.image,
|
||||
},
|
||||
title: mockData.event.title,
|
||||
users: [
|
||||
{
|
||||
name: mockData.event.users[0].name,
|
||||
username: mockData.event.users[0].username,
|
||||
},
|
||||
],
|
||||
},
|
||||
nextSeoProps: {
|
||||
nofollow: true,
|
||||
noindex: true,
|
||||
},
|
||||
},
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
71
calcom/packages/features/bookings/components/BookerSeo.tsx
Normal file
71
calcom/packages/features/bookings/components/BookerSeo.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
||||
interface BookerSeoProps {
|
||||
username: string;
|
||||
eventSlug: string;
|
||||
rescheduleUid: string | undefined;
|
||||
hideBranding?: boolean;
|
||||
isSEOIndexable?: boolean;
|
||||
isTeamEvent?: boolean;
|
||||
entity: {
|
||||
fromRedirectOfNonOrgLink: boolean;
|
||||
orgSlug?: string | null;
|
||||
teamSlug?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
bookingData?: GetBookingType | null;
|
||||
}
|
||||
|
||||
export const BookerSeo = (props: BookerSeoProps) => {
|
||||
const {
|
||||
eventSlug,
|
||||
username,
|
||||
rescheduleUid,
|
||||
hideBranding,
|
||||
isTeamEvent,
|
||||
entity,
|
||||
isSEOIndexable,
|
||||
bookingData,
|
||||
} = props;
|
||||
const { t } = useLocale();
|
||||
const { data: event } = trpc.viewer.public.event.useQuery(
|
||||
{
|
||||
username,
|
||||
eventSlug,
|
||||
isTeamEvent,
|
||||
org: entity.orgSlug ?? null,
|
||||
fromRedirectOfNonOrgLink: entity.fromRedirectOfNonOrgLink,
|
||||
},
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const profileName = event?.profile.name ?? "";
|
||||
const profileImage = event?.profile.image;
|
||||
const title = event?.title ?? "";
|
||||
return (
|
||||
<HeadSeo
|
||||
origin={getOrgFullOrigin(entity.orgSlug ?? null)}
|
||||
title={`${rescheduleUid && !!bookingData ? t("reschedule") : ""} ${title} | ${profileName}`}
|
||||
description={`${rescheduleUid ? t("reschedule") : ""} ${title}`}
|
||||
meeting={{
|
||||
title: title,
|
||||
profile: { name: profileName, image: profileImage },
|
||||
users: [
|
||||
...(event?.users || []).map((user) => ({
|
||||
name: `${user.name}`,
|
||||
username: `${user.username}`,
|
||||
})),
|
||||
],
|
||||
}}
|
||||
nextSeoProps={{
|
||||
nofollow: event?.hidden || !isSEOIndexable,
|
||||
noindex: event?.hidden || !isSEOIndexable,
|
||||
}}
|
||||
isBrandingHidden={hideBranding}
|
||||
/>
|
||||
);
|
||||
};
|
||||
107
calcom/packages/features/bookings/components/EventTypeFilter.tsx
Normal file
107
calcom/packages/features/bookings/components/EventTypeFilter.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Fragment, useMemo } from "react";
|
||||
|
||||
import {
|
||||
FilterCheckboxField,
|
||||
FilterCheckboxFieldsContainer,
|
||||
} from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { AnimatedPopover, Divider, Icon } from "@calcom/ui";
|
||||
|
||||
import { groupBy } from "../groupBy";
|
||||
import { useFilterQuery } from "../lib/useFilterQuery";
|
||||
|
||||
export type IEventTypesFilters = RouterOutputs["viewer"]["eventTypes"]["listWithTeam"];
|
||||
export type IEventTypeFilter = IEventTypesFilters[0];
|
||||
|
||||
type GroupedEventTypeState = Record<
|
||||
string,
|
||||
{
|
||||
team: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
}[]
|
||||
>;
|
||||
|
||||
export const EventTypeFilter = () => {
|
||||
const { t } = useLocale();
|
||||
const { data: user } = useSession();
|
||||
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery();
|
||||
|
||||
const eventTypes = trpc.viewer.eventTypes.listWithTeam.useQuery(undefined, {
|
||||
enabled: !!user,
|
||||
});
|
||||
const groupedEventTypes: GroupedEventTypeState | null = useMemo(() => {
|
||||
const data = eventTypes.data;
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
// Will be handled up the tree to redirect
|
||||
// Group event types by team
|
||||
const grouped = groupBy<IEventTypeFilter>(
|
||||
data.filter((el) => el.team),
|
||||
(item) => item?.team?.name || ""
|
||||
); // Add the team name
|
||||
const individualEvents = data.filter((el) => !el.team);
|
||||
// push indivdual events to the start of grouped array
|
||||
return individualEvents.length > 0 ? { user_own_event_types: individualEvents, ...grouped } : grouped;
|
||||
}, [eventTypes.data]);
|
||||
|
||||
if (!eventTypes.data) return null;
|
||||
const isEmpty = eventTypes.data.length === 0;
|
||||
|
||||
const getTextForPopover = () => {
|
||||
const eventTypeIds = query.eventTypeIds;
|
||||
if (eventTypeIds) {
|
||||
return `${t("number_selected", { count: eventTypeIds.length })}`;
|
||||
}
|
||||
return `${t("all")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedPopover text={getTextForPopover()} prefix={`${t("event_type")}: `}>
|
||||
{!isEmpty ? (
|
||||
<FilterCheckboxFieldsContainer>
|
||||
<FilterCheckboxField
|
||||
id="all"
|
||||
icon={<Icon name="link" className="h-4 w-4" />}
|
||||
checked={!query.eventTypeIds?.length}
|
||||
onChange={removeAllQueryParams}
|
||||
label={t("all_event_types_filter_label")}
|
||||
/>
|
||||
<Divider />
|
||||
{groupedEventTypes &&
|
||||
Object.keys(groupedEventTypes).map((teamName) => (
|
||||
<Fragment key={teamName}>
|
||||
<div className="text-subtle px-4 py-2 text-xs font-medium uppercase leading-none">
|
||||
{teamName === "user_own_event_types" ? t("individual") : teamName}
|
||||
</div>
|
||||
{groupedEventTypes[teamName].map((eventType) => (
|
||||
<FilterCheckboxField
|
||||
key={eventType.id}
|
||||
checked={query.eventTypeIds?.includes(eventType.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
pushItemToKey("eventTypeIds", eventType.id);
|
||||
} else if (!e.target.checked) {
|
||||
removeItemByKeyAndValue("eventTypeIds", eventType.id);
|
||||
}
|
||||
}}
|
||||
label={eventType.title}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</FilterCheckboxFieldsContainer>
|
||||
) : (
|
||||
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
|
||||
)}
|
||||
</AnimatedPopover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Badge, Button, Icon, Tooltip } from "@calcom/ui";
|
||||
|
||||
export interface FilterToggleProps {
|
||||
setIsFiltersVisible: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export function FilterToggle({ setIsFiltersVisible }: FilterToggleProps) {
|
||||
const {
|
||||
data: { teamIds, userIds, eventTypeIds },
|
||||
} = useFilterQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
function toggleFiltersVisibility() {
|
||||
setIsFiltersVisible((prev) => !prev);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button color="secondary" onClick={toggleFiltersVisibility} className="mb-4">
|
||||
<Icon name="filter" className="h-4 w-4" />
|
||||
<Tooltip content={t("filters")}>
|
||||
<div className="mx-2">{t("filters")}</div>
|
||||
</Tooltip>
|
||||
{(teamIds || userIds || eventTypeIds) && (
|
||||
<Badge variant="gray" rounded>
|
||||
{(teamIds ? 1 : 0) + (userIds ? 1 : 0) + (eventTypeIds ? 1 : 0)}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
|
||||
import { PeopleFilter } from "@calcom/features/bookings/components/PeopleFilter";
|
||||
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Tooltip, Button } from "@calcom/ui";
|
||||
|
||||
import { EventTypeFilter } from "./EventTypeFilter";
|
||||
|
||||
export interface FiltersContainerProps {
|
||||
isFiltersVisible: boolean;
|
||||
}
|
||||
|
||||
export function FiltersContainer({ isFiltersVisible }: FiltersContainerProps) {
|
||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const { removeAllQueryParams } = useFilterQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div ref={animationParentRef}>
|
||||
{isFiltersVisible ? (
|
||||
<div className="no-scrollbar flex w-full space-x-2 overflow-x-scroll rtl:space-x-reverse">
|
||||
<PeopleFilter />
|
||||
<EventTypeFilter />
|
||||
<TeamsFilter />
|
||||
<Tooltip content={t("remove_filters")}>
|
||||
<Button
|
||||
color="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
removeAllQueryParams();
|
||||
}}>
|
||||
{t("remove_filters")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import {
|
||||
FilterCheckboxField,
|
||||
FilterCheckboxFieldsContainer,
|
||||
} from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { AnimatedPopover, Avatar, Divider, FilterSearchField, Icon } from "@calcom/ui";
|
||||
|
||||
export const PeopleFilter = () => {
|
||||
const { t } = useLocale();
|
||||
const orgBranding = useOrgBranding();
|
||||
|
||||
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery();
|
||||
const isAdmin = currentOrg?.user.role === "ADMIN" || currentOrg?.user.role === "OWNER";
|
||||
const hasPermToView = !currentOrg?.isPrivate || isAdmin;
|
||||
|
||||
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
const members = trpc.viewer.teams.listMembers.useQuery({});
|
||||
|
||||
const filteredMembers = members?.data
|
||||
?.filter((member) => member.accepted)
|
||||
?.filter((member) =>
|
||||
searchText.trim() !== ""
|
||||
? member?.name?.toLowerCase()?.includes(searchText.toLowerCase()) ||
|
||||
member?.username?.toLowerCase()?.includes(searchText.toLowerCase())
|
||||
: true
|
||||
);
|
||||
|
||||
const getTextForPopover = () => {
|
||||
const userIds = query.userIds;
|
||||
if (userIds) {
|
||||
return `${t("number_selected", { count: userIds.length })}`;
|
||||
}
|
||||
return `${t("all")}`;
|
||||
};
|
||||
|
||||
if (!hasPermToView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedPopover text={getTextForPopover()} prefix={`${t("people")}: `}>
|
||||
<FilterCheckboxFieldsContainer>
|
||||
<FilterCheckboxField
|
||||
id="all"
|
||||
icon={<Icon name="user" className="h-4 w-4" />}
|
||||
checked={!query.userIds?.length}
|
||||
onChange={removeAllQueryParams}
|
||||
label={t("all_users_filter_label")}
|
||||
/>
|
||||
<Divider />
|
||||
<FilterSearchField onChange={(e) => setSearchText(e.target.value)} placeholder={t("search")} />
|
||||
{filteredMembers?.map((member) => (
|
||||
<FilterCheckboxField
|
||||
key={member.id}
|
||||
id={member.id.toString()}
|
||||
label={member?.name ?? member.username ?? t("no_name")}
|
||||
checked={!!query.userIds?.includes(member.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
pushItemToKey("userIds", member.id);
|
||||
} else if (!e.target.checked) {
|
||||
removeItemByKeyAndValue("userIds", member.id);
|
||||
}
|
||||
}}
|
||||
icon={<Avatar alt={`${member?.id} avatar`} imageSrc={member.avatarUrl} size="xs" />}
|
||||
/>
|
||||
))}
|
||||
{filteredMembers?.length === 0 && (
|
||||
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
|
||||
)}
|
||||
</FilterCheckboxFieldsContainer>
|
||||
</AnimatedPopover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Whether to show the exact number of seats available or not
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
showExact: boolean;
|
||||
/**
|
||||
* Shows available seats count as either whole number or fraction.
|
||||
*
|
||||
* Applies only when `showExact` is `true`
|
||||
*
|
||||
* @default "whole"
|
||||
*/
|
||||
variant?: "whole" | "fraction";
|
||||
/** Number of seats booked in the event */
|
||||
bookedSeats: number;
|
||||
/** Total number of seats in the event */
|
||||
totalSeats: number;
|
||||
};
|
||||
|
||||
export const SeatsAvailabilityText = ({
|
||||
showExact = true,
|
||||
bookedSeats,
|
||||
totalSeats,
|
||||
variant = "whole",
|
||||
}: Props) => {
|
||||
const { t } = useLocale();
|
||||
const availableSeats = totalSeats - bookedSeats;
|
||||
const isHalfFull = bookedSeats / totalSeats >= 0.5;
|
||||
const isNearlyFull = bookedSeats / totalSeats >= 0.83;
|
||||
|
||||
return (
|
||||
<span className={classNames(showExact && "lowercase", "truncate")}>
|
||||
{showExact
|
||||
? `${availableSeats}${variant === "fraction" ? ` / ${totalSeats}` : ""} ${t("seats_available", {
|
||||
count: availableSeats,
|
||||
})}`
|
||||
: isNearlyFull
|
||||
? t("seats_nearly_full")
|
||||
: isHalfFull
|
||||
? t("seats_half_full")
|
||||
: t("seats_available", {
|
||||
count: availableSeats,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { ToggleGroup } from "@calcom/ui";
|
||||
|
||||
import { useTimePreferences } from "../lib";
|
||||
|
||||
export const TimeFormatToggle = ({ customClassName }: { customClassName?: string }) => {
|
||||
const timeFormat = useTimePreferences((state) => state.timeFormat);
|
||||
const setTimeFormat = useTimePreferences((state) => state.setTimeFormat);
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
customClassNames={customClassName}
|
||||
onValueChange={(newFormat) => {
|
||||
if (newFormat && newFormat !== timeFormat) setTimeFormat(newFormat as TimeFormat);
|
||||
}}
|
||||
defaultValue={timeFormat}
|
||||
value={timeFormat}
|
||||
options={[
|
||||
{ value: TimeFormat.TWELVE_HOUR, label: t("12_hour_short") },
|
||||
{ value: TimeFormat.TWENTY_FOUR_HOUR, label: t("24_hour_short") },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Canvas, Meta } from "@storybook/blocks";
|
||||
|
||||
import { Title, CustomArgsTable } from "@calcom/storybook/components";
|
||||
|
||||
import { VerifyCodeDialog } from "./VerifyCodeDialog";
|
||||
import * as VerifyCodeDialogStories from "./VerifyCodeDialog.stories";
|
||||
|
||||
<Meta of={VerifyCodeDialogStories} />
|
||||
|
||||
<Title title="VerifyCodeDialog" suffix="Brief" subtitle="Version 1.0 — Last Update: 11 Sep 2023" />
|
||||
|
||||
## Definition
|
||||
|
||||
The `VerifyCodeDialog` component allows users to enter a verification code sent to their email. The component provides feedback in case of an error and can handle different verification processes depending on whether the user session is required.
|
||||
|
||||
## Structure
|
||||
|
||||
This component contains an input form to capture a 6-digit verification code, error handling UI, and action buttons.
|
||||
|
||||
<CustomArgsTable of={VerifyCodeDialog} />
|
||||
|
||||
{/* ## VerifyCodeDialog Story
|
||||
|
||||
<Canvas of={VerifyCodeDialogStories.Default} /> */}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { StorybookTrpcProvider } from "@calcom/ui";
|
||||
|
||||
import { VerifyCodeDialog } from "./VerifyCodeDialog";
|
||||
|
||||
type StoryArgs = ComponentProps<typeof VerifyCodeDialog>;
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
component: VerifyCodeDialog,
|
||||
title: "Features/VerifyCodeDialog",
|
||||
argTypes: {
|
||||
isOpenDialog: { control: "boolean", description: "Indicates whether the dialog is open or not." },
|
||||
setIsOpenDialog: { action: "setIsOpenDialog", description: "Function to set the dialog state." },
|
||||
email: { control: "text", description: "Email to which the verification code was sent." },
|
||||
onSuccess: { action: "onSuccess", description: "Callback function when verification succeeds." },
|
||||
// onError: { action: "onError", description: "Callback function when verification fails." },
|
||||
isUserSessionRequiredToVerify: {
|
||||
control: "boolean",
|
||||
description: "Indicates if user session is required for verification.",
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<StorybookTrpcProvider>
|
||||
<Story />
|
||||
</StorybookTrpcProvider>
|
||||
),
|
||||
],
|
||||
render: (args) => <VerifyCodeDialog {...args} />,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryArgs>;
|
||||
|
||||
export const Default: Story = {
|
||||
name: "Dialog",
|
||||
args: {
|
||||
isOpenDialog: true,
|
||||
email: "example@email.com",
|
||||
// onError: (err) => {
|
||||
// if (err.message === "invalid_code") {
|
||||
// alert("Code provided is invalid");
|
||||
// }
|
||||
// },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import useDigitInput from "react-digit-input";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
Icon,
|
||||
Input,
|
||||
Label,
|
||||
} from "@calcom/ui";
|
||||
|
||||
export const VerifyCodeDialog = ({
|
||||
isOpenDialog,
|
||||
setIsOpenDialog,
|
||||
email,
|
||||
isUserSessionRequiredToVerify = true,
|
||||
verifyCodeWithSessionNotRequired,
|
||||
verifyCodeWithSessionRequired,
|
||||
resetErrors,
|
||||
setIsPending,
|
||||
isPending,
|
||||
error,
|
||||
}: {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
email: string;
|
||||
isUserSessionRequiredToVerify?: boolean;
|
||||
verifyCodeWithSessionNotRequired: (code: string, email: string) => void;
|
||||
verifyCodeWithSessionRequired: (code: string, email: string) => void;
|
||||
resetErrors: () => void;
|
||||
isPending: boolean;
|
||||
setIsPending: (status: boolean) => void;
|
||||
error: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [value, setValue] = useState("");
|
||||
const [hasVerified, setHasVerified] = useState(false);
|
||||
|
||||
const digits = useDigitInput({
|
||||
acceptedCharacters: /^[0-9]$/,
|
||||
length: 6,
|
||||
value,
|
||||
onChange: useCallback((value: string) => {
|
||||
// whenever there's a change in the input, we reset the error value.
|
||||
resetErrors();
|
||||
setValue(value);
|
||||
}, []),
|
||||
});
|
||||
|
||||
const verifyCode = useCallback(() => {
|
||||
resetErrors();
|
||||
setIsPending(true);
|
||||
if (isUserSessionRequiredToVerify) {
|
||||
verifyCodeWithSessionRequired(value, email);
|
||||
} else {
|
||||
verifyCodeWithSessionNotRequired(value, email);
|
||||
}
|
||||
setHasVerified(true);
|
||||
}, [
|
||||
resetErrors,
|
||||
setIsPending,
|
||||
isUserSessionRequiredToVerify,
|
||||
verifyCodeWithSessionRequired,
|
||||
value,
|
||||
email,
|
||||
verifyCodeWithSessionNotRequired,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// trim the input value because "react-digit-input" creates a string of the given length,
|
||||
// even when some digits are missing. And finally we use regex to check if the value consists
|
||||
// of 6 non-empty digits.
|
||||
if (hasVerified || error || isPending || !/^\d{6}$/.test(value.trim())) return;
|
||||
|
||||
verifyCode();
|
||||
}, [error, isPending, value, verifyCode, hasVerified]);
|
||||
|
||||
useEffect(() => setValue(""), [isOpenDialog]);
|
||||
|
||||
const digitClassName = "h-12 w-12 !text-xl text-center";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpenDialog}
|
||||
onOpenChange={() => {
|
||||
setValue("");
|
||||
resetErrors();
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div className="flex flex-row">
|
||||
<div className="w-full">
|
||||
<DialogHeader title={t("verify_your_email")} subtitle={t("enter_digit_code", { email })} />
|
||||
<Label htmlFor="code">{t("code")}</Label>
|
||||
<div className="flex flex-row justify-between">
|
||||
<Input
|
||||
className={digitClassName}
|
||||
name="2fa1"
|
||||
inputMode="decimal"
|
||||
{...digits[0]}
|
||||
autoFocus
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<Input className={digitClassName} name="2fa2" inputMode="decimal" {...digits[1]} />
|
||||
<Input className={digitClassName} name="2fa3" inputMode="decimal" {...digits[2]} />
|
||||
<Input className={digitClassName} name="2fa4" inputMode="decimal" {...digits[3]} />
|
||||
<Input className={digitClassName} name="2fa5" inputMode="decimal" {...digits[4]} />
|
||||
<Input className={digitClassName} name="2fa6" inputMode="decimal" {...digits[5]} />
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-2 flex items-center gap-x-2 text-sm text-red-700">
|
||||
<div>
|
||||
<Icon name="info" className="h-3 w-3" />
|
||||
</div>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose onClick={() => setIsOpenDialog(false)} />
|
||||
<Button type="submit" onClick={verifyCode} loading={isPending}>
|
||||
{t("submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
DefaultEventLocationType,
|
||||
EventLocationTypeFromApp,
|
||||
LocationObject,
|
||||
} from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, getTranslatedLocation } from "@calcom/app-store/locations";
|
||||
import { useIsPlatform } from "@calcom/atoms/monorepo";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import invertLogoOnDark from "@calcom/lib/invertLogoOnDark";
|
||||
import { Icon, Tooltip } from "@calcom/ui";
|
||||
|
||||
const excludeNullValues = (value: unknown) => !!value;
|
||||
|
||||
function RenderIcon({
|
||||
eventLocationType,
|
||||
isTooltip,
|
||||
}: {
|
||||
eventLocationType: DefaultEventLocationType | EventLocationTypeFromApp;
|
||||
isTooltip: boolean;
|
||||
}) {
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
if (isPlatform) {
|
||||
if (eventLocationType.type === "conferencing") return <Icon name="video" className="me-[10px] h-4 w-4" />;
|
||||
if (eventLocationType.type === "attendeeInPerson" || eventLocationType.type === "inPerson")
|
||||
return <Icon name="map-pin" className="me-[10px] h-4 w-4" />;
|
||||
if (eventLocationType.type === "phone" || eventLocationType.type === "userPhone")
|
||||
return <Icon name="phone" className="me-[10px] h-4 w-4" />;
|
||||
if (eventLocationType.type === "link") return <Icon name="link" className="me-[10px] h-4 w-4" />;
|
||||
return <Icon name="book-user" className="me-[10px] h-4 w-4" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(invertLogoOnDark(eventLocationType?.iconUrl, false), "me-[10px] h-4 w-4")}
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderLocationTooltip({ locations }: { locations: LocationObject[] }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="my-2 me-2 flex w-full flex-col space-y-3 break-words">
|
||||
<p>{t("select_on_next_step")}</p>
|
||||
{locations.map(
|
||||
(
|
||||
location: Pick<Partial<LocationObject>, "link" | "address"> &
|
||||
Omit<LocationObject, "link" | "address">,
|
||||
index: number
|
||||
) => {
|
||||
const eventLocationType = getEventLocationType(location.type);
|
||||
if (!eventLocationType) {
|
||||
return null;
|
||||
}
|
||||
const translatedLocation = getTranslatedLocation(location, eventLocationType, t);
|
||||
return (
|
||||
<div key={`${location.type}-${index}`} className="font-sm flex flex-row items-center">
|
||||
<RenderIcon eventLocationType={eventLocationType} isTooltip />
|
||||
<p className="line-clamp-1">{translatedLocation}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AvailableEventLocations({ locations }: { locations: LocationObject[] }) {
|
||||
const { t } = useLocale();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const renderLocations = locations.map(
|
||||
(
|
||||
location: Pick<Partial<LocationObject>, "link" | "address"> & Omit<LocationObject, "link" | "address">,
|
||||
index: number
|
||||
) => {
|
||||
const eventLocationType = getEventLocationType(location.type);
|
||||
if (!eventLocationType) {
|
||||
// It's possible that the location app got uninstalled
|
||||
return null;
|
||||
}
|
||||
if (eventLocationType.variable === "hostDefault") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const translatedLocation = getTranslatedLocation(location, eventLocationType, t);
|
||||
|
||||
return (
|
||||
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
|
||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||
<Icon name="link" className="text-default h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
) : (
|
||||
<RenderIcon eventLocationType={eventLocationType} isTooltip={false} />
|
||||
)}
|
||||
<Tooltip content={translatedLocation}>
|
||||
<p className="line-clamp-1">{translatedLocation}</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const filteredLocations = renderLocations.filter(excludeNullValues) as JSX.Element[];
|
||||
|
||||
return filteredLocations.length > 1 ? (
|
||||
<div className="flex flex-row items-center text-sm font-medium">
|
||||
{isPlatform ? (
|
||||
<Icon name="map-pin" className={classNames("me-[10px] h-4 w-4 opacity-70 dark:invert")} />
|
||||
) : (
|
||||
<img
|
||||
src="/map-pin-dark.svg"
|
||||
className={classNames("me-[10px] h-4 w-4 opacity-70 dark:invert")}
|
||||
alt="map-pin"
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={<RenderLocationTooltip locations={locations} />}>
|
||||
<p className="line-clamp-1">
|
||||
{t("location_options", {
|
||||
locationCount: filteredLocations.length,
|
||||
})}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : filteredLocations.length === 1 ? (
|
||||
<div className="text-default mr-6 flex w-full flex-col space-y-4 break-words text-sm rtl:mr-2">
|
||||
{filteredLocations}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import { PriceIcon } from "@calcom/features/bookings/components/event-meta/PriceIcon";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon, type IconName } from "@calcom/ui";
|
||||
|
||||
import { EventDetailBlocks } from "../../types";
|
||||
import { AvailableEventLocations } from "./AvailableEventLocations";
|
||||
import { EventDuration } from "./Duration";
|
||||
import { EventOccurences } from "./Occurences";
|
||||
import { Price } from "./Price";
|
||||
|
||||
type EventDetailsPropsBase = {
|
||||
event: Pick<
|
||||
BookerEvent,
|
||||
| "currency"
|
||||
| "price"
|
||||
| "locations"
|
||||
| "requiresConfirmation"
|
||||
| "recurringEvent"
|
||||
| "length"
|
||||
| "metadata"
|
||||
| "isDynamic"
|
||||
>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type EventDetailDefaultBlock = {
|
||||
blocks?: EventDetailBlocks[];
|
||||
};
|
||||
|
||||
// Rendering a custom block requires passing a name prop,
|
||||
// which is used as a key for the block.
|
||||
type EventDetailCustomBlock = {
|
||||
blocks?: React.FC[];
|
||||
name: string;
|
||||
};
|
||||
|
||||
type EventDetailsProps = EventDetailsPropsBase & (EventDetailDefaultBlock | EventDetailCustomBlock);
|
||||
|
||||
interface EventMetaProps {
|
||||
customIcon?: React.ReactNode;
|
||||
icon?: IconName;
|
||||
iconUrl?: string;
|
||||
children: React.ReactNode;
|
||||
// Emphasises the text in the block. For now only
|
||||
// applying in dark mode.
|
||||
highlight?: boolean;
|
||||
contentClassName?: string;
|
||||
className?: string;
|
||||
isDark?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default order in which the event details will be rendered.
|
||||
*/
|
||||
const defaultEventDetailsBlocks = [
|
||||
EventDetailBlocks.REQUIRES_CONFIRMATION,
|
||||
EventDetailBlocks.DURATION,
|
||||
EventDetailBlocks.OCCURENCES,
|
||||
EventDetailBlocks.LOCATION,
|
||||
EventDetailBlocks.PRICE,
|
||||
];
|
||||
|
||||
/**
|
||||
* Helper component that ensures the meta data of an event is
|
||||
* rendered in a consistent way — adds an icon and children (text usually).
|
||||
*/
|
||||
export const EventMetaBlock = ({
|
||||
customIcon,
|
||||
icon,
|
||||
iconUrl,
|
||||
children,
|
||||
highlight,
|
||||
contentClassName,
|
||||
className,
|
||||
isDark,
|
||||
}: EventMetaProps) => {
|
||||
if (!React.Children.count(children)) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-start justify-start text-sm",
|
||||
highlight ? "text-emphasis" : "text-text",
|
||||
className
|
||||
)}>
|
||||
{iconUrl ? (
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
// @TODO: Use SVG's instead of images, so we can get rid of the filter.
|
||||
className={classNames(
|
||||
"mr-2 mt-[2px] h-4 w-4 flex-shrink-0",
|
||||
isDark === undefined && "[filter:invert(0.5)_brightness(0.5)]",
|
||||
(isDark === undefined || isDark) && "dark:[filter:invert(0.65)_brightness(0.9)]"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{customIcon ||
|
||||
(!!icon && (
|
||||
<Icon name={icon} className="relative z-20 mr-2 mt-[2px] h-4 w-4 flex-shrink-0 rtl:ml-2" />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className={classNames("relative z-10 max-w-full break-words", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Component that renders event meta data in a structured way, with icons and labels.
|
||||
* The component can be configured to show only specific blocks by overriding the
|
||||
* `blocks` prop. The blocks prop takes in an array of block names, defined
|
||||
* in the `EventDetailBlocks` enum. See the `defaultEventDetailsBlocks` const
|
||||
* for the default order in which the blocks will be rendered.
|
||||
*
|
||||
* As part of the blocks array you can also decide to render a custom React Component,
|
||||
* which will then also be rendered.
|
||||
*
|
||||
* Example:
|
||||
* const MyCustomBlock = () => <div>Something nice</div>;
|
||||
* <EventDetails event={event} blocks={[EventDetailBlocks.LOCATION, MyCustomBlock]} />
|
||||
*/
|
||||
export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: EventDetailsProps) => {
|
||||
const { t } = useLocale();
|
||||
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
|
||||
const isInstantMeeting = useBookerStore((store) => store.isInstantMeeting);
|
||||
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block) => {
|
||||
if (typeof block === "function") {
|
||||
return <Fragment key={block.name}>{block(event)}</Fragment>;
|
||||
}
|
||||
|
||||
switch (block) {
|
||||
case EventDetailBlocks.DURATION:
|
||||
return (
|
||||
<EventMetaBlock key={block} icon="clock">
|
||||
<EventDuration event={event} />
|
||||
</EventMetaBlock>
|
||||
);
|
||||
|
||||
case EventDetailBlocks.LOCATION:
|
||||
if (!event?.locations?.length || isInstantMeeting) return null;
|
||||
return (
|
||||
<EventMetaBlock key={block}>
|
||||
<AvailableEventLocations locations={event.locations} />
|
||||
</EventMetaBlock>
|
||||
);
|
||||
|
||||
case EventDetailBlocks.REQUIRES_CONFIRMATION:
|
||||
if (!event.requiresConfirmation) return null;
|
||||
|
||||
return (
|
||||
<EventMetaBlock key={block} icon="square-check">
|
||||
{t("requires_confirmation")}
|
||||
</EventMetaBlock>
|
||||
);
|
||||
|
||||
case EventDetailBlocks.OCCURENCES:
|
||||
if (!event.recurringEvent || rescheduleUid) return null;
|
||||
|
||||
return (
|
||||
<EventMetaBlock key={block} icon="refresh-ccw">
|
||||
<EventOccurences event={event} />
|
||||
</EventMetaBlock>
|
||||
);
|
||||
|
||||
case EventDetailBlocks.PRICE:
|
||||
const paymentAppData = getPaymentAppData(event);
|
||||
if (event.price <= 0 || paymentAppData.price <= 0) return null;
|
||||
|
||||
return (
|
||||
<EventMetaBlock
|
||||
key={block}
|
||||
customIcon={
|
||||
<PriceIcon
|
||||
className="relative z-20 mr-2 mt-[2px] h-4 w-4 flex-shrink-0 rtl:ml-2"
|
||||
currency={event.currency}
|
||||
/>
|
||||
}>
|
||||
<Price
|
||||
price={paymentAppData.price}
|
||||
currency={event.currency}
|
||||
displayAlternateSymbol={false}
|
||||
/>
|
||||
</EventMetaBlock>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useIsPlatform } from "@calcom/atoms/monorepo";
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Badge } from "@calcom/ui";
|
||||
|
||||
/** Render X mins as X hours or X hours Y mins instead of in minutes once >= 60 minutes */
|
||||
export const getDurationFormatted = (mins: number | undefined, t: TFunction) => {
|
||||
if (!mins) return null;
|
||||
|
||||
const hours = Math.floor(mins / 60);
|
||||
mins %= 60;
|
||||
// format minutes string
|
||||
let minStr = "";
|
||||
if (mins > 0) {
|
||||
minStr =
|
||||
mins === 1
|
||||
? t("minute_one", { count: 1 })
|
||||
: t("multiple_duration_timeUnit", { count: mins, unit: "minute" });
|
||||
}
|
||||
// format hours string
|
||||
let hourStr = "";
|
||||
if (hours > 0) {
|
||||
hourStr =
|
||||
hours === 1
|
||||
? t("hour_one", { count: 1 })
|
||||
: t("multiple_duration_timeUnit", { count: hours, unit: "hour" });
|
||||
}
|
||||
|
||||
if (hourStr && minStr) return `${hourStr} ${minStr}`;
|
||||
return hourStr || minStr;
|
||||
};
|
||||
|
||||
export const EventDuration = ({
|
||||
event,
|
||||
}: {
|
||||
event: Pick<BookerEvent, "length" | "metadata" | "isDynamic">;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const isPlatform = useIsPlatform();
|
||||
const [selectedDuration, setSelectedDuration, state] = useBookerStore((state) => [
|
||||
state.selectedDuration,
|
||||
state.setSelectedDuration,
|
||||
state.state,
|
||||
]);
|
||||
|
||||
const isDynamicEvent = "isDynamic" in event && event.isDynamic;
|
||||
|
||||
// Sets initial value of selected duration to the default duration.
|
||||
useEffect(() => {
|
||||
// Only store event duration in url if event has multiple durations.
|
||||
if (!selectedDuration && (event.metadata?.multipleDuration || isDynamicEvent))
|
||||
setSelectedDuration(event.length);
|
||||
}, [selectedDuration, setSelectedDuration, event.metadata?.multipleDuration, event.length, isDynamicEvent]);
|
||||
|
||||
if ((!event?.metadata?.multipleDuration && !isDynamicEvent) || isPlatform)
|
||||
return <>{getDurationFormatted(event.length, t)}</>;
|
||||
|
||||
const durations = event?.metadata?.multipleDuration || [15, 30, 60, 90];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{durations
|
||||
.filter((dur) => state !== "booking" || dur === selectedDuration)
|
||||
.map((duration) => (
|
||||
<Badge
|
||||
data-testId={`multiple-choice-${duration}mins`}
|
||||
data-active={selectedDuration === duration ? "true" : "false"}
|
||||
variant="gray"
|
||||
className={classNames(selectedDuration === duration && "bg-brand-default text-brand")}
|
||||
size="md"
|
||||
key={duration}
|
||||
onClick={() => setSelectedDuration(duration)}>
|
||||
{getDurationFormatted(duration, t)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Canvas, Meta } from "@storybook/blocks";
|
||||
|
||||
import { Title } from "@calcom/storybook/components";
|
||||
|
||||
import * as EventMetaStories from "./EventMeta.stories";
|
||||
|
||||
<Meta of={EventMetaStories} />
|
||||
|
||||
<Title title="Event Meta" suffix="Brief" subtitle="Version 2.0 — Last Update: 12 Dec 2022" />
|
||||
|
||||
<Canvas of={EventMetaStories.ExampleStory} />
|
||||
|
||||
<Canvas of={EventMetaStories.AllVariants} />
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { Examples, Example, VariantsTable, VariantRow } from "@calcom/storybook/components";
|
||||
|
||||
import { EventDetails } from "./Details";
|
||||
import { EventMembers } from "./Members";
|
||||
import { EventTitle } from "./Title";
|
||||
import { mockEvent } from "./event.mock";
|
||||
|
||||
type StoryArgs = ComponentProps<typeof EventDetails>;
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
component: EventDetails,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
title: "Features/Events/Meta",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryArgs>;
|
||||
|
||||
export const ExampleStory: Story = {
|
||||
name: "Examples",
|
||||
render: () => (
|
||||
<Examples title="Combined event meta block">
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
<Example title="Event Title">
|
||||
<EventTitle>{mockEvent.title}</EventTitle>
|
||||
</Example>
|
||||
<Example title="Event Details">
|
||||
<EventDetails event={mockEvent} />
|
||||
</Example>
|
||||
</div>
|
||||
</Examples>
|
||||
),
|
||||
};
|
||||
|
||||
export const AllVariants: Story = {
|
||||
name: "All variants",
|
||||
render: () => (
|
||||
<VariantsTable titles={["Event Meta Components"]} columnMinWidth={150}>
|
||||
<VariantRow variant="">
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
<EventMembers
|
||||
users={mockEvent.users}
|
||||
schedulingType="COLLECTIVE"
|
||||
entity={{
|
||||
considerUnpublished: false,
|
||||
fromRedirectOfNonOrgLink: true,
|
||||
teamSlug: null,
|
||||
name: "Example",
|
||||
orgSlug: null,
|
||||
}}
|
||||
// TODO remove type assertion
|
||||
profile={{ bookerLayouts: null }}
|
||||
/>
|
||||
<EventTitle>Quick catch-up</EventTitle>
|
||||
<EventDetails event={mockEvent} />
|
||||
</div>
|
||||
</VariantRow>
|
||||
</VariantsTable>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { getEventLocationType, getTranslatedLocation } from "@calcom/app-store/locations";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Tooltip } from "@calcom/ui";
|
||||
|
||||
import { EventMetaBlock } from "./Details";
|
||||
|
||||
export const EventLocations = ({ event }: { event: BookerEvent }) => {
|
||||
const { t } = useLocale();
|
||||
const locations = event.locations;
|
||||
|
||||
if (!locations?.length) return null;
|
||||
|
||||
const getLocationToDisplay = (location: BookerEvent["locations"][number]) => {
|
||||
const eventLocationType = getEventLocationType(location.type);
|
||||
const translatedLocation = getTranslatedLocation(location, eventLocationType, t);
|
||||
|
||||
return translatedLocation;
|
||||
};
|
||||
const eventLocationType = getEventLocationType(locations[0].type);
|
||||
const iconUrl = locations.length > 1 || !eventLocationType?.iconUrl ? undefined : eventLocationType.iconUrl;
|
||||
const icon = locations.length > 1 || !eventLocationType?.iconUrl ? "map-pin" : undefined;
|
||||
|
||||
return (
|
||||
<EventMetaBlock iconUrl={iconUrl} icon={icon} isDark={eventLocationType?.iconUrl?.includes("-dark")}>
|
||||
{locations.length === 1 && (
|
||||
<Tooltip content={getLocationToDisplay(locations[0])}>
|
||||
<div className="" key={locations[0].type}>
|
||||
{getLocationToDisplay(locations[0])}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{locations.length > 1 && (
|
||||
<div
|
||||
key={locations[0].type}
|
||||
className="before:bg-subtle relative before:pointer-events-none before:absolute before:inset-0 before:bottom-[-5px] before:left-[-30px] before:top-[-5px] before:w-[calc(100%_+_35px)] before:rounded-md before:py-3 before:opacity-0 before:transition-opacity hover:before:opacity-100">
|
||||
<Tooltip
|
||||
content={
|
||||
<>
|
||||
<p className="mb-2">{t("select_on_next_step")}</p>
|
||||
<ul className="pl-1">
|
||||
{locations.map((location, index) => (
|
||||
<li key={`${location.type}-${index}`} className="mt-1">
|
||||
<div className="flex flex-row items-center">
|
||||
<img
|
||||
src={getEventLocationType(location.type)?.iconUrl}
|
||||
className={classNames(
|
||||
"h-3 w-3 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
||||
!getEventLocationType(location.type)?.iconUrl?.startsWith("/app-store")
|
||||
? "dark:invert-[.65]"
|
||||
: ""
|
||||
)}
|
||||
alt={`${getEventLocationType(location.type)?.label} icon`}
|
||||
/>
|
||||
<span>{getLocationToDisplay(location)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
}>
|
||||
<span className="relative z-[2] py-2">{t("num_locations", { num: locations.length })}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</EventMetaBlock>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user