Merge pull request #2772 from calcom/main

v1.6
This commit is contained in:
Omar López
2022-05-16 16:06:31 -06:00
committed by GitHub
parent 27540b09ce
commit fc13a22162
193 changed files with 6091 additions and 1500 deletions
+5
View File
@@ -81,4 +81,9 @@ VITAL_WEBHOOK_SECRET=
VITAL_DEVELOPMENT_MODE="sandbox"
# "us" | "eu"
VITAL_REGION="us"
# - ZAPIER
# Used for the Zapier integration
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/zapier/README.md
ZAPIER_INVITE_LINK=""
# *********************************************************************************************************
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v2
- name: crowdin action
uses: crowdin/github-action@1.4.2
uses: crowdin/github-action@1.4.9
with:
upload_translations: true
download_translations: true
+5 -2
View File
@@ -81,7 +81,7 @@ export default function App({
}
}
getInstalledApp(type);
}, []);
}, [type]);
return (
<>
<Shell large isPublic>
@@ -94,7 +94,10 @@ export default function App({
</Link>
<div className="items-center justify-between py-4 sm:flex sm:py-8">
<div className="flex">
<img className="h-16 w-16" src={logo} alt={name} />
{
// eslint-disable-next-line @next/next/no-img-element
<img className="h-16 w-16" src={logo} alt={name} />
}
<header className="px-4 py-2">
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
<h2 className="text-sm text-gray-500">
+11 -1
View File
@@ -274,7 +274,17 @@ const BrandColor = ({
"--brand-text-color-dark-mode",
getContrastingTextColor(darkVal, true)
);
}, [lightVal, darkVal]);
}, [
embedBrandingColors.highlightColor,
embedBrandingColors.lightestColor,
embedBrandingColors.lighterColor,
embedBrandingColors.lightColor,
embedBrandingColors.medianColor,
embedBrandingColors.darkColor,
embedBrandingColors.darkerColor,
lightVal,
darkVal,
]);
return null;
};
+4 -1
View File
@@ -134,7 +134,10 @@ export default function ImageUploader({
{t("no_target", { target })}
</p>
)}
{imageSrc && <img className="h-20 w-20 rounded-full" src={imageSrc} alt={target} />}
{imageSrc && (
// eslint-disable-next-line @next/next/no-img-element
<img className="h-20 w-20 rounded-full" src={imageSrc} alt={target} />
)}
</div>
)}
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
+1 -7
View File
@@ -1,7 +1 @@
export default function Loader() {
return (
<div className="loader border-brand dark:border-darkmodebrand">
<span className="loader-inner bg-brand dark:bg-darkmodebrand"></span>
</div>
);
}
export { default } from "@calcom/ui/Loader";
+2
View File
@@ -3,8 +3,10 @@ export default function Logo({ small, icon }: { small?: boolean; icon?: boolean
<h1 className="inline">
<strong>
{icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="mx-auto w-9" alt="Cal" title="Cal" src="/cal-com-icon-white.svg" />
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
className={small ? "h-4 w-auto" : "h-5 w-auto"}
alt="Cal"
+9 -7
View File
@@ -197,7 +197,7 @@ const Layout = ({
</Link>
{/* logo icon for tablet */}
<Link href="/event-types">
<a className="md:inline lg:hidden">
<a className="text-center md:inline lg:hidden">
<Logo small icon />
</a>
</Link>
@@ -460,7 +460,6 @@ function UserDropdown({ small }: { small?: boolean }) {
},
});
const utils = trpc.useContext();
return (
<Dropdown>
<DropdownMenuTrigger asChild>
@@ -470,11 +469,14 @@ function UserDropdown({ small }: { small?: boolean }) {
small ? "h-8 w-8" : "h-10 w-10",
"relative flex-shrink-0 rounded-full bg-gray-300 ltr:mr-3 rtl:ml-3"
)}>
<img
className="rounded-full"
src={process.env.NEXT_PUBLIC_WEBSITE_URL + "/" + user?.username + "/avatar.png"}
alt={user?.username || "Nameless User"}
/>
{
// eslint-disable-next-line @next/next/no-img-element
<img
className="rounded-full"
src={process.env.NEXT_PUBLIC_WEBSITE_URL + "/" + user?.username + "/avatar.png"}
alt={user?.username || "Nameless User"}
/>
}
{!user?.away && (
<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white bg-green-500"></div>
)}
+7 -3
View File
@@ -16,11 +16,15 @@ interface AppCardProps {
export default function AppCard(props: AppCardProps) {
return (
<Link href={"/apps/" + props.slug}>
<a className="block h-full rounded-sm border border-gray-300 p-5 hover:bg-neutral-50">
<a
className="block h-full rounded-sm border border-gray-300 p-5 hover:bg-neutral-50"
data-testid={`app-store-app-card-${props.slug}`}>
<div className="flex">
<img src={props.logo} alt={props.name + " Logo"} className="mb-4 h-12 w-12 rounded-sm" />
{
// eslint-disable-next-line @next/next/no-img-element
<img src={props.logo} alt={props.name + " Logo"} className="mb-4 h-12 w-12 rounded-sm" />
}
<Button
data-testid={`app-store-app-card-${props.slug}`}
color="secondary"
className="ml-auto flex self-start"
onClick={() => {
@@ -57,7 +57,7 @@ const useOptions = () => {
t = t.add(increment, "minutes");
}
return options;
}, []);
}, [timeFormat]);
const filter = useCallback(
({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => {
@@ -0,0 +1,75 @@
import { DotsHorizontalIcon, TrashIcon } from "@heroicons/react/solid";
import Link from "next/link";
import { Fragment } from "react";
import { availabilityAsString } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Availability } from "@calcom/prisma/client";
import { Button } from "@calcom/ui";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
import { inferQueryOutput } from "@lib/trpc";
export function ScheduleListItem({
schedule,
deleteFunction,
isDeleting = false,
}: {
schedule: inferQueryOutput<"viewer.availability.list">["schedules"][number];
deleteFunction: Function;
isDeleting: boolean;
}) {
const { t, i18n } = useLocale();
return (
<li key={schedule.id}>
<div className="flex items-center justify-between py-5 hover:bg-neutral-50 ltr:pl-4 rtl:pr-4 sm:ltr:pl-0 sm:rtl:pr-0">
<div className="group flex w-full items-center justify-between hover:bg-neutral-50 sm:px-6">
<Link href={"/availability/" + schedule.id}>
<a className="flex-grow truncate text-sm" title={schedule.name}>
<div>
<span className="truncate font-medium text-neutral-900">{schedule.name}</span>
{schedule.isDefault && (
<span className="ml-2 inline items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800">
{t("default")}
</span>
)}
</div>
<p className="mt-1 text-xs text-neutral-500">
{schedule.availability.map((availability: Availability) => (
<Fragment key={availability.id}>
{availabilityAsString(availability, i18n.language)}
<br />
</Fragment>
))}
</p>
</a>
</Link>
</div>
<Dropdown>
<DropdownMenuTrigger className="group mr-5 h-10 w-10 border border-transparent p-0 text-neutral-500 hover:border-gray-200">
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Button
disabled={isDeleting}
onClick={() => {
deleteFunction({
scheduleId: schedule.id,
});
}}
type="button"
color="warn"
className="w-full font-normal"
StartIcon={isDeleting ? undefined : TrashIcon}
loading={isDeleting}>
{isDeleting ? t("deleting") : t("delete_schedule")}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</li>
);
}
@@ -31,3 +31,16 @@ function SkeletonItem() {
</li>
);
}
export const AvailabilitySelectSkeletonLoader = () => {
return (
<li className="group flex w-full items-center justify-between rounded-sm border border-gray-200 px-[10px] py-3">
<div className="flex-grow truncate text-sm">
<div className="flex justify-between">
<SkeletonText width="32" height="4"></SkeletonText>
<SkeletonText width="4" height="4"></SkeletonText>
</div>
</div>
</li>
);
};
@@ -2,8 +2,6 @@ import React from "react";
import { SkeletonText } from "@calcom/ui";
import BookingsShell from "@components/BookingsShell";
function SkeletonLoader() {
return (
<ul className="mt-6 animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
@@ -22,10 +20,9 @@ function SkeletonItem() {
<div className="flex-grow truncate text-sm">
<div className="flex">
<div className="flex flex-col space-y-2">
<SkeletonText width="32" height="5" />
<SkeletonText width="16" height="4" />
<SkeletonText width="16" height="5" />
<SkeletonText width="32" height="4" />
</div>
<SkeletonText width="24" height="5" className="ml-4" />
</div>
</div>
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
@@ -16,7 +16,7 @@ import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import utc from "dayjs/plugin/utc";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import { Frequency as RRuleFrequency } from "rrule";
@@ -76,7 +76,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
if (!contracts[(eventType.metadata.smartContractAddress || null) as number])
router.replace(`/${eventOwner.username}`);
}
}, [contracts, eventType.metadata.smartContractAddress, router]);
}, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]);
const selectedDate = useMemo(() => {
const dateString = asStringOrNull(router.query.date);
@@ -113,32 +113,42 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
telemetry.withJitsu((jitsu) =>
jitsu.track(
telemetryEventTypes.pageView,
collectPageParameters("availability", { isTeamBooking: document.URL.includes("team/") })
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
collectPageParameters("/availability", { isTeamBooking: document.URL.includes("team/") })
)
);
}, [telemetry]);
const changeDate = (newDate: Dayjs) => {
router.replace(
{
query: {
...router.query,
date: newDate.format("YYYY-MM-DDZZ"),
const changeDate = useCallback(
(newDate: Dayjs) => {
router.replace(
{
query: {
...router.query,
date: newDate.tz(timeZone(), true).format("YYYY-MM-DDZZ"),
},
},
},
undefined,
{
shallow: true,
}
);
};
undefined,
{ shallow: true }
);
},
[router]
);
useEffect(() => {
if (
selectedDate != null &&
selectedDate?.utcOffset() !== selectedDate.clone().utcOffset(0).tz(timeZone()).utcOffset()
) {
changeDate(selectedDate.tz(timeZone(), true));
}
}, [selectedDate, changeDate]);
const handleSelectTimeZone = (selectedTimeZone: string): void => {
timeZone(selectedTimeZone);
if (selectedDate) {
changeDate(selectedDate.tz(selectedTimeZone, true));
}
timeZone(selectedTimeZone);
setIsTimeOptionsOpen(false);
};
@@ -66,6 +66,7 @@ type BookingFormValues = {
locationType?: LocationType;
guests?: string[];
phone?: string;
hostPhoneNumber?: string; // Maybe come up with a better way to name this to distingish between two types of phone numbers
customInputs?: {
[key: string]: string;
};
@@ -89,6 +90,17 @@ const BookingPage = ({
const { contracts } = useContracts();
const { data: session } = useSession();
const isBackgroundTransparent = useIsBackgroundTransparent();
const telemetry = useTelemetry();
useEffect(() => {
telemetry.withJitsu((jitsu) =>
jitsu.track(
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
)
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (eventType.metadata.smartContractAddress) {
@@ -98,7 +110,7 @@ const BookingPage = ({
/* @ts-ignore */
router.replace(`/${eventOwner.username}`);
}
}, [contracts, eventType.metadata.smartContractAddress, router]);
}, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]);
const mutation = useMutation(createBooking, {
onSuccess: async (responseData) => {
@@ -182,7 +194,7 @@ const BookingPage = ({
const eventTypeDetail = { isWeb3Active: false, ...eventType };
type Location = { type: LocationType; address?: string; link?: string };
type Location = { type: LocationType; address?: string; link?: string; hostPhoneNumber?: string };
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
const locations: Location[] = useMemo(
() => (eventType.locations as Location[]) || [],
@@ -195,8 +207,6 @@ const BookingPage = ({
}
}, [router.query.guest]);
const telemetry = useTelemetry();
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
const loggedInIsOwner = eventType?.users[0]?.name === session?.user?.name;
const guestListEmails = !isDynamicGroupBooking
@@ -259,7 +269,9 @@ const BookingPage = ({
})(),
});
const getLocationValue = (booking: Pick<BookingFormValues, "locationType" | "phone">) => {
const getLocationValue = (
booking: Pick<BookingFormValues, "locationType" | "phone" | "hostPhoneNumber">
) => {
const { locationType } = booking;
switch (locationType) {
case LocationType.Phone: {
@@ -271,6 +283,9 @@ const BookingPage = ({
case LocationType.Link: {
return locationInfo(locationType)?.link || "";
}
case LocationType.UserPhone: {
return locationInfo(locationType)?.hostPhoneNumber || "";
}
// Catches all other location types, such as Google Meet, Zoom etc.
default:
return selectedLocation || "";
@@ -294,7 +309,7 @@ const BookingPage = ({
const bookEvent = (booking: BookingFormValues) => {
telemetry.withJitsu((jitsu) =>
jitsu.track(
telemetryEventTypes.bookingConfirmed,
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
)
);
@@ -410,7 +425,7 @@ const BookingPage = ({
"main overflow-hidden",
isEmbed ? "" : "border border-gray-200",
isBackgroundTransparent ? "" : "dark:border-1 bg-white dark:bg-gray-800",
"rounded-md sm:border sm:dark:border-gray-600"
"rounded-md dark:border-gray-600 sm:border"
)}>
<div className="px-4 py-5 sm:flex sm:p-4">
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-700">
@@ -3,15 +3,15 @@ import { CheckIcon } from "@heroicons/react/solid";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import React, { PropsWithChildren, ReactNode } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/Button";
import { DialogClose, DialogContent } from "@calcom/ui/Dialog";
import { useLocale } from "@lib/hooks/useLocale";
export type ConfirmationDialogContentProps = {
confirmBtn?: ReactNode;
confirmBtnText?: string;
cancelBtnText?: string;
isLoading?: boolean;
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
title: string;
variety?: "danger" | "warning" | "success";
@@ -25,6 +25,7 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
confirmBtn = null,
confirmBtnText = t("confirm"),
cancelBtnText = t("cancel"),
isLoading = false,
onConfirm,
children,
} = props;
@@ -59,10 +60,14 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
</div>
</div>
<div className="mt-5 flex flex-row-reverse gap-x-2 sm:mt-8">
<DialogClose onClick={onConfirm} asChild>
{confirmBtn || <Button color="primary">{confirmBtnText}</Button>}
<DialogClose disabled={isLoading} onClick={onConfirm} asChild>
{confirmBtn || (
<Button color="primary" loading={isLoading}>
{isLoading ? t("loading") : confirmBtnText}
</Button>
)}
</DialogClose>
<DialogClose asChild>
<DialogClose disabled={isLoading} asChild>
<Button color="secondary">{cancelBtnText}</Button>
</DialogClose>
</div>
@@ -0,0 +1,78 @@
import { ExclamationIcon } from "@heroicons/react/outline";
import { CheckIcon } from "@heroicons/react/solid";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import React, { PropsWithChildren, ReactNode } from "react";
import { Button } from "@calcom/ui/Button";
import { DialogClose, DialogContent } from "@calcom/ui/Dialog";
import { useLocale } from "@lib/hooks/useLocale";
export type DeleteStripeDialogContentProps = {
confirmBtn?: ReactNode;
cancelAllBookingsBtnText?: string;
removeBtnText?: string;
cancelBtnText?: string;
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
onRemove?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
title: string;
variety?: "danger" | "warning" | "success";
};
export default function DeleteStripeDialogContent(props: PropsWithChildren<DeleteStripeDialogContentProps>) {
const { t } = useLocale();
const {
title,
variety,
confirmBtn = null,
cancelAllBookingsBtnText,
removeBtnText,
cancelBtnText = t("cancel"),
onConfirm,
onRemove,
children,
} = props;
return (
<DialogContent>
<div className="flex">
{variety && (
<div className="mt-0.5 ltr:mr-3">
{variety === "danger" && (
<div className="mx-auto rounded-full bg-red-100 p-2 text-center">
<ExclamationIcon className="h-5 w-5 text-red-600" />
</div>
)}
{variety === "warning" && (
<div className="mx-auto rounded-full bg-orange-100 p-2 text-center">
<ExclamationIcon className="h-5 w-5 text-orange-600" />
</div>
)}
{variety === "success" && (
<div className="mx-auto rounded-full bg-green-100 p-2 text-center">
<CheckIcon className="h-5 w-5 text-green-600" />
</div>
)}
</div>
)}
<div>
<DialogPrimitive.Title className="font-cal text-xl text-gray-900">{title}</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-sm text-neutral-500">
{children}
</DialogPrimitive.Description>
</div>
</div>
<div className="mt-5 flex flex-row-reverse gap-x-2 sm:mt-8">
<DialogClose onClick={onConfirm} asChild>
<Button color="alert">{cancelAllBookingsBtnText}</Button>
</DialogClose>
<DialogClose onClick={onRemove} asChild>
<Button color="alert2">{removeBtnText}</Button>
</DialogClose>
<DialogClose asChild>
<Button color="secondary">{cancelBtnText}</Button>
</DialogClose>
</div>
</DialogContent>
);
}
@@ -104,6 +104,11 @@ export default function CreateEventTypeButton(props: Props) {
showToast(message, "error");
}
if (err.data?.code === "BAD_REQUEST") {
const message = `${err.data.code}: URL already exists.`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
const message = `${err.data.code}: You are not able to create this event`;
showToast(message, "error");
@@ -1,18 +1,26 @@
import { Collapsible, CollapsibleContent } from "@radix-ui/react-collapsible";
import React, { useState } from "react";
import { useState } from "react";
import { UseFormReturn } from "react-hook-form";
import { Frequency as RRuleFrequency } from "rrule";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RecurringEvent } from "@calcom/types/Calendar";
import { Alert } from "@calcom/ui/Alert";
import Select from "@components/ui/form/Select";
type RecurringEventControllerProps = { recurringEvent: RecurringEvent; formMethods: UseFormReturn<any, any> };
type RecurringEventControllerProps = {
recurringEvent: RecurringEvent;
formMethods: UseFormReturn<any, any>;
paymentEnabled: boolean;
onRecurringEventDefined: Function;
};
export default function RecurringEventController({
recurringEvent,
formMethods,
paymentEnabled,
onRecurringEventDefined,
}: RecurringEventControllerProps) {
const { t } = useLocale();
@@ -39,93 +47,100 @@ export default function RecurringEventController({
{t("recurring_event")}
</label>
</div>
<div className="w-full">
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
onChange={(event) => {
setRecurringEventDefined(event?.target.checked);
if (!event?.target.checked) {
formMethods.setValue("recurringEvent", {});
} else {
formMethods.setValue(
"recurringEvent",
recurringEventDefined
? recurringEvent
: {
interval: 1,
count: 12,
freq: RRuleFrequency.WEEKLY,
}
);
}
recurringEvent = formMethods.getValues("recurringEvent");
}}
type="checkbox"
className="text-primary-600 h-4 w-4 rounded border-gray-300"
defaultChecked={recurringEventDefined}
data-testid="recurring-event-check"
/>
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<p className="text-neutral-900">{t("recurring_event_description")}</p>
</div>
</div>
<Collapsible
open={recurringEventDefined}
data-testid="recurring-event-collapsible"
onOpenChange={() => setRecurringEventDefined(!recurringEventDefined)}>
<CollapsibleContent className="mt-4 text-sm">
<div className="flex items-center">
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
<input
type="number"
min="1"
max="20"
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
defaultValue={recurringEvent?.interval || 1}
onChange={(event) => {
setRecurringEventInterval(parseInt(event?.target.value));
recurringEvent.interval = parseInt(event?.target.value);
formMethods.setValue("recurringEvent", recurringEvent);
}}
/>
<Select
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventFrequency]}
isSearchable={false}
className="w-18 block min-w-0 rounded-sm sm:text-sm"
onChange={(e) => {
if (e?.value) {
setRecurringEventFrequency(parseInt(e?.value));
recurringEvent.freq = parseInt(e?.value);
formMethods.setValue("recurringEvent", recurringEvent);
}
}}
/>
<div className={!paymentEnabled ? "w-full" : ""}>
{paymentEnabled ? (
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
) : (
<>
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
onChange={(event) => {
setRecurringEventDefined(event?.target.checked);
onRecurringEventDefined(event?.target.checked);
if (!event?.target.checked) {
formMethods.setValue("recurringEvent", {});
} else {
formMethods.setValue(
"recurringEvent",
recurringEventDefined
? recurringEvent
: {
interval: 1,
count: 12,
freq: RRuleFrequency.WEEKLY,
}
);
}
recurringEvent = formMethods.getValues("recurringEvent");
}}
type="checkbox"
className="text-primary-600 h-4 w-4 rounded border-gray-300"
defaultChecked={recurringEventDefined}
data-testid="recurring-event-check"
/>
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<p className="text-neutral-900">{t("recurring_event_description")}</p>
</div>
</div>
<div className="mt-4 flex items-center">
<p className="mr-2 text-neutral-900">{t("max")}</p>
<input
type="number"
min="1"
max="20"
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
defaultValue={recurringEvent?.count || 12}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
recurringEvent.count = parseInt(event?.target.value);
formMethods.setValue("recurringEvent", recurringEvent);
}}
/>
<p className="mr-2 text-neutral-900">
{t(`${RRuleFrequency[recurringEventFrequency].toString().toLowerCase()}`, {
count: recurringEventCount,
})}
</p>
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible
open={recurringEventDefined}
data-testid="recurring-event-collapsible"
onOpenChange={() => setRecurringEventDefined(!recurringEventDefined)}>
<CollapsibleContent className="mt-4 text-sm">
<div className="flex items-center">
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
<input
type="number"
min="1"
max="20"
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
defaultValue={recurringEvent?.interval || 1}
onChange={(event) => {
setRecurringEventInterval(parseInt(event?.target.value));
recurringEvent.interval = parseInt(event?.target.value);
formMethods.setValue("recurringEvent", recurringEvent);
}}
/>
<Select
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventFrequency]}
isSearchable={false}
className="w-18 block min-w-0 rounded-sm sm:text-sm"
onChange={(e) => {
if (e?.value) {
setRecurringEventFrequency(parseInt(e?.value));
recurringEvent.freq = parseInt(e?.value);
formMethods.setValue("recurringEvent", recurringEvent);
}
}}
/>
</div>
<div className="mt-4 flex items-center">
<p className="mr-2 text-neutral-900">{t("max")}</p>
<input
type="number"
min="1"
max="20"
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
defaultValue={recurringEvent?.count || 12}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
recurringEvent.count = parseInt(event?.target.value);
formMethods.setValue("recurringEvent", recurringEvent);
}}
/>
<p className="mr-2 text-neutral-900">
{t(`${RRuleFrequency[recurringEventFrequency].toString().toLowerCase()}`, {
count: recurringEventCount,
})}
</p>
</div>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
</div>
);
@@ -0,0 +1,69 @@
import { useState } from "react";
import { useMutation } from "react-query";
import showToast from "@calcom/lib/notification";
import { ButtonBaseProps } from "@calcom/ui/Button";
import { Dialog } from "@calcom/ui/Dialog";
import DeleteStripeDialogContent from "@components/dialog/DeleteStripeDialogContent";
export default function DisconnectStripeIntegration(props: {
/** Integration credential id */
id: number;
render: (renderProps: ButtonBaseProps) => JSX.Element;
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
}) {
const [modalOpen, setModalOpen] = useState(false);
const mutation = useMutation(
async (action: string) => {
const res = await fetch("/api/integrations", {
method: "DELETE",
body: JSON.stringify({ id: props.id, action }),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error("Something went wrong");
}
return res.json();
},
{
async onSettled() {
await props.onOpenChange(modalOpen);
},
onSuccess(data) {
showToast(data.message, "success");
setModalOpen(false);
},
}
);
return (
<>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DeleteStripeDialogContent
variety="warning"
title="Disconnect Stripe Integration"
cancelAllBookingsBtnText="Cancel all bookings"
removeBtnText="Remove payment"
cancelBtnText="Cancel"
onConfirm={() => {
mutation.mutate("cancel");
}}
onRemove={() => {
mutation.mutate("remove");
}}>
If you have unpaid and unconfirmed bookings, you must choose to cancel them or remove the required
payment field.
</DeleteStripeDialogContent>
</Dialog>
{props.render({
onClick() {
setModalOpen(true);
},
disabled: modalOpen,
loading: mutation.isLoading,
})}
</>
);
}
@@ -14,7 +14,10 @@ function IntegrationListItem(props: {
return (
<ListItem expanded={!!props.children} className={classNames("flex-col")}>
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
{props.imageSrc && <img className="h-10 w-10" src={props.imageSrc} alt={props.title} />}
{
// eslint-disable-next-line @next/next/no-img-element
props.imageSrc && <img className="h-10 w-10" src={props.imageSrc} alt={props.title} />
}
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3">{props.title}</ListItemTitle>
<ListItemText component="p">{props.description}</ListItemText>
@@ -150,7 +150,10 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
<WithStep step={SetupStep.DisplayQrCode} current={step}>
<>
<div className="flex justify-center">
<img src={dataUri} />
{
// eslint-disable-next-line @next/next/no-img-element
<img src={dataUri} alt="" />
}
</div>
<p className="text-center font-mono text-xs">{secret}</p>
</>
+4 -2
View File
@@ -1,6 +1,6 @@
import showToast from "@calcom/lib/notification";
import { trpc, inferQueryOutput } from "@lib/trpc";
import { inferQueryOutput, trpc } from "@lib/trpc";
import TeamListItem from "./TeamListItem";
@@ -39,7 +39,9 @@ export default function TeamList(props: Props) {
<TeamListItem
key={team?.id as number}
team={team}
onActionSelect={(action: string) => selectAction(action, team?.id as number)}></TeamListItem>
onActionSelect={(action: string) => selectAction(action, team?.id as number)}
isLoading={deleteTeamMutation.isLoading}
/>
))}
</ul>
</div>
+10 -6
View File
@@ -1,10 +1,10 @@
import { LogoutIcon } from "@heroicons/react/outline";
import {
ExternalLinkIcon,
TrashIcon,
LinkIcon,
DotsHorizontalIcon,
ExternalLinkIcon,
LinkIcon,
PencilIcon,
TrashIcon,
} from "@heroicons/react/solid";
import { MembershipRole } from "@prisma/client";
import Link from "next/link";
@@ -16,14 +16,14 @@ import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@calcom/ui/Dropdown";
import { Tooltip } from "@calcom/ui/Tooltip";
import classNames from "@lib/classNames";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { trpc, inferQueryOutput } from "@lib/trpc";
import { inferQueryOutput, trpc } from "@lib/trpc";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar";
@@ -34,6 +34,7 @@ interface Props {
team: inferQueryOutput<"viewer.teams.list">[number];
key: number;
onActionSelect: (text: string) => void;
isLoading?: boolean;
}
export default function TeamListItem(props: Props) {
@@ -175,7 +176,10 @@ export default function TeamListItem(props: Props) {
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={() => props.onActionSelect("disband")}>
isLoading={props.isLoading}
onConfirm={() => {
props.onActionSelect("disband");
}}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
@@ -118,6 +118,7 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
)}
</div>
{props.team?.id && props.role !== MembershipRole.MEMBER && (
// eslint-disable-next-line @next/next/link-passhref
<Link href={`/settings/teams/${props.team.id}/availability`}>
<div className="mt-5 hidden space-y-1 sm:block">
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>
@@ -37,6 +37,7 @@ const Team = ({ team }: TeamPageProps) => {
);
return (
// eslint-disable-next-line @next/next/link-passhref
<Link key={member.id} href={`/${member.username}`}>
<div className={classes}>
<ArrowRightIcon
+3 -1
View File
@@ -1,3 +1,4 @@
import classNames from "classnames";
import React from "react";
import Loader from "@components/Loader";
@@ -16,8 +17,9 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
return (
<div className="flex min-h-screen flex-col justify-center bg-neutral-50 py-12 sm:px-6 lg:px-8">
<HeadSeo title={props.title} description={props.description} />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className={classNames(props.showLogo ? "text-center" : "", "sm:mx-auto sm:w-full sm:max-w-md")}>
{props.showLogo && (
// eslint-disable-next-line @next/next/no-img-element
<img className="mx-auto h-6" src="/calendso-logo-white-word.svg" alt="Cal.com Logo" />
)}
{props.heading && (
+1
View File
@@ -27,6 +27,7 @@ export default function Avatar(props: AvatarProps) {
/>
<AvatarPrimitive.Fallback delayMs={600}>
{gravatarFallbackMd5 && (
// eslint-disable-next-line @next/next/no-img-element
<img src={defaultAvatarSrc({ md5: gravatarFallbackMd5 })} alt={alt} className={className} />
)}
</AvatarPrimitive.Fallback>
+1
View File
@@ -28,5 +28,6 @@ export function AvatarSSR(props: AvatarProps) {
} else if (user.emailMd5) {
imgSrc = defaultAvatarSrc(user.emailMd5);
}
// eslint-disable-next-line @next/next/no-img-element
return imgSrc ? <img alt={alt} className={className} src={imgSrc}></img> : null;
}
+16 -10
View File
@@ -12,16 +12,22 @@ const PoweredByCal = () => {
<Link href={`https://cal.com?utm_source=embed&utm_medium=powered-by-button`}>
<a target="_blank" className="text-bookinglight opacity-50 hover:opacity-100 dark:text-white">
{t("powered_by")}{" "}
<img
className="relative -mt-px inline h-[10px] w-auto dark:hidden"
src="https://cal.com/logo.svg"
alt="Cal.com Logo"
/>
<img
className="relativ -mt-px hidden h-[10px] w-auto dark:inline"
src="https://cal.com/logo-white.svg"
alt="Cal.com Logo"
/>
{
// eslint-disable-next-line @next/next/no-img-element
<img
className="relative -mt-px inline h-[10px] w-auto dark:hidden"
src="/cal-logo-word.svg"
alt="Cal.com Logo"
/>
}
{
// eslint-disable-next-line @next/next/no-img-element
<img
className="relativ -mt-px hidden h-[10px] w-auto dark:inline"
src="/cal-logo-word-dark.svg"
alt="Cal.com Logo"
/>
}
</a>
</Link>
</div>
+3 -2
View File
@@ -6,6 +6,7 @@ interface WeekdaySelectProps {
}
export const WeekdaySelect = (props: WeekdaySelectProps) => {
const { onSelect } = props;
const [activeDays, setActiveDays] = useState<boolean[]>(
Array.from(Array(7).keys()).map((v, i) => (props.defaultValue || []).includes(i))
);
@@ -13,8 +14,8 @@ export const WeekdaySelect = (props: WeekdaySelectProps) => {
const days = ["S", "M", "T", "W", "T", "F", "S"];
useEffect(() => {
props.onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1));
}, [activeDays]);
onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1));
}, [onSelect, activeDays]);
const toggleDay = (idx: number) => {
activeDays[idx] = !activeDays[idx];
@@ -24,12 +24,13 @@ export type CheckedSelectProps = {
};
export const CheckedSelect = (props: CheckedSelectProps) => {
const { onChange } = props;
const [selectedOptions, setSelectedOptions] = useState<CheckedSelectValue>(props.defaultValue || []);
const { t } = useLocale();
useEffect(() => {
props.onChange(selectedOptions);
}, [selectedOptions]);
onChange(selectedOptions);
}, [onChange, selectedOptions]);
const options = props.options.map((option) => ({
...option,
@@ -35,6 +35,7 @@ type Props = {
eventType: { id: number };
user: { username: string | null };
location?: string | null;
bookingId: number;
};
type States =
@@ -86,6 +87,7 @@ export default function PaymentComponent(props: Props) {
type: props.eventType.id,
user: props.user.username,
name,
bookingId: props.bookingId,
};
if (props.location) {
@@ -49,7 +49,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
embedIframeWidth = e.detail.data.iframeWidth as number;
});
}
}, [isEmbed]);
}, [date, isEmbed]);
const eventName = props.booking.title;
@@ -137,6 +137,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
eventType={props.eventType}
user={props.user}
location={props.booking.location}
bookingId={props.booking.id}
/>
</Elements>
)}
+13 -4
View File
@@ -89,12 +89,15 @@ const CryptoSection = (props: CryptoSectionProps) => {
}
return <div />;
}, [props.verified]);
}, [props.pathname, props.verified]);
const verifyButton = useMemo(() => {
return (
<Button color="secondary" onClick={verifyWallet} type="button" id="hasToken" name="hasToken">
<img className="mr-1 h-5" src="/apps/metamask.svg" />
{
// eslint-disable-next-line @next/next/no-img-element
<img className="mr-1 h-5" src="/apps/metamask.svg" alt="MetaMask" />
}
{t("verify_wallet")}
</Button>
);
@@ -103,7 +106,10 @@ const CryptoSection = (props: CryptoSectionProps) => {
const connectButton = useMemo(() => {
return (
<Button color="secondary" onClick={connectMetamask} type="button">
<img className="mr-1 h-5" src="/apps/metamask.svg" />
{
// eslint-disable-next-line @next/next/no-img-element
<img className="mr-1 h-5" src="/apps/metamask.svg" alt="MetaMask" />
}
{t("connect_metamask")}
</Button>
);
@@ -118,7 +124,10 @@ const CryptoSection = (props: CryptoSectionProps) => {
await connectMetamask();
await verifyWallet();
}}>
<img className="mr-1 h-5" src="/apps/metamask.svg" />
{
// eslint-disable-next-line @next/next/no-img-element
<img className="mr-1 h-5" src="/apps/metamask.svg" alt="MetaMask" />
}
{t("verify_wallet")}
</Button>
);
+7 -2
View File
@@ -2,6 +2,7 @@ import { PaymentType, Prisma } from "@prisma/client";
import Stripe from "stripe";
import { v4 as uuidv4 } from "uuid";
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import prisma from "@calcom/prisma";
import { createPaymentLink } from "@calcom/stripe/client";
@@ -16,8 +17,8 @@ export type PaymentInfo = {
id?: string | null;
};
const paymentFeePercentage = process.env.PAYMENT_FEE_PERCENTAGE!;
const paymentFeeFixed = process.env.PAYMENT_FEE_FIXED!;
let paymentFeePercentage: number | undefined;
let paymentFeeFixed: number | undefined;
export async function handlePayment(
evt: CalendarEvent,
@@ -33,6 +34,10 @@ export async function handlePayment(
uid: string;
}
) {
const appKeys = await getAppKeysFromSlug("stripe");
if (typeof appKeys.payment_fee_fixed === "number") paymentFeePercentage = appKeys.payment_fee_fixed;
if (typeof appKeys.payment_fee_percentage === "number") paymentFeeFixed = appKeys.payment_fee_percentage;
const paymentFee = Math.round(
selectedEventType.price * parseFloat(`${paymentFeePercentage}`) + parseInt(`${paymentFeeFixed}`)
);
+1
View File
@@ -21,6 +21,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
bookingId: true,
booking: {
select: {
id: true,
description: true,
title: true,
startTime: true,
@@ -0,0 +1,42 @@
import nodemailer from "nodemailer";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig";
export default class BaseEmail {
name: string = "";
protected getNodeMailerPayload(): Record<string, unknown> {
return {};
}
public sendEmail() {
if (process.env.NEXT_PUBLIC_IS_E2E) return new Promise((r) => r("Skipped sendEmail for E2E"));
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
if (_err) {
const err = getErrorFromUnknown(_err);
this.printNodeMailerError(err);
reject(err);
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
protected getMailerOptions() {
return {
transport: serverConfig.transport,
from: serverConfig.from,
};
}
protected printNodeMailerError(error: Error): void {
/** Don't clog the logs with unsent emails in E2E */
if (process.env.NEXT_PUBLIC_IS_E2E) return;
console.error(`${this.name}_ERROR`, error);
}
}
@@ -4,22 +4,20 @@ import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray } from "ics";
import { DatasetJsonLdProps } from "next-seo";
import nodemailer from "nodemailer";
import rrule from "rrule";
import { getAppName } from "@calcom/app-store/utils";
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig";
import type { Person, CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import type { CalendarEvent, Person, RecurringEvent } from "@calcom/types/Calendar";
import BaseEmail from "@lib/emails/templates/_base-email";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailHead,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
emailSchedulingBodyHeader,
linkIcon,
} from "./common";
@@ -28,34 +26,19 @@ dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class AttendeeScheduledEmail {
export default class AttendeeScheduledEmail extends BaseEmail {
calEvent: CalendarEvent;
attendee: Person;
recurringEvent: RecurringEvent;
constructor(calEvent: CalendarEvent, attendee: Person, recurringEvent: RecurringEvent) {
super();
this.name = "SEND_BOOKING_CONFIRMATION";
this.calEvent = calEvent;
this.attendee = attendee;
this.recurringEvent = recurringEvent;
}
public sendEmail() {
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
if (_err) {
const err = getErrorFromUnknown(_err);
this.printNodeMailerError(err);
reject(err);
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
protected getiCalEventAsString(): string | undefined {
// Taking care of recurrence rule beforehand
let recurrenceRule: string | undefined = undefined;
@@ -115,12 +98,6 @@ export default class AttendeeScheduledEmail {
};
}
protected getMailerOptions() {
return {
transport: serverConfig.transport,
from: serverConfig.from,
};
}
protected getDescription(): string {
if (!this.calEvent.description) return "";
return `
@@ -144,10 +121,6 @@ ${getRichDescription(this.calEvent)}
`.trim();
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.attendee.email, error);
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.attendees[0].language.translate("confirmed_event_type_subject", {
eventType: this.calEvent.type,
@@ -1,10 +1,8 @@
import { TFunction } from "next-i18next";
import nodemailer from "nodemailer";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig";
import BaseEmail from "@lib/emails/templates/_base-email";
import { emailHead, linkIcon, emailBodyLogo } from "./common";
import { emailBodyLogo, emailHead, linkIcon } from "./common";
export type PasswordReset = {
language: TFunction;
@@ -17,37 +15,15 @@ export type PasswordReset = {
export const PASSWORD_RESET_EXPIRY_HOURS = 6;
export default class ForgotPasswordEmail {
export default class ForgotPasswordEmail extends BaseEmail {
passwordEvent: PasswordReset;
constructor(passwordEvent: PasswordReset) {
super();
this.name = "SEND_PASSWORD_RESET_EMAIL";
this.passwordEvent = passwordEvent;
}
public sendEmail() {
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
if (_err) {
const err = getErrorFromUnknown(_err);
this.printNodeMailerError(err);
reject(err);
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
protected getMailerOptions() {
return {
transport: serverConfig.transport,
from: serverConfig.from,
};
}
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.passwordEvent.user.name} <${this.passwordEvent.user.email}>`,
@@ -58,10 +34,6 @@ export default class ForgotPasswordEmail {
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_PASSWORD_RESET_EMAIL_ERROR", this.passwordEvent.user.email, error);
}
protected getTextBody(): string {
return `
${this.passwordEvent.language("reset_password_subject")}
@@ -4,21 +4,20 @@ import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray, Person } from "ics";
import nodemailer from "nodemailer";
import rrule from "rrule";
import { getAppName } from "@calcom/app-store/utils";
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig";
import type { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import BaseEmail from "@lib/emails/templates/_base-email";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailHead,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
emailSchedulingBodyHeader,
linkIcon,
} from "./common";
@@ -27,32 +26,17 @@ dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class OrganizerScheduledEmail {
export default class OrganizerScheduledEmail extends BaseEmail {
calEvent: CalendarEvent;
recurringEvent: RecurringEvent;
constructor(calEvent: CalendarEvent, recurringEvent: RecurringEvent) {
super();
this.name = "SEND_BOOKING_CONFIRMATION";
this.calEvent = calEvent;
this.recurringEvent = recurringEvent;
}
public sendEmail() {
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
if (_err) {
const err = getErrorFromUnknown(_err);
this.printNodeMailerError(err);
reject(err);
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
protected getiCalEventAsString(): string | undefined {
// Taking care of recurrence rule beforehand
let recurrenceRule: string | undefined = undefined;
@@ -121,13 +105,6 @@ export default class OrganizerScheduledEmail {
};
}
protected getMailerOptions() {
return {
transport: serverConfig.transport,
from: serverConfig.from,
};
}
protected getTextBody(): string {
return `
${this.calEvent.organizer.language.translate(
@@ -139,10 +116,6 @@ ${getRichDescription(this.calEvent)}
`.trim();
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.organizer.email, error);
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.organizer.language.translate("confirmed_event_type_subject", {
eventType: this.calEvent.type,
@@ -1,10 +1,8 @@
import { TFunction } from "next-i18next";
import nodemailer from "nodemailer";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig";
import BaseEmail from "@lib/emails/templates/_base-email";
import { emailHead, linkIcon, emailBodyLogo } from "./common";
import { emailBodyLogo, emailHead, linkIcon } from "./common";
export type TeamInvite = {
language: TFunction;
@@ -14,37 +12,15 @@ export type TeamInvite = {
joinLink: string;
};
export default class TeamInviteEmail {
export default class TeamInviteEmail extends BaseEmail {
teamInviteEvent: TeamInvite;
constructor(teamInviteEvent: TeamInvite) {
super();
this.name = "SEND_TEAM_INVITE_EMAIL";
this.teamInviteEvent = teamInviteEvent;
}
public sendEmail() {
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
if (_err) {
const err = getErrorFromUnknown(_err);
this.printNodeMailerError(err);
reject(err);
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
protected getMailerOptions() {
return {
transport: serverConfig.transport,
from: serverConfig.from,
};
}
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: this.teamInviteEvent.to,
@@ -58,10 +34,6 @@ export default class TeamInviteEmail {
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_TEAM_INVITE_EMAIL_ERROR", this.teamInviteEvent.to, error);
}
protected getTextBody(): string {
return "";
}
+58
View File
@@ -0,0 +1,58 @@
import { Credential, SelectedCalendar } from "@prisma/client";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import { getBusyVideoTimes } from "@calcom/core/videoClient";
import notEmpty from "@calcom/lib/notEmpty";
import type { EventBusyDate } from "@calcom/types/Calendar";
import prisma from "@lib/prisma";
async function getBusyTimes(params: {
credentials: Credential[];
userId: number;
eventTypeId?: number;
startTime: string;
endTime: string;
selectedCalendars: SelectedCalendar[];
}) {
const { credentials, userId, eventTypeId, startTime, endTime, selectedCalendars } = params;
const busyTimes: EventBusyDate[] = await prisma.booking
.findMany({
where: {
AND: [
{
userId,
eventTypeId,
startTime: { gte: new Date(startTime) },
endTime: { lte: new Date(endTime) },
},
{
OR: [
{
status: "ACCEPTED",
},
{
status: "PENDING",
},
],
},
],
},
select: {
startTime: true,
endTime: true,
},
})
.then((bookings) => bookings.map((booking) => ({ end: booking.endTime, start: booking.startTime })));
if (credentials) {
const calendarBusyTimes = await getBusyCalendarTimes(credentials, startTime, endTime, selectedCalendars);
busyTimes.push(...calendarBusyTimes);
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
busyTimes.push(...videoBusyTimes);
}
return busyTimes;
}
export default getBusyTimes;
+36 -26
View File
@@ -122,6 +122,31 @@ export const useSlots = (props: UseSlotsProps) => {
const dateTo = date.endOf("day").format();
const query = stringify({ dateFrom, dateTo, eventTypeId });
const handleAvailableSlots = async (res: Response) => {
const responseBody: AvailabilityUserResponse = await res.json();
const times = getSlots({
frequency: slotInterval || eventLength,
inviteeDate: date,
workingHours: responseBody.workingHours,
minimumBookingNotice,
eventLength,
});
const filterTimeProps = {
times,
busy: responseBody.busy,
eventLength,
beforeBufferTime,
afterBufferTime,
};
const filteredTimes = getFilteredTimes(filterTimeProps);
// temporary
const user = res.url.substring(res.url.lastIndexOf("/") + 1, res.url.indexOf("?"));
return filteredTimes.map((time) => ({
time,
users: [user],
}));
};
Promise.all<Slot[]>(
users.map((user) => fetch(`/api/availability/${user.username}?${query}`).then(handleAvailableSlots))
)
@@ -173,32 +198,17 @@ export const useSlots = (props: UseSlotsProps) => {
console.error(e);
setError(e);
});
}, [date]);
const handleAvailableSlots = async (res: Response) => {
const responseBody: AvailabilityUserResponse = await res.json();
const times = getSlots({
frequency: slotInterval || eventLength,
inviteeDate: date,
workingHours: responseBody.workingHours,
minimumBookingNotice,
eventLength,
});
const filterTimeProps = {
times,
busy: responseBody.busy,
eventLength,
beforeBufferTime,
afterBufferTime,
};
const filteredTimes = getFilteredTimes(filterTimeProps);
// temporary
const user = res.url.substring(res.url.lastIndexOf("/") + 1, res.url.indexOf("?"));
return filteredTimes.map((time) => ({
time,
users: [user],
}));
};
}, [
afterBufferTime,
beforeBufferTime,
eventLength,
minimumBookingNotice,
slotInterval,
eventTypeId,
props.schedulingType,
users,
date,
]);
return {
slots,
+9 -8
View File
@@ -1,10 +1,9 @@
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import getBusyTimes from "@lib/getBusyTimes";
import prisma from "@lib/prisma";
export async function getUserAvailability(query: {
@@ -60,12 +59,14 @@ export async function getUserAvailability(query: {
const { selectedCalendars, ...currentUser } = rawUser;
const busyTimes = await getBusyCalendarTimes(
currentUser.credentials,
dateFrom.format(),
dateTo.format(),
selectedCalendars
);
const busyTimes = await getBusyTimes({
credentials: currentUser.credentials,
startTime: dateFrom.format(),
endTime: dateTo.format(),
eventTypeId: query.eventTypeId,
userId: currentUser.id,
selectedCalendars,
});
const bufferedBusyTimes = busyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
+26 -3
View File
@@ -32,7 +32,7 @@ const splitAvailableTime = (
const periodTime = initialTime + frequency;
const slotEndTime = initialTime + eventLength;
/*
check if the slot end time surpasses availability end time of the user
check if the slot end time surpasses availability end time of the user
1 minute is added to round up the hour mark so that end of the slot is considered in the check instead of x9
eg: if finalization time is 11:59, slotEndTime is 12:00, we ideally want the slot to be available
*/
@@ -64,9 +64,32 @@ const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours,
const slots: Dayjs[] = [];
const slotsTimeFrameAvailable = [] as Array<WorkingHoursTimeFrame>;
// Here we split working hour in chunks for every frequency available that can fit in whole working hour
// Here we split working hour in chunks for every frequency available that can fit in whole working hours
const computedLocalWorkingHours: WorkingHoursTimeFrame[] = [];
let tempComputeTimeFrame: WorkingHoursTimeFrame | undefined;
const computeLength = localWorkingHours.length - 1;
const makeTimeFrame = (item: typeof localWorkingHours[0]): WorkingHoursTimeFrame => ({
startTime: item.startTime,
endTime: item.endTime,
});
localWorkingHours.forEach((item, index) => {
if (!tempComputeTimeFrame) {
tempComputeTimeFrame = makeTimeFrame(item);
} else {
// please check the comment in splitAvailableTime func for the added 1 minute
if (tempComputeTimeFrame.endTime + 1 === item.startTime) {
// to deal with time that across the day, e.g. from 11:59 to to 12:01
tempComputeTimeFrame.endTime = item.endTime;
} else {
computedLocalWorkingHours.push(tempComputeTimeFrame);
tempComputeTimeFrame = makeTimeFrame(item);
}
}
if (index == computeLength) {
computedLocalWorkingHours.push(tempComputeTimeFrame);
}
});
computedLocalWorkingHours.forEach((item, index) => {
slotsTimeFrameAvailable.push(...splitAvailableTime(item.startTime, item.endTime, frequency, eventLength));
});
+2
View File
@@ -13,6 +13,8 @@ export const telemetryEventTypes = {
googleLogin: "google_login",
samlLogin: "saml_login",
samlConfig: "saml_config",
embedView: "embed_view",
embedBookingConfirmed: "embed_booking_confirmed",
};
/**
@@ -8,7 +8,7 @@ export const hasTemplateIntegration = (props: WebhookIntegrationProps) => {
const ind = supportedWebhookIntegrationList.findIndex((integration) => {
return props.url.includes(integration);
});
return ind > -1 ? true : false;
return ind > -1;
};
const customTemplate = (props: WebhookIntegrationProps) => {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "1.5.4",
"version": "1.6.0",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -74,6 +74,7 @@
"ical.js": "^1.4.0",
"ics": "^2.31.0",
"jimp": "^0.16.1",
"libphonenumber-js": "^1.9.53",
"lodash": "^4.17.21",
"micro": "^9.3.4",
"mime-types": "^2.1.35",
+2 -2
View File
@@ -35,7 +35,7 @@ export default function Custom404() {
const [url, setUrl] = useState("https://cal.com/signup?username=");
useEffect(() => {
setUrl(`https://cal.com/signup?username=${username.replace("/", "")}`);
}, [router.query]);
}, [username, router.query]);
const isSubpage = router.asPath.includes("/", 2);
const isSignup = router.asPath.includes("/signup");
@@ -98,7 +98,7 @@ export default function Custom404() {
<ul role="list" className="divide-y divide-gray-200 border-gray-200">
<li className="px-4 py-2">
<Link href="https://docs.cal.com/self-hosting/install#setting-up-your-first-user">
<Link href="https://docs.cal.com/self-hosting/installation">
<a className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
<div className="flex-shrink-0">
<span className="flex h-12 w-12 items-center justify-center rounded-lg bg-gray-50">
+6 -1
View File
@@ -11,7 +11,12 @@ export default function Error500() {
</Head>
<div className="m-auto text-center">
<h1 className="font-cal text-[250px] text-gray-900">
5<img src="/error.svg" className="-mt-10 inline w-60" />0
5
{
// eslint-disable-next-line @next/next/no-img-element
<img src="/error.svg" className="-mt-10 inline w-60" alt="0" />
}
0
</h1>
<h2 className="mb-2 -mt-16 text-3xl text-gray-600">It&apos;s not you, it&apos;s us.</h2>
<p className="mb-4 max-w-2xl text-gray-500">
+4 -1
View File
@@ -119,7 +119,10 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
useEffect(() => {
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.pageView, collectPageParameters("/[user]"))
jitsu.track(
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
collectPageParameters("/[user]")
)
);
}, [telemetry]);
return (
+7 -2
View File
@@ -94,6 +94,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!users.length) return { notFound: true };
const [user] = users;
const isDynamicGroupBooking = users.length > 1;
// Dynamic Group link doesn't need a type but it must have a slug
if ((!isDynamicGroupBooking && !context.query.type) || (isDynamicGroupBooking && !eventTypeSlug)) {
return { notFound: true };
}
const eventTypeRaw =
usernameList.length > 1
? getDefaultEvent(eventTypeSlug)
@@ -173,8 +180,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
booking = await getBooking(prisma, context.query.rescheduleUid as string);
}
const isDynamicGroupBooking = users.length > 1;
const dynamicNames = isDynamicGroupBooking
? users.map((user) => {
return user.name || "";
+4 -1
View File
@@ -1,5 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -12,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const userId = req.session.user.id;
try {
const installedApp = await prisma?.credential.findFirst({
const installedApp = await prisma.credential.findFirst({
where: {
type: appCredentialType as string,
userId: userId,
@@ -25,6 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.status(404);
}
} catch (error) {
console.log(error);
res.status(500);
}
} else {
@@ -434,5 +434,12 @@ export default NextAuth({
return false;
},
async redirect({ url, baseUrl }) {
// Allows relative callback URLs
if (url.startsWith("/")) return `${baseUrl}${url}`;
// Allows callback URLs on the same origin
else if (new URL(url).origin === new URL(baseUrl || WEBSITE_URL).origin) return url;
return baseUrl;
},
},
});
+9 -11
View File
@@ -1,14 +1,12 @@
// import { getBusyVideoTimes } from "@calcom/core/videoClient";
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import getBusyTimes from "@lib/getBusyTimes";
import prisma from "@lib/prisma";
dayjs.extend(utc);
@@ -77,14 +75,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { selectedCalendars, ...currentUser } = rawUser;
const busyTimes = await getBusyCalendarTimes(
currentUser.credentials,
dateFrom.format(),
dateTo.format(),
selectedCalendars
);
// busyTimes.push(...await getBusyVideoTimes(currentUser.credentials, dateFrom.format(), dateTo.format()));
const busyTimes = await getBusyTimes({
credentials: currentUser.credentials,
startTime: dateFrom.format(),
endTime: dateTo.format(),
eventTypeId,
userId: currentUser.id,
selectedCalendars,
});
const bufferedBusyTimes = busyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute"),
+17
View File
@@ -106,6 +106,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
uid: true,
payment: true,
destinationCalendar: true,
paid: true,
recurringEventId: true,
},
});
@@ -122,6 +123,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "booking already confirmed" });
}
/** When a booking that requires payment its being confirmed but doesn't have any payment,
* we shouldn’t save it on DestinationCalendars
*/
if (booking.payment.length > 0 && !booking.paid) {
await prisma.booking.update({
where: {
id: bookingId,
},
data: {
confirmed: true,
},
});
return res.status(204).end();
}
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
+59 -65
View File
@@ -10,20 +10,12 @@ import rrule from "rrule";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import EventManager from "@calcom/core/EventManager";
import { getBusyVideoTimes } from "@calcom/core/videoClient";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import notEmpty from "@calcom/lib/notEmpty";
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
import type {
AdditionInformation,
CalendarEvent,
EventBusyDate,
RecurringEvent,
} from "@calcom/types/Calendar";
import type { AdditionInformation, CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import { handlePayment } from "@ee/lib/stripe/server";
@@ -35,6 +27,7 @@ import {
} from "@lib/emails/email-manager";
import { ensureArray } from "@lib/ensureArray";
import { getEventName } from "@lib/event";
import getBusyTimes from "@lib/getBusyTimes";
import prisma from "@lib/prisma";
import { BookingCreateBody } from "@lib/types/booking";
import sendPayload from "@lib/webhooks/sendPayload";
@@ -525,7 +518,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
return prisma.booking.create(createBookingObj);
/* Validate if there is any stripe_payment credential for this user */
const stripePaymentCredential = await prisma.credential.findFirst({
where: {
type: "stripe_payment",
userId: users[0].id,
},
select: {
id: true,
},
});
/** eventType doesn’t require payment then we create a booking
* OR
* stripePaymentCredential is found and price is higher than 0 then we create a booking
*/
if (!eventType.price || (stripePaymentCredential && eventType.price > 0)) {
return prisma.booking.create(createBookingObj);
}
// stripePaymentCredential not found and eventType requires payment we return null
return null;
}
let results: EventResult[] = [];
@@ -546,43 +557,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
const credentials = currentUser.credentials;
const busyTimes = await getBusyTimes({
credentials: currentUser.credentials,
startTime: reqBody.start,
endTime: reqBody.end,
eventTypeId,
userId: currentUser.id,
selectedCalendars,
});
const calendarBusyTimes: EventBusyDate[] = await prisma.booking
.findMany({
where: {
AND: [
{
userId: currentUser.id,
eventTypeId: eventTypeId,
},
{
OR: [
{
status: "ACCEPTED",
},
{
status: "PENDING",
},
],
},
],
},
})
.then((bookings) => bookings.map((booking) => ({ end: booking.endTime, start: booking.startTime })));
console.log("calendarBusyTimes==>>>", busyTimes);
if (credentials) {
await getBusyCalendarTimes(credentials, reqBody.start, reqBody.end, selectedCalendars).then(
(busyTimes) => calendarBusyTimes.push(...busyTimes)
);
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
calendarBusyTimes.push(...videoBusyTimes);
}
console.log("calendarBusyTimes==>>>", calendarBusyTimes);
const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({
const bufferedBusyTimes = busyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
}));
@@ -651,7 +637,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
let booking: Booking | null = null;
try {
booking = await createBooking();
evt.uid = booking.uid;
evt.uid = booking?.uid ?? null;
} catch (_err) {
const err = getErrorFromUnknown(_err);
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
@@ -671,15 +657,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (originalRescheduledBooking?.uid) {
// Use EventManager to conditionally use all needed integrations.
const updateManager = await eventManager.update(evt, originalRescheduledBooking.uid, booking.id);
const updateManager = await eventManager.update(evt, originalRescheduledBooking.uid, booking?.id);
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
results = updateManager.results;
referencesToCreate = updateManager.referencesToCreate;
if (results.length > 0 && results.every((res) => !res.success)) {
if (results.length > 0 && results.some((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
@@ -765,10 +750,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
);
}
if (typeof eventType.price === "number" && eventType.price > 0 && !originalRescheduledBooking?.paid) {
if (
!Number.isNaN(eventType.price) &&
eventType.price > 0 &&
!originalRescheduledBooking?.paid &&
!!booking
) {
try {
const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment");
if (!firstStripeCredential) return res.status(500).json({ message: "Missing payment credentials" });
if (!booking.user) booking.user = user;
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);
@@ -807,18 +799,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
);
await Promise.all(promises);
// Avoid passing referencesToCreate with id unique constrain values
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
references: {
createMany: {
data: referencesToCreate,
},
},
},
});
// refresh hashed link if used
const urlSeed = `${users[0].username}:${dayjs(req.body.start).utc().format()}`;
const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL));
@@ -833,9 +813,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
}
// booking successful
return res.status(201).json(booking);
if (booking) {
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
references: {
createMany: {
data: referencesToCreate,
},
},
},
});
// booking successful
return res.status(201).json(booking);
}
return res.status(400).json({ message: "There is not a stripe_payment credential" });
}
export function getLuckyUsers(
+5 -1
View File
@@ -51,6 +51,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
select: {
uid: true,
type: true,
externalCalendarId: true,
},
},
payment: true,
@@ -163,11 +164,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential: Credential) => {
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
const bookingExternalCalendarId = bookingToDelete.references.filter(
(ref) => ref.type === credential.type
)[0]?.externalCalendarId;
if (bookingRefUid) {
if (credential.type.endsWith("_calendar")) {
const calendar = getCalendar(credential);
return calendar?.deleteEvent(bookingRefUid, evt);
return calendar?.deleteEvent(bookingRefUid, evt, bookingExternalCalendarId);
} else if (credential.type.endsWith("_video")) {
return deleteMeeting(credential, bookingRefUid);
}
+1 -1
View File
@@ -57,7 +57,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
slug: eventType.slug,
length: eventType.durations[0],
description: eventType.description.replace(/<[^>]*>?/gm, ""),
hidden: eventType.state === "active" ? true : false,
hidden: eventType.state === "active",
users: {
connect: {
id: authenticatedUser.id,
+80
View File
@@ -69,6 +69,86 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
data,
});
if (req.body?.action === "cancel" || req.body?.action === "remove") {
try {
const bookingIdsWithPayments = await prisma.booking
.findMany({
where: {
userId: session?.user?.id,
paid: false,
NOT: {
payment: {
every: {
booking: null,
},
},
},
},
select: {
id: true,
},
})
.then((bookings) => bookings.map((booking) => booking.id));
const deletePayments = prisma.payment.deleteMany({
where: {
bookingId: {
in: bookingIdsWithPayments,
},
success: false,
},
});
const updateBookings = prisma.booking.updateMany({
where: {
id: {
in: bookingIdsWithPayments,
},
},
data: {
status: "CANCELLED",
rejectionReason: "Payment provider got removed",
},
});
const bookingReferences = await prisma.booking
.findMany({
where: {
confirmed: true,
rejected: false,
},
select: {
id: true,
},
})
.then((bookings) => bookings.map((booking) => booking.id));
const deleteBookingReferences = prisma.bookingReference.deleteMany({
where: {
bookingId: {
in: bookingReferences,
},
},
});
if (req.body?.action === "cancel") {
await prisma.$transaction([deletePayments, updateBookings, deleteBookingReferences]);
} else {
const updateBookings = prisma.booking.updateMany({
where: {
id: {
in: bookingIdsWithPayments,
},
},
data: {
paid: true,
},
});
await prisma.$transaction([deletePayments, updateBookings]);
}
} catch (e) {
console.error(e);
res.status(500).json({ message: "Integration could not be deleted" });
}
}
res.status(200).json({ message: "Integration deleted successfully" });
}
}
@@ -54,7 +54,7 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
type={data.type}
logo={data.logo}
categories={[data.category]}
author="Cal.com"
author={data.publisher}
feeType={data.feeType || "usage-based"}
price={data.price || 0}
commission={data.commission || 0}
+45
View File
@@ -0,0 +1,45 @@
import { InferGetStaticPropsType } from "next";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
import { AppSetupPageMap, getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
import prisma from "@calcom/prisma";
import Loader from "@calcom/ui/Loader";
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
const router = useRouter();
const slug = router.query.slug as string;
const { status } = useSession();
if (status === "loading") {
return (
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-200">
<Loader />
</div>
);
}
if (status === "unauthenticated") {
router.replace({
pathname: "/auth/login",
query: {
callbackUrl: `/apps/${slug}/setup`,
},
});
}
return <AppSetupPage slug={slug} {...props} />;
}
export const getStaticPaths = async () => {
const appStore = await prisma.app.findMany({ select: { slug: true } });
const paths = appStore.filter((a) => a.slug in AppSetupPageMap).map((app) => app.slug);
return {
paths: paths.map((slug) => ({ params: { slug } })),
fallback: false,
};
};
export { getStaticProps };
+33 -15
View File
@@ -1,15 +1,17 @@
import { ChevronLeftIcon } from "@heroicons/react/solid";
import { InferGetStaticPropsType } from "next";
import { AppCategories } from "@prisma/client";
import { GetStaticPropsContext, InferGetStaticPropsType } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import Shell from "@components/Shell";
import AppCard from "@components/apps/AppCard";
export default function Apps({ appStore }: InferGetStaticPropsType<typeof getStaticProps>) {
export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) {
const { t } = useLocale();
const router = useRouter();
@@ -28,18 +30,16 @@ export default function Apps({ appStore }: InferGetStaticPropsType<typeof getSta
<div className="mb-16">
<h2 className="mb-2 text-lg font-semibold text-gray-900">All {router.query.category} apps</h2>
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
{appStore.map((app) => {
{apps.map((app) => {
return (
app.category === router.query.category && (
<AppCard
key={app.name}
slug={app.slug}
name={app.name}
description={app.description}
logo={app.logo}
rating={app.rating}
/>
)
<AppCard
key={app.name}
slug={app.slug}
name={app.name}
description={app.description}
logo={app.logo}
rating={app.rating}
/>
);
})}
</div>
@@ -64,10 +64,28 @@ export const getStaticPaths = async () => {
};
};
export const getStaticProps = async () => {
export const getStaticProps = async (context: GetStaticPropsContext) => {
const category = context.params?.category as AppCategories;
const appQuery = await prisma.app.findMany({
where: {
categories: {
has: category,
},
},
select: {
slug: true,
},
});
const appSlugs = appQuery.map((category) => category.slug);
const appStore = await getAppRegistry();
const apps = appStore.filter((app) => appSlugs.includes(app.slug));
return {
props: {
appStore: await getAppRegistry(),
apps,
},
};
};
+11 -3
View File
@@ -2,6 +2,7 @@ import { InferGetStaticPropsType } from "next";
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import AppsShell from "@components/AppsShell";
import Shell from "@components/Shell";
@@ -25,11 +26,18 @@ export default function Apps({ appStore, categories }: InferGetStaticPropsType<t
export const getStaticProps = async () => {
const appStore = await getAppRegistry();
const categories = appStore.reduce((c, app) => {
c[app.category] = c[app.category] ? c[app.category] + 1 : 1;
const categoryQuery = await prisma.app.findMany({
select: {
categories: true,
},
});
const categories = categoryQuery.reduce((c, app) => {
for (const category of app.categories) {
c[category] = c[category] ? c[category] + 1 : 1;
}
return c;
}, {} as Record<string, number>);
return {
props: {
categories: Object.entries(categories).map(([name, count]) => ({ name, count })),
+15
View File
@@ -22,6 +22,7 @@ import Shell, { ShellSubHeading } from "@components/Shell";
import SkeletonLoader from "@components/apps/SkeletonLoader";
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
import DisconnectStripeIntegration from "@components/integrations/DisconnectStripeIntegration";
import IntegrationListItem from "@components/integrations/IntegrationListItem";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
import WebhookListContainer from "@components/webhook/WebhookListContainer";
@@ -35,12 +36,26 @@ function ConnectOrDisconnectIntegrationButton(props: {
}) {
const { t } = useLocale();
const [credentialId] = props.credentialIds;
const type = props.type;
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.invalidateQueries(["viewer.integrations"]);
};
if (credentialId) {
if (type === "stripe_payment") {
return (
<DisconnectStripeIntegration
id={credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
{t("disconnect")}
</Button>
)}
onOpenChange={handleOpenChange}
/>
);
}
return (
<DisconnectIntegration
id={credentialId}
-38
View File
@@ -1,38 +0,0 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import _zapierMetadata from "@calcom/app-store/zapier/_metadata";
import { ZapierSetup } from "@calcom/app-store/zapier/components";
import { trpc } from "@lib/trpc";
import Loader from "@components/Loader";
export default function SetupInformation() {
const router = useRouter();
const appName = router.query.appName;
const { status } = useSession();
if (status === "loading") {
return (
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-200">
<Loader />
</div>
);
}
if (status === "unauthenticated") {
router.replace({
pathname: "/auth/login",
query: {
callbackUrl: `/apps/setup/${appName}`,
},
});
}
if (appName === _zapierMetadata.name.toLowerCase() && status === "authenticated") {
return <ZapierSetup trpc={trpc}></ZapierSetup>;
}
return null;
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default function Error() {
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<Link href="/auth/login" passHref>
<Button className="flex w-full justify-center">{t("go_back_login")}</Button>
</Link>
</div>
+11 -8
View File
@@ -63,13 +63,16 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
</h2>
</div>
<p>{t("password_has_been_reset_login")}</p>
<Link href="/auth/login">
<button
type="button"
className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
{t("login")}
</button>
</Link>
{
// eslint-disable-next-line @next/next/link-passhref
<Link href="/auth/login">
<button
type="button"
className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
{t("login")}
</button>
</Link>
}
</div>
</>
);
@@ -84,7 +87,7 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
<h2 className="text-center text-3xl font-extrabold text-gray-900">{t("request_is_expired")}</h2>
</div>
<p>{t("request_is_expired_instructions")}</p>
<Link href="/auth/forgot-password">
<Link href="/auth/forgot-password" passHref>
<button
type="button"
className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
+1 -1
View File
@@ -159,7 +159,7 @@ export default function Login({
<Button
className="flex w-full justify-center"
type="submit"
disabled={form.formState.isSubmitting}>
disabled={form.formState.isSubmitting || form.formState.isSubmitted}>
{twoFactorRequired ? t("submit") : t("sign_in")}
</Button>
</div>
+2 -2
View File
@@ -25,7 +25,7 @@ export default function Logout(props: Props) {
if (props.query?.survey === "true") {
router.push("https://cal.com/cancellation");
}
}, []);
}, [props.query?.survey, router]);
const { t } = useLocale();
return (
@@ -43,7 +43,7 @@ export default function Logout(props: Props) {
</div>
</div>
</div>
<Link href="/auth/login">
<Link href="/auth/login" passHref>
<Button className="flex w-full justify-center"> {t("go_back_login")}</Button>
</Link>
</AuthContainer>
+1 -1
View File
@@ -38,7 +38,7 @@ export default function Provider(props: SSOProviderPageProps) {
} else {
signIn(props.provider);
}
}, []);
}, [props.isSAMLLoginEnabled, props.product, props.provider, props.tenant, router]);
return null;
}
+8 -54
View File
@@ -1,13 +1,7 @@
import { ClockIcon } from "@heroicons/react/outline";
import { DotsHorizontalIcon, TrashIcon } from "@heroicons/react/solid";
import { Availability } from "@prisma/client";
import Link from "next/link";
import { availabilityAsString } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { Button } from "@calcom/ui";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
import { withQuery } from "@lib/QueryCell";
import { HttpError } from "@lib/core/http/error";
@@ -16,10 +10,11 @@ import { inferQueryOutput, trpc } from "@lib/trpc";
import EmptyScreen from "@components/EmptyScreen";
import Shell from "@components/Shell";
import { NewScheduleButton } from "@components/availability/NewScheduleButton";
import { ScheduleListItem } from "@components/availability/ScheduleListItem";
import SkeletonLoader from "@components/availability/SkeletonLoader";
export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availability.list">) {
const { t, i18n } = useLocale();
const { t } = useLocale();
const utils = trpc.useContext();
const deleteMutation = trpc.useMutation("viewer.availability.schedule.delete", {
onSuccess: async () => {
@@ -45,53 +40,12 @@ export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availab
<div className="-mx-4 mb-16 overflow-hidden rounded-sm border border-gray-200 bg-white sm:mx-0">
<ul className="divide-y divide-neutral-200" data-testid="schedules">
{schedules.map((schedule) => (
<li key={schedule.id}>
<div className="flex items-center justify-between py-5 hover:bg-neutral-50 ltr:pl-4 rtl:pr-4 sm:ltr:pl-0 sm:rtl:pr-0">
<div className="group flex w-full items-center justify-between hover:bg-neutral-50 sm:px-6">
<Link href={"/availability/" + schedule.id}>
<a className="flex-grow truncate text-sm" title={schedule.name}>
<div>
<span className="truncate font-medium text-neutral-900">{schedule.name}</span>
{schedule.isDefault && (
<span className="ml-2 inline items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800">
{t("default")}
</span>
)}
</div>
<p className="mt-1 text-xs text-neutral-500">
{schedule.availability.map((availability: Availability) => (
<>
{availabilityAsString(availability, i18n.language)}
<br />
</>
))}
</p>
</a>
</Link>
</div>
<Dropdown>
<DropdownMenuTrigger className="group mr-5 h-10 w-10 border border-transparent p-0 text-neutral-500 hover:border-gray-200">
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Button
onClick={() =>
deleteMutation.mutate({
scheduleId: schedule.id,
})
}
type="button"
color="warn"
className="w-full font-normal"
StartIcon={TrashIcon}>
{t("delete_schedule")}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</li>
<ScheduleListItem
key={schedule.id}
schedule={schedule}
deleteFunction={deleteMutation.mutate}
isDeleting={deleteMutation.isLoading}
/>
))}
</ul>
</div>
+1 -1
View File
@@ -48,7 +48,7 @@ const AvailabilityView = ({ user }: { user: User }) => {
});
};
fetchAvailability(selectedDate);
}, [selectedDate]);
}, [user.username, selectedDate]);
return (
<div className="max-w-xl overflow-hidden rounded-sm bg-white shadow">
+1 -4
View File
@@ -60,10 +60,7 @@ export default function Bookings() {
};
return (
<Shell
heading={t("bookings")}
subtitle={t("bookings_description")}
customLoader={<SkeletonLoader></SkeletonLoader>}>
<Shell heading={t("bookings")} subtitle={t("bookings_description")} customLoader={<SkeletonLoader />}>
<WipeMyCalActionButton trpc={trpc} bookingStatus={status} bookingsEmpty={isEmpty} />
<BookingsShell>
<div className="-mx-4 flex flex-col sm:mx-auto">
+108 -38
View File
@@ -21,6 +21,7 @@ import classNames from "classnames";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
@@ -37,6 +38,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { StripeData } from "@calcom/stripe/server";
import { RecurringEvent } from "@calcom/types/Calendar";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
import Switch from "@calcom/ui/Switch";
@@ -60,6 +62,7 @@ import { EmbedButton, EmbedDialog } from "@components/Embed";
import Loader from "@components/Loader";
import Shell from "@components/Shell";
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
import { AvailabilitySelectSkeletonLoader } from "@components/availability/SkeletonLoader";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import RecurringEventController from "@components/eventtype/RecurringEventController";
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
@@ -69,6 +72,7 @@ import CheckboxField from "@components/ui/form/CheckboxField";
import CheckedSelect from "@components/ui/form/CheckedSelect";
import { DateRangePicker } from "@components/ui/form/DateRangePicker";
import MinutesField from "@components/ui/form/MinutesField";
import PhoneInput from "@components/ui/form/PhoneInput";
import Select from "@components/ui/form/Select";
import * as RadioArea from "@components/ui/form/radio-area";
import WebhookListContainer from "@components/webhook/WebhookListContainer";
@@ -164,6 +168,7 @@ const AvailabilitySelect = ({
return (
<QueryCell
query={query}
customLoader={<AvailabilitySelectSkeletonLoader />}
success={({ data }) => {
const options = data.schedules.map((schedule) => ({
value: schedule.id,
@@ -278,8 +283,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
const [requirePayment, setRequirePayment] = useState(
eventType.price > 0 && eventType.recurringEvent?.count !== undefined
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
const [recurringEventDefined, setRecurringEventDefined] = useState(
eventType.recurringEvent?.count !== undefined
);
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
@@ -327,6 +333,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
fetchTokens();
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0].id));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function deleteEventTypeHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
@@ -419,6 +426,34 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div>
</div>
);
case LocationType.UserPhone:
return (
<div>
<label htmlFor="phonenumber" className="block text-sm font-medium text-gray-700">
{t("set_your_phone_number")}
</label>
<div className="mt-1">
<PhoneInput
control={locationFormMethods.control}
name="locationPhoneNumber"
required
id="locationPhoneNumber"
placeholder={t("host_phone_number")}
rules={{}}
defaultValue={
formMethods
.getValues("locations")
.find((location) => location.type === LocationType.UserPhone)?.hostPhoneNumber
}
/>
{locationFormMethods.formState.errors.locationPhoneNumber && (
<p className="mt-1 text-red-500">
{locationFormMethods.formState.errors.locationPhoneNumber.message}
</p>
)}
</div>
</div>
);
case LocationType.Phone:
return <p className="text-sm">{t("cal_invitee_phone_number_scheduling")}</p>;
/* TODO: Render this dynamically from App Store */
@@ -506,7 +541,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
hidden: boolean;
hideCalendarNotes: boolean;
hashedLink: string | undefined;
locations: { type: LocationType; address?: string; link?: string }[];
locations: { type: LocationType; address?: string; link?: string; hostPhoneNumber?: string }[];
customInputs: EventTypeCustomInput[];
users: string[];
schedule: number;
@@ -539,11 +574,16 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const locationFormSchema = z.object({
locationType: z.string(),
locationAddress: z.string().optional(),
locationPhoneNumber: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
locationLink: z.string().url().optional(), // URL validates as new URL() - which requires HTTPS:// In the input field
});
const locationFormMethods = useForm<{
locationType: LocationType;
locationPhoneNumber?: string;
locationAddress?: string; // TODO: We should validate address or fetch the address from googles api to see if its valid?
locationLink?: string; // Currently this only accepts links that are HTTPS://
}>({
@@ -562,7 +602,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
if (e?.value) {
const newLocationType: LocationType = e.value;
locationFormMethods.setValue("locationType", newLocationType);
if (newLocationType === LocationType.InPerson || newLocationType === LocationType.Link) {
if (
newLocationType === LocationType.InPerson ||
newLocationType === LocationType.Link ||
newLocationType === LocationType.UserPhone
) {
openLocationModal(newLocationType);
} else {
addLocation(newLocationType);
@@ -599,6 +643,16 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
/>
</div>
)}
{location.type === LocationType.UserPhone && (
<div className="flex flex-grow items-center">
<PhoneIcon className="h-6 w-6" />
<input
disabled
className="w-full border-0 bg-transparent text-sm ltr:ml-2 rtl:mr-2"
value={location.hostPhoneNumber}
/>
</div>
)}
{location.type === LocationType.Phone && (
<div className="flex flex-grow items-center">
<PhoneIcon className="h-6 w-6" />
@@ -866,6 +920,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
locationFormMethods.setValue("locationType", location.type);
locationFormMethods.unregister("locationLink");
locationFormMethods.unregister("locationAddress");
locationFormMethods.unregister("locationPhoneNumber");
openLocationModal(location.type);
}}
aria-label={t("edit")}
@@ -911,7 +966,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<h1
style={{ fontSize: 22, letterSpacing: "-0.0009em" }}
className="inline pl-0 text-gray-900 focus:text-black group-hover:text-gray-500">
{eventType.title}
{formMethods.getValues("title") && formMethods.getValues("title") !== ""
? formMethods.getValues("title")
: eventType.title}
</h1>
<PencilIcon className="ml-1 -mt-1 inline h-4 w-4 text-gray-700 group-hover:text-gray-500" />
</>
@@ -926,6 +983,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
placeholder={t("quick_chat")}
{...formMethods.register("title")}
defaultValue={eventType.title}
onBlur={() => {
setEditIcon(true);
formMethods.getValues("title") === "" && formMethods.setValue("title", eventType.title);
}}
/>
</div>
)}
@@ -1353,6 +1414,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
/>
<RecurringEventController
paymentEnabled={hasPaymentIntegration && requirePayment}
onRecurringEventDefined={setRecurringEventDefined}
recurringEvent={eventType.recurringEvent}
formMethods={formMethods}
/>
@@ -1670,7 +1733,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<SuccessRedirectEdit<typeof formMethods>
formMethods={formMethods}
eventType={eventType}></SuccessRedirectEdit>
{hasPaymentIntegration && eventType.recurringEvent?.count !== undefined && (
{hasPaymentIntegration && (
<>
<hr className="border-neutral-200" />
<div className="block sm:flex">
@@ -1684,40 +1747,44 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="flex flex-col">
<div className="w-full">
<div className="block items-center sm:flex">
<div className="w-full">
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
onChange={(event) => {
setRequirePayment(event.target.checked);
if (!event.target.checked) {
formMethods.setValue("price", 0);
}
}}
id="requirePayment"
name="requirePayment"
type="checkbox"
className="text-primary-600 h-4 w-4 rounded border-gray-300"
defaultChecked={requirePayment}
/>
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<p className="text-neutral-900">
{t("require_payment")} (0.5% +{" "}
<IntlProvider locale="en">
<FormattedNumber
value={0.1}
style="currency"
currency={currency}
/>
</IntlProvider>{" "}
{t("commission_per_transaction")})
</p>
{recurringEventDefined ? (
<Alert severity="warning" title={t("warning_recurring_event_payment")} />
) : (
<div className="block items-center sm:flex">
<div className="w-full">
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
onChange={(event) => {
setRequirePayment(event.target.checked);
if (!event.target.checked) {
formMethods.setValue("price", 0);
}
}}
id="requirePayment"
name="requirePayment"
type="checkbox"
className="text-primary-600 h-4 w-4 rounded border-gray-300"
defaultChecked={requirePayment}
/>
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<p className="text-neutral-900">
{t("require_payment")} (0.5% +{" "}
<IntlProvider locale="en">
<FormattedNumber
value={0.1}
style="currency"
currency={currency}
/>
</IntlProvider>{" "}
{t("commission_per_transaction")})
</p>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{requirePayment && (
<div className="w-full">
@@ -1916,7 +1983,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
if (newLocation === LocationType.Link) {
details = { link: values.locationLink };
}
if (newLocation === LocationType.UserPhone) {
details = { hostPhoneNumber: values.locationPhoneNumber };
}
addLocation(newLocation, details);
setShowLocationModal(false);
}}>
@@ -1937,6 +2006,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
locationFormMethods.setValue("locationType", val.value);
locationFormMethods.unregister("locationLink");
locationFormMethods.unregister("locationAddress");
locationFormMethods.unregister("locationPhoneNumber");
setSelectedLocation(val);
}
}}
+56 -32
View File
@@ -78,7 +78,10 @@ const Item = ({ type, group, readOnly }: any) => {
return (
<Link href={"/event-types/" + type.id}>
<a
className="flex-grow truncate text-sm"
className={classNames(
"flex-grow truncate text-sm ",
type.$disabled && "pointer-events-none cursor-not-allowed opacity-30"
)}
title={`${type.title} ${type.description ? `– ${type.description}` : ""}`}>
<div>
<span
@@ -207,17 +210,19 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
{types.map((type, index) => (
<li
key={type.id}
className={classNames(
type.$disabled && "pointer-events-none cursor-not-allowed select-none opacity-30"
)}
className={classNames(type.$disabled && "select-none")}
data-disabled={type.$disabled ? 1 : 0}>
<div
className={classNames(
"flex items-center justify-between hover:bg-neutral-50 ",
type.$disabled && "pointer-events-none"
type.$disabled && "hover:bg-white"
)}>
<div className="group flex w-full items-center justify-between px-4 py-4 hover:bg-neutral-50 sm:px-6">
{types.length > 1 && (
<div
className={classNames(
"group flex w-full items-center justify-between px-4 py-4 hover:bg-neutral-50 sm:px-6",
type.$disabled && "hover:bg-white"
)}>
{types.length > 1 && !type.$disabled && (
<>
<button
className="invisible absolute left-1/2 -mt-4 mb-4 -ml-4 hidden h-7 w-7 scale-0 rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:left-[19px] sm:ml-0 sm:block"
@@ -238,7 +243,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
{type.users?.length > 1 && (
<AvatarGroup
border="border-2 border-white"
className="relative top-1 right-3"
className={classNames("relative top-1 right-3", type.$disabled && " opacity-30")}
size={8}
truncateAfter={4}
items={type.users.map((organizer) => ({
@@ -247,28 +252,38 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
}))}
/>
)}
<Tooltip content={t("preview")}>
<a
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${group.profile.slug}/${type.slug}`}
target="_blank"
rel="noreferrer"
className="btn-icon appearance-none">
<ExternalLinkIcon className="h-5 w-5 group-hover:text-black" />
</a>
</Tooltip>
<div
className={classNames(
"flex justify-between space-x-2 rtl:space-x-reverse ",
type.$disabled && "pointer-events-none cursor-not-allowed"
)}>
<Tooltip content={t("preview")}>
<a
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${group.profile.slug}/${type.slug}`}
target="_blank"
rel="noreferrer"
className={classNames("btn-icon appearance-none", type.$disabled && " opacity-30")}>
<ExternalLinkIcon
className={classNames("h-5 w-5", !type.$disabled && "group-hover:text-black")}
/>
</a>
</Tooltip>
<Tooltip content={t("copy_link")}>
<button
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(
`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${group.profile.slug}/${type.slug}`
);
}}
className="btn-icon">
<LinkIcon className="h-5 w-5 group-hover:text-black" />
</button>
</Tooltip>
<Tooltip content={t("copy_link")}>
<button
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(
`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${group.profile.slug}/${type.slug}`
);
}}
className={classNames("btn-icon", type.$disabled && " opacity-30")}>
<LinkIcon
className={classNames("h-5 w-5", !type.$disabled && "group-hover:text-black")}
/>
</button>
</Tooltip>
</div>
<Dropdown>
<DropdownMenuTrigger
className="h-10 w-10 cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900 focus:border-gray-300"
@@ -282,7 +297,10 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
type="button"
size="sm"
color="minimal"
className="w-full rounded-none"
className={classNames(
"w-full rounded-none",
type.$disabled && " pointer-events-none cursor-not-allowed opacity-30"
)}
StartIcon={PencilIcon}>
{" "}
{t("edit")}
@@ -294,7 +312,10 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
type="button"
color="minimal"
size="sm"
className="w-full rounded-none"
className={classNames(
"w-full rounded-none",
type.$disabled && " pointer-events-none cursor-not-allowed opacity-30"
)}
data-testid={"event-type-duplicate-" + type.id}
StartIcon={DuplicateIcon}
onClick={() => openModal(group, type)}>
@@ -304,7 +325,10 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<DropdownMenuItem>
<EmbedButton
dark
className="w-full rounded-none"
className={classNames(
"w-full rounded-none",
type.$disabled && " pointer-events-none cursor-not-allowed opacity-30"
)}
eventTypeId={type.id}></EmbedButton>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
+19 -16
View File
@@ -12,7 +12,7 @@ import { NextPageContext } from "next";
import { useSession } from "next-auth/react";
import Head from "next/head";
import { useRouter } from "next/router";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState, useCallback } from "react";
import { useForm } from "react-hook-form";
import TimezoneSelect from "react-timezone-select";
import * as z from "zod";
@@ -102,21 +102,24 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
const [selectedImport, setSelectedImport] = useState("");
const [error, setError] = useState<Error | null>(null);
const updateUser = async (data: Prisma.UserUpdateInput) => {
const res = await fetch(`/api/user/${props.user.id}`, {
method: "PATCH",
body: JSON.stringify({ data: { ...data } }),
headers: {
"Content-Type": "application/json",
},
});
const updateUser = useCallback(
async (data: Prisma.UserUpdateInput) => {
const res = await fetch(`/api/user/${props.user.id}`, {
method: "PATCH",
body: JSON.stringify({ data: { ...data } }),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error((await res.json()).message);
}
const responseData = await res.json();
return responseData.data;
};
if (!res.ok) {
throw new Error((await res.json()).message);
}
const responseData = await res.json();
return responseData.data;
},
[props.user.id]
);
const createEventType = async (data: Prisma.EventTypeCreateInput) => {
const res = await fetch(`/api/availability/eventtype`, {
@@ -288,7 +291,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
if (username) {
validateAndSave(username);
}
}, []);
}, [updateUser]);
const availabilityForm = useForm({ defaultValues: { schedule: DEFAULT_SCHEDULE } });
const steps = [
+24 -18
View File
@@ -35,6 +35,7 @@ import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { isSuccessRedirectAvailable } from "@lib/isSuccessRedirectAvailable";
import prisma from "@lib/prisma";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { isBrowserLocale24h } from "@lib/timeFormat";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -172,6 +173,15 @@ export default function Success(props: SuccessProps) {
const eventName = getEventName(eventNameObject);
const needsConfirmation = eventType.requiresConfirmation && reschedule != "true";
const telemetry = useTelemetry();
useEffect(() => {
telemetry.withJitsu((jitsu) =>
jitsu.track(
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
collectPageParameters("/success")
)
);
}, [telemetry]);
useEffect(() => {
const users = eventType.users;
@@ -190,7 +200,7 @@ export default function Success(props: SuccessProps) {
});
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
}, [eventType, needsConfirmation]);
}, [date, eventType, needsConfirmation]);
function eventLink(): string {
const optional: { location?: string } = {};
@@ -274,7 +284,10 @@ export default function Success(props: SuccessProps) {
"mx-auto flex items-center justify-center",
!giphyImage ? "h-12 w-12 rounded-full bg-green-100" : ""
)}>
{giphyImage && !needsConfirmation && <img src={giphyImage} alt={"Gif from Giphy"} />}
{giphyImage && !needsConfirmation && (
// eslint-disable-next-line @next/next/no-img-element
<img src={giphyImage} alt={"Gif from Giphy"} />
)}
{!giphyImage && !needsConfirmation && (
<CheckIcon className="h-8 w-8 text-green-600" />
)}
@@ -299,14 +312,6 @@ export default function Success(props: SuccessProps) {
<div className="font-medium">{t("what")}</div>
<div className="col-span-2 mb-6">{eventName}</div>
<div className="font-medium">{t("when")}</div>
{/* <div className="col-span-2 mb-6">
{date.format("MMMM DD, YYYY")}
<br />
{date.format("LT")} - {date.add(props.eventType.length, "m").format("LT")}{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
</div> */}
<div className="col-span-2 mb-6">
<RecurringBookings
isReschedule={reschedule === "true"}
@@ -317,7 +322,7 @@ export default function Success(props: SuccessProps) {
/>
</div>
<div className="font-medium">{t("who")}</div>
<div className="col-span-2">
<div className="col-span-2 mb-6">
{bookingInfo?.user && (
<div className="mb-3">
<p>{bookingInfo.user.name}</p>
@@ -546,9 +551,9 @@ function RecurringBookings({
{eventType.recurringEvent?.count &&
recurringBookings.slice(0, 4).map((dateStr, idx) => (
<div key={idx} className="mb-2">
{dayjs(dateStr).format("dddd, DD MMMM YYYY")}
{dayjs(dateStr).format("MMMM DD, YYYY")}
<br />
{dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
{dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
@@ -565,9 +570,9 @@ function RecurringBookings({
{eventType.recurringEvent?.count &&
recurringBookings.slice(4).map((dateStr, idx) => (
<div key={idx} className="mb-2">
{dayjs(dateStr).format("dddd, DD MMMM YYYY")}
{dayjs(dateStr).format("MMMM DD, YYYY")}
<br />
{dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
{dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
@@ -579,9 +584,9 @@ function RecurringBookings({
</>
) : !eventType.recurringEvent.freq ? (
<>
{date.format("dddd, DD MMMM YYYY")}
{date.format("MMMM DD, YYYY")}
<br />
{date.format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
{date.format("LT")} - {date.add(eventType.length, "m").format("LT")}{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
@@ -634,7 +639,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const recurringEventIdQuery = asStringOrNull(context.query.recur);
const typeSlug = asStringOrNull(context.query.eventSlug) ?? "15min";
const dynamicEventName = asStringOrNull(context.query.eventName) ?? "";
const bookingId = parseInt(context.query.bookingId as string);
if (typeof context.query.bookingId !== "string") return { notFound: true } as const;
const bookingId = parseInt(context.query.bookingId);
if (isNaN(typeId)) {
return {
+21 -12
View File
@@ -96,8 +96,14 @@ export default function JoinCall(props: JoinCallPageProps) {
token: props.booking.dailyRef?.dailytoken,
});
}
}, []);
}, [
emptyBooking,
meetingUnavailable,
props.booking?.dailyRef?.dailytoken,
props.booking?.dailyRef?.dailyurl,
props.booking?.user?.id,
session?.userid,
]);
return (
<>
<Head>
@@ -116,16 +122,19 @@ export default function JoinCall(props: JoinCallPageProps) {
<meta property="twitter:description" content={t("quick_video_meeting")} />
</Head>
<div style={{ zIndex: 2, position: "relative" }}>
<Link href="/">
<img
className="h-5·w-auto fixed z-10 hidden sm:inline-block"
src="https://cal.com/logo-white.svg"
alt="Cal.com Logo"
style={{
top: 46,
left: 24,
}}
/>
<Link href="/" passHref>
{
// eslint-disable-next-line @next/next/no-img-element
<img
className="h-5·w-auto fixed z-10 hidden sm:inline-block"
src="https://cal.com/logo-white.svg"
alt="Cal.com Logo"
style={{
top: 46,
left: 24,
}}
/>
}
</Link>
{JoinCall}
</div>
+10 -27
View File
@@ -1,44 +1,27 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("App Store - Authed", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test("Browse apple-calendar and try to install", async ({ page }) => {
await page.goto("/apps");
page.click('[data-testid="app-store-category-calendar"]');
await page.waitForNavigation({
url: (url) => {
console.log(url, url.pathname);
return url.pathname.includes("apps/categories/calendar");
},
});
page.click('[data-testid="app-store-app-card-apple-calendar"]');
await page.waitForNavigation({
url: (url) => {
return url.pathname.includes("apps/apple-calendar");
},
});
await page.click('[data-testid="app-store-category-calendar"]');
await page.click('[data-testid="app-store-app-card-apple-calendar"]');
await page.click('[data-testid="install-app-button"]');
await expect(page.locator(`text=Connect to Apple Server`)).toBeVisible();
});
});
test.describe("App Store - Unauthed", () => {
test("Browse apple-calendar and try to install", async ({ page }) => {
await page.goto("/apps");
await page.waitForSelector("[data-testid=dashboard-shell]");
await page.click('[data-testid="app-store-category-calendar"]');
if (!page.url().includes("apps/categories/calendar")) {
await page.waitForNavigation({
url: (url) => {
console.log(url, url.pathname);
return url.pathname.includes("apps/categories/calendar");
},
});
}
await page.click('[data-testid="app-store-app-card-apple-calendar"]');
await page.waitForNavigation({
url: (url) => {
return url.pathname.includes("/auth/login");
},
});
await page.click('[data-testid="install-app-button"]');
await expect(page.locator(`[data-testid="login-form"]`)).toBeVisible();
});
});
@@ -1,19 +1,13 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
import { test } from "../lib/fixtures";
test("Can delete user account", async ({ page, users }) => {
const user = await users.create({
username: "delete-me",
});
await user.login();
test("Can delete user account", async ({ page }) => {
//FIXME: This test depends on seed.
// Login to account to delete
await page.goto(`/auth/login`);
// Click input[name="email"]
await page.click('input[name="email"]');
// Fill input[name="email"]
await page.fill('input[name="email"]', `delete-me@example.com`);
// Press Tab
await page.press('input[name="email"]', "Tab");
// Fill input[name="password"]
await page.fill('input[name="password"]', "delete-me");
// Press Enter
await page.press('input[name="password"]', "Enter");
await page.waitForSelector("[data-testid=dashboard-shell]");
await page.goto(`/settings/profile`);
@@ -1,11 +1,16 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
test("Can reset forgotten password", async ({ page }) => {
import { test } from "../lib/fixtures";
test("Can reset forgotten password", async ({ page, users }) => {
const user = await users.create();
const newPassword = `${user.username!}-123`;
// Got to reset password flow
await page.goto("/auth/forgot-password");
await page.waitForSelector("text=Forgot Password");
// Fill [placeholder="john.doe@example.com"]
await page.fill('input[name="email"]', "pro@example.com");
await page.fill('input[name="email"]', `${user.username}@example.com`);
// Press Enter
await Promise.all([
@@ -18,7 +23,7 @@ test("Can reset forgotten password", async ({ page }) => {
// Wait for page to fully load
await page.waitForSelector("text=Reset Password");
// Fill input[name="password"]
await page.fill('input[name="password"]', "pro");
await page.fill('input[name="password"]', newPassword);
// Click text=Submit
await page.click('button[type="submit"]');
@@ -33,10 +38,12 @@ test("Can reset forgotten password", async ({ page }) => {
await Promise.all([page.waitForNavigation({ url: "/auth/login" }), page.click('button:has-text("Login")')]);
// Fill input[name="email"]
await page.fill('input[name="email"]', "pro@example.com");
await page.fill('input[name="password"]', "pro");
await page.fill('input[name="email"]', `${user.username}@example.com`);
await page.fill('input[name="password"]', newPassword);
await page.press('input[name="password"]', "Enter");
await page.waitForSelector("[data-testid=dashboard-shell]");
await expect(page.locator("[data-testid=dashboard-shell]")).toBeVisible();
await users.deleteAll();
});
+38 -49
View File
@@ -1,27 +1,28 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
import { deleteAllBookingsByEmail } from "./lib/teardown";
import { test } from "./lib/fixtures";
import {
bookFirstEvent,
bookTimeSlot,
selectFirstAvailableTimeSlotNextMonth,
selectSecondAvailableTimeSlotNextMonth,
todo,
} from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("free user", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/free");
test.beforeEach(async ({ page, users }) => {
const free = await users.create({ plan: "FREE" });
await page.goto(`/${free.username}`);
});
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test.afterEach(async () => {
// delete test bookings
await deleteAllBookingsByEmail("free@example.com");
});
test("only one visible event", async ({ page }) => {
await expect(page.locator(`[href="/free/30min"]`)).toBeVisible();
await expect(page.locator(`[href="/free/60min"]`)).not.toBeVisible();
test("only one visible event", async ({ page, users }) => {
const [free] = users.get();
await expect(page.locator(`[href="/${free.username}/${free.eventTypes[0].slug}"]`)).toBeVisible();
await expect(page.locator(`[href="/${free.username}/${free.eventTypes[1].slug}"]`)).not.toBeVisible();
});
test("cannot book same slot multiple times", async ({ page }) => {
@@ -44,11 +45,7 @@ test.describe("free user", () => {
await bookTimeSlot(page);
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// return to same time spot booking page
await page.goto(bookingUrl);
@@ -60,55 +57,44 @@ test.describe("free user", () => {
await expect(page.locator("[data-testid=booking-fail]")).toBeVisible();
});
// Why do we need this test. The previous test is testing /30min booking only ?
todo("`/free/30min` is bookable");
test("`/free/60min` is not bookable", async ({ page }) => {
test("Second event type is not bookable", async ({ page, users }) => {
const [free] = users.get();
// Not available in listing
await expect(page.locator('[href="/free/60min"]')).toHaveCount(0);
await expect(page.locator(`[href="/${free.username}/${free.eventTypes[1].slug}"]`)).toHaveCount(0);
await page.goto("/free/60min");
await page.goto(`/${free.username}/${free.eventTypes[1].slug}`);
// Not available on a direct visit to event type page
await expect(page.locator('[data-testid="404-page"]')).toBeVisible();
});
});
test.describe("pro user", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test.beforeEach(async ({ page }) => {
await deleteAllBookingsByEmail("pro@example.com");
await page.goto("/pro");
test.beforeEach(async ({ page, users }) => {
const pro = await users.create();
await page.goto(`/${pro.username}`);
});
test.afterEach(async () => {
await deleteAllBookingsByEmail("pro@example.com");
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("pro user's page has at least 2 visible events", async ({ page }) => {
// await page.pause();
const $eventTypes = await page.locator("[data-testid=event-types] > *");
const $eventTypes = page.locator("[data-testid=event-types] > *");
expect(await $eventTypes.count()).toBeGreaterThanOrEqual(2);
});
test("book an event first day in next month", async ({ page }) => {
// Click first event type
await page.click('[data-testid="event-type-link"]');
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
});
test("can reschedule a booking", async ({ page }) => {
await bookFirstEvent(page);
});
test("can reschedule a booking", async ({ page, users, bookings }) => {
const [pro] = users.get();
const [eventType] = pro.eventTypes;
await bookings.create(pro.id, pro.username, eventType.id);
await pro.login();
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="reschedule"]').click();
await page.locator('[data-testid="reschedule"]').nth(0).click();
await page.locator('[data-testid="edit"]').click();
await page.waitForNavigation({
url: (url) => {
@@ -126,9 +112,12 @@ test.describe("pro user", () => {
});
});
test("Can cancel the recently created booking and rebook the same timeslot", async ({ page }) => {
test("Can cancel the recently created booking and rebook the same timeslot", async ({ page, users }) => {
await bookFirstEvent(page);
const [pro] = users.get();
await pro.login();
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="cancel"]').first().click();
await page.waitForNavigation({
@@ -143,7 +132,7 @@ test.describe("pro user", () => {
return url.pathname === "/cancel/success";
},
});
await page.goto("/pro");
await page.goto(`/${pro.username}`);
await bookFirstEvent(page);
});
});
@@ -1,6 +1,6 @@
import { Page, test } from "@playwright/test";
import { expect } from "@playwright/test";
import { deleteAllBookingsByEmail } from "./lib/teardown";
import { test } from "./lib/fixtures";
import {
bookFirstEvent,
bookTimeSlot,
@@ -8,19 +8,18 @@ import {
selectSecondAvailableTimeSlotNextMonth,
} from "./lib/testUtils";
test.describe("dynamic booking", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test.describe.configure({ mode: "parallel" });
test.beforeEach(async ({ page }) => {
await deleteAllBookingsByEmail("pro@example.com");
await deleteAllBookingsByEmail("free@example.com");
await page.goto("/pro+free");
test.describe("dynamic booking", () => {
test.beforeEach(async ({ page, users }) => {
const pro = await users.create();
await pro.login();
const free = await users.create({ plan: "FREE" });
await page.goto(`/${pro.username}+${free.username}`);
});
test.afterAll(async () => {
// delete test bookings
await deleteAllBookingsByEmail("pro@example.com");
await deleteAllBookingsByEmail("free@example.com");
test.afterEach(async ({ page, users }) => {
await users.deleteAll();
});
test("book an event first day in next month", async ({ page }) => {
@@ -28,13 +27,7 @@ test.describe("dynamic booking", () => {
await page.click('[data-testid="event-type-link"]');
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("can reschedule a booking", async ({ page }) => {
@@ -42,7 +35,7 @@ test.describe("dynamic booking", () => {
// Logged in
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="reschedule"]').click();
await page.locator('[data-testid="reschedule"]').nth(0).click();
await page.locator('[data-testid="edit"]').click();
await page.waitForNavigation({
url: (url) => {
@@ -58,6 +51,7 @@ test.describe("dynamic booking", () => {
return url.pathname === "/success" && url.searchParams.get("reschedule") === "true";
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("Can cancel the recently created booking", async ({ page }) => {
@@ -5,6 +5,8 @@ function chooseEmbedType(page: Page, embedType: string) {
}
async function gotToPreviewTab(page: Page) {
// To prevent early timeouts
await page.waitForTimeout(1000);
await page.locator("[data-testid=embed-tabs]").locator("text=Preview").click();
}
@@ -79,6 +81,8 @@ async function expectToContainValidPreviewIframe(
);
}
test.describe.configure({ mode: "parallel" });
test.describe("Embed Code Generator Tests", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
@@ -177,7 +181,7 @@ test.describe("Embed Code Generator Tests", () => {
embedType: "inline",
});
gotToPreviewTab(page);
await gotToPreviewTab(page);
await expectToContainValidPreviewIframe(page, {
embedType: "inline",
+2
View File
@@ -3,6 +3,8 @@ import { expect, Locator, test } from "@playwright/test";
import { randomString } from "../lib/random";
import { deleteEventTypeByTitle } from "./lib/teardown";
test.describe.configure({ mode: "parallel" });
test.describe("Event Types tests", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/event-types");
+88
View File
@@ -0,0 +1,88 @@
import type { Page } from "@playwright/test";
import type { Booking, Prisma } from "@prisma/client";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import short from "short-uuid";
import { v5 as uuidv5, v4 as uuidv4 } from "uuid";
import { prisma } from "@calcom/prisma";
dayjs.extend(utc);
const translator = short();
type BookingFixture = ReturnType<typeof createBookingFixture>;
// creates a user fixture instance and stores the collection
export const createBookingsFixture = (page: Page) => {
let store = { bookings: [], page } as { bookings: BookingFixture[]; page: typeof page };
return {
create: async (
userId: number,
username: string | null,
eventTypeId = -1,
{
confirmed = true,
rescheduled = false,
paid = false,
status = "ACCEPTED",
}: Partial<Prisma.BookingCreateInput> = {}
) => {
const startDate = dayjs().add(1, "day").toDate();
const seed = `${username}:${dayjs(startDate).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const booking = await prisma.booking.create({
data: {
uid: uid,
title: "30min",
startTime: startDate,
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
user: {
connect: {
id: userId,
},
},
attendees: {
create: {
email: "attendee@example.com",
name: "Attendee Example",
timeZone: "Europe/London",
},
},
eventType: {
connect: {
id: eventTypeId,
},
},
confirmed,
rescheduled,
paid,
status,
},
});
const bookingFixture = createBookingFixture(booking, store.page!);
store.bookings.push(bookingFixture);
return bookingFixture;
},
get: () => store.bookings,
delete: async (id: number) => {
await prisma.booking.delete({
where: { id },
});
store.bookings = store.bookings.filter((b) => b.id !== id);
},
};
};
// creates the single user fixture
const createBookingFixture = (booking: Booking, page: Page) => {
const store = { booking, page };
// self is a reflective method that return the Prisma object that references this fixture.
return {
id: store.booking.id,
uid: store.booking.uid,
self: async () => (await prisma.booking.findUnique({ where: { id: store.booking.id } }))!,
delete: async () => (await prisma.booking.delete({ where: { id: store.booking.id } }))!,
};
};
+59
View File
@@ -0,0 +1,59 @@
import type { Page } from "@playwright/test";
import type { Payment } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import { prisma } from "@calcom/prisma";
type PaymentFixture = ReturnType<typeof createPaymentFixture>;
// creates a user fixture instance and stores the collection
export const createPaymentsFixture = (page: Page) => {
let store = { payments: [], page } as { payments: PaymentFixture[]; page: typeof page };
return {
create: async (
bookingId: number,
{ success = false, refunded = false }: { success?: boolean; refunded?: boolean } = {}
) => {
const payment = await prisma.payment.create({
data: {
uid: uuidv4(),
amount: 20000,
fee: 160,
currency: "usd",
success,
refunded,
type: "STRIPE",
data: {},
externalId: "DEMO_PAYMENT_FROM_DB_" + Date.now(),
booking: {
connect: {
id: bookingId,
},
},
},
});
const paymentFixture = createPaymentFixture(payment, store.page!);
store.payments.push(paymentFixture);
return paymentFixture;
},
get: () => store.payments,
delete: async (id: number) => {
await prisma.payment.delete({
where: { id },
});
store.payments = store.payments.filter((b) => b.id !== id);
},
};
};
// creates the single user fixture
const createPaymentFixture = (payment: Payment, page: Page) => {
const store = { payment, page };
// self is a reflective method that return the Prisma object that references this fixture.
return {
id: store.payment.id,
self: async () => (await prisma.payment.findUnique({ where: { id: store.payment.id } }))!,
delete: async () => (await prisma.payment.delete({ where: { id: store.payment.id } }))!,
};
};
+84 -14
View File
@@ -1,6 +1,6 @@
import type { Page } from "@playwright/test";
import { UserPlan } from "@prisma/client";
import type { Page, WorkerInfo } from "@playwright/test";
import type Prisma from "@prisma/client";
import { Prisma as PrismaType, UserPlan } from "@prisma/client";
import { hashPassword } from "@calcom/lib/auth";
import { prisma } from "@calcom/prisma";
@@ -9,13 +9,42 @@ import { TimeZoneEnum } from "./types";
type UserFixture = ReturnType<typeof createUserFixture>;
const userIncludes = PrismaType.validator<PrismaType.UserInclude>()({
eventTypes: true,
credentials: true,
});
const userWithEventTypes = PrismaType.validator<PrismaType.UserArgs>()({
include: userIncludes,
});
type UserWithIncludes = PrismaType.UserGetPayload<typeof userWithEventTypes>;
// creates a user fixture instance and stores the collection
export const createUsersFixture = (page: Page) => {
export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
let store = { users: [], page } as { users: UserFixture[]; page: typeof page };
return {
create: async (opts?: CustomUserOpts) => {
const user = await prisma.user.create({
data: await createUser(opts),
const _user = await prisma.user.create({
data: await createUser(workerInfo, opts),
});
await prisma.eventType.create({
data: {
users: {
connect: {
id: _user.id,
},
},
title: "Paid",
slug: "paid",
length: 30,
price: 1000,
},
});
const user = await prisma.user.findUnique({
rejectOnNotFound: true,
where: { id: _user.id },
include: userIncludes,
});
const userFixture = createUserFixture(user, store.page!);
store.users.push(userFixture);
@@ -25,25 +54,39 @@ export const createUsersFixture = (page: Page) => {
logout: async () => {
await page.goto("/auth/logout");
},
deleteAll: async () => {
const ids = store.users.map((u) => u.id);
await prisma.user.deleteMany({ where: { id: { in: ids } } });
store.users = [];
},
delete: async (id: number) => {
await prisma.user.delete({ where: { id } });
store.users = store.users.filter((b) => b.id !== id);
},
};
};
type JSONValue = string | number | boolean | { [x: string]: JSONValue } | Array<JSONValue>;
// creates the single user fixture
const createUserFixture = (user: Prisma.User, page: Page) => {
const createUserFixture = (user: UserWithIncludes, page: Page) => {
const store = { user, page };
// self is a reflective method that return the Prisma object that references this fixture.
const self = async () => (await prisma.user.findUnique({ where: { id: store.user.id } }))!;
const self = async () =>
(await prisma.user.findUnique({ where: { id: store.user.id }, include: { eventTypes: true } }))!;
return {
id: user.id,
username: user.username,
eventTypes: user.eventTypes!,
self,
login: async () => login({ ...(await self()), password: user.username }, store.page),
getPaymentCredential: async () => getPaymentCredential(store.page),
// ths is for developemnt only aimed to inject debugging messages in the metadata field of the user
debug: async (message: string | Record<string, JSONValue>) => {
await prisma.user.update({ where: { id: store.user.id }, data: { metadata: { debug: message } } });
},
delete: async () => (await prisma.user.delete({ where: { id: store.user.id } }))!,
};
};
@@ -51,10 +94,14 @@ type CustomUserOptsKeys = "username" | "password" | "plan" | "completedOnboardin
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & { timeZone?: TimeZoneEnum };
// creates the actual user in the db.
const createUser = async (opts?: CustomUserOpts) => {
const createUser = async (
workerInfo: WorkerInfo,
opts?: CustomUserOpts
): Promise<PrismaType.UserCreateInput> => {
// build a unique name for our user
const uname =
(opts?.username ?? opts?.plan?.toLocaleLowerCase() ?? UserPlan.PRO.toLowerCase()) + "-" + Date.now();
const uname = `${opts?.username ?? opts?.plan?.toLocaleLowerCase() ?? UserPlan.PRO.toLowerCase()}-${
workerInfo.workerIndex
}-${Date.now()}`;
return {
username: uname,
name: (opts?.username ?? opts?.plan ?? UserPlan.PRO).toUpperCase(),
@@ -65,6 +112,13 @@ const createUser = async (opts?: CustomUserOpts) => {
completedOnboarding: opts?.completedOnboarding ?? true,
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
locale: opts?.locale ?? "en",
eventTypes: {
create: {
title: "30 min",
slug: "30-min",
length: 30,
},
},
};
};
@@ -74,10 +128,10 @@ export async function login(
page: Page
) {
// get locators
const loginLocator = await page.locator("[data-testid=login-form]");
const emailLocator = await loginLocator.locator("#email");
const passwordLocator = await loginLocator.locator("#password");
const signInLocator = await loginLocator.locator('[type="submit"]');
const loginLocator = page.locator("[data-testid=login-form]");
const emailLocator = loginLocator.locator("#email");
const passwordLocator = loginLocator.locator("#password");
const signInLocator = loginLocator.locator('[type="submit"]');
//login
await page.goto("/");
@@ -88,3 +142,19 @@ export async function login(
// 2 seconds of delay to give the session enough time for a clean load
await page.waitForTimeout(2000);
}
export async function getPaymentCredential(page: Page) {
await page.goto("/apps/installed");
/** We start the Stripe flow */
await Promise.all([
page.waitForNavigation({ url: "https://connect.stripe.com/oauth/v2/authorize?*" }),
page.click('li:has-text("Stripe") >> [data-testid="integration-connection-button"]'),
]);
await Promise.all([
page.waitForNavigation({ url: "/apps/installed" }),
/** We skip filling Stripe forms (testing mode only) */
page.click('[id="skip-account-app"]'),
]);
}
+16 -25
View File
@@ -1,23 +1,18 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
import { deleteAllBookingsByEmail } from "./lib/teardown";
import { test } from "./lib/fixtures";
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("hash my url", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
let $url = "";
test.beforeEach(async ({ page }) => {
await deleteAllBookingsByEmail("pro@example.com");
await page.goto("/event-types");
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
test.beforeEach(async ({ users }) => {
const user = await users.create();
await user.login();
});
test.afterAll(async () => {
// delete test bookings
await deleteAllBookingsByEmail("pro@example.com");
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("generate url hash", async ({ page }) => {
// await page.pause();
await page.goto("/event-types");
@@ -34,26 +29,22 @@ test.describe("hash my url", () => {
!isChecked && (await page.click('//*[@id="hashedLinkCheck"]'));
// we wait for the hashedLink setting to load
await page.waitForSelector('//*[@data-testid="generated-hash-url"]');
$url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
const $url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
// click update
await page.focus('//button[@type="submit"]');
await page.keyboard.press("Enter");
});
test("book using generated url hash", async ({ page }) => {
// To prevent an early 404
await page.waitForTimeout(1000);
// book using generated url hash
await page.goto($url);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
test("hash regenerates after successful booking", async ({ page }) => {
// hash regenerates after successful booking
await page.goto("/event-types");
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
+25 -34
View File
@@ -1,53 +1,45 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
import * as teardown from "./lib/teardown";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth, todo } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
const IS_STRIPE_ENABLED = !!(
process.env.STRIPE_CLIENT_ID &&
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
process.env.STRIPE_PRIVATE_KEY
);
test.describe.serial("Stripe integration", () => {
test.afterAll(() => {
teardown.deleteAllPaymentsByEmail("pro@example.com");
teardown.deleteAllBookingsByEmail("pro@example.com");
teardown.deleteAllPaymentCredentialsByEmail("pro@example.com");
});
test.describe("Stripe integration", () => {
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
test.describe.serial("Stripe integration dashboard", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test("Can add Stripe integration", async ({ page }) => {
test.describe("Stripe integration dashboard", () => {
test("Can add Stripe integration", async ({ page, users }) => {
const user = await users.create();
await user.login();
await page.goto("/apps/installed");
/** We should see the "Connect" button for Stripe */
await expect(
page.locator(`li:has-text("Stripe") >> [data-testid="integration-connection-button"]`)
).toContainText("Connect");
/** We start the Stripe flow */
await Promise.all([
page.waitForNavigation({ url: "https://connect.stripe.com/oauth/v2/authorize?*" }),
page.click('li:has-text("Stripe") >> [data-testid="integration-connection-button"]'),
]);
await Promise.all([
page.waitForNavigation({ url: "/apps/installed" }),
/** We skip filling Stripe forms (testing mode only) */
page.click('[id="skip-account-app"]'),
]);
await user.getPaymentCredential();
/** If Stripe is added correctly we should see the "Disconnect" button */
await expect(
page.locator(`li:has-text("Stripe") >> [data-testid="integration-connection-button"]`)
).toContainText("Disconnect");
// Cleanup
await user.delete();
});
});
test("Can book a paid booking", async ({ page }) => {
await page.goto("/pro/paid");
test("Can book a paid booking", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
await user.login();
await page.goto("/apps/installed");
await user.getPaymentCredential();
await page.goto(`${user.username}/${eventType.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
// --- fill form
await page.fill('[name="name"]', "Stripe Stripeson");
@@ -71,11 +63,10 @@ test.describe.serial("Stripe integration", () => {
await page.click('button:has-text("Pay now")');
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// Cleanup
await user.delete();
});
todo("Pending payment booking should not be confirmed by default");
+14 -15
View File
@@ -1,20 +1,11 @@
import { expect, test } from "@playwright/test";
import { expect } from "@playwright/test";
import * as teardown from "./lib/teardown";
import { test } from "./lib/fixtures";
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth, todo, waitFor } from "./lib/testUtils";
test.describe("integrations", () => {
//teardown
test.afterAll(async () => {
await teardown.deleteAllWebhooksByEmail("pro@example.com");
await teardown.deleteAllBookingsByEmail("pro@example.com");
});
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test.beforeEach(async ({ page }) => {
await page.goto("/apps/installed");
});
test.describe.configure({ mode: "parallel" });
test.describe("Integrations", () => {
todo("Can add Zoom integration");
todo("Can add Google Calendar");
@@ -25,8 +16,15 @@ test.describe("integrations", () => {
todo("Can add Apple Calendar");
test("add webhook & test that creating an event triggers a webhook call", async ({ page }, testInfo) => {
test("add webhook & test that creating an event triggers a webhook call", async ({
page,
users,
}, testInfo) => {
const webhookReceiver = createHttpServer();
const user = await users.create();
const [eventType] = user.eventTypes;
await user.login();
await page.goto("/apps/installed");
// --- add webhook
await page.click('[data-testid="new_webhook"]');
@@ -43,7 +41,7 @@ test.describe("integrations", () => {
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
// --- Book the first available day next month in the pro user's "30min"-event
await page.goto(`/pro/30min`);
await page.goto(`/${user.username}/${eventType.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
// --- fill form
@@ -69,6 +67,7 @@ test.describe("integrations", () => {
attendee.timeZone = dynamic;
attendee.language = dynamic;
}
body.payload.organizer.email = dynamic;
body.payload.organizer.timeZone = dynamic;
body.payload.organizer.language = dynamic;
body.payload.uid = dynamic;
@@ -1 +1 @@
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30min","title":"30min between Pro Example and Test Testson","description":"","additionalNotes":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Pro Example","email":"pro@example.com","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"uid":"[redacted/dynamic]","metadata":{},"additionInformation":"[redacted/dynamic]"}}
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"uid":"[redacted/dynamic]","metadata":{},"additionInformation":"[redacted/dynamic]"}}
-80
View File
@@ -1,80 +0,0 @@
import { Booking } from "@prisma/client";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import short from "short-uuid";
import { v5 as uuidv5, v4 as uuidv4 } from "uuid";
dayjs.extend(utc);
const translator = short();
const TestUtilCreateBookingOnUserId = async (
userId: number,
username: string,
eventTypeId: number,
{ confirmed = true, rescheduled = false, paid = false, status = "ACCEPTED" }: Partial<Booking>
) => {
const startDate = dayjs().add(1, "day").toDate();
const seed = `${username}:${dayjs(startDate).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
return await prisma?.booking.create({
data: {
uid: uid,
title: "30min",
startTime: startDate,
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
user: {
connect: {
id: userId,
},
},
attendees: {
create: {
email: "attendee@example.com",
name: "Attendee Example",
timeZone: "Europe/London",
},
},
eventType: {
connect: {
id: eventTypeId,
},
},
confirmed,
rescheduled,
paid,
status,
},
select: {
id: true,
uid: true,
user: true,
},
});
};
const TestUtilCreatePayment = async (
bookingId: number,
{ success = false, refunded = false }: { success?: boolean; refunded?: boolean }
) => {
return await prisma?.payment.create({
data: {
uid: uuidv4(),
amount: 20000,
fee: 160,
currency: "usd",
success,
refunded,
type: "STRIPE",
data: {},
externalId: "DEMO_PAYMENT_FROM_DB",
booking: {
connect: {
id: bookingId,
},
},
},
});
};
export { TestUtilCreateBookingOnUserId, TestUtilCreatePayment };
+17 -2
View File
@@ -1,14 +1,29 @@
import { test as base } from "@playwright/test";
import { createBookingsFixture } from "../fixtures/bookings";
import { createPaymentsFixture } from "../fixtures/payments";
import { createUsersFixture } from "../fixtures/users";
interface Fixtures {
users: ReturnType<typeof createUsersFixture>;
bookings: ReturnType<typeof createBookingsFixture>;
payments: ReturnType<typeof createPaymentsFixture>;
}
/**
* @see https://playwright.dev/docs/test-fixtures
*/
export const test = base.extend<Fixtures>({
users: async ({ page }, use) => {
const usersFixture = createUsersFixture(page);
users: async ({ page }, use, workerInfo) => {
const usersFixture = createUsersFixture(page, workerInfo);
await use(usersFixture);
},
bookings: async ({ page }, use) => {
const bookingsFixture = createBookingsFixture(page);
await use(bookingsFixture);
},
payments: async ({ page }, use) => {
const payemntsFixture = createPaymentsFixture(page);
await use(payemntsFixture);
},
});
+8
View File
@@ -2,6 +2,14 @@ import { Prisma } from "@prisma/client";
import prisma from "@lib/prisma";
/**
* @deprecated
* DO NOT USE, since test run in parallel this will cause flaky tests. The reason
* being that a set of test may end earlier than other trigger a delete of all bookings
* than other tests may depend on them. The proper ettiquete should be that EACH test
* should cleanup ONLY the booking that we're created in that specific test to se DB
* remains "pristine" after each test
*/
export const deleteAllBookingsByEmail = async (
email: string,
whereConditional: Prisma.BookingWhereInput = {}
+4 -6
View File
@@ -1,4 +1,4 @@
import { Page, test } from "@playwright/test";
import { expect, Page, test } from "@playwright/test";
import { createServer, IncomingMessage, ServerResponse } from "http";
export function todo(title: string) {
@@ -75,7 +75,7 @@ export async function selectFirstAvailableTimeSlotNextMonth(page: Page) {
await page.waitForTimeout(1000);
// TODO: Find out why the first day is always booked on tests
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
await page.click('[data-testid="time"]');
await page.locator('[data-testid="time"]').nth(0).click();
}
export async function selectSecondAvailableTimeSlotNextMonth(page: Page) {
@@ -93,10 +93,7 @@ export async function bookFirstEvent(page: Page) {
// Click first event type
await page.click('[data-testid="event-type-link"]');
await selectFirstAvailableTimeSlotNextMonth(page);
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.press('[name="email"]', "Enter");
await bookTimeSlot(page);
// Make sure we're navigated to the success page
await page.waitForNavigation({
@@ -104,6 +101,7 @@ export async function bookFirstEvent(page: Page) {
return url.pathname.endsWith("/success");
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
}
export const bookTimeSlot = async (page: Page) => {
+13 -11
View File
@@ -1,22 +1,25 @@
import { expect } from "@playwright/test";
import { UserPlan } from "@prisma/client";
import { ErrorCode } from "@lib/auth";
import { login } from "./fixtures/users";
import { test } from "./lib/fixtures";
import { localize } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("Login and logout tests", () => {
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
// Test login with all plans
const plans = [UserPlan.PRO, UserPlan.FREE, UserPlan.TRIAL];
plans.forEach((plan) => {
test(`Should login with a ${plan} account`, async ({ page, users }) => {
// Create user and login
const pro = await users.create({ plan });
await pro.login();
const user = await users.create({ plan });
await user.login();
const shellLocator = await page.locator(`[data-testid=dashboard-shell]`);
const shellLocator = page.locator(`[data-testid=dashboard-shell]`);
// expects the home page for an authorized user
await page.goto("/");
@@ -25,7 +28,7 @@ test.describe("Login and logout tests", () => {
// Asserts to read the tested plan
const planLocator = shellLocator.locator(`[data-testid=plan-${plan.toLowerCase()}]`);
await expect(planLocator).toBeVisible();
await await expect(planLocator).toHaveText;
await expect(planLocator).toHaveText(`-${plan}`);
// When TRIAL check if the TRIAL banner is visible
if (plan === UserPlan.TRIAL) {
@@ -34,7 +37,7 @@ test.describe("Login and logout tests", () => {
});
});
test("Should warn when user does not exist", async ({ page, users }) => {
test("Should warn when user does not exist", async ({ page }) => {
const alertMessage = (await localize("en"))("no_account_exists");
// Login with a non-existent user
@@ -51,7 +54,7 @@ test.describe("Login and logout tests", () => {
const pro = await users.create({ username: "pro" });
// login with a wrong password
await login({ username: "pro", password: "wrong" }, page);
await login({ username: pro.username, password: "wrong" }, page);
// assert for the visibility of the localized alert message
await expect(page.locator(`text=${alertMessage}`)).toBeVisible();
@@ -59,8 +62,7 @@ test.describe("Login and logout tests", () => {
test("Should logout", async ({ page, users }) => {
const signOutLabel = (await localize("en"))("sign_out");
const userDropdownDisclose = async () =>
(await page.locator("[data-testid=user-dropdown-trigger]")).click();
const userDropdownDisclose = async () => page.locator("[data-testid=user-dropdown-trigger]").click();
// creates a user and login
const pro = await users.create();
@@ -68,7 +70,7 @@ test.describe("Login and logout tests", () => {
// disclose and click the sign out button from the user dropdown
await userDropdownDisclose();
const signOutBtn = await page.locator(`text=${signOutLabel}`);
const signOutBtn = page.locator(`text=${signOutLabel}`);
await signOutBtn.click();
// 2s of delay to assure the session is cleared
+5
View File
@@ -30,6 +30,11 @@ test.describe("Onboarding", () => {
test.describe("Onboarding", () => {
test("update onboarding username via localstorage", async ({ page }) => {
/**
* We need to come up with a better test since all test are run in an incognito window.
* Meaning that all localstorage access is null here.
*/
test.fixme();
await page.addInitScript(() => {
window.localStorage.setItem("username", "alwaysavailable");
}, {});

Some files were not shown because too many files have changed in this diff Show More