2
0

first commit

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

View File

@@ -0,0 +1,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>;

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

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1 @@
# Auth-related code will live here

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

View 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",
}

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

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

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

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

View File

@@ -0,0 +1,6 @@
import { hash } from "bcryptjs";
export async function hashPassword(password: string) {
const hashedPassword = await hash(password, 12);
return hashedPassword;
}

View File

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { BookEventForm } from "./BookEventForm";

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,3 @@
import { domAnimation } from "framer-motion";
export default domAnimation;

View File

@@ -0,0 +1,2 @@
export { Booker } from "./Booker";
export type { BookerProps } from "./types";

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

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
# Bookings related code will live here

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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} /> */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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