@@ -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=""
|
||||
# *********************************************************************************************************
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 +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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}`)
|
||||
);
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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's not you, it's us.</h2>
|
||||
<p className="mb-4 max-w-2xl text-gray-500">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 || "";
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 } }))!,
|
||||
};
|
||||
};
|
||||
@@ -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 } }))!,
|
||||
};
|
||||
};
|
||||
@@ -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"]'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]"}}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user