-
+
{startTime}
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
+
+ {booking.recurringCount &&
+ booking.eventType?.recurringEvent?.freq &&
+ booking.listingStatus === "upcoming" && (
+
+
+
(
+ {aDate}
+ ))}>
+
+
+ {`${t("every_for_freq", {
+ freq: t(
+ `${RRuleFrequency[booking.eventType.recurringEvent.freq]
+ .toString()
+ .toLowerCase()}`
+ ),
+ })} ${booking.recurringCount} ${t(
+ `${RRuleFrequency[booking.eventType.recurringEvent.freq].toString().toLowerCase()}`,
+ { count: booking.recurringCount }
+ )}`}
+
+
+
+
+ )}
+
diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx
index 296eb0da..cdd91101 100644
--- a/apps/web/components/booking/pages/AvailabilityPage.tsx
+++ b/apps/web/components/booking/pages/AvailabilityPage.tsx
@@ -8,6 +8,7 @@ import {
CreditCardIcon,
GlobeIcon,
InformationCircleIcon,
+ RefreshIcon,
} from "@heroicons/react/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useContracts } from "contexts/contractsContext";
@@ -17,6 +18,7 @@ import utc from "dayjs/plugin/utc";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
+import { Frequency as RRuleFrequency } from "rrule";
import {
useEmbedStyles,
@@ -27,11 +29,12 @@ import {
useEmbedNonStylesConfig,
} from "@calcom/embed-core";
import classNames from "@calcom/lib/classNames";
+import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { localStorage } from "@calcom/lib/webstorage";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
-import { BASE_URL, WEBAPP_URL } from "@lib/config/constants";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
@@ -101,6 +104,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
}
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
+ const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
const telemetry = useTelemetry();
@@ -142,6 +146,15 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
};
+ // Recurring event sidebar requires more space
+ const maxWidth = selectedDate
+ ? recurringEventCount
+ ? "max-w-6xl"
+ : "max-w-5xl"
+ : recurringEventCount
+ ? "max-w-4xl"
+ : "max-w-3xl";
+
return (
<>
@@ -158,9 +171,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
className={classNames(
shouldAlignCentrally ? "mx-auto" : "",
isEmbed
- ? classNames(selectedDate ? "max-w-5xl" : "max-w-3xl")
- : "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
- (selectedDate ? "max-w-5xl" : "max-w-3xl")
+ ? classNames(maxWidth)
+ : classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
)}>
{isReady && (
{/* mobile: details */}
@@ -243,7 +255,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
{eventType?.description && (
-
+
{eventType.description}
)}
-
+
{eventType.length} {t("minutes")}
+ {!rescheduleUid && eventType.recurringEvent?.count && eventType.recurringEvent?.freq && (
+
+
+
+ {t("every_for_freq", {
+ freq: t(
+ `${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`
+ ),
+ })}
+
+
{
+ setRecurringEventCount(parseInt(event?.target.value));
+ }}
+ />
+
+ {t(`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`, {
+ count: recurringEventCount,
+ })}
+
+
+ )}
{eventType.price > 0 && (
@@ -302,7 +341,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
{booking?.startTime && rescheduleUid && (
{t("former_time")}
@@ -340,6 +379,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
eventTypeSlug={eventType.slug}
slotInterval={eventType.slotInterval}
eventLength={eventType.length}
+ recurringCount={recurringEventCount}
date={selectedDate}
users={eventType.users}
schedulingType={eventType.schedulingType ?? null}
diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx
index 08bb10bd..f4ea09b6 100644
--- a/apps/web/components/booking/pages/BookingPage.tsx
+++ b/apps/web/components/booking/pages/BookingPage.tsx
@@ -2,8 +2,10 @@ import {
CalendarIcon,
ClockIcon,
CreditCardIcon,
+ ExclamationCircleIcon,
ExclamationIcon,
InformationCircleIcon,
+ RefreshIcon,
} from "@heroicons/react/solid";
import { zodResolver } from "@hookform/resolvers/zod";
import { EventTypeCustomInputType } from "@prisma/client";
@@ -18,20 +20,17 @@ import { Controller, useForm, useWatch } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
import { useMutation } from "react-query";
+import { Frequency as RRuleFrequency } from "rrule";
+import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
-import {
- useIsEmbed,
- useEmbedStyles,
- useIsBackgroundTransparent,
- useEmbedType,
- useEmbedNonStylesConfig,
-} from "@calcom/embed-core";
+import { useEmbedNonStylesConfig, useIsBackgroundTransparent, useIsEmbed } from "@calcom/embed-core";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { createPaymentLink } from "@calcom/stripe/client";
import { Button } from "@calcom/ui/Button";
+import { Tooltip } from "@calcom/ui/Tooltip";
import { EmailInput, Form } from "@calcom/ui/form/fields";
import { asStringOrNull } from "@lib/asStringOrNull";
@@ -40,7 +39,8 @@ import { ensureArray } from "@lib/ensureArray";
import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking";
-import { parseDate } from "@lib/parseDate";
+import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
+import { parseDate, parseRecurringDates } from "@lib/parseDate";
import slugify from "@lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
@@ -76,6 +76,7 @@ const BookingPage = ({
booking,
profile,
isDynamicGroupBooking,
+ recurringEventCount,
locationLabels,
hasHashedBookingLink,
hashedLink,
@@ -101,7 +102,7 @@ const BookingPage = ({
const mutation = useMutation(createBooking, {
onSuccess: async (responseData) => {
- const { attendees, paymentUid } = responseData;
+ const { id, attendees, paymentUid } = responseData;
if (paymentUid) {
return await router.push(
createPaymentLink({
@@ -135,6 +136,38 @@ const BookingPage = ({
email: attendees[0].email,
location,
eventName: profile.eventName || "",
+ bookingId: id,
+ },
+ });
+ },
+ });
+
+ const recurringMutation = useMutation(createRecurringBooking, {
+ onSuccess: async (responseData = []) => {
+ const { attendees = [], recurringEventId } = responseData[0] || {};
+ const location = (function humanReadableLocation(location) {
+ if (!location) {
+ return;
+ }
+ if (location.includes("integration")) {
+ return t("web_conferencing_details_to_follow");
+ }
+ return location;
+ })(responseData[0].location);
+
+ return router.push({
+ pathname: "/success",
+ query: {
+ date,
+ type: eventType.id,
+ eventSlug: eventType.slug,
+ recur: recurringEventId,
+ user: profile.slug,
+ reschedule: !!rescheduleUid,
+ name: attendees[0].name,
+ email: attendees[0].email,
+ location,
+ eventName: profile.eventName || "",
},
});
},
@@ -243,6 +276,20 @@ const BookingPage = ({
}
};
+ // Calculate the booking date(s)
+ let recurringStrings: string[] = [],
+ recurringDates: Date[] = [];
+ if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
+ [recurringStrings, recurringDates] = parseRecurringDates(
+ {
+ startDate: date,
+ recurringEvent: eventType.recurringEvent,
+ recurringCount: parseInt(recurringEventCount.toString()),
+ },
+ i18n
+ );
+ }
+
const bookEvent = (booking: BookingFormValues) => {
telemetry.withJitsu((jitsu) =>
jitsu.track(
@@ -265,7 +312,7 @@ const BookingPage = ({
{}
);
- let web3Details;
+ let web3Details: Record<"userWallet" | "userSignature", string> | undefined;
if (eventTypeDetail.metadata.smartContractAddress) {
web3Details = {
// @ts-ignore
@@ -274,28 +321,59 @@ const BookingPage = ({
};
}
- mutation.mutate({
- ...booking,
- web3Details,
- start: dayjs(date).format(),
- end: dayjs(date).add(eventType.length, "minute").format(),
- eventTypeId: eventType.id,
- eventTypeSlug: eventType.slug,
- timeZone: timeZone(),
- language: i18n.language,
- rescheduleUid,
- user: router.query.user,
- location: getLocationValue(
- booking.locationType ? booking : { ...booking, locationType: selectedLocation }
- ),
- metadata,
- customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
- label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
- value: booking.customInputs![inputId],
- })),
- hasHashedBookingLink,
- hashedLink,
- });
+ if (recurringDates.length) {
+ // Identify set of bookings to one intance of recurring event to support batch changes
+ const recurringEventId = uuidv4();
+ const recurringBookings = recurringDates.map((recurringDate) => ({
+ ...booking,
+ web3Details,
+ start: dayjs(recurringDate).format(),
+ end: dayjs(recurringDate).add(eventType.length, "minute").format(),
+ eventTypeId: eventType.id,
+ eventTypeSlug: eventType.slug,
+ recurringEventId,
+ // Added to track down the number of actual occurrences selected by the user
+ recurringCount: recurringDates.length,
+ timeZone: timeZone(),
+ language: i18n.language,
+ rescheduleUid,
+ user: router.query.user,
+ location: getLocationValue(
+ booking.locationType ? booking : { ...booking, locationType: selectedLocation }
+ ),
+ metadata,
+ customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
+ label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
+ value: booking.customInputs![inputId],
+ })),
+ hasHashedBookingLink,
+ hashedLink,
+ }));
+ recurringMutation.mutate(recurringBookings);
+ } else {
+ mutation.mutate({
+ ...booking,
+ web3Details,
+ start: dayjs(date).format(),
+ end: dayjs(date).add(eventType.length, "minute").format(),
+ eventTypeId: eventType.id,
+ eventTypeSlug: eventType.slug,
+ timeZone: timeZone(),
+ language: i18n.language,
+ rescheduleUid,
+ user: router.query.user,
+ location: getLocationValue(
+ booking.locationType ? booking : { ...booking, locationType: selectedLocation }
+ ),
+ metadata,
+ customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
+ label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
+ value: booking.customInputs![inputId],
+ })),
+ hasHashedBookingLink,
+ hashedLink,
+ });
+ }
};
const disableInput = !!rescheduleUid;
@@ -331,7 +409,7 @@ const BookingPage = ({
"main overflow-hidden",
isEmbed ? "" : "border border-gray-200",
isBackgroundTransparent ? "" : "dark:border-1 bg-white dark:bg-gray-800",
- "rounded-md dark:border-gray-600 sm:border"
+ "rounded-md sm:border sm:dark:border-gray-600"
)}>
@@ -375,10 +453,41 @@ const BookingPage = ({
)}
-
-
- {parseDate(date, i18n)}
-
+ {!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
+
+
+
+ {`${t("every_for_freq", {
+ freq: t(`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`),
+ })} ${recurringEventCount} ${t(
+ `${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`,
+ { count: parseInt(recurringEventCount.toString()) }
+ )}`}
+
+
+ )}
+
+
+
+ {(rescheduleUid || !eventType.recurringEvent.freq) &&
+ parseDate(dayjs.tz(date, timeZone()), i18n)}
+ {!rescheduleUid &&
+ eventType.recurringEvent.freq &&
+ recurringStrings.slice(0, 5).map((aDate, key) =>
{aDate}
)}
+ {!rescheduleUid && eventType.recurringEvent.freq && recurringStrings.length > 5 && (
+
+
(
+ {aDate}
+ ))}>
+
+ {t("plus_more", { count: recurringStrings.length - 5 })}
+
+
+
+ )}
+
+
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
@@ -429,13 +538,22 @@ const BookingPage = ({
{...bookingForm.register("email")}
required
className={classNames(
- "focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
- disableInput ? "bg-gray-200 dark:text-gray-500" : ""
+ "focus:border-brand block w-full rounded-sm shadow-sm focus:ring-black dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
+ disableInput ? "bg-gray-200 dark:text-gray-500" : "",
+ bookingForm.formState.errors.email
+ ? "border-red-700 focus:ring-red-700"
+ : " border-gray-300 dark:border-gray-900"
)}
placeholder="you@example.com"
type="search" // Disables annoying 1password intrusive popup (non-optimal, I know I know...)
disabled={disableInput}
/>
+ {bookingForm.formState.errors.email && (
+
+
+
{t("email_validation_error")}
+
+ )}
{locations.length > 1 && (
diff --git a/apps/web/components/dialog/RescheduleDialog.tsx b/apps/web/components/dialog/RescheduleDialog.tsx
index a93a0f48..fd228f89 100644
--- a/apps/web/components/dialog/RescheduleDialog.tsx
+++ b/apps/web/components/dialog/RescheduleDialog.tsx
@@ -77,7 +77,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
/>
-
+
{t("cancel")}
()({
select: {
@@ -14,6 +16,7 @@ const eventTypeData = Prisma.validator()({
price: true,
currency: true,
schedulingType: true,
+ recurringEvent: true,
description: true,
},
});
@@ -28,6 +31,11 @@ export type EventTypeDescriptionProps = {
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
const { t } = useLocale();
+ const recurringEvent: RecurringEvent = useMemo(
+ () => (eventType.recurringEvent as RecurringEvent) || [],
+ [eventType.recurringEvent]
+ );
+
return (
<>
@@ -54,6 +62,12 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
{t("1_on_1")}
)}
+ {recurringEvent?.count && recurringEvent.count > 0 && (
+
+
+ {t("repeats_up_to", { count: recurringEvent.count })}
+
+ )}
{eventType.price > 0 && (
diff --git a/apps/web/components/eventtype/RecurringEventController.tsx b/apps/web/components/eventtype/RecurringEventController.tsx
new file mode 100644
index 00000000..7034f2fc
--- /dev/null
+++ b/apps/web/components/eventtype/RecurringEventController.tsx
@@ -0,0 +1,132 @@
+import { Collapsible, CollapsibleContent } from "@radix-ui/react-collapsible";
+import React, { 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 Select from "@components/ui/form/Select";
+
+type RecurringEventControllerProps = { recurringEvent: RecurringEvent; formMethods: UseFormReturn };
+
+export default function RecurringEventController({
+ recurringEvent,
+ formMethods,
+}: RecurringEventControllerProps) {
+ const { t } = useLocale();
+
+ const [recurringEventDefined, setRecurringEventDefined] = useState(recurringEvent?.count !== undefined);
+
+ const [recurringEventInterval, setRecurringEventInterval] = useState(recurringEvent?.interval || 1);
+ const [recurringEventFrequency, setRecurringEventFrequency] = useState(
+ recurringEvent?.freq || RRuleFrequency.WEEKLY
+ );
+ const [recurringEventCount, setRecurringEventCount] = useState(recurringEvent?.count || 12);
+
+ /* Just yearly-0, monthly-1 and weekly-2 */
+ const recurringEventFreqOptions = Object.entries(RRuleFrequency)
+ .filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
+ .map(([key, value]) => ({
+ label: t(`${key.toString().toLowerCase()}`, { count: recurringEventInterval }),
+ value: value.toString(),
+ }));
+
+ return (
+
+
+
+ {t("recurring_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"
+ />
+
+
+
{t("recurring_event_description")}
+
+
+
setRecurringEventDefined(!recurringEventDefined)}>
+
+
+
{t("repeats_every")}
+
{
+ setRecurringEventInterval(parseInt(event?.target.value));
+ recurringEvent.interval = parseInt(event?.target.value);
+ formMethods.setValue("recurringEvent", recurringEvent);
+ }}
+ />
+
{
+ if (e?.value) {
+ setRecurringEventFrequency(parseInt(e?.value));
+ recurringEvent.freq = parseInt(e?.value);
+ formMethods.setValue("recurringEvent", recurringEvent);
+ }
+ }}
+ />
+
+
+
{t("max")}
+
{
+ setRecurringEventCount(parseInt(event?.target.value));
+ recurringEvent.count = parseInt(event?.target.value);
+ formMethods.setValue("recurringEvent", recurringEvent);
+ }}
+ />
+
+ {t(`${RRuleFrequency[recurringEventFrequency].toString().toLowerCase()}`, {
+ count: recurringEventCount,
+ })}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/security/ChangePasswordSection.tsx b/apps/web/components/security/ChangePasswordSection.tsx
index 3c8684ad..3b8562cf 100644
--- a/apps/web/components/security/ChangePasswordSection.tsx
+++ b/apps/web/components/security/ChangePasswordSection.tsx
@@ -61,8 +61,8 @@ const ChangePasswordSection = () => {
{t("change_password")}
-
-
+
+
{t("current_password")}
@@ -79,7 +79,7 @@ const ChangePasswordSection = () => {
/>
-
+
{t("new_password")}
@@ -98,7 +98,7 @@ const ChangePasswordSection = () => {
{errorMessage &&
{errorMessage}
}
-
+
{t("save")}
diff --git a/apps/web/components/security/TwoFactorAuthSection.tsx b/apps/web/components/security/TwoFactorAuthSection.tsx
index 72aaeb88..b21273fd 100644
--- a/apps/web/components/security/TwoFactorAuthSection.tsx
+++ b/apps/web/components/security/TwoFactorAuthSection.tsx
@@ -17,7 +17,7 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
return (
<>
-
+
{t("2fa")}
@@ -27,7 +27,7 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
{t("add_an_extra_layer_of_security")}
-
+
<>
@@ -76,7 +76,7 @@ export default function MemberChangeRoleModal(props: {
{/*{t("owner")} - needs dialog to confirm change of ownership */}
option && setRole(option)}
id="role"
diff --git a/apps/web/components/team/MemberInvitationModal.tsx b/apps/web/components/team/MemberInvitationModal.tsx
index d064f216..b1a6448a 100644
--- a/apps/web/components/team/MemberInvitationModal.tsx
+++ b/apps/web/components/team/MemberInvitationModal.tsx
@@ -16,6 +16,7 @@ import Select from "@components/ui/form/Select";
type MemberInvitationModalProps = {
isOpen: boolean;
team: TeamWithMembers | null;
+ currentMember: MembershipRole;
onExit: () => void;
};
@@ -24,7 +25,7 @@ type MembershipRoleOption = {
label?: string;
};
-const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }];
+const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
const [errorMessage, setErrorMessage] = useState("");
@@ -100,7 +101,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
{
+ const { members } = props.team;
+ const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]);
+ return owners.length;
+ };
+
+ const currentUserId = useCurrentUserId();
+
const name =
props.member.name ||
(() => {
@@ -65,7 +74,7 @@ export default function MemberListItem(props: Props) {
@@ -121,8 +130,12 @@ export default function MemberListItem(props: Props) {
- {(props.team.membership.role === MembershipRole.OWNER ||
- props.team.membership.role === MembershipRole.ADMIN) && (
+ {((props.team.membership.role === MembershipRole.OWNER &&
+ (props.member.role !== MembershipRole.OWNER ||
+ ownersInTeam() > 1 ||
+ props.member.id !== currentUserId)) ||
+ (props.team.membership.role === MembershipRole.ADMIN &&
+ props.member.role !== MembershipRole.OWNER)) && (
<>
setShowTeamAvailabilityModal(false)}>{t("done")}
{props.team.membership.role !== MembershipRole.MEMBER && (
-
+
{t("Open Team Availability")}
)}
diff --git a/apps/web/components/team/TeamListItem.tsx b/apps/web/components/team/TeamListItem.tsx
index 6c3d12fb..776a9139 100644
--- a/apps/web/components/team/TeamListItem.tsx
+++ b/apps/web/components/team/TeamListItem.tsx
@@ -19,12 +19,12 @@ import Dropdown, {
DropdownMenuTrigger,
DropdownMenuSeparator,
} 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 { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar";
diff --git a/apps/web/components/team/screens/Team.tsx b/apps/web/components/team/screens/Team.tsx
index f04707bf..a7a26338 100644
--- a/apps/web/components/team/screens/Team.tsx
+++ b/apps/web/components/team/screens/Team.tsx
@@ -5,9 +5,9 @@ import Link from "next/link";
import { TeamPageProps } from "pages/team/[slug]";
import React from "react";
+import { WEBSITE_URL } from "@calcom/lib/constants";
import Button from "@calcom/ui/Button";
-import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useLocale } from "@lib/hooks/useLocale";
import Avatar from "@components/ui/Avatar";
@@ -52,7 +52,7 @@ const Team = ({ team }: TeamPageProps) => {
diff --git a/apps/web/components/ui/InfoBadge.tsx b/apps/web/components/ui/InfoBadge.tsx
index 41761cc4..90bc2275 100644
--- a/apps/web/components/ui/InfoBadge.tsx
+++ b/apps/web/components/ui/InfoBadge.tsx
@@ -1,6 +1,6 @@
import { InformationCircleIcon } from "@heroicons/react/solid";
-import { Tooltip } from "@components/Tooltip";
+import { Tooltip } from "@calcom/ui/Tooltip";
export default function InfoBadge({ content }: { content: string }) {
return (
diff --git a/apps/web/components/webhook/WebhookDialogForm.tsx b/apps/web/components/webhook/WebhookDialogForm.tsx
index 6e44bdac..b0026626 100644
--- a/apps/web/components/webhook/WebhookDialogForm.tsx
+++ b/apps/web/components/webhook/WebhookDialogForm.tsx
@@ -29,7 +29,7 @@ export default function WebhookDialogForm(props: {
subscriberUrl: "",
active: true,
payloadTemplate: null,
- } as Omit,
+ } as Omit,
} = props;
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
@@ -58,7 +58,9 @@ export default function WebhookDialogForm(props: {
props.handleClose();
}}
className="space-y-4">
-
+
+
+
- {
- form.setValue("subscriberUrl", e.target.value);
- if (hasTemplateIntegration({ url: e.target.value })) {
- setUseCustomPayloadTemplate(true);
- form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
- }
- }}
- />
-
+
+ {
+ form.setValue("subscriberUrl", e.target.value);
+ if (hasTemplateIntegration({ url: e.target.value })) {
+ setUseCustomPayloadTemplate(true);
+ form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
+ }
+ }}
+ />
+
{t("event_triggers")}
diff --git a/apps/web/components/webhook/WebhookListItem.tsx b/apps/web/components/webhook/WebhookListItem.tsx
index 970b1917..f78fdf9e 100644
--- a/apps/web/components/webhook/WebhookListItem.tsx
+++ b/apps/web/components/webhook/WebhookListItem.tsx
@@ -3,12 +3,12 @@ import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import classNames from "@calcom/lib/classNames";
import Button from "@calcom/ui/Button";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
+import { Tooltip } from "@calcom/ui/Tooltip";
import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { ListItem } from "@components/List";
-import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
diff --git a/apps/web/contexts/contractsContext.tsx b/apps/web/contexts/contractsContext.tsx
index 29e1cca5..a65682e4 100644
--- a/apps/web/contexts/contractsContext.tsx
+++ b/apps/web/contexts/contractsContext.tsx
@@ -1,5 +1,7 @@
import { createContext, ReactNode, useContext } from "react";
+import { localStorage } from "@calcom/lib/webstorage";
+
type contractsContextType = Record;
const contractsContextDefaultValue: contractsContextType = {};
@@ -21,18 +23,17 @@ interface addContractsPayload {
export function ContractsProvider({ children }: Props) {
const addContract = (payload: addContractsPayload) => {
- window.localStorage.setItem(
+ localStorage.setItem(
"contracts",
JSON.stringify({
- ...JSON.parse(window.localStorage.getItem("contracts") || "{}"),
+ ...JSON.parse(localStorage.getItem("contracts") || "{}"),
[payload.address]: payload.signature,
})
);
};
const value = {
- contracts:
- typeof window !== "undefined" ? JSON.parse(window.localStorage.getItem("contracts") || "{}") : {},
+ contracts: typeof window !== "undefined" ? JSON.parse(localStorage.getItem("contracts") || "{}") : {},
addContract,
};
diff --git a/apps/web/ee/components/TrialBanner.tsx b/apps/web/ee/components/TrialBanner.tsx
index eed8e19e..6c6ccacb 100644
--- a/apps/web/ee/components/TrialBanner.tsx
+++ b/apps/web/ee/components/TrialBanner.tsx
@@ -4,8 +4,7 @@ import Button from "@calcom/ui/Button";
import { TRIAL_LIMIT_DAYS } from "@lib/config/constants";
import { useLocale } from "@lib/hooks/useLocale";
-
-import { useMeQuery } from "@components/Shell";
+import useMeQuery from "@lib/hooks/useMeQuery";
const TrialBanner = () => {
const { t } = useLocale();
diff --git a/apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx b/apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx
index b3b168bf..99730950 100644
--- a/apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx
+++ b/apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx
@@ -8,11 +8,11 @@ import showToast from "@calcom/lib/notification";
import Button from "@calcom/ui/Button";
import { DialogFooter } from "@calcom/ui/Dialog";
import Switch from "@calcom/ui/Switch";
+import { Tooltip } from "@calcom/ui/Tooltip";
import { Form, TextField } from "@calcom/ui/form/fields";
import { trpc } from "@lib/trpc";
-import { Tooltip } from "@components/Tooltip";
import { DatePicker } from "@components/ui/form/DatePicker";
import { TApiKeys } from "./ApiKeyListItem";
@@ -102,17 +102,18 @@ export default function ApiKeyDialogForm(props: {
setSuccessfulNewApiKeyModal(true);
}}
className="space-y-4">
-
+
{props.title}
{t("api_key_modal_subtitle")}
-
-
+
+
+
{t("expire_date")}
diff --git a/apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx b/apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx
index d4305406..5e71b13c 100644
--- a/apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx
+++ b/apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx
@@ -24,12 +24,12 @@ export default function ApiKeyListContainer() {
query={query}
success={({ data }) => (
<>
-
+
{t("api_keys")}
{t("api_keys_subtitle")}
-
+
setNewApiKeyModal(true)}>
{t("generate_new_api_key")}
diff --git a/apps/web/ee/components/apiKeys/ApiKeyListItem.tsx b/apps/web/ee/components/apiKeys/ApiKeyListItem.tsx
index 6c74f9fd..b3c064b8 100644
--- a/apps/web/ee/components/apiKeys/ApiKeyListItem.tsx
+++ b/apps/web/ee/components/apiKeys/ApiKeyListItem.tsx
@@ -7,11 +7,11 @@ import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
+import { Tooltip } from "@calcom/ui/Tooltip";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { ListItem } from "@components/List";
-import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Badge from "@components/ui/Badge";
diff --git a/apps/web/ee/components/team/availability/TeamAvailabilityModal.tsx b/apps/web/ee/components/team/availability/TeamAvailabilityModal.tsx
index bb7c3ae9..0b8ece47 100644
--- a/apps/web/ee/components/team/availability/TeamAvailabilityModal.tsx
+++ b/apps/web/ee/components/team/availability/TeamAvailabilityModal.tsx
@@ -3,7 +3,8 @@ import utc from "dayjs/plugin/utc";
import React, { useState, useEffect } from "react";
import TimezoneSelect, { ITimezone } from "react-timezone-select";
-import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
+import { WEBSITE_URL } from "@calcom/lib/constants";
+
import { trpc, inferQueryOutput } from "@lib/trpc";
import Avatar from "@components/ui/Avatar";
@@ -36,7 +37,7 @@ export default function TeamAvailabilityModal(props: Props) {
diff --git a/apps/web/ee/components/team/availability/TeamAvailabilityScreen.tsx b/apps/web/ee/components/team/availability/TeamAvailabilityScreen.tsx
index a5b56977..83b81e9d 100644
--- a/apps/web/ee/components/team/availability/TeamAvailabilityScreen.tsx
+++ b/apps/web/ee/components/team/availability/TeamAvailabilityScreen.tsx
@@ -4,7 +4,8 @@ import TimezoneSelect, { ITimezone } from "react-timezone-select";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
-import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
+import { WEBSITE_URL } from "@calcom/lib/constants";
+
import { trpc, inferQueryOutput } from "@lib/trpc";
import Avatar from "@components/ui/Avatar";
@@ -45,7 +46,7 @@ export default function TeamAvailabilityScreen(props: Props) {
HeaderComponent={
diff --git a/apps/web/ee/pages/api/integrations/stripepayment/webhook.ts b/apps/web/ee/pages/api/integrations/stripepayment/webhook.ts
index 7bddc5b3..79f317c2 100644
--- a/apps/web/ee/pages/api/integrations/stripepayment/webhook.ts
+++ b/apps/web/ee/pages/api/integrations/stripepayment/webhook.ts
@@ -7,7 +7,7 @@ import EventManager from "@calcom/core/EventManager";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import prisma from "@calcom/prisma";
import stripe from "@calcom/stripe/server";
-import { CalendarEvent } from "@calcom/types/Calendar";
+import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import { IS_PRODUCTION } from "@lib/config/constants";
import { HttpError as HttpCode } from "@lib/core/http/error";
@@ -49,6 +49,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
confirmed: true,
attendees: true,
location: true,
+ eventTypeId: true,
userId: true,
id: true,
uid: true,
@@ -70,6 +71,23 @@ async function handlePaymentSuccess(event: Stripe.Event) {
if (!booking) throw new Error("No booking found");
+ const eventTypeSelect = Prisma.validator
()({ recurringEvent: true });
+ const eventTypeData = Prisma.validator()({ select: eventTypeSelect });
+ type EventTypeRaw = Prisma.EventTypeGetPayload;
+ let eventTypeRaw: EventTypeRaw | null = null;
+ if (booking.eventTypeId) {
+ eventTypeRaw = await prisma.eventType.findUnique({
+ where: {
+ id: booking.eventTypeId,
+ },
+ select: eventTypeSelect,
+ });
+ }
+
+ const eventType = {
+ recurringEvent: (eventTypeRaw?.recurringEvent || {}) as RecurringEvent,
+ };
+
const { user } = booking;
if (!user) throw new Error("No user found");
@@ -137,7 +155,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
await prisma.$transaction([paymentUpdate, bookingUpdate]);
- await sendScheduledEmails({ ...evt });
+ await sendScheduledEmails({ ...evt }, eventType.recurringEvent);
throw new HttpCode({
statusCode: 200,
diff --git a/apps/web/ee/pages/settings/teams/[id]/availability.tsx b/apps/web/ee/pages/settings/teams/[id]/availability.tsx
index 3e22e8d8..49cba959 100644
--- a/apps/web/ee/pages/settings/teams/[id]/availability.tsx
+++ b/apps/web/ee/pages/settings/teams/[id]/availability.tsx
@@ -5,10 +5,11 @@ import { Alert } from "@calcom/ui/Alert";
import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
+import useMeQuery from "@lib/hooks/useMeQuery";
import { trpc } from "@lib/trpc";
import Loader from "@components/Loader";
-import Shell, { useMeQuery } from "@components/Shell";
+import Shell from "@components/Shell";
import Avatar from "@components/ui/Avatar";
export function TeamAvailabilityPage() {
diff --git a/apps/web/lib/clock.ts b/apps/web/lib/clock.ts
index 75b5939c..cc3d0b82 100644
--- a/apps/web/lib/clock.ts
+++ b/apps/web/lib/clock.ts
@@ -3,6 +3,8 @@ import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
+import { localStorage } from "@calcom/lib/webstorage";
+
import { isBrowserLocale24h } from "./timeFormat";
dayjs.extend(utc);
@@ -21,11 +23,11 @@ const timeOptions: TimeOptions = {
const isInitialized = false;
const initClock = () => {
- if (typeof localStorage === "undefined" || isInitialized) {
+ if (isInitialized) {
return;
}
// This only sets browser locale if there's no preference on localStorage.
- if (!localStorage || !localStorage.getItem("timeOption.is24hClock")) set24hClock(isBrowserLocale24h());
+ if (!localStorage.getItem("timeOption.is24hClock")) set24hClock(isBrowserLocale24h());
timeOptions.is24hClock = localStorage.getItem("timeOption.is24hClock") === "true";
timeOptions.inviteeTimeZone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
};
diff --git a/apps/web/lib/emails/email-manager.ts b/apps/web/lib/emails/email-manager.ts
index ae681f70..6cb35484 100644
--- a/apps/web/lib/emails/email-manager.ts
+++ b/apps/web/lib/emails/email-manager.ts
@@ -1,4 +1,5 @@
-import type { CalendarEvent, Person } from "@calcom/types/Calendar";
+import { recurringEvent } from "@calcom/prisma/zod-utils";
+import type { CalendarEvent, Person, RecurringEvent } from "@calcom/types/Calendar";
import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email";
@@ -17,14 +18,14 @@ import OrganizerRescheduledEmail from "@lib/emails/templates/organizer-reschedul
import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email";
import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email";
-export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
+export const sendScheduledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
const emailsToSend: Promise[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
- const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee);
+ const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee, recurringEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
@@ -36,7 +37,7 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
emailsToSend.push(
new Promise((resolve, reject) => {
try {
- const scheduledEmail = new OrganizerScheduledEmail(calEvent);
+ const scheduledEmail = new OrganizerScheduledEmail(calEvent, recurringEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
@@ -47,14 +48,14 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend);
};
-export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
+export const sendRescheduledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
const emailsToSend: Promise[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
- const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee);
+ const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee, recurringEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
@@ -66,7 +67,7 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
emailsToSend.push(
new Promise((resolve, reject) => {
try {
- const scheduledEmail = new OrganizerRescheduledEmail(calEvent);
+ const scheduledEmail = new OrganizerRescheduledEmail(calEvent, recurringEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
@@ -77,10 +78,13 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend);
};
-export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
+export const sendOrganizerRequestEmail = async (
+ calEvent: CalendarEvent,
+ recurringEvent: RecurringEvent = {}
+) => {
await new Promise((resolve, reject) => {
try {
- const organizerRequestEmail = new OrganizerRequestEmail(calEvent);
+ const organizerRequestEmail = new OrganizerRequestEmail(calEvent, recurringEvent);
resolve(organizerRequestEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerRequestEmail.sendEmail failed", e));
@@ -88,10 +92,14 @@ export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
});
};
-export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee: Person) => {
+export const sendAttendeeRequestEmail = async (
+ calEvent: CalendarEvent,
+ attendee: Person,
+ recurringEvent: RecurringEvent = {}
+) => {
await new Promise((resolve, reject) => {
try {
- const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee);
+ const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee, recurringEvent);
resolve(attendeeRequestEmail.sendEmail());
} catch (e) {
reject(console.error("AttendRequestEmail.sendEmail failed", e));
@@ -99,14 +107,14 @@ export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee
});
};
-export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
+export const sendDeclinedEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
const emailsToSend: Promise[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
- const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee);
+ const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee, recurringEvent);
resolve(declinedEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
@@ -118,14 +126,14 @@ export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend);
};
-export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
+export const sendCancelledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
const emailsToSend: Promise[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
- const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee);
+ const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee, recurringEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeCancelledEmail.sendEmail failed", e));
@@ -137,7 +145,7 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
emailsToSend.push(
new Promise((resolve, reject) => {
try {
- const scheduledEmail = new OrganizerCancelledEmail(calEvent);
+ const scheduledEmail = new OrganizerCancelledEmail(calEvent, recurringEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerCancelledEmail.sendEmail failed", e));
@@ -148,10 +156,13 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend);
};
-export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => {
+export const sendOrganizerRequestReminderEmail = async (
+ calEvent: CalendarEvent,
+ recurringEvent: RecurringEvent = {}
+) => {
await new Promise((resolve, reject) => {
try {
- const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent);
+ const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent, recurringEvent);
resolve(organizerRequestReminderEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerRequestReminderEmail.sendEmail failed", e));
@@ -159,14 +170,17 @@ export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent)
});
};
-export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
+export const sendAwaitingPaymentEmail = async (
+ calEvent: CalendarEvent,
+ recurringEvent: RecurringEvent = {}
+) => {
const emailsToSend: Promise[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
- const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee);
+ const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee, recurringEvent);
resolve(paymentEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeAwaitingPaymentEmail.sendEmail failed", e));
@@ -178,10 +192,13 @@ export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend);
};
-export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => {
+export const sendOrganizerPaymentRefundFailedEmail = async (
+ calEvent: CalendarEvent,
+ recurringEvent: RecurringEvent = {}
+) => {
await new Promise((resolve, reject) => {
try {
- const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent);
+ const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent, recurringEvent);
resolve(paymentRefundFailedEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e));
@@ -213,14 +230,19 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
export const sendRequestRescheduleEmail = async (
calEvent: CalendarEvent,
- metadata: { rescheduleLink: string }
+ metadata: { rescheduleLink: string },
+ recurringEvent: RecurringEvent = {}
) => {
const emailsToSend: Promise[] = [];
emailsToSend.push(
new Promise((resolve, reject) => {
try {
- const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(calEvent, metadata);
+ const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(
+ calEvent,
+ metadata,
+ recurringEvent
+ );
resolve(requestRescheduleEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
@@ -231,7 +253,11 @@ export const sendRequestRescheduleEmail = async (
emailsToSend.push(
new Promise((resolve, reject) => {
try {
- const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(calEvent, metadata);
+ const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(
+ calEvent,
+ metadata,
+ recurringEvent
+ );
resolve(requestRescheduleEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));
diff --git a/apps/web/lib/emails/templates/attendee-declined-email.ts b/apps/web/lib/emails/templates/attendee-declined-email.ts
index b1e36496..f60a9c47 100644
--- a/apps/web/lib/emails/templates/attendee-declined-email.ts
+++ b/apps/web/lib/emails/templates/attendee-declined-email.ts
@@ -42,7 +42,9 @@ export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail {
protected getTextBody(): string {
return `
-${this.attendee.language.translate("event_request_declined")}
+${this.attendee.language.translate(
+ this.recurringEvent?.count ? "event_request_declined_recurring" : "event_request_declined"
+)}
${this.attendee.language.translate("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
@@ -75,7 +77,9 @@ ${this.getRejectionReason()}
${emailSchedulingBodyHeader("xCircle")}
${emailScheduledBodyHeaderContent(
- this.attendee.language.translate("event_request_declined"),
+ this.attendee.language.translate(
+ this.recurringEvent?.count ? "event_request_declined_recurring" : "event_request_declined"
+ ),
this.attendee.language.translate("emailed_you_and_any_other_attendees")
)}
${emailSchedulingBodyDivider()}
diff --git a/apps/web/lib/emails/templates/attendee-request-email.ts b/apps/web/lib/emails/templates/attendee-request-email.ts
index 98dec12b..6393cb62 100644
--- a/apps/web/lib/emails/templates/attendee-request-email.ts
+++ b/apps/web/lib/emails/templates/attendee-request-email.ts
@@ -87,10 +87,17 @@ ${this.getAdditionalNotes()}
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
- this.calEvent.organizer.language.translate("booking_submitted"),
- this.calEvent.organizer.language.translate("user_needs_to_confirm_or_reject_booking", {
- user: this.calEvent.organizer.name,
- })
+ this.calEvent.organizer.language.translate(
+ this.recurringEvent?.count ? "booking_submitted_recurring" : "booking_submitted"
+ ),
+ this.calEvent.organizer.language.translate(
+ this.recurringEvent.count
+ ? "user_needs_to_confirm_or_reject_booking_recurring"
+ : "user_needs_to_confirm_or_reject_booking",
+ {
+ user: this.calEvent.organizer.name,
+ }
+ )
)}
${emailSchedulingBodyDivider()}
diff --git a/apps/web/lib/emails/templates/attendee-request-reschedule-email.ts b/apps/web/lib/emails/templates/attendee-request-reschedule-email.ts
index 179b62e0..3999389c 100644
--- a/apps/web/lib/emails/templates/attendee-request-reschedule-email.ts
+++ b/apps/web/lib/emails/templates/attendee-request-reschedule-email.ts
@@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
import { createEvent, DateArray, Person } from "ics";
import { getCancelLink } from "@calcom/lib/CalEventParser";
-import { CalendarEvent } from "@calcom/types/Calendar";
+import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import {
emailHead,
@@ -24,8 +24,8 @@ dayjs.extend(toArray);
export default class AttendeeRequestRescheduledEmail extends OrganizerScheduledEmail {
private metadata: { rescheduleLink: string };
- constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
- super(calEvent);
+ constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
+ super(calEvent, recurringEvent);
this.metadata = metadata;
}
protected getNodeMailerPayload(): Record
{
diff --git a/apps/web/lib/emails/templates/attendee-scheduled-email.ts b/apps/web/lib/emails/templates/attendee-scheduled-email.ts
index 1a4c045b..7df53952 100644
--- a/apps/web/lib/emails/templates/attendee-scheduled-email.ts
+++ b/apps/web/lib/emails/templates/attendee-scheduled-email.ts
@@ -4,13 +4,15 @@ 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 } from "@calcom/types/Calendar";
+import type { Person, CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import {
emailHead,
@@ -29,10 +31,12 @@ dayjs.extend(toArray);
export default class AttendeeScheduledEmail {
calEvent: CalendarEvent;
attendee: Person;
+ recurringEvent: RecurringEvent;
- constructor(calEvent: CalendarEvent, attendee: Person) {
+ constructor(calEvent: CalendarEvent, attendee: Person, recurringEvent: RecurringEvent) {
this.calEvent = calEvent;
this.attendee = attendee;
+ this.recurringEvent = recurringEvent;
}
public sendEmail() {
@@ -53,6 +57,11 @@ export default class AttendeeScheduledEmail {
}
protected getiCalEventAsString(): string | undefined {
+ // Taking care of recurrence rule beforehand
+ let recurrenceRule: string | undefined = undefined;
+ if (this.recurringEvent?.count) {
+ recurrenceRule = new rrule(this.recurringEvent).toString();
+ }
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime)
.utc()
@@ -72,6 +81,7 @@ export default class AttendeeScheduledEmail {
name: attendee.name,
email: attendee.email,
})),
+ ...{ recurrenceRule },
status: "CONFIRMED",
});
if (icsEvent.error) {
@@ -125,7 +135,9 @@ export default class AttendeeScheduledEmail {
}
protected getTextBody(): string {
return `
-${this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled")}
+${this.calEvent.attendees[0].language.translate(
+ this.recurringEvent?.count ? "your_event_has_been_scheduled_recurring" : "your_event_has_been_scheduled"
+)}
${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")}
${getRichDescription(this.calEvent)}
@@ -157,7 +169,11 @@ ${getRichDescription(this.calEvent)}
${emailSchedulingBodyHeader("checkCircle")}
${emailScheduledBodyHeaderContent(
- this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled"),
+ this.calEvent.attendees[0].language.translate(
+ this.recurringEvent?.count
+ ? "your_event_has_been_scheduled_recurring"
+ : "your_event_has_been_scheduled"
+ ),
this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")
)}
${emailSchedulingBodyDivider()}
@@ -250,12 +266,30 @@ ${getRichDescription(this.calEvent)}
`;
}
+ protected getRecurringWhen(): string {
+ if (this.recurringEvent?.freq) {
+ return ` - ${this.calEvent.attendees[0].language.translate("every_for_freq", {
+ freq: this.calEvent.attendees[0].language.translate(
+ `${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`
+ ),
+ })} ${this.recurringEvent.count} ${this.calEvent.attendees[0].language.translate(
+ `${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`,
+ { count: this.recurringEvent.count }
+ )}`;
+ } else {
+ return "";
+ }
+ }
+
protected getWhen(): string {
return `
-
${this.calEvent.attendees[0].language.translate("when")}
+
${this.calEvent.attendees[0].language.translate("when")}${
+ this.recurringEvent?.count ? this.getRecurringWhen() : ""
+ }
+ ${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
${this.calEvent.attendees[0].language.translate(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.attendees[0].language.translate(
diff --git a/apps/web/lib/emails/templates/organizer-request-email.ts b/apps/web/lib/emails/templates/organizer-request-email.ts
index b9459329..69735c0d 100644
--- a/apps/web/lib/emails/templates/organizer-request-email.ts
+++ b/apps/web/lib/emails/templates/organizer-request-email.ts
@@ -86,7 +86,9 @@ ${process.env.NEXT_PUBLIC_WEBAPP_URL} + "/bookings/upcoming"
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
- this.calEvent.organizer.language.translate("event_awaiting_approval"),
+ this.calEvent.organizer.language.translate(
+ this.recurringEvent?.count ? "event_awaiting_approval_recurring" : "event_awaiting_approval"
+ ),
this.calEvent.organizer.language.translate("someone_requested_an_event")
)}
${emailSchedulingBodyDivider()}
diff --git a/apps/web/lib/emails/templates/organizer-request-reschedule-email.ts b/apps/web/lib/emails/templates/organizer-request-reschedule-email.ts
index 4bfaa14f..fb32b2e5 100644
--- a/apps/web/lib/emails/templates/organizer-request-reschedule-email.ts
+++ b/apps/web/lib/emails/templates/organizer-request-reschedule-email.ts
@@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
import { createEvent, DateArray, Person } from "ics";
import { getCancelLink } from "@calcom/lib/CalEventParser";
-import { CalendarEvent } from "@calcom/types/Calendar";
+import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import {
emailHead,
@@ -24,8 +24,8 @@ dayjs.extend(toArray);
export default class OrganizerRequestRescheduledEmail extends OrganizerScheduledEmail {
private metadata: { rescheduleLink: string };
- constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
- super(calEvent);
+ constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
+ super(calEvent, recurringEvent);
this.metadata = metadata;
}
protected getNodeMailerPayload(): Record
{
diff --git a/apps/web/lib/emails/templates/organizer-scheduled-email.ts b/apps/web/lib/emails/templates/organizer-scheduled-email.ts
index 5d871924..5b62f460 100644
--- a/apps/web/lib/emails/templates/organizer-scheduled-email.ts
+++ b/apps/web/lib/emails/templates/organizer-scheduled-email.ts
@@ -5,12 +5,13 @@ 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 } from "@calcom/types/Calendar";
+import type { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import {
emailHead,
@@ -28,9 +29,11 @@ dayjs.extend(toArray);
export default class OrganizerScheduledEmail {
calEvent: CalendarEvent;
+ recurringEvent: RecurringEvent;
- constructor(calEvent: CalendarEvent) {
+ constructor(calEvent: CalendarEvent, recurringEvent: RecurringEvent) {
this.calEvent = calEvent;
+ this.recurringEvent = recurringEvent;
}
public sendEmail() {
@@ -51,6 +54,11 @@ export default class OrganizerScheduledEmail {
}
protected getiCalEventAsString(): string | undefined {
+ // Taking care of recurrence rule beforehand
+ let recurrenceRule: string | undefined = undefined;
+ if (this.recurringEvent?.count) {
+ recurrenceRule = new rrule(this.recurringEvent).toString();
+ }
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime)
.utc()
@@ -66,6 +74,7 @@ export default class OrganizerScheduledEmail {
description: this.getTextBody(),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
+ ...{ recurrenceRule },
attendees: this.calEvent.attendees.map((attendee: Person) => ({
name: attendee.name,
email: attendee.email,
@@ -121,7 +130,9 @@ export default class OrganizerScheduledEmail {
protected getTextBody(): string {
return `
-${this.calEvent.organizer.language.translate("new_event_scheduled")}
+${this.calEvent.organizer.language.translate(
+ this.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
+)}
${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")}
${getRichDescription(this.calEvent)}
@@ -153,7 +164,9 @@ ${getRichDescription(this.calEvent)}
${emailSchedulingBodyHeader("checkCircle")}
${emailScheduledBodyHeaderContent(
- this.calEvent.organizer.language.translate("new_event_scheduled"),
+ this.calEvent.organizer.language.translate(
+ this.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
+ ),
this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")
)}
${emailSchedulingBodyDivider()}
@@ -240,12 +253,30 @@ ${getRichDescription(this.calEvent)}
`;
}
+ protected getRecurringWhen(): string {
+ if (this.recurringEvent?.freq) {
+ return ` - ${this.calEvent.attendees[0].language.translate("every_for_freq", {
+ freq: this.calEvent.attendees[0].language.translate(
+ `${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`
+ ),
+ })} ${this.recurringEvent.count} ${this.calEvent.attendees[0].language.translate(
+ `${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`,
+ { count: this.recurringEvent.count }
+ )}`;
+ } else {
+ return "";
+ }
+ }
+
protected getWhen(): string {
return `
-
${this.calEvent.organizer.language.translate("when")}
+
${this.calEvent.organizer.language.translate("when")}${
+ this.recurringEvent?.count ? this.getRecurringWhen() : ""
+ }
+ ${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate(
diff --git a/apps/web/lib/hooks/useCurrentUserId.ts b/apps/web/lib/hooks/useCurrentUserId.ts
new file mode 100644
index 00000000..a9023f63
--- /dev/null
+++ b/apps/web/lib/hooks/useCurrentUserId.ts
@@ -0,0 +1,9 @@
+import useMeQuery from "./useMeQuery";
+
+export const useCurrentUserId = () => {
+ const query = useMeQuery();
+ const user = query.data;
+ return user?.id;
+};
+
+export default useCurrentUserId;
diff --git a/apps/web/lib/hooks/useMeQuery.ts b/apps/web/lib/hooks/useMeQuery.ts
new file mode 100644
index 00000000..a8d0a051
--- /dev/null
+++ b/apps/web/lib/hooks/useMeQuery.ts
@@ -0,0 +1,13 @@
+import { trpc } from "../trpc";
+
+export function useMeQuery() {
+ const meQuery = trpc.useQuery(["viewer.me"], {
+ retry(failureCount) {
+ return failureCount > 3;
+ },
+ });
+
+ return meQuery;
+}
+
+export default useMeQuery;
diff --git a/apps/web/lib/hooks/useTheme.tsx b/apps/web/lib/hooks/useTheme.tsx
index 9af36630..b0afafe6 100644
--- a/apps/web/lib/hooks/useTheme.tsx
+++ b/apps/web/lib/hooks/useTheme.tsx
@@ -40,7 +40,7 @@ export default function useTheme(theme?: Maybe) {
// TODO: isReady doesn't seem required now. This is also impacting PSI Score for pages which are using isReady.
setIsReady(true);
setTheme(theme);
- }, []);
+ }, [theme]);
function Theme() {
const code = applyThemeAndAddListener.toString();
diff --git a/apps/web/lib/mutations/bookings/create-recurring-booking.ts b/apps/web/lib/mutations/bookings/create-recurring-booking.ts
new file mode 100644
index 00000000..1f64d6c9
--- /dev/null
+++ b/apps/web/lib/mutations/bookings/create-recurring-booking.ts
@@ -0,0 +1,22 @@
+import * as fetch from "@lib/core/http/fetch-wrapper";
+import { BookingCreateBody, BookingResponse } from "@lib/types/booking";
+
+type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number };
+
+const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
+ return Promise.all(
+ data.map((booking, key) => {
+ // We only want to send the first occurrence of the meeting at the moment, not all at once
+ if (key === 0) {
+ return fetch.post("/api/book/event", booking);
+ } else {
+ return fetch.post("/api/book/event", {
+ ...booking,
+ noEmail: true,
+ });
+ }
+ })
+ );
+};
+
+export default createRecurringBooking;
diff --git a/apps/web/lib/parseDate.ts b/apps/web/lib/parseDate.ts
index 755c98c7..c0a05057 100644
--- a/apps/web/lib/parseDate.ts
+++ b/apps/web/lib/parseDate.ts
@@ -1,14 +1,42 @@
import dayjs, { Dayjs } from "dayjs";
import { I18n } from "next-i18next";
+import { RRule } from "rrule";
+
+import { recurringEvent } from "@calcom/prisma/zod-utils";
+import { RecurringEvent } from "@calcom/types/Calendar";
import { detectBrowserTimeFormat } from "@lib/timeFormat";
import { parseZone } from "./parseZone";
-export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
- if (!date) return "No date";
+const processDate = (date: string | null | Dayjs, i18n: I18n) => {
const parsedZone = parseZone(date);
if (!parsedZone?.isValid()) return "Invalid date";
const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
};
+
+export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
+ if (!date) return ["No date"];
+ return processDate(date, i18n);
+};
+
+export const parseRecurringDates = (
+ {
+ startDate,
+ recurringEvent,
+ recurringCount,
+ }: { startDate: string | null | Dayjs; recurringEvent: RecurringEvent; recurringCount: number },
+ i18n: I18n
+): [string[], Date[]] => {
+ const { count, ...restRecurringEvent } = recurringEvent;
+ const rule = new RRule({
+ ...restRecurringEvent,
+ count: recurringCount,
+ dtstart: dayjs(startDate).toDate(),
+ });
+ const dateStrings = rule.all().map((r) => {
+ return processDate(dayjs(r), i18n);
+ });
+ return [dateStrings, rule.all()];
+};
diff --git a/apps/web/lib/queries/teams/index.ts b/apps/web/lib/queries/teams/index.ts
index 34255077..b25869d5 100644
--- a/apps/web/lib/queries/teams/index.ts
+++ b/apps/web/lib/queries/teams/index.ts
@@ -11,7 +11,6 @@ export type TeamWithMembers = AsyncReturnType;
export async function getTeamWithMembers(id?: number, slug?: string) {
const userSelect = Prisma.validator()({
username: true,
- avatar: true,
email: true,
name: true,
id: true,
@@ -44,6 +43,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
length: true,
slug: true,
schedulingType: true,
+ recurringEvent: true,
price: true,
currency: true,
users: {
@@ -72,7 +72,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
...obj.user,
isMissingSeat: obj.user.plan === UserPlan.FREE,
role: membership?.role,
- accepted: membership?.role === "OWNER" ? true : membership?.accepted,
+ accepted: membership?.accepted,
};
});
diff --git a/apps/web/lib/types/booking.ts b/apps/web/lib/types/booking.ts
index d2ffeaa9..22d0898f 100644
--- a/apps/web/lib/types/booking.ts
+++ b/apps/web/lib/types/booking.ts
@@ -19,6 +19,7 @@ export type BookingCreateBody = {
name: string;
notes?: string;
rescheduleUid?: string;
+ recurringEventId?: string;
start: string;
timeZone: string;
user?: string | string[];
diff --git a/apps/web/lib/webhooks/sendPayload.tsx b/apps/web/lib/webhooks/sendPayload.tsx
index f201e76d..e6a81cb0 100644
--- a/apps/web/lib/webhooks/sendPayload.tsx
+++ b/apps/web/lib/webhooks/sendPayload.tsx
@@ -1,3 +1,4 @@
+import { Webhook } from "@prisma/client";
import { compile } from "handlebars";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -24,13 +25,13 @@ function jsonParse(jsonString: string) {
const sendPayload = async (
triggerEvent: string,
createdAt: string,
- subscriberUrl: string,
+ webhook: Pick,
data: CalendarEvent & {
metadata?: { [key: string]: string };
rescheduleUid?: string;
- },
- template?: string | null
+ }
) => {
+ const { subscriberUrl, appId, payloadTemplate: template } = webhook;
if (!subscriberUrl || !data) {
throw new Error("Missing required elements to send webhook payload.");
}
@@ -38,13 +39,22 @@ const sendPayload = async (
const contentType =
!template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded";
- const body = template
- ? applyTemplate(template, data, contentType)
- : JSON.stringify({
- triggerEvent: triggerEvent,
- createdAt: createdAt,
- payload: data,
- });
+ data.description = data.description || data.additionalNotes;
+
+ let body;
+
+ /* Zapier id is hardcoded in the DB, we send the raw data for this case */
+ if (appId === "zapier") {
+ body = JSON.stringify(data);
+ } else if (template) {
+ body = applyTemplate(template, data, contentType);
+ } else {
+ body = JSON.stringify({
+ triggerEvent: triggerEvent,
+ createdAt: createdAt,
+ payload: data,
+ });
+ }
const response = await fetch(subscriberUrl, {
method: "POST",
diff --git a/apps/web/lib/webhooks/subscriptions.tsx b/apps/web/lib/webhooks/subscriptions.tsx
index dc8ac85c..25db083a 100644
--- a/apps/web/lib/webhooks/subscriptions.tsx
+++ b/apps/web/lib/webhooks/subscriptions.tsx
@@ -8,7 +8,7 @@ export type GetSubscriberOptions = {
triggerEvent: WebhookTriggerEvents;
};
-const getSubscribers = async (options: GetSubscriberOptions) => {
+const getWebhooks = async (options: GetSubscriberOptions) => {
const { userId, eventTypeId } = options;
const allWebhooks = await prisma.webhook.findMany({
where: {
@@ -32,10 +32,11 @@ const getSubscribers = async (options: GetSubscriberOptions) => {
select: {
subscriberUrl: true,
payloadTemplate: true,
+ appId: true,
},
});
return allWebhooks;
};
-export default getSubscribers;
+export default getWebhooks;
diff --git a/apps/web/next-i18next.config.js b/apps/web/next-i18next.config.js
index 336d25a8..923695bd 100644
--- a/apps/web/next-i18next.config.js
+++ b/apps/web/next-i18next.config.js
@@ -1,6 +1,7 @@
const path = require("path");
-module.exports = {
+/** @type {import("next-i18next").UserConfig} */
+const config = {
i18n: {
defaultLocale: "en",
locales: [
@@ -31,3 +32,5 @@ module.exports = {
localePath: path.resolve("./public/static/locales"),
reloadOnPrerender: process.env.NODE_ENV !== "production",
};
+
+module.exports = config;
diff --git a/apps/web/next.config.js b/apps/web/next.config.js
index 74cc454a..91b23ea7 100644
--- a/apps/web/next.config.js
+++ b/apps/web/next.config.js
@@ -9,6 +9,7 @@ const withTM = require("next-transpile-modules")([
"@calcom/stripe",
"@calcom/ui",
"@calcom/embed-core",
+ "@calcom/embed-snippet",
]);
const { i18n } = require("./next-i18next.config");
diff --git a/apps/web/package.json b/apps/web/package.json
index 70e86622..4ef3ab48 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
- "version": "1.5.3",
+ "version": "1.5.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -22,7 +22,7 @@
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts"
},
"engines": {
- "node": ">=14.x",
+ "node": ">=14.x < 15",
"yarn": ">=1.19.0 < 2.0.0"
},
"dependencies": {
@@ -35,6 +35,7 @@
"@calcom/stripe": "*",
"@calcom/tsconfig": "*",
"@calcom/ui": "*",
+ "@calcom/embed-core": "*",
"@daily-co/daily-js": "^0.21.0",
"@glidejs/glide": "^3.5.2",
"@heroicons/react": "^1.0.6",
@@ -104,6 +105,7 @@
"react-use-intercom": "1.4.0",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.6",
+ "rrule": "^2.6.9",
"short-uuid": "^4.2.0",
"stripe": "^8.191.0",
"superjson": "1.8.1",
diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx
index ddf754b0..b29cab95 100644
--- a/apps/web/pages/[user].tsx
+++ b/apps/web/pages/[user].tsx
@@ -18,6 +18,7 @@ import defaultEvents, {
getUsernameSlugLink,
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { RecurringEvent } from "@calcom/types/Calendar";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import useTheme from "@lib/hooks/useTheme";
@@ -272,6 +273,7 @@ const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) =>
description: true,
hidden: true,
schedulingType: true,
+ recurringEvent: true,
price: true,
currency: true,
metadata: true,
diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx
index 9662c8e4..30add178 100644
--- a/apps/web/pages/[user]/[type].tsx
+++ b/apps/web/pages/[user]/[type].tsx
@@ -5,6 +5,7 @@ import { JSONObject } from "superjson/dist/types";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { RecurringEvent } from "@calcom/types/Calendar";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
@@ -84,6 +85,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodDays: true,
periodCountCalendarDays: true,
schedulingType: true,
+ recurringEvent: true,
schedule: {
select: {
availability: true,
@@ -256,6 +258,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
metadata: (eventType.metadata || {}) as JSONObject,
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
+ recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent,
});
const schedule = eventType.schedule
diff --git a/apps/web/pages/[user]/book.tsx b/apps/web/pages/[user]/book.tsx
index 9019cb25..ec255ab7 100644
--- a/apps/web/pages/[user]/book.tsx
+++ b/apps/web/pages/[user]/book.tsx
@@ -12,8 +12,9 @@ import {
getUsernameList,
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { RecurringEvent } from "@calcom/types/Calendar";
-import { asStringOrThrow } from "@lib/asStringOrNull";
+import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -69,6 +70,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const usernameList = getUsernameList(asStringOrThrow(context.query.user as string));
const eventTypeSlug = context.query.slug as string;
+ const recurringEventCountQuery = asStringOrNull(context.query.count);
const users = await prisma.user.findMany({
where: {
username: {
@@ -111,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
periodDays: true,
periodStartDate: true,
periodEndDate: true,
+ recurringEvent: true,
metadata: true,
periodCountCalendarDays: true,
price: true,
@@ -150,6 +153,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventType = {
...eventTypeRaw,
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
+ recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
isWeb3Active:
web3Credentials && web3Credentials.key
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
@@ -204,6 +208,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const t = await getTranslation(context.locale ?? "en", "common");
+ // Checking if number of recurring event ocurrances is valid against event type configuration
+ const recurringEventCount =
+ (eventType.recurringEvent?.count &&
+ recurringEventCountQuery &&
+ (parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
+ ? recurringEventCountQuery
+ : eventType.recurringEvent.count)) ||
+ null;
+
return {
props: {
away: user.away,
@@ -211,6 +224,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
profile,
eventType: eventTypeObject,
booking,
+ recurringEventCount,
trpcState: ssr.dehydrate(),
isDynamicGroupBooking,
hasHashedBookingLink: false,
diff --git a/apps/web/pages/api/book/confirm.ts b/apps/web/pages/api/book/confirm.ts
index 64a0b920..e915dbe9 100644
--- a/apps/web/pages/api/book/confirm.ts
+++ b/apps/web/pages/api/book/confirm.ts
@@ -1,9 +1,10 @@
import { Prisma, User, Booking, SchedulingType, BookingStatus } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
+import rrule from "rrule";
import EventManager from "@calcom/core/EventManager";
import logger from "@calcom/lib/logger";
-import type { AdditionInformation } from "@calcom/types/Calendar";
+import type { AdditionInformation, RecurringEvent } from "@calcom/types/Calendar";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { refund } from "@ee/lib/stripe/server";
@@ -94,12 +95,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
confirmed: true,
attendees: true,
eventTypeId: true,
+ eventType: {
+ select: {
+ recurringEvent: true,
+ },
+ },
location: true,
userId: true,
id: true,
uid: true,
payment: true,
destinationCalendar: true,
+ recurringEventId: true,
},
});
@@ -147,6 +154,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar,
};
+ const recurringEvent = booking.eventType?.recurringEvent as RecurringEvent;
+
+ if (req.body.recurringEventId && recurringEvent) {
+ const groupedRecurringBookings = await prisma.booking.groupBy({
+ where: {
+ recurringEventId: booking.recurringEventId,
+ },
+ by: [Prisma.BookingScalarFieldEnum.recurringEventId],
+ _count: true,
+ });
+ // Overriding the recurring event configuration count to be the actual number of events booked for
+ // the recurring event (equal or less than recurring event configuration count)
+ recurringEvent.count = groupedRecurringBookings[0]._count;
+ }
+
if (reqBody.confirmed) {
const eventManager = new EventManager(currentUser);
const scheduleResult = await eventManager.create(evt);
@@ -170,43 +192,93 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
metadata.entryPoints = results[0].createdEvent?.entryPoints;
}
try {
- await sendScheduledEmails({ ...evt, additionInformation: metadata });
+ await sendScheduledEmails(
+ { ...evt, additionInformation: metadata },
+ req.body.recurringEventId ? recurringEvent : {} // Send email with recurring event info only on recurring event context
+ );
} catch (error) {
log.error(error);
}
}
- // @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
- // Should perform update on booking (confirm) -> then trigger the rest handlers
- await prisma.booking.update({
- where: {
- id: bookingId,
- },
- data: {
- confirmed: true,
- references: {
- create: scheduleResult.referencesToCreate,
+ if (req.body.recurringEventId) {
+ // The booking to confirm is a recurring event and comes from /booking/upcoming, proceeding to mark all related
+ // bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now.
+ const unconfirmedRecurringBookings = await prisma.booking.findMany({
+ where: {
+ recurringEventId: req.body.recurringEventId,
+ confirmed: false,
},
- },
- });
+ });
+ unconfirmedRecurringBookings.map(async (recurringBooking) => {
+ await prisma.booking.update({
+ where: {
+ id: recurringBooking.id,
+ },
+ data: {
+ confirmed: true,
+ references: {
+ create: scheduleResult.referencesToCreate,
+ },
+ },
+ });
+ });
+ } else {
+ // @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
+ // Should perform update on booking (confirm) -> then trigger the rest handlers
+ await prisma.booking.update({
+ where: {
+ id: bookingId,
+ },
+ data: {
+ confirmed: true,
+ references: {
+ create: scheduleResult.referencesToCreate,
+ },
+ },
+ });
+ }
res.status(204).end();
} else {
- await refund(booking, evt);
const rejectionReason = asStringOrNull(req.body.reason) || "";
evt.rejectionReason = rejectionReason;
- await prisma.booking.update({
- where: {
- id: bookingId,
- },
- data: {
- rejected: true,
- status: BookingStatus.REJECTED,
- rejectionReason: rejectionReason,
- },
- });
+ if (req.body.recurringEventId) {
+ // The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related
+ // bookings as rejected. Prisma updateMany does not support relations, so doing this in two steps for now.
+ const unconfirmedRecurringBookings = await prisma.booking.findMany({
+ where: {
+ recurringEventId: req.body.recurringEventId,
+ confirmed: false,
+ },
+ });
+ unconfirmedRecurringBookings.map(async (recurringBooking) => {
+ await prisma.booking.update({
+ where: {
+ id: recurringBooking.id,
+ },
+ data: {
+ rejected: true,
+ status: BookingStatus.REJECTED,
+ rejectionReason: rejectionReason,
+ },
+ });
+ });
+ } else {
+ await refund(booking, evt); // No payment integration for recurring events for v1
+ await prisma.booking.update({
+ where: {
+ id: bookingId,
+ },
+ data: {
+ rejected: true,
+ status: BookingStatus.REJECTED,
+ rejectionReason: rejectionReason,
+ },
+ });
+ }
- await sendDeclinedEmails(evt);
+ await sendDeclinedEmails(evt, req.body.recurringEventId ? recurringEvent : {}); // Send email with recurring event info only on recurring event context
res.status(204).end();
}
diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts
index 385a5021..05dcc2b5 100644
--- a/apps/web/pages/api/book/event.ts
+++ b/apps/web/pages/api/book/event.ts
@@ -1,11 +1,4 @@
-import {
- BookingStatus,
- Credential,
- Payment,
- Prisma,
- SchedulingType,
- WebhookTriggerEvents,
-} from "@prisma/client";
+import { BookingStatus, Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
import async from "async";
import dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-time";
@@ -13,18 +6,24 @@ import isBetween from "dayjs/plugin/isBetween";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
+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, getUsernameList, getGroupName } from "@calcom/lib/defaultEvents";
+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, Person } from "@calcom/types/Calendar";
+import type {
+ AdditionInformation,
+ CalendarEvent,
+ EventBusyDate,
+ RecurringEvent,
+} from "@calcom/types/Calendar";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import { handlePayment } from "@ee/lib/stripe/server";
@@ -83,7 +82,7 @@ async function refreshCredentials(credentials: Array): Promise {
- return await prisma.eventType.findUnique({
+ const eventType = await prisma.eventType.findUnique({
rejectOnNotFound: true,
where: {
id: eventTypeId,
@@ -220,14 +219,22 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
metadata: true,
destinationCalendar: true,
hideCalendarNotes: true,
+ recurringEvent: true,
},
});
+
+ return {
+ ...eventType,
+ recurringEvent: (eventType.recurringEvent || undefined) as RecurringEvent,
+ };
};
type User = Prisma.UserGetPayload;
+type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number };
+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- const reqBody = req.body as BookingCreateBody;
+ const { recurringCount, noEmail, ...reqBody } = req.body as ExtendedBookingCreateBody;
// handle dynamic user
const dynamicUserList = Array.isArray(reqBody.user)
@@ -382,6 +389,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}; // used for invitee emails
}
+ if (reqBody.recurringEventId && eventType.recurringEvent) {
+ // Overriding the recurring event configuration count to be the actual number of events booked for
+ // the recurring event (equal or less than recurring event configuration count)
+ eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { count: recurringCount });
+ }
+
// Initialize EventManager with credentials
const rescheduleUid = reqBody.rescheduleUid;
async function getOriginalRescheduledBooking(uid: string) {
@@ -481,6 +494,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
: undefined,
};
+ if (reqBody.recurringEventId) {
+ newBookingData.recurringEventId = reqBody.recurringEventId;
+ }
if (originalRescheduledBooking) {
newBookingData["paid"] = originalRescheduledBooking.paid;
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
@@ -573,7 +589,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
let isAvailableToBeBooked = true;
try {
- isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length);
+ if (eventType.recurringEvent) {
+ const allBookingDates = new rrule({
+ dtstart: new Date(reqBody.start),
+ ...eventType.recurringEvent,
+ }).all();
+ // Go through each date for the recurring event and check if each one's availability
+ isAvailableToBeBooked = allBookingDates
+ .map((aDate) => isAvailable(bufferedBusyTimes, aDate, eventType.length)) // <-- array of booleans
+ .reduce((acc, value) => acc && value, true); // <-- checks boolean array applying "AND" to each value and the current one, starting in true
+ } else {
+ isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length);
+ }
} catch {
log.debug({
message: "Unable set isAvailableToBeBooked. Using true. ",
@@ -674,11 +701,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
- await sendRescheduledEmails({
- ...evt,
- additionInformation: metadata,
- additionalNotes, // Resets back to the addtionalNote input and not the overriden value
- });
+ if (noEmail !== true) {
+ await sendRescheduledEmails(
+ {
+ ...evt,
+ additionInformation: metadata,
+ additionalNotes, // Resets back to the addtionalNote input and not the overriden value
+ },
+ reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
+ );
+ }
}
// If it's not a reschedule, doesn't require confirmation and there's no price,
// Create a booking
@@ -708,17 +740,29 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
}
- await sendScheduledEmails({
- ...evt,
- additionInformation: metadata,
- additionalNotes,
- });
+ if (noEmail !== true) {
+ await sendScheduledEmails(
+ {
+ ...evt,
+ additionInformation: metadata,
+ additionalNotes,
+ },
+ reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
+ );
+ }
}
}
- if (eventType.requiresConfirmation && !rescheduleUid) {
- await sendOrganizerRequestEmail({ ...evt, additionalNotes });
- await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
+ if (eventType.requiresConfirmation && !rescheduleUid && noEmail !== true) {
+ await sendOrganizerRequestEmail(
+ { ...evt, additionalNotes },
+ reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
+ );
+ await sendAttendeeRequestEmail(
+ { ...evt, additionalNotes },
+ attendeesList[0],
+ reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
+ );
}
if (typeof eventType.price === "number" && eventType.price > 0 && !originalRescheduledBooking?.paid) {
@@ -753,17 +797,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
metadata: reqBody.metadata,
});
const promises = subscribers.map((sub) =>
- sendPayload(
- eventTrigger,
- new Date().toISOString(),
- sub.subscriberUrl,
- {
- ...evt,
- rescheduleUid,
- metadata: reqBody.metadata,
- },
- sub.payloadTemplate
- ).catch((e) => {
+ sendPayload(eventTrigger, new Date().toISOString(), sub, {
+ ...evt,
+ rescheduleUid,
+ metadata: reqBody.metadata,
+ }).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
})
);
diff --git a/apps/web/pages/api/book/request-reschedule.ts b/apps/web/pages/api/book/request-reschedule.ts
index a4c96646..9458d4a1 100644
--- a/apps/web/pages/api/book/request-reschedule.ts
+++ b/apps/web/pages/api/book/request-reschedule.ts
@@ -1,4 +1,4 @@
-import { BookingStatus, User, Booking, Attendee, BookingReference } from "@prisma/client";
+import { BookingStatus, User, Booking, Attendee, BookingReference, EventType } from "@prisma/client";
import dayjs from "dayjs";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/react";
@@ -6,7 +6,6 @@ import type { TFunction } from "next-i18next";
import { z, ZodError } from "zod";
import { getCalendar } from "@calcom/core/CalendarManager";
-import EventManager from "@calcom/core/EventManager";
import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
import { deleteMeeting } from "@calcom/core/videoClient";
@@ -29,7 +28,7 @@ const rescheduleSchema = z.object({
rescheduleReason: z.string().optional(),
});
-const findUserOwnerByUserId = async (userId: number) => {
+const findUserDataByUserId = async (userId: number) => {
return await prisma.user.findUnique({
rejectOnNotFound: true,
where: {
@@ -57,10 +56,10 @@ const handler = async (
bookingId,
rescheduleReason: cancellationReason,
}: { bookingId: string; rescheduleReason: string; cancellationReason: string } = req.body;
- let userOwner: Awaited>;
+ let userOwner: Awaited>;
try {
if (session?.user?.id) {
- userOwner = await findUserOwnerByUserId(session?.user.id);
+ userOwner = await findUserDataByUserId(session?.user.id);
} else {
return res.status(501);
}
@@ -76,6 +75,10 @@ const handler = async (
location: true,
attendees: true,
references: true,
+ userId: true,
+ dynamicEventSlugRef: true,
+ dynamicGroupSlugRef: true,
+ destinationCalendar: true,
},
rejectOnNotFound: true,
where: {
@@ -88,18 +91,22 @@ const handler = async (
},
});
- if (bookingToReschedule && bookingToReschedule.eventTypeId && userOwner) {
- const event = await prisma.eventType.findFirst({
- select: {
- title: true,
- users: true,
- schedulingType: true,
- },
- rejectOnNotFound: true,
- where: {
- id: bookingToReschedule.eventTypeId,
- },
- });
+ if (bookingToReschedule && userOwner) {
+ let event: Partial = {};
+ if (bookingToReschedule.eventTypeId) {
+ event = await prisma.eventType.findFirst({
+ select: {
+ title: true,
+ users: true,
+ schedulingType: true,
+ recurringEvent: true,
+ },
+ rejectOnNotFound: true,
+ where: {
+ id: bookingToReschedule.eventTypeId,
+ },
+ });
+ }
await prisma.booking.update({
where: {
id: bookingToReschedule.id,
@@ -136,7 +143,7 @@ const handler = async (
const builder = new CalendarEventBuilder();
builder.init({
title: bookingToReschedule.title,
- type: event.title,
+ type: event && event.title ? event.title : bookingToReschedule.title,
startTime: bookingToReschedule.startTime.toISOString(),
endTime: bookingToReschedule.endTime.toISOString(),
attendees: usersToPeopleType(
@@ -149,9 +156,13 @@ const handler = async (
const director = new CalendarEventDirector();
director.setBuilder(builder);
- director.setExistingBooking(bookingToReschedule as unknown as Booking);
+ director.setExistingBooking(bookingToReschedule);
director.setCancellationReason(cancellationReason);
- await director.buildForRescheduleEmail();
+ if (!!event) {
+ await director.buildWithoutEventTypeForRescheduleEmail();
+ } else {
+ await director.buildForRescheduleEmail();
+ }
// Handling calendar and videos cancellation
// This can set previous time as available, until virtual calendar is done
@@ -174,6 +185,31 @@ const handler = async (
}
});
+ // Updating attendee destinationCalendar if required
+ if (
+ bookingToReschedule.destinationCalendar &&
+ bookingToReschedule.destinationCalendar.userId &&
+ bookingToReschedule.destinationCalendar.integration.endsWith("_calendar")
+ ) {
+ const { destinationCalendar } = bookingToReschedule;
+ if (destinationCalendar.userId) {
+ const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter(
+ (ref) => !!credentialsMap.get(ref.type)
+ );
+ const attendeeData = await findUserDataByUserId(destinationCalendar.userId);
+ const attendeeCredentialsMap = new Map();
+ attendeeData.credentials.forEach((credential) => {
+ attendeeCredentialsMap.set(credential.type, credential);
+ });
+ bookingRefsFiltered.forEach((bookingRef) => {
+ if (bookingRef.uid) {
+ const calendar = getCalendar(attendeeCredentialsMap.get(destinationCalendar.integration));
+ calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
+ }
+ });
+ }
+ }
+
// Send emails
await sendRequestRescheduleEmail(builder.calendarEvent, {
rescheduleLink: builder.rescheduleLink,
diff --git a/apps/web/pages/api/cancel.ts b/apps/web/pages/api/cancel.ts
index d5dc45e6..9473efba 100644
--- a/apps/web/pages/api/cancel.ts
+++ b/apps/web/pages/api/cancel.ts
@@ -14,7 +14,7 @@ import { getSession } from "@lib/auth";
import { sendCancelledEmails } from "@lib/emails/email-manager";
import prisma from "@lib/prisma";
import sendPayload from "@lib/webhooks/sendPayload";
-import getSubscribers from "@lib/webhooks/subscriptions";
+import getWebhooks from "@lib/webhooks/subscriptions";
import { getTranslation } from "@server/lib/i18n";
@@ -136,13 +136,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
eventTypeId: (bookingToDelete.eventTypeId as number) || 0,
triggerEvent: eventTrigger,
};
- const subscribers = await getSubscribers(subscriberOptions);
- const promises = subscribers.map((sub) =>
- sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch(
- (e) => {
- console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
- }
- )
+ const webhooks = await getWebhooks(subscriberOptions);
+ const promises = webhooks.map((webhook) =>
+ sendPayload(eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => {
+ console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
+ })
);
await Promise.all(promises);
diff --git a/apps/web/pages/api/integrations.ts b/apps/web/pages/api/integrations.ts
index 76b0b296..06f40cb2 100644
--- a/apps/web/pages/api/integrations.ts
+++ b/apps/web/pages/api/integrations.ts
@@ -1,3 +1,4 @@
+import { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
@@ -10,8 +11,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Check that user is authenticated
const session = await getSession({ req });
+ const userId = session?.user?.id;
- if (!session) {
+ if (!userId) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
@@ -19,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (req.method === "GET") {
const credentials = await prisma.credential.findMany({
where: {
- userId: session.user?.id,
+ userId,
},
select: {
type: true,
@@ -31,18 +33,40 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (req.method == "DELETE") {
const id = req.body.id;
+ const data: Prisma.UserUpdateInput = {
+ credentials: {
+ delete: {
+ id,
+ },
+ },
+ };
+ const integration = await prisma.credential.findUnique({
+ where: {
+ id,
+ },
+ });
+ /* If the user deletes a zapier integration, we delete all his api keys as well. */
+ if (integration?.appId === "zapier") {
+ data.apiKeys = {
+ deleteMany: {
+ userId,
+ appId: "zapier",
+ },
+ };
+ /* We also delete all user's zapier wehbooks */
+ data.webhooks = {
+ deleteMany: {
+ userId,
+ appId: "zapier",
+ },
+ };
+ }
await prisma.user.update({
where: {
- id: session?.user?.id,
- },
- data: {
- credentials: {
- delete: {
- id,
- },
- },
+ id: userId,
},
+ data,
});
res.status(200).json({ message: "Integration deleted successfully" });
diff --git a/apps/web/pages/apps/installed.tsx b/apps/web/pages/apps/installed.tsx
index 98370e0a..5a1cbb29 100644
--- a/apps/web/pages/apps/installed.tsx
+++ b/apps/web/pages/apps/installed.tsx
@@ -3,7 +3,7 @@ import Image from "next/image";
import React, { useEffect, useState } from "react";
import { JSONObject } from "superjson/dist/types";
-import { InstallAppButton } from "@calcom/app-store/components";
+import { AppConfiguration, InstallAppButton } from "@calcom/app-store/components";
import showToast from "@calcom/lib/notification";
import { App } from "@calcom/types/App";
import { Alert } from "@calcom/ui/Alert";
@@ -26,87 +26,6 @@ import IntegrationListItem from "@components/integrations/IntegrationListItem";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
import WebhookListContainer from "@components/webhook/WebhookListContainer";
-function IframeEmbedContainer() {
- const { t } = useLocale();
- // doesn't need suspense as it should already be loaded
- const user = trpc.useQuery(["viewer.me"]).data;
-
- const iframeTemplate = ``;
- const htmlTemplate = `${t(
- "schedule_a_meeting"
- )} ${iframeTemplate}`;
-
- return (
- <>
-
-
-
-
-
-
-
- {t("standard_iframe")}
- {t("embed_your_calendar")}
-
-
-
- {
- navigator.clipboard.writeText(iframeTemplate);
- showToast("Copied to clipboard", "success");
- }}>
-
-
-
-
-
-
-
-
-
- {t("responsive_fullscreen_iframe")}
- A fullscreen scheduling experience on your website
-
-
-
- {
- navigator.clipboard.writeText(htmlTemplate);
- showToast("Copied to clipboard", "success");
- }}>
-
-
-
-
-
-
-
-
- >
- );
-}
-
function ConnectOrDisconnectIntegrationButton(props: {
//
credentialIds: number[];
@@ -242,8 +161,9 @@ function IntegrationsContainer() {
isGlobal={item.isGlobal}
installed={item.installed}
/>
- }
- />
+ }>
+
+
))}
>
@@ -342,7 +262,6 @@ export default function IntegrationsPage() {
-
diff --git a/apps/web/pages/apps/setup/[appName].tsx b/apps/web/pages/apps/setup/[appName].tsx
new file mode 100644
index 00000000..b8bdc7c6
--- /dev/null
+++ b/apps/web/pages/apps/setup/[appName].tsx
@@ -0,0 +1,38 @@
+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 (
+
+
+
+ );
+ }
+
+ if (status === "unauthenticated") {
+ router.replace({
+ pathname: "/auth/login",
+ query: {
+ callbackUrl: `/apps/setup/${appName}`,
+ },
+ });
+ }
+
+ if (appName === _zapierMetadata.name.toLowerCase() && status === "authenticated") {
+ return ;
+ }
+
+ return null;
+}
diff --git a/apps/web/pages/bookings/[status].tsx b/apps/web/pages/bookings/[status].tsx
index 9f7833b5..b6e77cb8 100644
--- a/apps/web/pages/bookings/[status].tsx
+++ b/apps/web/pages/bookings/[status].tsx
@@ -8,7 +8,7 @@ import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
-import { inferQueryInput, trpc } from "@lib/trpc";
+import { inferQueryInput, inferQueryOutput, trpc } from "@lib/trpc";
import BookingsShell from "@components/BookingsShell";
import EmptyScreen from "@components/EmptyScreen";
@@ -17,6 +17,8 @@ import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
+type BookingOutput = inferQueryOutput<"viewer.bookings">["bookings"][0];
+type BookingPage = inferQueryOutput<"viewer.bookings">;
export default function Bookings() {
const router = useRouter();
@@ -26,6 +28,7 @@ export default function Bookings() {
const descriptionByStatus: Record = {
upcoming: t("upcoming_bookings"),
+ recurring: t("recurring_bookings"),
past: t("past_bookings"),
cancelled: t("cancelled_bookings"),
};
@@ -44,6 +47,18 @@ export default function Bookings() {
const isEmpty = !query.data?.pages[0]?.bookings.length;
+ // Get the recurrentCount value from the grouped recurring bookings
+ // created with the same recurringEventId
+ const defineRecurrentCount = (booking: BookingOutput, page: BookingPage) => {
+ let recurringCount = undefined;
+ if (booking.recurringEventId !== null) {
+ recurringCount = page.groupedRecurringBookings.filter(
+ (group) => group.recurringEventId === booking.recurringEventId
+ )[0]._count; // If found, only one object exists, just assing the needed _count value
+ }
+ return { recurringCount };
+ };
+
return (
(
{page.bookings.map((booking) => (
-
+
))}
))}
diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx
index 58508b4a..49aa8136 100644
--- a/apps/web/pages/d/[link]/[slug].tsx
+++ b/apps/web/pages/d/[link]/[slug].tsx
@@ -2,6 +2,8 @@ import { Prisma } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
+import { RecurringEvent } from "@calcom/types/Calendar";
+
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import { GetBookingType } from "@lib/getBooking";
@@ -37,6 +39,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
+ recurringEvent: true,
schedulingType: true,
userId: true,
schedule: {
@@ -131,6 +134,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const [user] = users;
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
metadata: {} as JSONObject,
+ recurringEvent: (eventTypeSelect.recurringEvent || {}) as RecurringEvent,
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
slug,
diff --git a/apps/web/pages/d/[link]/book.tsx b/apps/web/pages/d/[link]/book.tsx
index 91c309b6..48b0eafc 100644
--- a/apps/web/pages/d/[link]/book.tsx
+++ b/apps/web/pages/d/[link]/book.tsx
@@ -6,8 +6,9 @@ import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { getLocationLabels } from "@calcom/app-store/utils";
+import { RecurringEvent } from "@calcom/types/Calendar";
-import { asStringOrThrow } from "@lib/asStringOrNull";
+import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -28,6 +29,7 @@ export default function Book(props: HashLinkPageProps) {
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const link = asStringOrThrow(context.query.link as string);
+ const recurringEventCountQuery = asStringOrNull(context.query.count);
const slug = context.query.slug as string;
const eventTypeSelect = Prisma.validator()({
@@ -41,6 +43,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
periodType: true,
periodDays: true,
periodStartDate: true,
+ recurringEvent: true,
periodEndDate: true,
metadata: true,
periodCountCalendarDays: true,
@@ -122,6 +125,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventType = {
...eventTypeRaw,
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
+ recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
isWeb3Active:
web3Credentials && web3Credentials.key
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
@@ -148,6 +152,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const t = await getTranslation(context.locale ?? "en", "common");
+ // Checking if number of recurring event ocurrances is valid against event type configuration
+ const recurringEventCount =
+ (eventTypeObject?.recurringEvent?.count &&
+ recurringEventCountQuery &&
+ (parseInt(recurringEventCountQuery) <= eventTypeObject.recurringEvent.count
+ ? recurringEventCountQuery
+ : eventType.recurringEvent.count)) ||
+ null;
+
return {
props: {
locationLabels: getLocationLabels(t),
@@ -155,6 +168,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventType: eventTypeObject,
booking: null,
trpcState: ssr.dehydrate(),
+ recurringEventCount,
isDynamicGroupBooking: false,
hasHashedBookingLink: true,
hashedLink: link,
diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx
index d02b82a9..0f6ff47a 100644
--- a/apps/web/pages/event-types/[type].tsx
+++ b/apps/web/pages/event-types/[type].tsx
@@ -26,7 +26,9 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { Controller, Noop, useForm, UseFormReturn } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
+import short, { generate } from "short-uuid";
import { JSONObject } from "superjson/dist/types";
+import { v5 as uuidv5 } from "uuid";
import { z } from "zod";
import { SelectGifInput } from "@calcom/app-store/giphy/components";
@@ -34,9 +36,11 @@ import getApps, { getLocationOptions } from "@calcom/app-store/utils";
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 Button from "@calcom/ui/Button";
import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
import Switch from "@calcom/ui/Switch";
+import { Tooltip } from "@calcom/ui/Tooltip";
import { Form } from "@calcom/ui/form/fields";
import { QueryCell } from "@lib/QueryCell";
@@ -52,11 +56,12 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
import { ClientSuspense } from "@components/ClientSuspense";
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
+import { EmbedButton, EmbedDialog } from "@components/Embed";
import Loader from "@components/Loader";
import Shell from "@components/Shell";
-import { Tooltip } from "@components/Tooltip";
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
+import RecurringEventController from "@components/eventtype/RecurringEventController";
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
import Badge from "@components/ui/Badge";
import InfoBadge from "@components/ui/InfoBadge";
@@ -64,7 +69,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 Select, { SelectProps } from "@components/ui/form/Select";
+import Select from "@components/ui/form/Select";
import * as RadioArea from "@components/ui/form/radio-area";
import WebhookListContainer from "@components/webhook/WebhookListContainer";
@@ -271,9 +276,21 @@ const EventTypePage = (props: inferSSRProps) => {
PERIOD_TYPES.find((s) => s.type === eventType.periodType) ||
PERIOD_TYPES.find((s) => s.type === "UNLIMITED");
- const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
+
+ const [requirePayment, setRequirePayment] = useState(
+ eventType.price > 0 && eventType.recurringEvent?.count !== undefined
+ );
+
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
+ const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
+
+ const generateHashedLink = (id: number) => {
+ const translator = short();
+ const seed = `${id}:${new Date().getTime()}`;
+ const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
+ return uid;
+ };
useEffect(() => {
const fetchTokens = async () => {
@@ -308,6 +325,8 @@ const EventTypePage = (props: inferSSRProps) => {
console.log(tokensList); // Just here to make sure it passes the gc hook. Can remove once actual use is made of tokensList.
fetchTokens();
+
+ !hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0].id));
}, []);
async function deleteEventTypeHandler(event: React.MouseEvent) {
@@ -454,9 +473,7 @@ const EventTypePage = (props: inferSSRProps) => {
team ? `team/${team.slug}` : eventType.users[0].username
}/${eventType.slug}`;
- const placeholderHashedLink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/d/${
- eventType.hashedLink ? eventType.hashedLink.link : "xxxxxxxxxxxxxxxxx"
- }/${eventType.slug}`;
+ const placeholderHashedLink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/d/${hashedUrl}/${eventType.slug}`;
const mapUserToValue = ({
id,
@@ -482,12 +499,13 @@ const EventTypePage = (props: inferSSRProps) => {
description: string;
disableGuests: boolean;
requiresConfirmation: boolean;
+ recurringEvent: RecurringEvent;
schedulingType: SchedulingType | null;
price: number;
currency: string;
hidden: boolean;
hideCalendarNotes: boolean;
- hashedLink: boolean;
+ hashedLink: string | undefined;
locations: { type: LocationType; address?: string; link?: string }[];
customInputs: EventTypeCustomInput[];
users: string[];
@@ -509,6 +527,7 @@ const EventTypePage = (props: inferSSRProps) => {
}>({
defaultValues: {
locations: eventType.locations || [],
+ recurringEvent: eventType.recurringEvent || {},
schedule: eventType.schedule?.id,
periodDates: {
startDate: periodDates.startDate,
@@ -927,15 +946,15 @@ const EventTypePage = (props: inferSSRProps) => {
giphyThankYouPage,
beforeBufferTime,
afterBufferTime,
+ recurringEvent,
locations,
...input
} = values;
- if (requirePayment) input.currency = currency;
-
updateMutation.mutate({
...input,
locations,
+ recurringEvent,
periodStartDate: periodDates.startDate,
periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1",
@@ -1333,6 +1352,11 @@ const EventTypePage = (props: inferSSRProps) => {
)}
/>
+
+
) => {
(
<>
{
setHashedLinkVisible(e?.target.checked);
- formMethods.setValue("hashedLink", e?.target.checked);
+ formMethods.setValue(
+ "hashedLink",
+ e?.target.checked ? hashedUrl : undefined
+ );
}}
/>
{hashedLinkVisible && (
-
+
) => {
{
+ navigator.clipboard.writeText(placeholderHashedLink);
if (eventType.hashedLink) {
- navigator.clipboard.writeText(placeholderHashedLink);
- showToast("Link copied!", "success");
+ showToast(t("private_link_copied"), "success");
+ } else {
+ showToast(t("enabled_after_update_description"), "warning");
}
}}
type="button"
@@ -1640,7 +1670,7 @@ const EventTypePage = (props: inferSSRProps) => {
formMethods={formMethods}
eventType={eventType}>
- {hasPaymentIntegration && (
+ {hasPaymentIntegration && eventType.recurringEvent?.count !== undefined && (
<>
@@ -1822,6 +1852,26 @@ const EventTypePage = (props: inferSSRProps
) => {
{t("copy_link")}
+ {hashedLinkVisible && (
+ {
+ navigator.clipboard.writeText(placeholderHashedLink);
+ if (eventType.hashedLink) {
+ showToast(t("private_link_copied"), "success");
+ } else {
+ showToast(t("enabled_after_update_description"), "warning");
+ }
+ }}
+ type="button"
+ className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
+
+ {t("copy_private_link")}
+
+ )}
+
@@ -1870,28 +1920,30 @@ const EventTypePage = (props: inferSSRProps) => {
addLocation(newLocation, details);
setShowLocationModal(false);
}}>
- (
- {
- if (val) {
- locationFormMethods.setValue("locationType", val.value);
- locationFormMethods.unregister("locationLink");
- locationFormMethods.unregister("locationAddress");
- setSelectedLocation(val);
- }
- }}
- />
- )}
- />
+
+ (
+ {
+ if (val) {
+ locationFormMethods.setValue("locationType", val.value);
+ locationFormMethods.unregister("locationLink");
+ locationFormMethods.unregister("locationAddress");
+ setSelectedLocation(val);
+ }
+ }}
+ />
+ )}
+ />
+
setShowLocationModal(false)} type="button" color="secondary">
@@ -1969,6 +2021,7 @@ const EventTypePage = (props: inferSSRProps) => {
/>
)}
+
);
@@ -2048,6 +2101,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodEndDate: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
+ recurringEvent: true,
hideCalendarNotes: true,
disableGuests: true,
minimumBookingNotice: true,
@@ -2112,6 +2166,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { locations, metadata, ...restEventType } = rawEventType;
const eventType = {
...restEventType,
+ recurringEvent: (restEventType.recurringEvent || {}) as RecurringEvent,
locations: locations as unknown as Location[],
metadata: (metadata || {}) as JSONObject,
isWeb3Active:
diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx
index 2550cb20..69846ff3 100644
--- a/apps/web/pages/event-types/index.tsx
+++ b/apps/web/pages/event-types/index.tsx
@@ -10,13 +10,14 @@ import {
ClipboardCopyIcon,
TrashIcon,
PencilIcon,
+ CodeIcon,
} from "@heroicons/react/solid";
import { UsersIcon } from "@heroicons/react/solid";
import { Trans } from "next-i18next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
-import React, { Fragment, useEffect, useState } from "react";
+import React, { Fragment, useEffect, useRef, useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -30,15 +31,16 @@ import Dropdown, {
DropdownMenuItem,
DropdownMenuSeparator,
} from "@calcom/ui/Dropdown";
+import { Tooltip } from "@calcom/ui/Tooltip";
import { withQuery } from "@lib/QueryCell";
import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { inferQueryOutput, trpc } from "@lib/trpc";
+import { EmbedButton, EmbedDialog } from "@components/Embed";
import EmptyScreen from "@components/EmptyScreen";
import Shell from "@components/Shell";
-import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
@@ -299,6 +301,12 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
{t("duplicate")}
+
+
+
@@ -519,9 +527,9 @@ const CTA = () => {
};
const WithQuery = withQuery(["viewer.eventTypes"]);
+
const EventTypesPage = () => {
const { t } = useLocale();
-
return (
@@ -574,6 +582,7 @@ const EventTypesPage = () => {
{data.eventTypeGroups.length === 0 && (
)}
+
>
)}
/>
diff --git a/apps/web/pages/settings/billing.tsx b/apps/web/pages/settings/billing.tsx
index de79b1b3..4e6df89b 100644
--- a/apps/web/pages/settings/billing.tsx
+++ b/apps/web/pages/settings/billing.tsx
@@ -5,9 +5,10 @@ import { useIntercom } from "react-use-intercom";
import Button from "@calcom/ui/Button";
import { useLocale } from "@lib/hooks/useLocale";
+import useMeQuery from "@lib/hooks/useMeQuery";
import SettingsShell from "@components/SettingsShell";
-import Shell, { useMeQuery } from "@components/Shell";
+import Shell from "@components/Shell";
type CardProps = { title: string; description: string; className?: string; children: ReactNode };
const Card = ({ title, description, className = "", children }: CardProps): JSX.Element => (
diff --git a/apps/web/pages/settings/teams/[id]/index.tsx b/apps/web/pages/settings/teams/[id]/index.tsx
index 1ced8949..443fdb2b 100644
--- a/apps/web/pages/settings/teams/[id]/index.tsx
+++ b/apps/web/pages/settings/teams/[id]/index.tsx
@@ -145,6 +145,7 @@ export function TeamSettingsPage() {
setShowMemberInvitationModal(false)}
/>
)}
diff --git a/apps/web/pages/settings/teams/index.tsx b/apps/web/pages/settings/teams/index.tsx
index 034f77c1..be391687 100644
--- a/apps/web/pages/settings/teams/index.tsx
+++ b/apps/web/pages/settings/teams/index.tsx
@@ -8,12 +8,13 @@ import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { useLocale } from "@lib/hooks/useLocale";
+import useMeQuery from "@lib/hooks/useMeQuery";
import { trpc } from "@lib/trpc";
import EmptyScreen from "@components/EmptyScreen";
import Loader from "@components/Loader";
import SettingsShell from "@components/SettingsShell";
-import Shell, { useMeQuery } from "@components/Shell";
+import Shell from "@components/Shell";
import TeamCreateModal from "@components/team/TeamCreateModal";
import TeamList from "@components/team/TeamList";
diff --git a/apps/web/pages/success.tsx b/apps/web/pages/success.tsx
index ccfe1f84..7be45f1c 100644
--- a/apps/web/pages/success.tsx
+++ b/apps/web/pages/success.tsx
@@ -1,7 +1,9 @@
import { CheckIcon } from "@heroicons/react/outline";
import { ArrowLeftIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import classNames from "classnames";
import dayjs from "dayjs";
+import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
@@ -11,6 +13,7 @@ import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
+import RRule from "rrule";
import { SpaceBookingSuccessPage } from "@calcom/app-store/spacebooking/components";
import {
@@ -21,6 +24,8 @@ import {
} from "@calcom/embed-core";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { localStorage } from "@calcom/lib/webstorage";
+import { RecurringEvent } from "@calcom/types/Calendar";
import Button from "@calcom/ui/Button";
import { EmailInput } from "@calcom/ui/form/fields";
@@ -41,6 +46,7 @@ import { ssrInit } from "@server/lib/ssr";
dayjs.extend(utc);
dayjs.extend(toArray);
dayjs.extend(timezone);
+dayjs.extend(localizedFormat);
function redirectToExternalUrl(url: string) {
window.parent.location.href = url;
@@ -133,7 +139,9 @@ function RedirectionToast({ url }: { url: string }) {
);
}
-export default function Success(props: inferSSRProps) {
+type SuccessProps = inferSSRProps;
+
+export default function Success(props: SuccessProps) {
const { t } = useLocale();
const router = useRouter();
const { location: _location, name, reschedule } = router.query;
@@ -143,7 +151,7 @@ export default function Success(props: inferSSRProps)
const [date, setDate] = useState(dayjs.utc(asStringOrThrow(router.query.date)));
const { isReady, Theme } = useTheme(props.profile.theme);
- const { eventType } = props;
+ const { eventType, bookingInfo } = props;
const isBackgroundTransparent = useIsBackgroundTransparent();
const isEmbed = useIsEmbed();
@@ -212,7 +220,23 @@ export default function Success(props: inferSSRProps)
return encodeURIComponent(event.value ? event.value : false);
}
+
+ function getTitle(): string {
+ const titleSuffix = props.recurringBookings ? "_recurring" : "";
+ if (needsConfirmation) {
+ if (props.profile.name !== null) {
+ return t("user_needs_to_confirm_or_reject_booking" + titleSuffix, {
+ user: props.profile.name,
+ });
+ }
+ return t("needs_to_be_confirmed_or_rejected" + titleSuffix);
+ }
+ return t("emailed_you_and_attendees" + titleSuffix);
+ }
const userIsOwner = !!(session?.user?.id && eventType.users.find((user) => (user.id = session.user.id)));
+ const title = t(
+ `booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}`
+ );
return (
(isReady && (
<>
@@ -220,10 +244,7 @@ export default function Success(props: inferSSRProps)
className={isEmbed ? "" : "h-screen bg-neutral-100 dark:bg-neutral-900"}
data-testid="success-page">
-
+
@@ -263,30 +284,55 @@ export default function Success(props: inferSSRProps
)
- {needsConfirmation ? t("submitted") : t("meeting_is_scheduled")}
+ {needsConfirmation
+ ? props.recurringBookings
+ ? t("submitted_recurring")
+ : t("submitted")
+ : props.recurringBookings
+ ? t("meeting_is_scheduled_recurring")
+ : t("meeting_is_scheduled")}
-
- {needsConfirmation
- ? props.profile.name !== null
- ? t("user_needs_to_confirm_or_reject_booking", { user: props.profile.name })
- : t("needs_to_be_confirmed_or_rejected")
- : t("emailed_you_and_attendees")}
-
+
{getTitle()}
-
{t("what")}
{eventName}
{t("when")}
-
- {date.format("dddd, DD MMMM YYYY")}
+
+ {date.format("MMMM DD, YYYY")}
- {date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
+ {date.format("LT")} - {date.add(props.eventType.length, "m").format("LT")}{" "}
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
+
+
+
+
{t("who")}
+
+ {bookingInfo?.user && (
+
+
{bookingInfo.user.name}
+
{bookingInfo.user.email}
+
+ )}
+ {bookingInfo?.attendees.map((attendee, index) => (
+
+
{attendee.name}
+
{attendee.email}
+
+ ))}
+
{location && (
<>
{t("where")}
@@ -301,6 +347,14 @@ export default function Success(props: inferSSRProps
)
>
)}
+ {bookingInfo?.description && (
+ <>
+
{t("additional_notes")}
+
+
{bookingInfo.description}
+
+ >
+ )}
@@ -322,6 +376,10 @@ export default function Success(props: inferSSRProps)
}` +
(typeof location === "string"
? "&location=" + encodeURIComponent(location)
+ : "") +
+ (props.eventType.recurringEvent
+ ? "&recur=" +
+ encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
: "")
}>
@@ -447,21 +505,15 @@ export default function Success(props: inferSSRProps)
{props.userHasSpaceBooking && (
@@ -472,6 +524,71 @@ export default function Success(props: inferSSRProps)
);
}
+type RecurringBookingsProps = {
+ isReschedule: boolean;
+ eventType: SuccessProps["eventType"];
+ recurringBookings: SuccessProps["recurringBookings"];
+ date: dayjs.Dayjs;
+ is24h: boolean;
+};
+
+function RecurringBookings({
+ isReschedule = false,
+ eventType,
+ recurringBookings,
+ date,
+ is24h,
+}: RecurringBookingsProps) {
+ const [moreEventsVisible, setMoreEventsVisible] = useState(false);
+ const { t } = useLocale();
+ return !isReschedule && recurringBookings ? (
+ <>
+ {eventType.recurringEvent?.count &&
+ recurringBookings.slice(0, 4).map((dateStr, idx) => (
+
+ {dayjs(dateStr).format("dddd, DD MMMM YYYY")}
+
+ {dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
+
+ ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
+
+
+ ))}
+ {recurringBookings.length > 4 && (
+ setMoreEventsVisible(!moreEventsVisible)}>
+
+ {t("plus_more", { count: recurringBookings.length - 4 })}
+
+
+ {eventType.recurringEvent?.count &&
+ recurringBookings.slice(4).map((dateStr, idx) => (
+
+ {dayjs(dateStr).format("dddd, DD MMMM YYYY")}
+
+ {dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
+
+ ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
+
+
+ ))}
+
+
+ )}
+ >
+ ) : !eventType.recurringEvent.freq ? (
+ <>
+ {date.format("dddd, DD MMMM YYYY")}
+
+ {date.format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
+
+ ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
+
+ >
+ ) : null;
+}
+
const getEventTypesFromDB = async (typeId: number) => {
return await prisma.eventType.findUnique({
where: {
@@ -483,6 +600,7 @@ const getEventTypesFromDB = async (typeId: number) => {
description: true,
length: true,
eventName: true,
+ recurringEvent: true,
requiresConfirmation: true,
userId: true,
successRedirectUrl: true,
@@ -513,8 +631,10 @@ const getEventTypesFromDB = async (typeId: number) => {
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
+ 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 (isNaN(typeId)) {
return {
@@ -522,9 +642,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
- const eventType = !typeId ? getDefaultEvent(typeSlug) : await getEventTypesFromDB(typeId);
+ let eventTypeRaw = !typeId ? getDefaultEvent(typeSlug) : await getEventTypesFromDB(typeId);
- if (!eventType) {
+ if (!eventTypeRaw) {
return {
notFound: true,
};
@@ -532,11 +652,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
let spaceBookingAvailable = false;
let userHasSpaceBooking = false;
- if (eventType.users[0] && eventType.users[0].id) {
+ if (eventTypeRaw.users[0] && eventTypeRaw.users[0].id) {
const credential = await prisma.credential.findFirst({
where: {
type: "spacebooking_other",
- userId: eventType.users[0].id,
+ userId: eventTypeRaw.users[0].id,
},
});
if (credential && credential.type === "spacebooking_other") {
@@ -544,11 +664,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
}
- if (!eventType.users.length && eventType.userId) {
+ if (!eventTypeRaw.users.length && eventTypeRaw.userId) {
// TODO we should add `user User` relation on `EventType` so this extra query isn't needed
const user = await prisma.user.findUnique({
where: {
- id: eventType.userId,
+ id: eventTypeRaw.userId,
},
select: {
id: true,
@@ -563,17 +683,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
});
if (user) {
- eventType.users.push(user);
+ eventTypeRaw.users.push(user as any);
}
}
- if (!eventType.users.length) {
+ if (!eventTypeRaw.users.length) {
return {
notFound: true,
};
}
- // if (!typeId) eventType["eventName"] = getDynamicEventName(users, typeSlug);
+ const eventType = {
+ ...eventTypeRaw,
+ recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
+ };
const profile = {
name: eventType.team?.name || eventType.users[0]?.name || null,
@@ -583,14 +706,49 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null,
};
+ const bookingInfo = await prisma.booking.findUnique({
+ where: {
+ id: bookingId,
+ },
+ select: {
+ description: true,
+ user: {
+ select: {
+ name: true,
+ email: true,
+ },
+ },
+ attendees: {
+ select: {
+ name: true,
+ email: true,
+ },
+ },
+ },
+ });
+ let recurringBookings = null;
+ if (recurringEventIdQuery) {
+ // We need to get the dates for the bookings to be able to show them in the UI
+ recurringBookings = await prisma.booking.findMany({
+ where: {
+ recurringEventId: recurringEventIdQuery,
+ },
+ select: {
+ startTime: true,
+ },
+ });
+ }
+
return {
props: {
hideBranding: eventType.team ? eventType.team.hideBranding : isBrandingHidden(eventType.users[0]),
profile,
eventType,
+ recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null,
trpcState: ssr.dehydrate(),
dynamicEventName,
userHasSpaceBooking,
+ bookingInfo,
},
};
}
diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx
index 1e2160d4..90a3ba4d 100644
--- a/apps/web/pages/team/[slug].tsx
+++ b/apps/web/pages/team/[slug].tsx
@@ -6,6 +6,7 @@ import Link from "next/link";
import React, { useEffect } from "react";
import { useIsEmbed } from "@calcom/embed-core";
+import { WEBSITE_URL } from "@calcom/lib/constants";
import Button from "@calcom/ui/Button";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
@@ -13,7 +14,6 @@ import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
-import { defaultAvatarSrc } from "@lib/profile";
import { getTeamWithMembers } from "@lib/queries/teams";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -68,7 +68,7 @@ function TeamPage({ team }: TeamPageProps) {
size={10}
items={type.users.map((user) => ({
alt: user.name || "",
- image: user.avatar || "",
+ image: WEBSITE_URL + "/" + user.username + "/avatar.png" || "",
}))}
/>
@@ -86,7 +86,7 @@ function TeamPage({ team }: TeamPageProps) {
-
+
0 && (
{eventTypes}
-
@@ -148,7 +147,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
...type,
users: type.users.map((user) => ({
...user,
- avatar: user.avatar || defaultAvatarSrc({ email: user.email || "" }),
+ avatar: WEBSITE_URL + "/" + user.username + "/avatar.png",
})),
}));
diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx
index 0b55693c..1034f403 100644
--- a/apps/web/pages/team/[slug]/[type].tsx
+++ b/apps/web/pages/team/[slug]/[type].tsx
@@ -2,6 +2,8 @@ import { UserPlan } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
+import { RecurringEvent } from "@calcom/types/Calendar";
+
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import getBooking, { GetBookingType } from "@lib/getBooking";
@@ -68,6 +70,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
+ recurringEvent: true,
price: true,
currency: true,
timeZone: true,
@@ -107,6 +110,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
metadata: (eventType.metadata || {}) as JSONObject,
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
+ recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent,
});
eventTypeObject.availability = [];
diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx
index 24c46d54..fbf79f0e 100644
--- a/apps/web/pages/team/[slug]/book.tsx
+++ b/apps/web/pages/team/[slug]/book.tsx
@@ -3,8 +3,9 @@ import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { getLocationLabels } from "@calcom/app-store/utils";
+import { RecurringEvent } from "@calcom/types/Calendar";
-import { asStringOrThrow } from "@lib/asStringOrNull";
+import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -21,13 +22,14 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
+ const recurringEventCountQuery = asStringOrNull(context.query.count);
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
return {
notFound: true,
} as const;
}
- const eventType = await prisma.eventType.findUnique({
+ const eventTypeRaw = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
@@ -44,6 +46,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
+ recurringEvent: true,
disableGuests: true,
price: true,
currency: true,
@@ -65,7 +68,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
});
- if (!eventType) return { notFound: true };
+ if (!eventTypeRaw) return { notFound: true };
+
+ const eventType = {
+ ...eventTypeRaw,
+ recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
+ };
const eventTypeObject = [eventType].map((e) => {
return {
@@ -83,6 +91,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const t = await getTranslation(context.locale ?? "en", "common");
+ // Checking if number of recurring event ocurrances is valid against event type configuration
+ const recurringEventCount =
+ (eventType.recurringEvent?.count &&
+ recurringEventCountQuery &&
+ (parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
+ ? recurringEventCountQuery
+ : eventType.recurringEvent.count)) ||
+ null;
+
return {
props: {
locationLabels: getLocationLabels(t),
@@ -96,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventName: null,
},
eventType: eventTypeObject,
+ recurringEventCount,
booking,
isDynamicGroupBooking: false,
hasHashedBookingLink: false,
diff --git a/apps/web/playwright/app-store.test.ts b/apps/web/playwright/app-store.test.ts
index 22b1309e..326d093c 100644
--- a/apps/web/playwright/app-store.test.ts
+++ b/apps/web/playwright/app-store.test.ts
@@ -4,16 +4,14 @@ 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");
- 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"]');
+ 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");
diff --git a/apps/web/playwright/embed-code-generator.test.ts b/apps/web/playwright/embed-code-generator.test.ts
new file mode 100644
index 00000000..8f8e442c
--- /dev/null
+++ b/apps/web/playwright/embed-code-generator.test.ts
@@ -0,0 +1,188 @@
+import { expect, Page, test } from "@playwright/test";
+
+function chooseEmbedType(page: Page, embedType: string) {
+ page.locator(`[data-testid=${embedType}]`).click();
+}
+
+async function gotToPreviewTab(page: Page) {
+ await page.locator("[data-testid=embed-tabs]").locator("text=Preview").click();
+}
+
+async function clickEmbedButton(page: Page) {
+ const embedButton = page.locator("[data-testid=event-type-embed]");
+ const eventTypeId = await embedButton.getAttribute("data-test-eventtype-id");
+ embedButton.click();
+ return eventTypeId;
+}
+
+async function clickFirstEventTypeEmbedButton(page: Page) {
+ const menu = page.locator("[data-testid*=event-type-options]").first();
+ await menu.click();
+ const eventTypeId = await clickEmbedButton(page);
+ return eventTypeId;
+}
+
+async function expectToBeNavigatingToEmbedTypesDialog(
+ page: Page,
+ { eventTypeId, basePage }: { eventTypeId: string | null; basePage: string }
+) {
+ if (!eventTypeId) {
+ throw new Error("Couldn't find eventTypeId");
+ }
+ await page.waitForNavigation({
+ url: (url) => {
+ return (
+ url.pathname === basePage &&
+ url.searchParams.get("dialog") === "embed" &&
+ url.searchParams.get("eventTypeId") === eventTypeId
+ );
+ },
+ });
+}
+
+async function expectToBeNavigatingToEmbedCodeAndPreviewDialog(
+ page: Page,
+ { eventTypeId, embedType, basePage }: { eventTypeId: string | null; embedType: string; basePage: string }
+) {
+ if (!eventTypeId) {
+ throw new Error("Couldn't find eventTypeId");
+ }
+ await page.waitForNavigation({
+ url: (url) => {
+ return (
+ url.pathname === basePage &&
+ url.searchParams.get("dialog") === "embed" &&
+ url.searchParams.get("eventTypeId") === eventTypeId &&
+ url.searchParams.get("embedType") === embedType &&
+ url.searchParams.get("tabName") === "embed-code"
+ );
+ },
+ });
+}
+
+async function expectToContainValidCode(page: Page, { embedType }: { embedType: string }) {
+ const embedCode = await page.locator("[data-testid=embed-code]").inputValue();
+ expect(embedCode.includes("(function (C, A, L)")).toBe(true);
+ expect(embedCode.includes(`Cal ${embedType} embed code begins`)).toBe(true);
+ return {
+ message: () => `passed`,
+ pass: true,
+ };
+}
+
+async function expectToContainValidPreviewIframe(
+ page: Page,
+ { embedType, calLink }: { embedType: string; calLink: string }
+) {
+ expect(await page.locator("[data-testid=embed-preview]").getAttribute("src")).toContain(
+ `/preview.html?embedType=${embedType}&calLink=${calLink}`
+ );
+}
+
+test.describe("Embed Code Generator Tests", () => {
+ test.use({ storageState: "playwright/artifacts/proStorageState.json" });
+
+ test.describe("Event Types Page", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/event-types");
+ });
+
+ test("open Embed Dialog and choose Inline for First Event Type", async ({ page }) => {
+ const eventTypeId = await clickFirstEventTypeEmbedButton(page);
+ await expectToBeNavigatingToEmbedTypesDialog(page, {
+ eventTypeId,
+ basePage: "/event-types",
+ });
+
+ chooseEmbedType(page, "inline");
+
+ await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
+ eventTypeId,
+ embedType: "inline",
+ basePage: "/event-types",
+ });
+
+ await expectToContainValidCode(page, { embedType: "inline" });
+
+ await gotToPreviewTab(page);
+
+ await expectToContainValidPreviewIframe(page, { embedType: "inline", calLink: "pro/30min" });
+ });
+
+ test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page }) => {
+ const eventTypeId = await clickFirstEventTypeEmbedButton(page);
+
+ await expectToBeNavigatingToEmbedTypesDialog(page, {
+ eventTypeId,
+ basePage: "/event-types",
+ });
+
+ chooseEmbedType(page, "floating-popup");
+
+ await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
+ eventTypeId,
+ embedType: "floating-popup",
+ basePage: "/event-types",
+ });
+ await expectToContainValidCode(page, { embedType: "floating-popup" });
+
+ await gotToPreviewTab(page);
+ await expectToContainValidPreviewIframe(page, { embedType: "floating-popup", calLink: "pro/30min" });
+ });
+
+ test("open Embed Dialog and choose element-click for First Event Type", async ({ page }) => {
+ const eventTypeId = await clickFirstEventTypeEmbedButton(page);
+
+ await expectToBeNavigatingToEmbedTypesDialog(page, {
+ eventTypeId,
+ basePage: "/event-types",
+ });
+
+ chooseEmbedType(page, "element-click");
+
+ await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
+ eventTypeId,
+ embedType: "element-click",
+ basePage: "/event-types",
+ });
+ await expectToContainValidCode(page, { embedType: "element-click" });
+
+ await gotToPreviewTab(page);
+ await expectToContainValidPreviewIframe(page, { embedType: "element-click", calLink: "pro/30min" });
+ });
+ });
+
+ test.describe("Event Type Edit Page", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/event-types/3");
+ });
+
+ test("open Embed Dialog for the Event Type", async ({ page }) => {
+ const eventTypeId = await clickEmbedButton(page);
+
+ await expectToBeNavigatingToEmbedTypesDialog(page, {
+ eventTypeId,
+ basePage: "/event-types/3",
+ });
+
+ chooseEmbedType(page, "inline");
+
+ await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
+ eventTypeId,
+ basePage: "/event-types/3",
+ embedType: "inline",
+ });
+
+ await expectToContainValidCode(page, {
+ embedType: "inline",
+ });
+
+ gotToPreviewTab(page);
+
+ await expectToContainValidPreviewIframe(page, {
+ embedType: "inline",
+ calLink: "pro/30min",
+ });
+ });
+ });
+});
diff --git a/apps/web/playwright/event-types.test.ts b/apps/web/playwright/event-types.test.ts
index f277a3ca..ff52af67 100644
--- a/apps/web/playwright/event-types.test.ts
+++ b/apps/web/playwright/event-types.test.ts
@@ -49,6 +49,45 @@ test.describe("Event Types tests", () => {
isCreated = await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
});
+ test("enabling recurring event comes with default options", async ({ page }) => {
+ await page.click("[data-testid=new-event-type]");
+ const nonce = randomString(3);
+ eventTitle = `my recurring event ${nonce}`;
+
+ await page.fill("[name=title]", eventTitle);
+ await page.fill("[name=length]", "15");
+ await page.click("[type=submit]");
+
+ await page.waitForNavigation({
+ url(url) {
+ return url.pathname !== "/event-types";
+ },
+ });
+
+ await page.click("[data-testid=show-advanced-settings]");
+ await expect(await page.locator("[data-testid=recurring-event-collapsible] > *")).not.toBeVisible();
+ await page.click("[data-testid=recurring-event-check]");
+ isCreated = await expect(
+ await page.locator("[data-testid=recurring-event-collapsible] > *")
+ ).toBeVisible();
+
+ await expect(
+ await page
+ .locator("[data-testid=recurring-event-collapsible] input[type=number]")
+ .nth(0)
+ .getAttribute("value")
+ ).toBe("1");
+ await expect(
+ await page.locator("[data-testid=recurring-event-collapsible] div[class$=singleValue]").textContent()
+ ).toBe("week");
+ await expect(
+ await page
+ .locator("[data-testid=recurring-event-collapsible] input[type=number]")
+ .nth(1)
+ .getAttribute("value")
+ ).toBe("12");
+ });
+
test("can duplicate an existing event type", async ({ page }) => {
const firstTitle = await page.locator("[data-testid=event-type-title-3]").innerText();
const firstFullSlug = await page.locator("[data-testid=event-type-slug-3]").innerText();
diff --git a/apps/web/playwright/hash-my-url.test.ts b/apps/web/playwright/hash-my-url.test.ts
index 74bb136d..a66d41c9 100644
--- a/apps/web/playwright/hash-my-url.test.ts
+++ b/apps/web/playwright/hash-my-url.test.ts
@@ -28,25 +28,19 @@ test.describe("hash my url", () => {
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
await page.click('//*[@data-testid="show-advanced-settings"]');
// we wait for the hashedLink setting to load
- await page.waitForSelector('//*[@id="hashedLink"]');
- await page.click('//*[@id="hashedLink"]');
+ await page.waitForSelector('//*[@id="hashedLinkCheck"]');
+ // ignore if it is already checked, and click if unchecked
+ const isChecked = await page.isChecked('//*[@id="hashedLinkCheck"]');
+ !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();
// click update
await page.focus('//button[@type="submit"]');
await page.keyboard.press("Enter");
});
test("book using generated url hash", async ({ page }) => {
- // await page.pause();
- await page.goto("/event-types");
- // We wait until loading is finished
- await page.waitForSelector('[data-testid="event-types"]');
- await page.click('//ul[@data-testid="event-types"]/li[1]');
- // We wait for the page to load
- await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
- await page.click('//*[@data-testid="show-advanced-settings"]');
- // 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();
await page.goto($url);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 33476b76..f9ab7153 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -12,6 +12,7 @@
"event_declined_subject": "Declined: {{eventType}} with {{name}} at {{date}}",
"event_cancelled_subject": "Cancelled: {{eventType}} with {{name}} at {{date}}",
"event_request_declined": "Your event request has been declined",
+ "event_request_declined_recurring": "Your recurring event request has been declined",
"event_request_cancelled": "Your scheduled event was cancelled",
"organizer": "Organizer",
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
@@ -23,6 +24,7 @@
"rejection_confirmation": "Reject the booking",
"manage_this_event": "Manage this event",
"your_event_has_been_scheduled": "Your event has been scheduled",
+ "your_event_has_been_scheduled_recurring": "Your recurring event has been scheduled",
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT1> to '{{agree}}'.",
"remove_banner_instructions": "To remove this banner, please open your .env file and change the <1>NEXT_PUBLIC_LICENSE_CONSENT1> variable to '{{agree}}'.",
"error_message": "The error message was: '{{errorMessage}}'",
@@ -57,6 +59,7 @@
"confirm_or_reject_request": "Confirm or reject the request",
"check_bookings_page_to_confirm_or_reject": "Check your bookings page to confirm or reject the booking.",
"event_awaiting_approval": "An event is waiting for your approval",
+ "event_awaiting_approval_recurring": "A recurring event is waiting for your approval",
"someone_requested_an_event": "Someone has requested to schedule an event on your calendar.",
"someone_requested_password_reset": "Someone has requested a link to change your password.",
"password_reset_instructions": "If you didn't request this, you can safely ignore this email and your password will not be changed.",
@@ -79,6 +82,7 @@
"manage_my_bookings": "Manage my bookings",
"need_to_make_a_change": "Need to make a change?",
"new_event_scheduled": "A new event has been scheduled.",
+ "new_event_scheduled_recurring": "A new recurring event has been scheduled.",
"invitee_email": "Invitee Email",
"invitee_timezone": "Invitee Time Zone",
"event_type": "Event Type",
@@ -128,6 +132,7 @@
"ping_test": "Ping test",
"add_to_homescreen": "Add this app to your home screen for faster access and improved experience.",
"upcoming": "Upcoming",
+ "recurring": "Recurring",
"past": "Past",
"choose_a_file": "Choose a file...",
"upload_image": "Upload image",
@@ -232,13 +237,20 @@
"add_to_calendar": "Add to calendar",
"other": "Other",
"emailed_you_and_attendees": "We emailed you and the other attendees a calendar invitation with all the details.",
+ "emailed_you_and_attendees_recurring": "We emailed you and the other attendees a calendar invitation for the first of these recurring events.",
"emailed_you_and_any_other_attendees": "You and any other attendees have been emailed with this information.",
"needs_to_be_confirmed_or_rejected": "Your booking still needs to be confirmed or rejected.",
+ "needs_to_be_confirmed_or_rejected_recurring": "Your recurring meeting still needs to be confirmed or rejected.",
"user_needs_to_confirm_or_reject_booking": "{{user}} still needs to confirm or reject the booking.",
+ "user_needs_to_confirm_or_reject_booking_recurring": "{{user}} still needs to confirm or reject each booking of the recurring meeting.",
"meeting_is_scheduled": "This meeting is scheduled",
+ "meeting_is_scheduled_recurring": "The recurring events are scheduled",
"submitted": "Your booking has been submitted",
+ "submitted_recurring": "Your recurring meeting has been submitted",
"booking_submitted": "Your booking has been submitted",
+ "booking_submitted_recurring": "Your recurring meeting has been submitted",
"booking_confirmed": "Your booking has been confirmed",
+ "booking_confirmed_recurring": "Your recurring meeting has been confirmed",
"enter_new_password": "Enter the new password you'd like for your account.",
"reset_password": "Reset Password",
"change_your_password": "Change your password",
@@ -282,6 +294,7 @@
"bookings": "Bookings",
"bookings_description": "See upcoming and past events booked through your event type links.",
"upcoming_bookings": "As soon as someone books a time with you it will show up here.",
+ "recurring_bookings": "As soon as someone books a recurring meeting with you it will show up here.",
"past_bookings": "Your past bookings will show up here.",
"cancelled_bookings": "Your cancelled bookings will show up here.",
"on": "on",
@@ -432,6 +445,7 @@
"edit_role": "Edit Role",
"edit_team": "Edit team",
"reject": "Reject",
+ "reject_all": "Reject all",
"accept": "Accept",
"leave": "Leave",
"profile": "Profile",
@@ -460,6 +474,7 @@
"cancel_event": "Cancel this event",
"continue": "Continue",
"confirm": "Confirm",
+ "confirm_all": "Confirm all",
"disband_team": "Disband Team",
"disband_team_confirmation_message": "Are you sure you want to disband this team? Anyone who you've shared this team link with will no longer be able to book using it.",
"remove_member_confirmation_message": "Are you sure you want to remove this member from the team?",
@@ -485,6 +500,7 @@
"user_from_team": "{{user}} from {{team}}",
"preview": "Preview",
"link_copied": "Link copied!",
+ "private_link_copied": "Private link copied!",
"link_shared": "Link shared!",
"title": "Title",
"description": "Description",
@@ -526,6 +542,18 @@
"language": "Language",
"timezone": "Timezone",
"first_day_of_week": "First Day of Week",
+ "repeats_up_to": "Repeats up to {{count}} time",
+ "repeats_up_to_plural": "Repeats up to {{count}} times",
+ "every_for_freq": "Every {{freq}} for",
+ "repeats_every": "Repeats every",
+ "weekly": "week",
+ "weekly_plural": "weeks",
+ "monthly": "month",
+ "monthly_plural": "months",
+ "yearly": "year",
+ "yearly_plural": "years",
+ "plus_more": "+ {{count}} more",
+ "max": "Max",
"single_theme": "Single Theme",
"brand_color": "Brand Color",
"light_brand_color": "Brand Color (Light Theme)",
@@ -582,10 +610,14 @@
"disable_notes_description": "For privacy reasons, additional inputs and notes will be hidden in the calendar entry. They will still be sent to your email.",
"opt_in_booking": "Opt-in Booking",
"opt_in_booking_description": "The booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.",
+ "recurring_event": "Recurring Event",
+ "recurring_event_description": "People can subscribe for recurring events",
+ "starting": "Starting",
"disable_guests": "Disable Guests",
"disable_guests_description": "Disable adding additional guests while booking.",
- "hashed_link": "Generate hashed URL",
- "hashed_link_description": "Generate a hashed URL to share without exposing your Cal username",
+ "private_link": "Generate private URL",
+ "copy_private_link": "Copy private link",
+ "private_link_description": "Generate a private URL to share without exposing your Cal username",
"invitees_can_schedule": "Invitees can schedule",
"date_range": "Date Range",
"calendar_days": "calendar days",
@@ -749,6 +781,7 @@
"you_will_only_view_it_once": "You will not be able to view it again once you close this modal.",
"copy_to_clipboard": "Copy to clipboard",
"enabled_after_update": "Enabled after update",
+ "enabled_after_update_description": "The private link will work after saving",
"confirm_delete_api_key": "Revoke this API key",
"revoke_api_key": "Revoke API key",
"api_key_copied": "API key copied!",
@@ -774,5 +807,19 @@
"impersonate_user_tip":"All uses of this feature is audited.",
"impersonating_user_warning":"Impersonating username \"{{user}}\".",
"impersonating_stop_instructions": "<0>Click Here to stop0>.",
- "calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions"
+ "email_validation_error":"That doesn't look like an email address",
+ "place_where_cal_widget_appear": "Place this code in your HTML where you want your Cal widget to appear.",
+ "copy_code": "Copy Code",
+ "code_copied": "Code copied!",
+ "how_you_want_add_cal_site":"How do you want to add Cal to your site?",
+ "choose_ways_put_cal_site":"Choose one of the following ways to put Cal on your site.",
+ "setting_up_zapier": "Setting up your Zapier integration",
+ "generate_api_key": "Generate Api Key",
+ "your_unique_api_key": "Your unique API key",
+ "copy_safe_api_key": "Copy this API key and save it somewhere safe. If you lose this key you have to generate a new one.",
+ "zapier_setup_instructions": "<0>Log into your Zapier account and create a new Zap.0><1>Select Cal.com as your Trigger app. Also choose a Trigger event.1><2>Choose your account and then enter your Unique API Key.2><3>Test your Trigger.3><4>You're set!4>",
+ "install_zapier_app": "Please first install the Zapier App in the app store.",
+ "go_to_app_store": "Go to App Store",
+ "calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions",
+ "calendar_no_busy_slots": "There are no busy slots"
}
diff --git a/apps/web/public/static/locales/en/vital.json b/apps/web/public/static/locales/en/vital.json
new file mode 100644
index 00000000..a08a9058
--- /dev/null
+++ b/apps/web/public/static/locales/en/vital.json
@@ -0,0 +1,13 @@
+{
+ "connected_vital_app": "Connected with",
+ "vital_app_sleep_automation": "Sleeping reschedule automation",
+ "vital_app_automation_description": "You can select different parameters to trigger the reschedule based on your sleeping metrics.",
+ "vital_app_parameter": "Parameter",
+ "vital_app_trigger": "Trigger at below or equal than",
+ "vital_app_save_button": "Save configuration",
+ "vital_app_total_label": "Total (total = rem + light sleep + deep sleep)",
+ "vital_app_duration_label": "Duration (duration = bedtime end - bedtime start)",
+ "vital_app_hours": "hours",
+ "vital_app_save_success": "Success saving your Vital Configurations",
+ "vital_app_save_error": "An error ocurred saving your Vital Configurations"
+}
diff --git a/apps/web/public/static/locales/es/common.json b/apps/web/public/static/locales/es/common.json
index 25a81a58..7d59283c 100644
--- a/apps/web/public/static/locales/es/common.json
+++ b/apps/web/public/static/locales/es/common.json
@@ -660,5 +660,6 @@
"availability_updated_successfully": "Disponibilidad actualizada correctamente",
"requires_ownership_of_a_token": "Requiere la propiedad de un token perteneciente a la siguiente dirección:",
"example_name": "Juan Pérez",
- "you_are_being_redirected": "Serás redirigido a {{ url }} en $t(second, {\"count\": {{seconds}} })."
+ "you_are_being_redirected": "Serás redirigido a {{ url }} en $t(second, {\"count\": {{seconds}} }).",
+ "zapier_setup_instructions": "<0>Inicia sesión en tu cuenta de Zapier y crea un nuevo Zap.0><1>Selecciona Cal.com cómo tu aplicación disparadora. Tambien elige tu evento disparador.1><2>Elige tu cuenta e ingresa tu Clave API única.2><3>Prueba tu disparador.3><4>¡Listo!4>"
}
diff --git a/apps/web/public/static/locales/es/vital.json b/apps/web/public/static/locales/es/vital.json
new file mode 100644
index 00000000..d571e02e
--- /dev/null
+++ b/apps/web/public/static/locales/es/vital.json
@@ -0,0 +1,13 @@
+{
+ "connected_vital_app": "Conectado con",
+ "vital_app_sleep_automation": "Automatización de reagendado en base al patron de sueño",
+ "vital_app_automation_description": "Puedes seleccionar diferentes parámetros para activar el reagendado automático en base a tus patrones de sueño.",
+ "vital_app_parameter": "Parámetro",
+ "vital_app_trigger": "Activar cuando sea igual o menor que",
+ "vital_app_save_button": "Guardar configuración",
+ "vital_app_total_label": "Total (total = rem + sueño ligero + sueño profundo)",
+ "vital_app_duration_label": "Duration (duration = Hora que te levantaste de la cama - Hora que te acostaste en la cama)",
+ "vital_app_hours": "horas",
+ "vital_app_save_success": "Fue un éxito el guardado de tus configuraciones de App Vital",
+ "vital_app_save_error": "Ocurrió un error al intentar guardar tus configuraciones de App Vital"
+}
diff --git a/apps/web/server/createContext.ts b/apps/web/server/createContext.ts
index c17325da..6b4c6cfd 100644
--- a/apps/web/server/createContext.ts
+++ b/apps/web/server/createContext.ts
@@ -105,7 +105,7 @@ export const createContext = async ({ req }: CreateContextOptions) => {
const user = await getUserFromSession({ session, req });
const locale = user?.locale ?? getLocaleFromHeaders(req);
- const i18n = await serverSideTranslations(locale, ["common"]);
+ const i18n = await serverSideTranslations(locale, ["common", "vital"]);
return {
i18n,
prisma,
diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx
index 8dcc1bf1..b5d84e02 100644
--- a/apps/web/server/routers/viewer.tsx
+++ b/apps/web/server/routers/viewer.tsx
@@ -6,6 +6,7 @@ import { z } from "zod";
import getApps from "@calcom/app-store/utils";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
+import { RecurringEvent } from "@calcom/types/Calendar";
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
import jackson from "@lib/jackson";
@@ -127,6 +128,7 @@ const loggedInViewerRouter = createProtectedRouter()
description: true,
length: true,
schedulingType: true,
+ recurringEvent: true,
slug: true,
hidden: true,
price: true,
@@ -298,7 +300,7 @@ const loggedInViewerRouter = createProtectedRouter()
})
.query("bookings", {
input: z.object({
- status: z.enum(["upcoming", "past", "cancelled"]),
+ status: z.enum(["upcoming", "recurring", "past", "cancelled"]),
limit: z.number().min(1).max(100).nullish(),
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
}),
@@ -311,9 +313,30 @@ const loggedInViewerRouter = createProtectedRouter()
const bookingListingByStatus = input.status;
const bookingListingFilters: Record
= {
upcoming: [
+ {
+ endTime: { gte: new Date() },
+ // These changes are needed to not show confirmed recurring events,
+ // as rescheduling or cancel for recurring event bookings should be
+ // handled separately for each occurrence
+ OR: [
+ {
+ AND: [{ NOT: { recurringEventId: { equals: null } } }, { confirmed: false }],
+ },
+ {
+ AND: [
+ { recurringEventId: { equals: null } },
+ { NOT: { status: { equals: BookingStatus.CANCELLED } } },
+ { NOT: { status: { equals: BookingStatus.REJECTED } } },
+ ],
+ },
+ ],
+ },
+ ],
+ recurring: [
{
endTime: { gte: new Date() },
AND: [
+ { NOT: { recurringEventId: { equals: null } } },
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
],
@@ -342,6 +365,7 @@ const loggedInViewerRouter = createProtectedRouter()
Prisma.BookingOrderByWithAggregationInput
> = {
upcoming: { startTime: "asc" },
+ recurring: { startTime: "asc" },
past: { startTime: "desc" },
cancelled: { startTime: "desc" },
};
@@ -373,10 +397,12 @@ const loggedInViewerRouter = createProtectedRouter()
rejected: true,
id: true,
startTime: true,
+ recurringEventId: true,
endTime: true,
eventType: {
select: {
price: true,
+ recurringEvent: true,
team: {
select: {
name: true,
@@ -398,14 +424,42 @@ const loggedInViewerRouter = createProtectedRouter()
skip,
});
- const bookings = bookingsQuery.map((booking) => {
+ const groupedRecurringBookings = await prisma.booking.groupBy({
+ by: [Prisma.BookingScalarFieldEnum.recurringEventId],
+ _count: true,
+ });
+
+ let bookings = bookingsQuery.map((booking) => {
return {
...booking,
+ eventType: {
+ ...booking.eventType,
+ recurringEvent: ((booking.eventType && booking.eventType.recurringEvent) || {}) as RecurringEvent,
+ },
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
};
});
+ const seenBookings: Record = {};
+
+ // Remove duplicate recurring bookings for upcoming status.
+ // Couldn't use distinct in query because the distinct column would be different for recurring and non recurring event.
+ // We might be actually sending less then the limit, due to this filter
+ // TODO: Figure out a way to fix it.
+ if (bookingListingByStatus === "upcoming") {
+ bookings = bookings.filter((booking) => {
+ if (!booking.recurringEventId) {
+ return true;
+ }
+ if (seenBookings[booking.recurringEventId]) {
+ return false;
+ }
+ seenBookings[booking.recurringEventId] = true;
+ return true;
+ });
+ }
+
let nextCursor: typeof skip | null = skip;
if (bookings.length > take) {
bookings.shift();
@@ -416,6 +470,7 @@ const loggedInViewerRouter = createProtectedRouter()
return {
bookings,
+ groupedRecurringBookings,
nextCursor,
};
},
diff --git a/apps/web/server/routers/viewer/apiKeys.tsx b/apps/web/server/routers/viewer/apiKeys.tsx
index 0b95986f..b11420f7 100644
--- a/apps/web/server/routers/viewer/apiKeys.tsx
+++ b/apps/web/server/routers/viewer/apiKeys.tsx
@@ -11,16 +11,46 @@ export const apiKeysRouter = createProtectedRouter()
return await ctx.prisma.apiKey.findMany({
where: {
userId: ctx.user.id,
+ OR: [
+ {
+ NOT: {
+ appId: "zapier",
+ },
+ },
+ {
+ appId: null,
+ },
+ ],
},
orderBy: { createdAt: "desc" },
});
},
})
+ .query("findKeyOfType", {
+ input: z.object({
+ appId: z.string().optional().nullable(),
+ }),
+ async resolve({ ctx, input }) {
+ return await ctx.prisma.apiKey.findFirst({
+ where: {
+ AND: [
+ {
+ userId: ctx.user.id,
+ },
+ {
+ appId: input.appId,
+ },
+ ],
+ },
+ });
+ },
+ })
.mutation("create", {
input: z.object({
note: z.string().optional().nullish(),
expiresAt: z.date().optional().nullable(),
neverExpires: z.boolean().optional(),
+ appId: z.string().optional().nullable(),
}),
async resolve({ ctx, input }) {
const [hashedApiKey, apiKey] = generateUniqueAPIKey();
diff --git a/apps/web/server/routers/viewer/eventTypes.tsx b/apps/web/server/routers/viewer/eventTypes.tsx
index 24794f30..dd225c18 100644
--- a/apps/web/server/routers/viewer/eventTypes.tsx
+++ b/apps/web/server/routers/viewer/eventTypes.tsx
@@ -1,12 +1,11 @@
import { EventTypeCustomInput, MembershipRole, PeriodType, Prisma } from "@prisma/client";
-import short from "short-uuid";
-import { v5 as uuidv5 } from "uuid";
import { z } from "zod";
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { _DestinationCalendarModel, _EventTypeCustomInputModel, _EventTypeModel } from "@calcom/prisma/zod";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
+import { RecurringEvent } from "@calcom/types/Calendar";
import { createProtectedRouter } from "@server/createRouter";
import { viewerRouter } from "@server/routers/viewer";
@@ -87,7 +86,7 @@ const EventTypeUpdateInput = _EventTypeModel
}),
users: z.array(stringOrNumber).optional(),
schedule: z.number().optional(),
- hashedLink: z.boolean(),
+ hashedLink: z.string(),
})
.partial()
.merge(
@@ -211,6 +210,40 @@ export const eventTypesRouter = createProtectedRouter()
return next();
})
+ .query("get", {
+ input: z.object({
+ id: z.number(),
+ }),
+ async resolve({ ctx, input }) {
+ const user = await ctx.prisma.user.findUnique({
+ where: {
+ id: ctx.user.id,
+ },
+ select: {
+ id: true,
+ username: true,
+ name: true,
+ startTime: true,
+ endTime: true,
+ bufferTime: true,
+ avatar: true,
+ plan: true,
+ },
+ });
+ if (!user) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
+ }
+ return await ctx.prisma.eventType.findUnique({
+ where: {
+ id: input.id,
+ },
+ include: {
+ team: true,
+ users: true,
+ },
+ });
+ },
+ })
.mutation("update", {
input: EventTypeUpdateInput.strict(),
async resolve({ ctx, input }) {
@@ -220,6 +253,7 @@ export const eventTypesRouter = createProtectedRouter()
locations,
destinationCalendar,
customInputs,
+ recurringEvent,
users,
id,
hashedLink,
@@ -232,6 +266,17 @@ export const eventTypesRouter = createProtectedRouter()
data.periodType = handlePeriodType(periodType);
}
+ if (recurringEvent) {
+ data.recurringEvent = {
+ dstart: recurringEvent.dtstart as unknown as Prisma.InputJsonObject,
+ interval: recurringEvent.interval,
+ count: recurringEvent.count,
+ freq: recurringEvent.freq,
+ until: recurringEvent.until as unknown as Prisma.InputJsonObject,
+ tzid: recurringEvent.tzid,
+ };
+ }
+
if (destinationCalendar) {
/** We connect or create a destination calendar to the event type instead of the user */
await viewerRouter.createCaller(ctx).mutation("setDestinationCalendar", {
@@ -271,19 +316,16 @@ export const eventTypesRouter = createProtectedRouter()
if (hashedLink) {
// check if hashed connection existed. If it did, do nothing. If it didn't, add a new connection
if (!connectedLink) {
- const translator = short();
- const seed = `${input.eventName}:${input.id}:${new Date().getTime()}`;
- const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
// create a hashed link
await ctx.prisma.hashedLink.upsert({
where: {
eventTypeId: input.id,
},
update: {
- link: uid,
+ link: hashedLink,
},
create: {
- link: uid,
+ link: hashedLink,
eventType: {
connect: { id: input.id },
},
diff --git a/apps/web/server/routers/viewer/teams.tsx b/apps/web/server/routers/viewer/teams.tsx
index 001c9cb0..7fe98980 100644
--- a/apps/web/server/routers/viewer/teams.tsx
+++ b/apps/web/server/routers/viewer/teams.tsx
@@ -65,7 +65,7 @@ export const viewerTeamsRouter = createProtectedRouter()
return memberships.map((membership) => ({
role: membership.role,
- accepted: membership.role === "OWNER" ? true : membership.accepted,
+ accepted: membership.accepted,
...teams.find((team) => team.id === membership.teamId),
}));
},
diff --git a/apps/web/server/routers/viewer/webhook.tsx b/apps/web/server/routers/viewer/webhook.tsx
index 8386d854..0c161f38 100644
--- a/apps/web/server/routers/viewer/webhook.tsx
+++ b/apps/web/server/routers/viewer/webhook.tsx
@@ -1,3 +1,4 @@
+import { Prisma } from "@prisma/client";
import { v4 } from "uuid";
import { z } from "zod";
@@ -17,17 +18,18 @@ export const webhookRouter = createProtectedRouter()
})
.optional(),
async resolve({ ctx, input }) {
- if (input?.eventTypeId) {
- return await ctx.prisma.webhook.findMany({
- where: {
- eventTypeId: input.eventTypeId,
- },
- });
+ let where: Prisma.WebhookWhereInput = {
+ AND: [{ appId: null /* Don't mixup zapier webhooks with normal ones */ }],
+ };
+ if (Array.isArray(where.AND)) {
+ if (input?.eventTypeId) {
+ where.AND?.push({ eventTypeId: input.eventTypeId });
+ } else {
+ where.AND?.push({ userId: ctx.user.id });
+ }
}
return await ctx.prisma.webhook.findMany({
- where: {
- userId: ctx.user.id,
- },
+ where,
});
},
})
@@ -38,6 +40,7 @@ export const webhookRouter = createProtectedRouter()
active: z.boolean(),
payloadTemplate: z.string().nullable(),
eventTypeId: z.number().optional(),
+ appId: z.string().optional().nullable(),
}),
async resolve({ ctx, input }) {
if (input.eventTypeId) {
@@ -65,6 +68,7 @@ export const webhookRouter = createProtectedRouter()
active: z.boolean().optional(),
payloadTemplate: z.string().nullable(),
eventTypeId: z.number().optional(),
+ appId: z.string().optional().nullable(),
}),
async resolve({ ctx, input }) {
const { id, ...data } = input;
@@ -139,7 +143,7 @@ export const webhookRouter = createProtectedRouter()
payloadTemplate: z.string().optional().nullable(),
}),
async resolve({ input }) {
- const { url, type, payloadTemplate } = input;
+ const { url, type, payloadTemplate = null } = input;
const translation = await getTranslation("en", "common");
const language = {
locale: "en",
@@ -170,7 +174,8 @@ export const webhookRouter = createProtectedRouter()
};
try {
- return await sendPayload(type, new Date().toISOString(), url, data, payloadTemplate);
+ const webhook = { subscriberUrl: url, payloadTemplate, appId: null };
+ return await sendPayload(type, new Date().toISOString(), webhook, data);
} catch (_err) {
const error = getErrorFromUnknown(_err);
return {
diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css
index 480828ac..884e3778 100644
--- a/apps/web/styles/globals.css
+++ b/apps/web/styles/globals.css
@@ -9,9 +9,9 @@
--brand-text-color-dark-mode: #292929;
}
-/*
+/*
* Override the default tailwindcss-forms styling (default is: 'colors.blue.600')
- * @see: https://github.com/tailwindlabs/tailwindcss-forms/issues/14#issuecomment-1005376006
+ * @see: https://github.com/tailwindlabs/tailwindcss-forms/issues/14#issuecomment-1005376006
*/
[type="text"]:focus,
[type="email"]:focus,
diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js
index ad441af5..3a93df35 100644
--- a/apps/web/tailwind.config.js
+++ b/apps/web/tailwind.config.js
@@ -1,5 +1,9 @@
const base = require("@calcom/config/tailwind-preset");
module.exports = {
...base,
- content: [...base.content, "../../packages/ui/**/*.{js,ts,jsx,tsx}"],
+ content: [
+ ...base.content,
+ "../../packages/ui/**/*.{js,ts,jsx,tsx}",
+ "../../packages/app-store/**/components/*.{js,ts,jsx,tsx}",
+ ],
};
diff --git a/apps/website b/apps/website
index 797f725d..b86553c8 160000
--- a/apps/website
+++ b/apps/website
@@ -1 +1 @@
-Subproject commit 797f725d982988ec7c2767ee2250b6fb83a82086
+Subproject commit b86553c8497b25a347fa8e8efcabd30f981506ac
diff --git a/package.json b/package.json
index e1358293..f8bbfc62 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"deploy": "turbo run deploy",
"dev": "turbo run dev --scope=\"@calcom/web\"",
"dev:website": "yarn predev && turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\"",
- "dev:api": "yarn predev && turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api\"",
+ "dev:api": "yarn predev && turbo run dev --scope=\"@calcom/api\"",
"dev:swagger": "yarn predev && turbo run dev --scope=\"@calcom/api\" --scope=\"@calcom/swagger\"",
"dev:console": "yarn predev && turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/console\"",
"docs-dev": "yarn predev && turbo run dev --scope=\"@calcom/docs\"",
@@ -30,7 +30,9 @@
"lint:report": "turbo run lint:report",
"postinstall": "turbo run postinstall",
"pre-commit": "lint-staged",
- "predev": "dotenv-checker --schema .env.example --env .env",
+ "env-check:common": "dotenv-checker --schema .env.example --env .env",
+ "env-check:app-store": "dotenv-checker --schema .env.appStore.example --env .env.appStore",
+ "predev": "yarn env-check:common && yarn env-check:app-store ",
"prepare": "husky install",
"start": "turbo run start --scope=\"@calcom/web\"",
"test": "turbo run test",
diff --git a/packages/app-store/_components/AppConfiguration.tsx b/packages/app-store/_components/AppConfiguration.tsx
new file mode 100644
index 00000000..e7e909b2
--- /dev/null
+++ b/packages/app-store/_components/AppConfiguration.tsx
@@ -0,0 +1,19 @@
+import dynamic from "next/dynamic";
+
+export const ConfigAppMap = {
+ vital: dynamic(() => import("../vital/components/AppConfiguration")),
+};
+
+export const AppConfiguration = (props: { type: string } & { credentialIds: number[] }) => {
+ let appName = props.type.replace(/_/g, "");
+ let ConfigAppComponent = ConfigAppMap[appName as keyof typeof ConfigAppMap];
+ /** So we can either call it by simple name (ex. `slack`, `giphy`) instead of
+ * `slackmessaging`, `giphyother` while maintaining retro-compatibility. */
+ if (!ConfigAppComponent) {
+ [appName] = props.type.split("_");
+ ConfigAppComponent = ConfigAppMap[appName as keyof typeof ConfigAppMap];
+ }
+ if (!ConfigAppComponent) return null;
+
+ return ;
+};
diff --git a/packages/app-store/apiHandlers.tsx b/packages/app-store/apiHandlers.tsx
index 7788005d..c29bcc13 100644
--- a/packages/app-store/apiHandlers.tsx
+++ b/packages/app-store/apiHandlers.tsx
@@ -8,6 +8,7 @@ export const apiHandlers = {
slackmessaging: import("./slackmessaging/api"),
stripepayment: import("./stripepayment/api"),
tandemvideo: import("./tandemvideo/api"),
+ vital: import("./vital/api"),
zoomvideo: import("@calcom/zoomvideo/api"),
office365video: import("@calcom/office365video/api"),
wipemycalother: import("./wipemycalother/api"),
@@ -16,6 +17,8 @@ export const apiHandlers = {
metamask: import("./metamask/api"),
giphy: import("./giphy/api"),
spacebookingother: import("./spacebooking/api"),
+ // @todo Until we use DB slugs everywhere
+ zapierother: import("./zapier/api"),
};
export default apiHandlers;
diff --git a/packages/app-store/components.tsx b/packages/app-store/components.tsx
index df67638e..849b71ca 100644
--- a/packages/app-store/components.tsx
+++ b/packages/app-store/components.tsx
@@ -21,11 +21,13 @@ export const InstallAppButtonMap = {
zoomvideo: dynamic(() => import("./zoomvideo/components/InstallAppButton")),
office365video: dynamic(() => import("./office365video/components/InstallAppButton")),
wipemycalother: dynamic(() => import("./wipemycalother/components/InstallAppButton")),
+ zapier: dynamic(() => import("./zapier/components/InstallAppButton")),
jitsivideo: dynamic(() => import("./jitsivideo/components/InstallAppButton")),
huddle01video: dynamic(() => import("./huddle01video/components/InstallAppButton")),
metamask: dynamic(() => import("./metamask/components/InstallAppButton")),
giphy: dynamic(() => import("./giphy/components/InstallAppButton")),
spacebookingother: dynamic(() => import("./spacebooking/components/InstallAppButton")),
+ vital: dynamic(() => import("./vital/components/InstallAppButton")),
};
export const InstallAppButton = (
@@ -60,3 +62,5 @@ export const InstallAppButton = (
);
return ;
};
+
+export { AppConfiguration } from "./_components/AppConfiguration";
diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts
index 88ef3248..24c94dd6 100644
--- a/packages/app-store/index.ts
+++ b/packages/app-store/index.ts
@@ -15,7 +15,9 @@ import * as slackmessaging from "./slackmessaging";
import * as spacebooking from "./spacebooking";
import * as stripepayment from "./stripepayment";
import * as tandemvideo from "./tandemvideo";
+import * as vital from "./vital";
import * as wipemycalother from "./wipemycalother";
+import * as zapier from "./zapier";
import * as zoomvideo from "./zoomvideo";
const appStore = {
@@ -34,10 +36,12 @@ const appStore = {
stripepayment,
spacebooking,
tandemvideo,
+ vital,
zoomvideo,
wipemycalother,
metamask,
giphy,
+ zapier,
};
export default appStore;
diff --git a/packages/app-store/metadata.ts b/packages/app-store/metadata.ts
index 6714de84..c14c136f 100644
--- a/packages/app-store/metadata.ts
+++ b/packages/app-store/metadata.ts
@@ -14,7 +14,9 @@ import { metadata as slackmessaging } from "./slackmessaging/_metadata";
import { metadata as spacebooking } from "./spacebooking/_metadata";
import { metadata as stripepayment } from "./stripepayment/_metadata";
import { metadata as tandemvideo } from "./tandemvideo/_metadata";
+import { metadata as vital } from "./vital/_metadata";
import { metadata as wipemycalother } from "./wipemycalother/_metadata";
+import { metadata as zapier } from "./zapier/_metadata";
import { metadata as zoomvideo } from "./zoomvideo/_metadata";
export const appStoreMetadata = {
@@ -32,10 +34,12 @@ export const appStoreMetadata = {
stripepayment,
spacebooking,
tandemvideo,
+ vital,
zoomvideo,
wipemycalother,
metamask,
giphy,
+ zapier,
};
export default appStoreMetadata;
diff --git a/packages/app-store/office365video/_metadata.ts b/packages/app-store/office365video/_metadata.ts
index c4825514..d56e7752 100644
--- a/packages/app-store/office365video/_metadata.ts
+++ b/packages/app-store/office365video/_metadata.ts
@@ -4,7 +4,7 @@ import { LocationType } from "../locations";
import _package from "./package.json";
export const metadata = {
- name: "Microsoft 365/Teams",
+ name: "Microsoft 365/Teams (Requires work/school account)",
description: _package.description,
type: "office365_video",
imageSrc: "/api/app-store/office365video/icon.svg",
@@ -17,7 +17,7 @@ export const metadata = {
reviews: 69, // TODO: placeholder for now, pull this from TrustPilot or G2
category: "video",
slug: "msteams",
- title: "MS Teams",
+ title: "MS Teams (Requires work/school account)",
trending: true,
email: "help@cal.com",
locationType: LocationType.Teams,
diff --git a/packages/app-store/office365video/components/AccountDialog.tsx b/packages/app-store/office365video/components/AccountDialog.tsx
new file mode 100644
index 00000000..42d58640
--- /dev/null
+++ b/packages/app-store/office365video/components/AccountDialog.tsx
@@ -0,0 +1,43 @@
+import Button from "@calcom/ui/Button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogProps,
+} from "@calcom/ui/Dialog";
+
+import useAddAppMutation from "../../_utils/useAddAppMutation";
+
+export function AccountDialog(props: DialogProps) {
+ const mutation = useAddAppMutation("office365_calendar");
+ return (
+
+
+
+
+
+ {
+ props.onOpenChange?.(false);
+ }}
+ asChild>
+
+ Cancel
+
+
+
+ mutation.mutate("")}>
+ Continue
+
+
+
+
+ );
+}
+
+export default AccountDialog;
diff --git a/packages/app-store/office365video/components/InstallAppButton.tsx b/packages/app-store/office365video/components/InstallAppButton.tsx
index aaa8eabb..778d0042 100644
--- a/packages/app-store/office365video/components/InstallAppButton.tsx
+++ b/packages/app-store/office365video/components/InstallAppButton.tsx
@@ -1,18 +1,20 @@
-import type { InstallAppButtonProps } from "@calcom/app-store/types";
+import { useState } from "react";
-import useAddAppMutation from "../../_utils/useAddAppMutation";
+import { InstallAppButtonProps } from "../../types";
+import AddIntegration from "./AccountDialog";
export default function InstallAppButton(props: InstallAppButtonProps) {
- const mutation = useAddAppMutation("office365_video");
+ const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
{props.render({
onClick() {
- mutation.mutate("");
+ setIsModalOpen(true);
},
- loading: mutation.isLoading,
+ disabled: isModalOpen,
})}
+
>
);
}
diff --git a/packages/app-store/slackmessaging/api/commandHandler.ts b/packages/app-store/slackmessaging/api/commandHandler.ts
index 57845cb3..6b78eecd 100644
--- a/packages/app-store/slackmessaging/api/commandHandler.ts
+++ b/packages/app-store/slackmessaging/api/commandHandler.ts
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { showCreateEventMessage, showTodayMessage } from "../lib";
import showLinksMessage from "../lib/showLinksMessage";
+import slackVerify from "../lib/slackVerify";
export enum SlackAppCommands {
CREATE_EVENT = "create-event",
@@ -12,7 +13,7 @@ export enum SlackAppCommands {
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
const command = req.body.command.split("/").pop();
-
+ await slackVerify(req, res);
switch (command) {
case SlackAppCommands.CREATE_EVENT:
return await showCreateEventMessage(req, res);
diff --git a/packages/app-store/slackmessaging/api/interactiveHandler.ts b/packages/app-store/slackmessaging/api/interactiveHandler.ts
index 290c4774..25c07b16 100644
--- a/packages/app-store/slackmessaging/api/interactiveHandler.ts
+++ b/packages/app-store/slackmessaging/api/interactiveHandler.ts
@@ -1,6 +1,7 @@
import { NextApiRequest, NextApiResponse } from "next";
import createEvent from "../lib/actions/createEvent";
+import slackVerify from "../lib/slackVerify";
enum InteractionEvents {
CREATE_EVENT = "cal.event.create",
@@ -8,6 +9,7 @@ enum InteractionEvents {
export default async function interactiveHandler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
+ await slackVerify(req, res);
const payload = JSON.parse(req.body.payload);
const actions = payload.view.callback_id;
diff --git a/packages/app-store/slackmessaging/lib/slackVerify.ts b/packages/app-store/slackmessaging/lib/slackVerify.ts
new file mode 100644
index 00000000..fc43b502
--- /dev/null
+++ b/packages/app-store/slackmessaging/lib/slackVerify.ts
@@ -0,0 +1,36 @@
+import { createHmac } from "crypto";
+import dayjs from "dayjs";
+import { NextApiRequest, NextApiResponse } from "next";
+import { stringify } from "querystring";
+
+import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
+
+let signingSecret = "";
+
+export default async function slackVerify(req: NextApiRequest, res: NextApiResponse) {
+ const body = req.body;
+ const timeStamp = req.headers["x-slack-request-timestamp"] as string; // Always returns a string and not a string[]
+ const slackSignature = req.headers["x-slack-signature"] as string;
+ const currentTime = dayjs().unix();
+ let { signing_secret } = await getAppKeysFromSlug("slack");
+ if (typeof signing_secret === "string") signingSecret = signing_secret;
+
+ if (!timeStamp) {
+ return res.status(400).json({ message: "Missing X-Slack-Request-Timestamp header" });
+ }
+
+ if (!signingSecret) {
+ return res.status(400).json({ message: "Missing Slack's signing_secret" });
+ }
+
+ if (Math.abs(currentTime - parseInt(timeStamp)) > 60 * 5) {
+ return res.status(400).json({ message: "Request is too old" });
+ }
+
+ const signature_base = `v0:${timeStamp}:${stringify(body)}`;
+ const signed_sig = "v0=" + createHmac("sha256", signingSecret).update(signature_base).digest("hex");
+
+ if (signed_sig !== slackSignature) {
+ return res.status(400).json({ message: "Invalid signature" });
+ }
+}
diff --git a/packages/app-store/spacebooking/index.ts b/packages/app-store/spacebooking/index.ts
index 32511bac..db3c2b10 100644
--- a/packages/app-store/spacebooking/index.ts
+++ b/packages/app-store/spacebooking/index.ts
@@ -1,3 +1,4 @@
export * as api from "./api";
-export { metadata } from "./_metadata";
export * as components from "./components";
+export * as lib from "./lib";
+export { metadata } from "./_metadata";
diff --git a/packages/app-store/spacebooking/lib/index.ts b/packages/app-store/spacebooking/lib/index.ts
new file mode 100644
index 00000000..cb0ff5c3
--- /dev/null
+++ b/packages/app-store/spacebooking/lib/index.ts
@@ -0,0 +1 @@
+export {};
diff --git a/packages/app-store/vital/README.mdx b/packages/app-store/vital/README.mdx
new file mode 100644
index 00000000..6762f80b
--- /dev/null
+++ b/packages/app-store/vital/README.mdx
@@ -0,0 +1,6 @@
+Vital App is an app that can can help you combine your health peripherals with your calendar.
+
+#### Supported Actions:
+
+Sleep reschedule automation: Had a hard night? 🌕
+Automatically reschedule your whole day schedule based on your sleep parameters. (Setup your desired configuration on installed apps page.)
diff --git a/packages/app-store/vital/_metadata.ts b/packages/app-store/vital/_metadata.ts
new file mode 100644
index 00000000..2081cf79
--- /dev/null
+++ b/packages/app-store/vital/_metadata.ts
@@ -0,0 +1,27 @@
+import type { App } from "@calcom/types/App";
+
+import _package from "./package.json";
+
+export const metadata = {
+ name: "Vital",
+ description: _package.description,
+ installed: true,
+ category: "other",
+ // If using static next public folder, can then be referenced from the base URL (/).
+ imageSrc: "/api/app-store/vital/icon.svg",
+ logo: "/api/app-store/vital/icon.svg",
+ label: "Vital",
+ publisher: "Vital",
+ rating: 5,
+ reviews: 69,
+ slug: "vital-automation",
+ title: "Vital",
+ trending: true,
+ type: "vital_other",
+ url: "https://tryvital.io",
+ variant: "other",
+ verified: true,
+ email: "support@tryvital.io",
+} as App;
+
+export default metadata;
diff --git a/packages/app-store/vital/api/callback.ts b/packages/app-store/vital/api/callback.ts
new file mode 100644
index 00000000..7130f6be
--- /dev/null
+++ b/packages/app-store/vital/api/callback.ts
@@ -0,0 +1,41 @@
+import { Prisma } from "@prisma/client";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import prisma from "@calcom/prisma";
+
+/**
+ * This is will generate a user token for a client_user_id`
+ * @param req
+ * @param res
+ */
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const userWithMetadata = await prisma.user.findFirst({
+ where: {
+ id: req?.session?.user.id,
+ },
+ select: {
+ id: true,
+ metadata: true,
+ },
+ });
+
+ await prisma.user.update({
+ where: {
+ id: req?.session?.user.id,
+ },
+ data: {
+ metadata: {
+ ...(userWithMetadata?.metadata as Prisma.JsonObject),
+ vitalSettings: {
+ ...((userWithMetadata?.metadata as Prisma.JsonObject)?.vitalSettings as Prisma.JsonObject),
+ connected: true,
+ },
+ },
+ },
+ });
+ return res.redirect("/apps/installed");
+ } catch (e) {
+ return res.status(500);
+ }
+}
diff --git a/packages/app-store/vital/api/index.ts b/packages/app-store/vital/api/index.ts
new file mode 100644
index 00000000..f2c0c888
--- /dev/null
+++ b/packages/app-store/vital/api/index.ts
@@ -0,0 +1,5 @@
+export { default as token } from "./token";
+export { default as callback } from "./callback";
+export { default as webhook } from "./webhook";
+export { default as settings } from "./settings";
+export { default as save } from "./save";
diff --git a/packages/app-store/vital/api/save.ts b/packages/app-store/vital/api/save.ts
new file mode 100644
index 00000000..c3b45bad
--- /dev/null
+++ b/packages/app-store/vital/api/save.ts
@@ -0,0 +1,91 @@
+import { Prisma } from "@prisma/client";
+import type { NextApiRequest, NextApiResponse } from "next";
+import { z, ZodError } from "zod";
+
+import prisma from "@calcom/prisma";
+
+export type VitalSettingsResponse = {
+ connected: boolean;
+ sleepValue: number;
+ selectedParam: string;
+};
+
+const vitalSettingsUpdateSchema = z.object({
+ connected: z.boolean().optional(),
+ selectedParam: z.string().optional(),
+ sleepValue: z.number().optional(),
+});
+
+const handler = async (
+ req: NextApiRequest,
+ res: NextApiResponse
+): Promise => {
+ if (req.method === "PUT" && req.session && req.session.user.id) {
+ const userId = req.session.user.id;
+ const body = req.body;
+ try {
+ const userWithMetadata = await prisma.user.findFirst({
+ where: {
+ id: userId,
+ },
+ select: {
+ id: true,
+ metadata: true,
+ },
+ });
+ const userMetadata = userWithMetadata?.metadata as Prisma.JsonObject;
+ const vitalSettings =
+ ((userWithMetadata?.metadata as Prisma.JsonObject)?.vitalSettings as Prisma.JsonObject) || {};
+ await prisma.user.update({
+ where: {
+ id: userId,
+ },
+ data: {
+ metadata: {
+ ...userMetadata,
+ vitalSettings: {
+ ...vitalSettings,
+ ...body,
+ },
+ },
+ },
+ });
+
+ if (vitalSettings) {
+ res.status(200).json(vitalSettings);
+ } else {
+ res.status(404);
+ }
+ } catch (error) {
+ res.status(500);
+ }
+ } else {
+ res.status(400);
+ }
+ res.end();
+};
+
+function validate(
+ handler: (
+ req: NextApiRequest,
+ res: NextApiResponse
+ ) => Promise
+) {
+ return async (req: NextApiRequest, res: NextApiResponse) => {
+ if (req.method === "POST" || req.method === "PUT") {
+ try {
+ vitalSettingsUpdateSchema.parse(req.body);
+ } catch (error) {
+ if (error instanceof ZodError && error?.name === "ZodError") {
+ return res.status(400).json(error?.issues);
+ }
+ return res.status(402);
+ }
+ } else {
+ return res.status(405);
+ }
+ await handler(req, res);
+ };
+}
+
+export default validate(handler);
diff --git a/packages/app-store/vital/api/settings.ts b/packages/app-store/vital/api/settings.ts
new file mode 100644
index 00000000..4e489a6c
--- /dev/null
+++ b/packages/app-store/vital/api/settings.ts
@@ -0,0 +1,29 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { JSONObject } from "superjson/dist/types";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method === "GET" && req.session && req.session.user.id) {
+ const userId = req.session.user.id;
+ try {
+ const user = await prisma?.user.findFirst({
+ select: {
+ metadata: true,
+ },
+ where: {
+ id: userId,
+ },
+ });
+
+ if (user && user.metadata && (user.metadata as JSONObject)?.vitalSettings) {
+ res.status(200).json((user.metadata as JSONObject).vitalSettings);
+ } else {
+ res.status(404);
+ }
+ } catch (error) {
+ res.status(500);
+ }
+ } else {
+ res.status(400);
+ }
+ res.end();
+}
diff --git a/packages/app-store/vital/api/sleep.create.payload.example b/packages/app-store/vital/api/sleep.create.payload.example
new file mode 100644
index 00000000..b87371d8
--- /dev/null
+++ b/packages/app-store/vital/api/sleep.create.payload.example
@@ -0,0 +1,23 @@
+https://docs.tryvital.io/summary-data#sleep-stream
+{
+ event: {
+ event_type: 'daily.data.sleep.created',
+ data: {
+ id: 'a27b89dc-4a43-4f59-b72a-267fa9a93c8c',
+ date: '1974-12-15T18:28:10+00:00',
+ bedtime_start: '1980-07-11T22:10:32+00:00',
+ bedtime_stop: '1993-03-03T04:21:02+00:00',
+ duration: 7225, // seconds
+ total: 6909, // seconds
+ awake: 7763,
+ light: 2011, // Total amount of light sleep registered during the sleep period
+ rem: 8106, // Total amount of REM sleep registered during the sleep period, minutes
+ deep: 6553,
+ hr_lowest: 9085,
+ efficiency: 1780,
+ latency: 8519,
+ source: [Object],
+ user_id: 'a1dfefe0-ccdb-46b8-adac-35e9ba597496'
+ }
+ }
+ }
\ No newline at end of file
diff --git a/packages/app-store/vital/api/token.ts b/packages/app-store/vital/api/token.ts
new file mode 100644
index 00000000..ae083b4c
--- /dev/null
+++ b/packages/app-store/vital/api/token.ts
@@ -0,0 +1,57 @@
+import { Prisma } from "@prisma/client";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import prisma from "@calcom/prisma";
+
+import { initVitalClient, vitalEnv } from "../lib/client";
+
+/**
+ * This is will generate a user token for a client_user_id`
+ * @param req
+ * @param res
+ */
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ // Get user id
+ const calcomUserId = req.session?.user?.id;
+ if (!calcomUserId) {
+ return res.status(401).json({ message: "You must be logged in to do this" });
+ }
+
+ const vitalClient = await initVitalClient();
+
+ if (!vitalClient || !vitalEnv)
+ return res.status(400).json({ message: "Missing vital client, try calling `initVitalClient`" });
+
+ // Create a user on vital
+ let userVital;
+ try {
+ userVital = await vitalClient.User.create(`cal_${calcomUserId}`);
+ } catch (e) {
+ userVital = await vitalClient.User.resolve(`cal_${calcomUserId}`);
+ }
+
+ try {
+ if (userVital?.user_id) {
+ await prisma.credential.create({
+ data: {
+ type: "vital_other",
+ key: { userVitalId: userVital.user_id } as unknown as Prisma.InputJsonObject,
+ userId: calcomUserId,
+ appId: "vital-automation",
+ },
+ });
+ }
+ const token = await vitalClient.Link.create(
+ userVital?.user_id,
+ undefined,
+ WEBAPP_URL + "/api/integrations/vital/callback"
+ );
+ return res.status(200).json({
+ token: token.link_token,
+ url: `https://link.tryvital.io/?env=${vitalEnv.mode}®ion=${vitalEnv.region}`,
+ });
+ } catch (e) {
+ return res.status(400).json({ error: JSON.stringify(e) });
+ }
+}
diff --git a/packages/app-store/vital/api/webhook.ts b/packages/app-store/vital/api/webhook.ts
new file mode 100644
index 00000000..992b21b9
--- /dev/null
+++ b/packages/app-store/vital/api/webhook.ts
@@ -0,0 +1,158 @@
+import { BookingStatus, Prisma } from "@prisma/client";
+import dayjs from "dayjs";
+import type { NextApiRequest, NextApiResponse } from "next";
+import queue from "queue";
+
+import { IS_PRODUCTION } from "@calcom/lib/constants";
+import { getErrorFromUnknown } from "@calcom/lib/errors";
+import { HttpError as HttpCode } from "@calcom/lib/http-error";
+import logger from "@calcom/lib/logger";
+import prisma from "@calcom/prisma";
+
+import { Reschedule } from "../lib";
+import { initVitalClient, vitalEnv } from "../lib/client";
+
+// @Note: not being used anymore but left as example
+const getOuraSleepScore = async (user_id: string, bedtime_start: Date) => {
+ const vitalClient = await initVitalClient();
+ if (!vitalClient) throw Error("Missing vital client");
+ const sleep_data = await vitalClient.Sleep.get_raw(user_id, bedtime_start, undefined, "oura");
+ if (sleep_data.sleep.length === 0) {
+ throw Error("No sleep score found");
+ }
+ return +sleep_data.sleep[0].data.score;
+};
+
+/**
+ * This is will generate a user token for a client_user_id`
+ * @param req
+ * @param res
+ */
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ if (req.method !== "POST") {
+ throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
+ }
+ const sig = req.headers["svix-signature"];
+ if (!sig) {
+ throw new HttpCode({ statusCode: 400, message: "Missing svix-signature" });
+ }
+
+ const vitalClient = await initVitalClient();
+
+ if (!vitalClient || !vitalEnv)
+ return res.status(400).json({ message: "Missing vital client, try calling `initVitalClient`" });
+
+ const payload = JSON.stringify(req.body);
+
+ const event: any = vitalClient.Webhooks.constructWebhookEvent(
+ payload,
+ req.headers as Record,
+ vitalEnv.webhook_secret as string
+ );
+
+ if (event.event_type == "daily.data.sleep.created") {
+ // Carry out logic here to determine what to do if sleep is less
+ // than 8 hours or readiness score is less than 70
+ try {
+ if (event.data.user_id) {
+ const json = { userVitalId: event.data.user_id as string };
+ const credential = await prisma.credential.findFirst({
+ rejectOnNotFound: true,
+ where: {
+ type: "vital_other",
+ key: {
+ equals: json,
+ },
+ },
+ });
+ if (!credential) {
+ return res.status(404).json({ message: "Missing vital credential" });
+ }
+
+ // Getting total hours of sleep seconds/60/60 = hours
+ const userWithMetadata = await prisma.user.findFirst({
+ select: {
+ metadata: true,
+ },
+ where: {
+ id: credential.userId as number,
+ },
+ });
+ let minimumSleepTime = 0;
+ let parameterFilter = "";
+ const userMetadata = userWithMetadata?.metadata as Prisma.JsonObject;
+ const vitalSettings =
+ ((userWithMetadata?.metadata as Prisma.JsonObject)?.vitalSettings as Prisma.JsonObject) || {};
+ if (!!userMetadata && !!vitalSettings) {
+ minimumSleepTime = vitalSettings.sleepValue as number;
+ parameterFilter = vitalSettings.parameter as string;
+ } else {
+ res.status(404).json({ message: "Vital configuration not found for user" });
+ return;
+ }
+
+ if (!event.data.hasOwnProperty(parameterFilter)) {
+ res.status(500).json({ message: "Selected param not available" });
+ return;
+ }
+ const totalHoursSleep = event.data[parameterFilter] / 60 / 60;
+
+ if (minimumSleepTime > 0 && parameterFilter !== "" && totalHoursSleep <= minimumSleepTime) {
+ // Trigger reschedule
+ try {
+ const todayDate = dayjs();
+ const todayBookings = await prisma.booking.findMany({
+ where: {
+ startTime: {
+ gte: todayDate.startOf("day").toISOString(),
+ },
+ endTime: {
+ lte: todayDate.endOf("day").toISOString(),
+ },
+ status: {
+ in: [BookingStatus.ACCEPTED, BookingStatus.PENDING],
+ },
+ // @NOTE: very important filter
+ userId: credential?.userId,
+ },
+ select: {
+ id: true,
+ uid: true,
+ userId: true,
+ status: true,
+ },
+ });
+
+ const q = queue({ results: [] });
+ if (todayBookings.length > 0) {
+ todayBookings.forEach((booking) =>
+ q.push(() => {
+ return Reschedule(booking.uid, "");
+ })
+ );
+ }
+ await q.start();
+ } catch (error) {
+ throw new Error("Failed to reschedule bookings");
+ }
+ }
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error(error.message);
+ }
+ logger.error("Failed to get sleep score");
+ }
+ }
+ return res.status(200).json({ body: req.body });
+ } catch (_err) {
+ const err = getErrorFromUnknown(_err);
+ console.error(`Webhook Error: ${err.message}`);
+ res.status(err.statusCode ?? 500).send({
+ message: err.message,
+ stack: IS_PRODUCTION ? undefined : err.stack,
+ });
+ return;
+ }
+}
diff --git a/packages/app-store/vital/components/AppConfiguration.tsx b/packages/app-store/vital/components/AppConfiguration.tsx
new file mode 100644
index 00000000..c1a9ca1b
--- /dev/null
+++ b/packages/app-store/vital/components/AppConfiguration.tsx
@@ -0,0 +1,177 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import classNames from "@calcom/lib/classNames";
+import showToast from "@calcom/lib/notification";
+import { Button, Select } from "@calcom/ui";
+
+export interface IAppConfigurationProps {
+ credentialIds: number[];
+}
+
+const saveSettings = async ({
+ parameter,
+ sleepValue,
+}: {
+ parameter: { label: string; value: string };
+ sleepValue: number;
+}) => {
+ try {
+ const response = await fetch("/api/integrations/vital/save", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sleepValue,
+ parameter: parameter.value,
+ }),
+ });
+ if (response.ok && response.status === 200) {
+ return true;
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ console.error(error.message);
+ }
+ }
+};
+
+const AppConfiguration = (props: IAppConfigurationProps) => {
+ const { t } = useTranslation();
+ const [credentialId] = props.credentialIds;
+
+ const options = [
+ {
+ label: t("vital_app_total_label", { ns: "vital" }),
+ value: "total",
+ },
+ {
+ label: t("vital_app_duration_label", { ns: "vital" }),
+ value: "duration",
+ },
+ ];
+ const [selectedParam, setSelectedParam] = useState<{ label: string; value: string }>(options[0]);
+ const [touchedForm, setTouchedForm] = useState(false);
+ const defaultSleepValue = 0;
+ const [sleepValue, setSleepValue] = useState(defaultSleepValue);
+ const [connected, setConnected] = useState(false);
+ const [saveLoading, setSaveLoading] = useState(false);
+ useEffect(() => {
+ async function getVitalsConfig() {
+ const response = await fetch("/api/integrations/vital/settings", {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ if (response.status === 200) {
+ const vitalSettings: {
+ connected: boolean;
+ parameter: string;
+ sleepValue: number;
+ } = await response.json();
+
+ if (vitalSettings && vitalSettings.connected) {
+ setConnected(vitalSettings.connected);
+ }
+ if (vitalSettings.sleepValue && vitalSettings.parameter) {
+ const selectedParam = options.find((item) => item.value === vitalSettings.parameter);
+ if (selectedParam) {
+ setSelectedParam(selectedParam);
+ }
+ setSleepValue(vitalSettings.sleepValue);
+ }
+ }
+ }
+ getVitalsConfig();
+ }, []);
+
+ if (!credentialId) {
+ return <>>;
+ }
+
+ const disabledSaveButton = !touchedForm || sleepValue === 0;
+ return (
+
+
+
+ {t("connected_vital_app", { ns: "vital" })} Vital App: {connected ? "Yes" : "No"}
+
+
+
+
+ {t("vital_app_sleep_automation", { ns: "vital" })}
+
+
{t("vital_app_automation_description", { ns: "vital" })}
+
+
+
+
+
+ {t("vital_app_parameter", { ns: "vital" })}
+
+
+
+ {
+ e && setSelectedParam(e);
+ setTouchedForm(true);
+ }}
+ />
+
+
+
+
+
+
+
+ {t("vital_app_trigger", { ns: "vital" })}
+
+
+
+
{
+ setSleepValue(Number(e.currentTarget.value));
+ setTouchedForm(true);
+ }}
+ className={
+ "pr-12shadow-sm mt-1 block w-full rounded-sm border border-gray-300 py-2 pl-6 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
+ }
+ />
+
+ {t("vital_app_hours", { ns: "vital" })}
+
+
+
+
+
+ {
+ try {
+ setSaveLoading(true);
+ await saveSettings({ parameter: selectedParam, sleepValue: sleepValue });
+ showToast(t("vital_app_save_success"), "success");
+ } catch (error) {
+ showToast(t("vital_app_save_error"), "error");
+ setSaveLoading(false);
+ }
+ setTouchedForm(false);
+ setSaveLoading(false);
+ }}
+ loading={saveLoading}
+ disabled={disabledSaveButton}>
+ {t("vital_app_save_button", { ns: "vital" })}
+
+
+
+ );
+};
+
+export default AppConfiguration;
diff --git a/packages/app-store/vital/components/InstallAppButton.tsx b/packages/app-store/vital/components/InstallAppButton.tsx
new file mode 100644
index 00000000..0c29769f
--- /dev/null
+++ b/packages/app-store/vital/components/InstallAppButton.tsx
@@ -0,0 +1,39 @@
+import { useState } from "react";
+
+import { InstallAppButtonProps } from "../../types";
+
+export default function InstallAppButton(props: InstallAppButtonProps) {
+ const getLinkToken = async () => {
+ const res = await fetch("/api/integrations/vital/token", {
+ method: "POST",
+ body: JSON.stringify({}),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ if (!res.ok) {
+ throw new Error("Failed to get link token");
+ }
+ return await res.json();
+ };
+ const [loading, setLoading] = useState(false);
+ return (
+ <>
+ {props.render({
+ onClick() {
+ setLoading(true);
+ getLinkToken()
+ .then((data) => {
+ setLoading(false);
+ window.open(`${data.url}&token=${data.token}`, "_self");
+ })
+ .catch((error) => {
+ setLoading(false);
+ console.error(error);
+ });
+ },
+ loading: loading,
+ })}
+ >
+ );
+}
diff --git a/packages/app-store/vital/components/index.ts b/packages/app-store/vital/components/index.ts
new file mode 100644
index 00000000..dcb4515b
--- /dev/null
+++ b/packages/app-store/vital/components/index.ts
@@ -0,0 +1,2 @@
+export { default as AppConfiguration } from "./AppConfiguration";
+export { default as InstallAppButton } from "./InstallAppButton";
diff --git a/packages/app-store/vital/index.ts b/packages/app-store/vital/index.ts
new file mode 100644
index 00000000..8160a6e1
--- /dev/null
+++ b/packages/app-store/vital/index.ts
@@ -0,0 +1,4 @@
+export * as api from "./api";
+export * as lib from "./lib";
+export * as components from "./components";
+export { metadata } from "./_metadata";
diff --git a/packages/app-store/vital/lib/client.ts b/packages/app-store/vital/lib/client.ts
new file mode 100644
index 00000000..0196a8ec
--- /dev/null
+++ b/packages/app-store/vital/lib/client.ts
@@ -0,0 +1,34 @@
+import { VitalClient } from "@tryvital/vital-node";
+import type { ClientConfig } from "@tryvital/vital-node/dist/lib/models";
+
+import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
+
+type VitalEnv = ClientConfig & {
+ mode: string;
+ webhook_secret: string;
+};
+
+export let vitalClient: VitalClient | null = null;
+export let vitalEnv: VitalEnv | null = null;
+
+export async function initVitalClient(): Promise {
+ if (vitalClient) return vitalClient;
+ const appKeys = (await getAppKeysFromSlug("vital-automation")) as unknown as VitalEnv;
+ if (
+ typeof appKeys !== "object" ||
+ typeof appKeys.api_key !== "string" ||
+ typeof appKeys.webhook_secret !== "string" ||
+ typeof appKeys.region !== "string" ||
+ typeof appKeys.mode !== "string"
+ )
+ throw Error("Missing properties in vital-automation DB keys");
+ vitalEnv = appKeys;
+ vitalClient = new VitalClient({
+ region: appKeys.region,
+ api_key: appKeys.api_key || "",
+ environment: (appKeys.mode as ClientConfig["environment"]) || "sandbox",
+ });
+ return vitalClient;
+}
+
+export default vitalClient;
diff --git a/packages/app-store/vital/lib/emailManager.ts b/packages/app-store/vital/lib/emailManager.ts
new file mode 100644
index 00000000..abf2f520
--- /dev/null
+++ b/packages/app-store/vital/lib/emailManager.ts
@@ -0,0 +1,35 @@
+import { CalendarEvent } from "@calcom/types/Calendar";
+
+import AttendeeRequestRescheduledEmail from "./templates/attendee-request-reschedule-email";
+import OrganizerRequestRescheduledEmail from "./templates/organizer-request-reschedule-email";
+
+export const sendRequestRescheduleEmail = async (
+ calEvent: CalendarEvent,
+ metadata: { rescheduleLink: string }
+) => {
+ const emailsToSend: Promise[] = [];
+
+ emailsToSend.push(
+ new Promise((resolve, reject) => {
+ try {
+ const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(calEvent, metadata);
+ resolve(requestRescheduleEmail.sendEmail());
+ } catch (e) {
+ reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
+ }
+ })
+ );
+
+ emailsToSend.push(
+ new Promise((resolve, reject) => {
+ try {
+ const requestRescheduleEmail = new OrganizerRequestRescheduledEmail(calEvent, metadata);
+ resolve(requestRescheduleEmail.sendEmail());
+ } catch (e) {
+ reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));
+ }
+ })
+ );
+
+ await Promise.all(emailsToSend);
+};
diff --git a/packages/app-store/vital/lib/emailServerConfig.ts b/packages/app-store/vital/lib/emailServerConfig.ts
new file mode 100644
index 00000000..a51d0c7e
--- /dev/null
+++ b/packages/app-store/vital/lib/emailServerConfig.ts
@@ -0,0 +1,34 @@
+import SendmailTransport from "nodemailer/lib/sendmail-transport";
+import SMTPConnection from "nodemailer/lib/smtp-connection";
+
+function detectTransport(): SendmailTransport.Options | SMTPConnection.Options | string {
+ if (process.env.EMAIL_SERVER) {
+ return process.env.EMAIL_SERVER;
+ }
+
+ if (process.env.EMAIL_SERVER_HOST) {
+ const port = parseInt(process.env.EMAIL_SERVER_PORT!);
+ const transport = {
+ host: process.env.EMAIL_SERVER_HOST,
+ port,
+ auth: {
+ user: process.env.EMAIL_SERVER_USER,
+ pass: process.env.EMAIL_SERVER_PASSWORD,
+ },
+ secure: port === 465,
+ };
+
+ return transport;
+ }
+
+ return {
+ sendmail: true,
+ newline: "unix",
+ path: "/usr/sbin/sendmail",
+ };
+}
+
+export const serverConfig = {
+ transport: detectTransport(),
+ from: process.env.EMAIL_FROM,
+};
diff --git a/packages/app-store/vital/lib/index.ts b/packages/app-store/vital/lib/index.ts
new file mode 100644
index 00000000..7eac0f92
--- /dev/null
+++ b/packages/app-store/vital/lib/index.ts
@@ -0,0 +1 @@
+export { default as Reschedule } from "./reschedule";
diff --git a/packages/app-store/vital/lib/reschedule.ts b/packages/app-store/vital/lib/reschedule.ts
new file mode 100644
index 00000000..5602e548
--- /dev/null
+++ b/packages/app-store/vital/lib/reschedule.ts
@@ -0,0 +1,170 @@
+import { BookingStatus, User, Booking, BookingReference } from "@prisma/client";
+import dayjs from "dayjs";
+import type { TFunction } from "next-i18next";
+
+import EventManager from "@calcom/core/EventManager";
+import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
+import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
+import { deleteMeeting } from "@calcom/core/videoClient";
+import logger from "@calcom/lib/logger";
+import { getTranslation } from "@calcom/lib/server/i18n";
+import prisma from "@calcom/prisma";
+import { Person } from "@calcom/types/Calendar";
+
+import { getCalendar } from "../../_utils/getCalendar";
+import { sendRequestRescheduleEmail } from "./emailManager";
+
+type PersonAttendeeCommonFields = Pick;
+
+const Reschedule = async (bookingUid: string, cancellationReason: string) => {
+ const bookingToReschedule = await prisma.booking.findFirst({
+ select: {
+ id: true,
+ uid: true,
+ title: true,
+ startTime: true,
+ endTime: true,
+ userId: true,
+ eventTypeId: true,
+ location: true,
+ attendees: true,
+ references: true,
+ user: {
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ timeZone: true,
+ locale: true,
+ username: true,
+ credentials: true,
+ destinationCalendar: true,
+ },
+ },
+ },
+ rejectOnNotFound: true,
+ where: {
+ uid: bookingUid,
+ NOT: {
+ status: {
+ in: [BookingStatus.CANCELLED, BookingStatus.REJECTED],
+ },
+ },
+ },
+ });
+
+ if (bookingToReschedule && bookingToReschedule.eventTypeId && bookingToReschedule.user) {
+ const userOwner = bookingToReschedule.user;
+ const event = await prisma.eventType.findFirst({
+ select: {
+ title: true,
+ users: true,
+ schedulingType: true,
+ },
+ rejectOnNotFound: true,
+ where: {
+ id: bookingToReschedule.eventTypeId,
+ },
+ });
+ await prisma.booking.update({
+ where: {
+ id: bookingToReschedule.id,
+ },
+ data: {
+ rescheduled: true,
+ cancellationReason,
+ status: BookingStatus.CANCELLED,
+ updatedAt: dayjs().toISOString(),
+ },
+ });
+ const [mainAttendee] = bookingToReschedule.attendees;
+ // @NOTE: Should we assume attendees language?
+ const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common");
+ const usersToPeopleType = (
+ users: PersonAttendeeCommonFields[],
+ selectedLanguage: TFunction
+ ): Person[] => {
+ return users?.map((user) => {
+ return {
+ email: user.email || "",
+ name: user.name || "",
+ username: user?.username || "",
+ language: { translate: selectedLanguage, locale: user.locale || "en" },
+ timeZone: user?.timeZone,
+ };
+ });
+ };
+ const userOwnerTranslation = await getTranslation(userOwner.locale ?? "en", "common");
+ const [userOwnerAsPeopleType] = usersToPeopleType([userOwner], userOwnerTranslation);
+ const builder = new CalendarEventBuilder();
+ builder.init({
+ title: bookingToReschedule.title,
+ type: event.title,
+ startTime: bookingToReschedule.startTime.toISOString(),
+ endTime: bookingToReschedule.endTime.toISOString(),
+ attendees: usersToPeopleType(
+ // username field doesn't exists on attendee but could be in the future
+ bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
+ tAttendees
+ ),
+ organizer: userOwnerAsPeopleType,
+ });
+ const director = new CalendarEventDirector();
+ director.setBuilder(builder);
+ director.setExistingBooking(bookingToReschedule as unknown as Booking);
+ director.setCancellationReason(cancellationReason);
+ await director.buildForRescheduleEmail();
+ // Handling calendar and videos cancellation
+ // This can set previous time as available, until virtual calendar is done
+ const credentialsMap = new Map();
+ userOwner.credentials.forEach((credential) => {
+ credentialsMap.set(credential.type, credential);
+ });
+ const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter(
+ (ref) => !!credentialsMap.get(ref.type)
+ );
+ try {
+ bookingRefsFiltered.forEach((bookingRef) => {
+ if (bookingRef.uid) {
+ if (bookingRef.type.endsWith("_calendar")) {
+ const calendar = getCalendar(credentialsMap.get(bookingRef.type));
+ return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
+ } else if (bookingRef.type.endsWith("_video")) {
+ return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
+ }
+ }
+ });
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error(error.message);
+ }
+ }
+ // Creating cancelled event as placeholders in calendars, remove when virtual calendar handles it
+ try {
+ const eventManager = new EventManager({
+ credentials: userOwner.credentials,
+ destinationCalendar: userOwner.destinationCalendar,
+ });
+ builder.calendarEvent.title = `Cancelled: ${builder.calendarEvent.title}`;
+ await eventManager.updateAndSetCancelledPlaceholder(builder.calendarEvent, bookingToReschedule);
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error(error.message);
+ }
+ }
+
+ // Send emails
+ try {
+ await sendRequestRescheduleEmail(builder.calendarEvent, {
+ rescheduleLink: builder.rescheduleLink,
+ });
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error(error.message);
+ }
+ }
+ return true;
+ }
+};
+
+export default Reschedule;
diff --git a/packages/app-store/vital/lib/templates/attendee-request-reschedule-email.ts b/packages/app-store/vital/lib/templates/attendee-request-reschedule-email.ts
new file mode 100644
index 00000000..6052ef19
--- /dev/null
+++ b/packages/app-store/vital/lib/templates/attendee-request-reschedule-email.ts
@@ -0,0 +1,208 @@
+import dayjs from "dayjs";
+import localizedFormat from "dayjs/plugin/localizedFormat";
+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 { getCancelLink } from "@calcom/lib/CalEventParser";
+import { CalendarEvent } from "@calcom/types/Calendar";
+
+import BaseTemplate from "./base-template";
+import {
+ emailHead,
+ emailSchedulingBodyHeader,
+ emailBodyLogo,
+ emailScheduledBodyHeaderContent,
+ emailSchedulingBodyDivider,
+} from "./common";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(localizedFormat);
+dayjs.extend(toArray);
+
+export default class AttendeeRequestRescheduledEmail extends BaseTemplate {
+ private metadata: { rescheduleLink: string };
+ constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
+ super(calEvent);
+ this.metadata = metadata;
+ }
+ protected getNodeMailerPayload(): Record {
+ const toAddresses = [this.calEvent.attendees[0].email];
+
+ return {
+ icalEvent: {
+ filename: "event.ics",
+ content: this.getiCalEventAsString(),
+ },
+ from: `Cal.com <${this.getMailerOptions().from}>`,
+ to: toAddresses.join(","),
+ subject: `${this.calEvent.organizer.language.translate("requested_to_reschedule_subject_attendee", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ })}`,
+ html: this.getHtmlBody(),
+ text: this.getTextBody(),
+ };
+ }
+
+ // @OVERRIDE
+ protected getiCalEventAsString(): string | undefined {
+ const icsEvent = createEvent({
+ start: dayjs(this.calEvent.startTime)
+ .utc()
+ .toArray()
+ .slice(0, 6)
+ .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
+ startInputType: "utc",
+ productId: "calendso/ics",
+ title: this.calEvent.organizer.language.translate("ics_event_title", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ }),
+ description: this.getTextBody(),
+ duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
+ organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
+ attendees: this.calEvent.attendees.map((attendee: Person) => ({
+ name: attendee.name,
+ email: attendee.email,
+ })),
+ status: "CANCELLED",
+ method: "CANCEL",
+ });
+ if (icsEvent.error) {
+ throw icsEvent.error;
+ }
+ return icsEvent.value;
+ }
+ // @OVERRIDE
+ protected getWhen(): string {
+ return `
+
+
+
${this.calEvent.organizer.language.translate("when")}
+
+ ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format(
+ "YYYY"
+ )} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )} (${this.getTimezone()})
+
+
`;
+ }
+
+ protected getTextBody(): string {
+ return `
+${this.calEvent.organizer.language.translate("request_reschedule_title_attendee")}
+${this.calEvent.organizer.language.translate("request_reschedule_subtitle", {
+ organizer: this.calEvent.organizer.name,
+})},
+${this.getWhat()}
+${this.getWhen()}
+${this.getAdditionalNotes()}
+${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")}
+${getCancelLink(this.calEvent)}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("calendarCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.organizer.language.translate("request_reschedule_title_attendee"),
+ this.calEvent.organizer.language.translate("request_reschedule_subtitle", {
+ organizer: this.calEvent.organizer.name,
+ })
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getAdditionalNotes()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailSchedulingBodyDivider()}
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+}
diff --git a/packages/app-store/vital/lib/templates/base-template.ts b/packages/app-store/vital/lib/templates/base-template.ts
new file mode 100644
index 00000000..cf0479bc
--- /dev/null
+++ b/packages/app-store/vital/lib/templates/base-template.ts
@@ -0,0 +1,409 @@
+import dayjs, { Dayjs } from "dayjs";
+import localizedFormat from "dayjs/plugin/localizedFormat";
+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 { getAppName } from "@calcom/app-store/utils";
+import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
+import { getErrorFromUnknown } from "@calcom/lib/errors";
+import type { CalendarEvent } from "@calcom/types/Calendar";
+
+import { serverConfig } from "../emailServerConfig";
+import {
+ emailHead,
+ emailSchedulingBodyHeader,
+ emailBodyLogo,
+ emailScheduledBodyHeaderContent,
+ emailSchedulingBodyDivider,
+ linkIcon,
+} from "./common";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(localizedFormat);
+dayjs.extend(toArray);
+
+export default class OrganizerScheduledEmail {
+ calEvent: CalendarEvent;
+
+ constructor(calEvent: CalendarEvent) {
+ this.calEvent = calEvent;
+ }
+
+ 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 {
+ const icsEvent = createEvent({
+ start: dayjs(this.calEvent.startTime)
+ .utc()
+ .toArray()
+ .slice(0, 6)
+ .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
+ startInputType: "utc",
+ productId: "calendso/ics",
+ title: this.calEvent.organizer.language.translate("ics_event_title", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ }),
+ description: this.getTextBody(),
+ duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
+ organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
+ attendees: this.calEvent.attendees.map((attendee: Person) => ({
+ name: attendee.name,
+ email: attendee.email,
+ })),
+ status: "CONFIRMED",
+ });
+ if (icsEvent.error) {
+ throw icsEvent.error;
+ }
+ return icsEvent.value;
+ }
+
+ protected getNodeMailerPayload(): Record {
+ const toAddresses = [this.calEvent.organizer.email];
+ if (this.calEvent.team) {
+ this.calEvent.team.members.forEach((member) => {
+ const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
+ if (memberAttendee) {
+ toAddresses.push(memberAttendee.email);
+ }
+ });
+ }
+
+ return {
+ icalEvent: {
+ filename: "event.ics",
+ content: this.getiCalEventAsString(),
+ },
+ from: `Cal.com <${this.getMailerOptions().from}>`,
+ to: toAddresses.join(","),
+ subject: `${this.calEvent.organizer.language.translate("confirmed_event_type_subject", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ })}`,
+ html: this.getHtmlBody(),
+ text: this.getTextBody(),
+ };
+ }
+
+ protected getMailerOptions() {
+ return {
+ transport: serverConfig.transport,
+ from: serverConfig.from,
+ };
+ }
+
+ protected getTextBody(): string {
+ return `
+${this.calEvent.organizer.language.translate("new_event_scheduled")}
+${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")}
+
+${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,
+ name: this.calEvent.attendees[0].name,
+ date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("checkCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.organizer.language.translate("new_event_scheduled"),
+ this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getDescription()}
+ ${this.getAdditionalNotes()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getManageLink()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+
+ protected getManageLink(): string {
+ const manageText = this.calEvent.organizer.language.translate("manage_this_event");
+ return `${this.calEvent.organizer.language.translate(
+ "need_to_reschedule_or_cancel"
+ )}
${manageText}
`;
+ }
+
+ protected getWhat(): string {
+ return `
+
+
${this.calEvent.organizer.language.translate("what")}
+
${this.calEvent.type}
+
`;
+ }
+
+ protected getWhen(): string {
+ return `
+
+
+
${this.calEvent.organizer.language.translate("when")}
+
+ ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format(
+ "YYYY"
+ )} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )} (${this.getTimezone()})
+
+
`;
+ }
+
+ protected getWho(): string {
+ const attendees = this.calEvent.attendees
+ .map((attendee) => {
+ return ``;
+ })
+ .join("");
+
+ const organizer = ``;
+
+ return `
+
+
+
${this.calEvent.organizer.language.translate("who")}
+ ${organizer + attendees}
+
`;
+ }
+
+ protected getAdditionalNotes(): string {
+ if (!this.calEvent.additionalNotes) return "";
+ return `
+
+
+
${this.calEvent.organizer.language.translate("additional_notes")}
+
${
+ this.calEvent.additionalNotes
+ }
+
+ `;
+ }
+
+ protected getDescription(): string {
+ if (!this.calEvent.description) return "";
+ return `
+
+
+
${this.calEvent.organizer.language.translate("description")}
+
${
+ this.calEvent.description
+ }
+
+ `;
+ }
+
+ protected getLocation(): string {
+ let providerName = this.calEvent.location ? getAppName(this.calEvent.location) : "";
+
+ if (this.calEvent.location && this.calEvent.location.includes("integrations:")) {
+ const location = this.calEvent.location.split(":")[1];
+ providerName = location[0].toUpperCase() + location.slice(1);
+ }
+
+ // If location its a url, probably we should be validating it with a custom library
+ if (this.calEvent.location && /^https?:\/\//.test(this.calEvent.location)) {
+ providerName = this.calEvent.location;
+ }
+
+ if (this.calEvent.videoCallData) {
+ const meetingId = this.calEvent.videoCallData.id;
+ const meetingPassword = this.calEvent.videoCallData.password;
+ const meetingUrl = this.calEvent.videoCallData.url;
+
+ return `
+
+
+
${this.calEvent.organizer.language.translate("where")}
+
${providerName} ${
+ meetingUrl &&
+ ` `
+ }
+ ${
+ meetingId &&
+ `
${this.calEvent.organizer.language.translate(
+ "meeting_id"
+ )}: ${meetingId}
`
+ }
+ ${
+ meetingPassword &&
+ `
${this.calEvent.organizer.language.translate(
+ "meeting_password"
+ )}: ${meetingPassword}
`
+ }
+ ${
+ meetingUrl &&
+ `
${this.calEvent.organizer.language.translate(
+ "meeting_url"
+ )}:
${meetingUrl} `
+ }
+
+ `;
+ }
+
+ if (this.calEvent.additionInformation?.hangoutLink) {
+ const hangoutLink: string = this.calEvent.additionInformation.hangoutLink;
+
+ return `
+
+
+
${this.calEvent.organizer.language.translate("where")}
+
${providerName} ${
+ hangoutLink &&
+ ` `
+ }
+
+
+ `;
+ }
+
+ return `
+
+
+
${this.calEvent.organizer.language.translate("where")}
+
${
+ providerName || this.calEvent.location
+ }
+
+ `;
+ }
+
+ protected getTimezone(): string {
+ return this.calEvent.organizer.timeZone;
+ }
+
+ protected getOrganizerStart(): Dayjs {
+ return dayjs(this.calEvent.startTime).tz(this.getTimezone());
+ }
+
+ protected getOrganizerEnd(): Dayjs {
+ return dayjs(this.calEvent.endTime).tz(this.getTimezone());
+ }
+}
diff --git a/packages/app-store/vital/lib/templates/common/body-logo.ts b/packages/app-store/vital/lib/templates/common/body-logo.ts
new file mode 100644
index 00000000..3b5b143e
--- /dev/null
+++ b/packages/app-store/vital/lib/templates/common/body-logo.ts
@@ -0,0 +1,44 @@
+import { IS_PRODUCTION, BASE_URL } from "@lib/config/constants";
+
+export const emailBodyLogo = (): string => {
+ const image = IS_PRODUCTION
+ ? BASE_URL + "/emails/CalLogo@2x.png"
+ : "https://app.cal.com/emails/CalLogo@2x.png";
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+};
diff --git a/packages/app-store/vital/lib/templates/common/head.ts b/packages/app-store/vital/lib/templates/common/head.ts
new file mode 100644
index 00000000..be224038
--- /dev/null
+++ b/packages/app-store/vital/lib/templates/common/head.ts
@@ -0,0 +1,91 @@
+export const emailHead = (headerContent: string): string => {
+ return `
+
+ ${headerContent}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+};
diff --git a/packages/app-store/vital/lib/templates/common/index.ts b/packages/app-store/vital/lib/templates/common/index.ts
new file mode 100644
index 00000000..686d871f
--- /dev/null
+++ b/packages/app-store/vital/lib/templates/common/index.ts
@@ -0,0 +1,6 @@
+export { emailHead } from "./head";
+export { emailSchedulingBodyHeader } from "./scheduling-body-head";
+export { emailBodyLogo } from "./body-logo";
+export { emailScheduledBodyHeaderContent } from "./scheduling-body-head-content";
+export { emailSchedulingBodyDivider } from "./scheduling-body-divider";
+export { linkIcon } from "./link-icon";
diff --git a/packages/app-store/vital/lib/templates/common/link-icon.ts b/packages/app-store/vital/lib/templates/common/link-icon.ts
new file mode 100644
index 00000000..434ae2cb
--- /dev/null
+++ b/packages/app-store/vital/lib/templates/common/link-icon.ts
@@ -0,0 +1,5 @@
+import { IS_PRODUCTION, BASE_URL } from "@lib/config/constants";
+
+export const linkIcon = (): string => {
+ return IS_PRODUCTION ? BASE_URL + "/emails/linkIcon.png" : "https://app.cal.com/emails/linkIcon.png";
+};
diff --git a/packages/app-store/vital/lib/templates/common/scheduling-body-divider.ts b/packages/app-store/vital/lib/templates/common/scheduling-body-divider.ts
new file mode 100644
index 00000000..b8723c3e
--- /dev/null
+++ b/packages/app-store/vital/lib/templates/common/scheduling-body-divider.ts
@@ -0,0 +1,31 @@
+export const emailSchedulingBodyDivider = (): string => {
+ return `
+
+
+ `;
+};
diff --git a/packages/app-store/vital/lib/templates/common/scheduling-body-head-content.ts b/packages/app-store/vital/lib/templates/common/scheduling-body-head-content.ts
new file mode 100644
index 00000000..31515fc7
--- /dev/null
+++ b/packages/app-store/vital/lib/templates/common/scheduling-body-head-content.ts
@@ -0,0 +1,33 @@
+export const emailScheduledBodyHeaderContent = (title: string, subtitle: string): string => {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+ ${title}
+
+
+
+
+ ${subtitle}
+
+
+
+
+
+
+
+
+
+
+
+ `;
+};
diff --git a/packages/app-store/vital/lib/templates/common/scheduling-body-head.ts b/packages/app-store/vital/lib/templates/common/scheduling-body-head.ts
new file mode 100644
index 00000000..fc715d2a
--- /dev/null
+++ b/packages/app-store/vital/lib/templates/common/scheduling-body-head.ts
@@ -0,0 +1,71 @@
+import { IS_PRODUCTION, BASE_URL } from "@lib/config/constants";
+
+export type BodyHeadType = "checkCircle" | "xCircle" | "calendarCircle";
+
+export const getHeadImage = (headerType: BodyHeadType): string => {
+ switch (headerType) {
+ case "checkCircle":
+ return IS_PRODUCTION
+ ? BASE_URL + "/emails/checkCircle@2x.png"
+ : "https://app.cal.com/emails/checkCircle@2x.png";
+ case "xCircle":
+ return IS_PRODUCTION
+ ? BASE_URL + "/emails/xCircle@2x.png"
+ : "https://app.cal.com/emails/xCircle@2x.png";
+ case "calendarCircle":
+ return IS_PRODUCTION
+ ? BASE_URL + "/emails/calendarCircle@2x.png"
+ : "https://app.cal.com/emails/calendarCircle@2x.png";
+ }
+};
+
+export const emailSchedulingBodyHeader = (headerType: BodyHeadType): string => {
+ const image = getHeadImage(headerType);
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+};
diff --git a/packages/app-store/vital/lib/templates/organizer-request-reschedule-email.ts b/packages/app-store/vital/lib/templates/organizer-request-reschedule-email.ts
new file mode 100644
index 00000000..716d47d3
--- /dev/null
+++ b/packages/app-store/vital/lib/templates/organizer-request-reschedule-email.ts
@@ -0,0 +1,188 @@
+import dayjs from "dayjs";
+import localizedFormat from "dayjs/plugin/localizedFormat";
+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 { getCancelLink } from "@calcom/lib/CalEventParser";
+import { CalendarEvent } from "@calcom/types/Calendar";
+
+import BaseTemplate from "./base-template";
+import {
+ emailHead,
+ emailSchedulingBodyHeader,
+ emailBodyLogo,
+ emailScheduledBodyHeaderContent,
+ emailSchedulingBodyDivider,
+} from "./common";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(localizedFormat);
+dayjs.extend(toArray);
+
+export default class OrganizerRequestRescheduledEmail extends BaseTemplate {
+ private metadata: { rescheduleLink: string };
+ constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
+ super(calEvent);
+ this.metadata = metadata;
+ }
+ protected getNodeMailerPayload(): Record {
+ const toAddresses = [this.calEvent.organizer.email];
+
+ return {
+ icalEvent: {
+ filename: "event.ics",
+ content: this.getiCalEventAsString(),
+ },
+ from: `Cal.com <${this.getMailerOptions().from}>`,
+ to: toAddresses.join(","),
+ subject: `${this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ })}`,
+ html: this.getHtmlBody(),
+ text: this.getTextBody(),
+ };
+ }
+
+ // @OVERRIDE
+ protected getiCalEventAsString(): string | undefined {
+ const icsEvent = createEvent({
+ start: dayjs(this.calEvent.startTime)
+ .utc()
+ .toArray()
+ .slice(0, 6)
+ .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
+ startInputType: "utc",
+ productId: "calendso/ics",
+ title: this.calEvent.organizer.language.translate("ics_event_title", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ }),
+ description: this.getTextBody(),
+ duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
+ organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
+ attendees: this.calEvent.attendees.map((attendee: Person) => ({
+ name: attendee.name,
+ email: attendee.email,
+ })),
+ status: "CANCELLED",
+ method: "CANCEL",
+ });
+ if (icsEvent.error) {
+ throw icsEvent.error;
+ }
+ return icsEvent.value;
+ }
+ // @OVERRIDE
+ protected getWhen(): string {
+ return `
+
+
+
${this.calEvent.organizer.language.translate("when")}
+
+ ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format(
+ "YYYY"
+ )} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )} (${this.getTimezone()})
+
+
`;
+ }
+
+ protected getTextBody(): string {
+ return `
+${this.calEvent.organizer.language.translate("request_reschedule_title_organizer", {
+ attendee: this.calEvent.attendees[0].name,
+})}
+${this.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
+ attendee: this.calEvent.attendees[0].name,
+})},
+${this.getWhat()}
+${this.getWhen()}
+${this.getLocation()}
+${this.getAdditionalNotes()}
+${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")}
+${getCancelLink(this.calEvent)}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("calendarCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.organizer.language.translate("request_reschedule_title_organizer", {
+ attendee: this.calEvent.attendees[0].name,
+ }),
+ this.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
+ attendee: this.calEvent.attendees[0].name,
+ })
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getAdditionalNotes()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+}
diff --git a/packages/app-store/vital/package.json b/packages/app-store/vital/package.json
new file mode 100644
index 00000000..f395909b
--- /dev/null
+++ b/packages/app-store/vital/package.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "private": true,
+ "name": "@calcom/vital",
+ "version": "0.1.0",
+ "main": "./index.ts",
+ "description": "Connect your health data or wearables to trigger actions on your calendar.",
+ "dependencies": {
+ "@calcom/prisma": "*",
+ "@tryvital/vital-node": "^1.3.6",
+ "queue": "^6.0.2"
+ },
+ "devDependencies": {
+ "@calcom/types": "*"
+ }
+}
diff --git a/packages/app-store/vital/static/icon.svg b/packages/app-store/vital/static/icon.svg
new file mode 100644
index 00000000..de067c51
--- /dev/null
+++ b/packages/app-store/vital/static/icon.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/packages/app-store/wipemycalother/components/wipeMyCalActionButton.tsx b/packages/app-store/wipemycalother/components/wipeMyCalActionButton.tsx
index 68ab7edf..aa50dd02 100644
--- a/packages/app-store/wipemycalother/components/wipeMyCalActionButton.tsx
+++ b/packages/app-store/wipemycalother/components/wipeMyCalActionButton.tsx
@@ -7,7 +7,7 @@ import { ConfirmDialog } from "./confirmDialog";
interface IWipeMyCalActionButtonProps {
trpc: any;
bookingsEmpty: boolean;
- bookingStatus: "upcoming" | "past" | "cancelled";
+ bookingStatus: "upcoming" | "recurring" | "past" | "cancelled";
}
const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
diff --git a/packages/app-store/zapier/README.md b/packages/app-store/zapier/README.md
new file mode 100644
index 00000000..f00a3752
--- /dev/null
+++ b/packages/app-store/zapier/README.md
@@ -0,0 +1,71 @@
+
+
+
+# Setting up Zapier Integration
+
+If you run it on localhost, check out the [additional information](https://github.com/CarinaWolli/cal.com/edit/feat/zapier-app/packages/app-store/zapier/README.md#localhost) below.
+
+1. Create [Zapier Account](https://zapier.com/sign-up?next=https%3A%2F%2Fdeveloper.zapier.com%2F)
+2. If not redirected to developer account, go to: [Zapier Developer Account](https://developer.zapier.com)
+3. Click **Start a Zapier Integration**
+4. Create Integration
+ - Name: Cal.com
+ - Description: Cal.com is a scheduling infrastructure for absolutely everyone.
+ - Intended Audience: Private
+ - Role: choose whatever is appropriate
+ - Category: Calendar
+
+## Authentication
+
+1. Go to Authentication, choose Api key and click save
+2. Click Add Fields
+ - Key: apiKey
+ - Check the box ‘is this field required?’
+3. Configure a Test
+ - Test: GET ``````/api/integrations/zapier/listBookings
+ - URL Params
+ - apiKey: {{bundle.authData.apiKey}}
+4. Test your authentication —> First you have to install Zapier in the Cal.com App Store and generate an API key, use this API key to test your authentication (only zapier Api key works)
+
+## Triggers
+
+Booking created, Booking rescheduled, Booking cancelled
+
+### Booking created
+
+1. Settings
+ - Key: booking_created
+ - Name: Booking created
+ - Noun: Booking
+ - Description: Triggers when a new booking is created
+2. API Configuration (apiKey is set automatically, leave it like it is):
+ - Trigger Type: REST Hook
+ - Subscribe: POST ``````/api/integrations/zapier/addSubscription
+ - Request Body
+ - subscriberUrl: {{bundle.targetUrl}}
+ - triggerEvent: BOOKING_CREATED
+ - Unsubscribe: DELETE ``````/api/integrations/zapier/deleteSubscription
+ - URL Params (in addition to apiKey)
+ - id: {{bundle.subscribeData.id}}
+ - PerformList: GET ``````/api/integrations/zapier/listBookings
+3. Test your API request
+
+Create the other two triggers (booking rescheduled, booking cancelled) exactly like this one, just use the appropriate naming (e.g. booking_rescheduled instead of booking_created)
+
+### Testing integration
+
+Use the sharing link under Manage → Sharing to create your first Cal.com trigger in Zapier
+
+## Localhost
+
+Localhost urls can not be used as the base URL for api endpoints
+
+Possible solution: using [https://ngrok.com/](https://ngrok.com/)
+
+1. Create Account
+2. [Download](https://ngrok.com/download) gnork and start a tunnel to your running localhost
+ - Use forwarding url as your baseUrl for the URL endpoints
diff --git a/packages/app-store/zapier/README.mdx b/packages/app-store/zapier/README.mdx
new file mode 100644
index 00000000..52d0240a
--- /dev/null
+++ b/packages/app-store/zapier/README.mdx
@@ -0,0 +1,6 @@
+Workflow automation for everyone. Use the Cal.com Zapier app to trigger your workflows when a booking is created, rescheduled or cancelled.
+
+
+**After Installation:** You lost your generated API key? Here you can generate a new key and find all information
+on how to use the installed app: Zapier App Setup
+
diff --git a/packages/app-store/zapier/_metadata.ts b/packages/app-store/zapier/_metadata.ts
new file mode 100644
index 00000000..6273c2d1
--- /dev/null
+++ b/packages/app-store/zapier/_metadata.ts
@@ -0,0 +1,25 @@
+import type { App } from "@calcom/types/App";
+
+import _package from "./package.json";
+
+export const metadata = {
+ name: "Zapier",
+ description: _package.description,
+ installed: true,
+ category: "other",
+ imageSrc: "/api/app-store/zapier/icon.svg",
+ logo: "/api/app-store/zapier/icon.svg",
+ publisher: "Cal.com",
+ rating: 0,
+ reviews: 0,
+ slug: "zapier",
+ title: "Zapier",
+ trending: true,
+ type: "zapier_other",
+ url: "https://cal.com/apps/zapier",
+ variant: "other",
+ verified: true,
+ email: "help@cal.com",
+} as App;
+
+export default metadata;
diff --git a/packages/app-store/zapier/api/add.ts b/packages/app-store/zapier/api/add.ts
new file mode 100644
index 00000000..0c01b593
--- /dev/null
+++ b/packages/app-store/zapier/api/add.ts
@@ -0,0 +1,39 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import prisma from "@calcom/prisma";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (!req.session?.user?.id) {
+ return res.status(401).json({ message: "You must be logged in to do this" });
+ }
+ const appType = "zapier_other";
+ try {
+ const alreadyInstalled = await prisma.credential.findFirst({
+ where: {
+ type: appType,
+ userId: req.session.user.id,
+ },
+ });
+ if (alreadyInstalled) {
+ throw new Error("Already installed");
+ }
+ const installation = await prisma.credential.create({
+ data: {
+ type: appType,
+ key: {},
+ userId: req.session.user.id,
+ appId: "zapier",
+ },
+ });
+ if (!installation) {
+ throw new Error("Unable to create user credential for zapier");
+ }
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ return res.status(500).json({ message: error.message });
+ }
+ return res.status(500);
+ }
+
+ return res.status(200).json({ url: "/apps/setup/zapier" });
+}
diff --git a/packages/app-store/zapier/api/index.ts b/packages/app-store/zapier/api/index.ts
new file mode 100644
index 00000000..f3f3d07a
--- /dev/null
+++ b/packages/app-store/zapier/api/index.ts
@@ -0,0 +1,4 @@
+export { default as add } from "./add";
+export { default as listBookings } from "./subscriptions/listBookings";
+export { default as deleteSubscription } from "./subscriptions/deleteSubscription";
+export { default as addSubscription } from "./subscriptions/addSubscription";
diff --git a/packages/app-store/zapier/api/subscriptions/addSubscription.ts b/packages/app-store/zapier/api/subscriptions/addSubscription.ts
new file mode 100644
index 00000000..d1e32c6c
--- /dev/null
+++ b/packages/app-store/zapier/api/subscriptions/addSubscription.ts
@@ -0,0 +1,39 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { v4 } from "uuid";
+
+import findValidApiKey from "@calcom/ee/lib/api/findValidApiKey";
+import prisma from "@calcom/prisma";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const apiKey = req.query.apiKey as string;
+
+ if (!apiKey) {
+ return res.status(401).json({ message: "No API key provided" });
+ }
+
+ const validKey = await findValidApiKey(apiKey, "zapier");
+
+ if (!validKey) {
+ return res.status(401).json({ message: "API key not valid" });
+ }
+
+ const { subscriberUrl, triggerEvent } = req.body;
+
+ if (req.method === "POST") {
+ try {
+ const createSubscription = await prisma.webhook.create({
+ data: {
+ id: v4(),
+ userId: validKey.userId,
+ eventTriggers: [triggerEvent],
+ subscriberUrl,
+ active: true,
+ appId: "zapier",
+ },
+ });
+ res.status(200).json(createSubscription);
+ } catch (error) {
+ return res.status(500).json({ message: "Could not create subscription." });
+ }
+ }
+}
diff --git a/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts b/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts
new file mode 100644
index 00000000..52084db6
--- /dev/null
+++ b/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts
@@ -0,0 +1,29 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import findValidApiKey from "@calcom/ee/lib/api/findValidApiKey";
+import prisma from "@calcom/prisma";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const apiKey = req.query.apiKey as string;
+
+ if (!apiKey) {
+ return res.status(401).json({ message: "No API key provided" });
+ }
+
+ const validKey = await findValidApiKey(apiKey, "zapier");
+
+ if (!validKey) {
+ return res.status(401).json({ message: "API key not valid" });
+ }
+
+ const id = req.query.id as string;
+
+ if (req.method === "DELETE") {
+ await prisma.webhook.delete({
+ where: {
+ id,
+ },
+ });
+ res.status(204).json({ message: "Subscription is deleted." });
+ }
+}
diff --git a/packages/app-store/zapier/api/subscriptions/listBookings.ts b/packages/app-store/zapier/api/subscriptions/listBookings.ts
new file mode 100644
index 00000000..8751b06b
--- /dev/null
+++ b/packages/app-store/zapier/api/subscriptions/listBookings.ts
@@ -0,0 +1,49 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import findValidApiKey from "@calcom/ee/lib/api/findValidApiKey";
+import prisma from "@calcom/prisma";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const apiKey = req.query.apiKey as string;
+
+ if (!apiKey) {
+ return res.status(401).json({ message: "No API key provided" });
+ }
+
+ const validKey = await findValidApiKey(apiKey, "zapier");
+
+ if (!validKey) {
+ return res.status(401).json({ message: "API key not valid" });
+ }
+
+ if (req.method === "GET") {
+ try {
+ const bookings = await prisma.booking.findMany({
+ take: 3,
+ where: {
+ userId: validKey.userId,
+ },
+ select: {
+ description: true,
+ startTime: true,
+ endTime: true,
+ title: true,
+ location: true,
+ cancellationReason: true,
+ attendees: {
+ select: {
+ name: true,
+ email: true,
+ timeZone: true,
+ },
+ },
+ },
+ });
+
+ res.status(201).json(bookings);
+ } catch (error) {
+ console.error(error);
+ return res.status(500).json({ message: "Unable to get bookings." });
+ }
+ }
+}
diff --git a/packages/app-store/zapier/components/InstallAppButton.tsx b/packages/app-store/zapier/components/InstallAppButton.tsx
new file mode 100644
index 00000000..ee3e1d10
--- /dev/null
+++ b/packages/app-store/zapier/components/InstallAppButton.tsx
@@ -0,0 +1,18 @@
+import type { InstallAppButtonProps } from "@calcom/app-store/types";
+
+import useAddAppMutation from "../../_utils/useAddAppMutation";
+
+export default function InstallAppButton(props: InstallAppButtonProps) {
+ const mutation = useAddAppMutation("zapier_other");
+
+ return (
+ <>
+ {props.render({
+ onClick() {
+ mutation.mutate("");
+ },
+ loading: mutation.isLoading,
+ })}
+ >
+ );
+}
diff --git a/packages/app-store/zapier/components/icon.tsx b/packages/app-store/zapier/components/icon.tsx
new file mode 100644
index 00000000..b4c2401c
--- /dev/null
+++ b/packages/app-store/zapier/components/icon.tsx
@@ -0,0 +1,15 @@
+export default function Icon() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/app-store/zapier/components/index.ts b/packages/app-store/zapier/components/index.ts
new file mode 100644
index 00000000..5f2c7965
--- /dev/null
+++ b/packages/app-store/zapier/components/index.ts
@@ -0,0 +1,3 @@
+export { default as InstallAppButton } from "./InstallAppButton";
+export { default as ZapierSetup } from "./zapierSetup";
+export { default as Icon } from "./icon";
diff --git a/packages/app-store/zapier/components/zapierSetup.tsx b/packages/app-store/zapier/components/zapierSetup.tsx
new file mode 100644
index 00000000..362135c8
--- /dev/null
+++ b/packages/app-store/zapier/components/zapierSetup.tsx
@@ -0,0 +1,121 @@
+import { ClipboardCopyIcon } from "@heroicons/react/solid";
+import { Trans } from "next-i18next";
+import Link from "next/link";
+import { useState } from "react";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import showToast from "@calcom/lib/notification";
+import { Button } from "@calcom/ui";
+import { Tooltip } from "@calcom/ui/Tooltip";
+import Loader from "@calcom/web/components/Loader";
+
+import Icon from "./icon";
+
+interface IZapierSetupProps {
+ trpc: any;
+}
+
+const ZAPIER = "zapier";
+
+export default function ZapierSetup(props: IZapierSetupProps) {
+ const { trpc } = props;
+ const [newApiKey, setNewApiKey] = useState("");
+ const { t } = useLocale();
+ const utils = trpc.useContext();
+ const integrations = trpc.useQuery(["viewer.integrations"]);
+ const oldApiKey = trpc.useQuery(["viewer.apiKeys.findKeyOfType", { appId: ZAPIER }]);
+ const deleteApiKey = trpc.useMutation("viewer.apiKeys.delete");
+ const zapierCredentials: { credentialIds: number[] } | undefined = integrations.data?.other?.items.find(
+ (item: { type: string }) => item.type === "zapier_other"
+ );
+ const [credentialId] = zapierCredentials?.credentialIds || [false];
+ const showContent = integrations.data && integrations.isSuccess && credentialId;
+
+ async function createApiKey() {
+ const event = { note: "Zapier", expiresAt: null, appId: ZAPIER };
+ const apiKey = await utils.client.mutation("viewer.apiKeys.create", event);
+ if (oldApiKey.data) {
+ deleteApiKey.mutate({
+ id: oldApiKey.data.id,
+ });
+ }
+ setNewApiKey(apiKey);
+ }
+
+ if (integrations.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {showContent ? (
+
+
+
+
+
+
+
{t("setting_up_zapier")}
+ {!newApiKey ? (
+ <>
+
{t("generate_api_key")}:
+
createApiKey()} className="mt-4 mb-4">
+ {t("generate_api_key")}
+
+ >
+ ) : (
+ <>
+
{t("your_unique_api_key")}
+
+
{newApiKey}
+
+ {
+ navigator.clipboard.writeText(newApiKey);
+ showToast(t("api_key_copied"), "success");
+ }}
+ type="button"
+ className="px-4 text-base ">
+
+ {t("copy")}
+
+
+
+
+ {t("copy_safe_api_key")}
+
+ >
+ )}
+
+
+
+ Log into your Zapier account and create a new Zap.
+ Select Cal.com as your Trigger app. Also choose a Trigger event.
+ Choose your account and then enter your Unique API Key.
+ Test your Trigger.
+ You're set!
+
+
+
+
{t("done")}
+
+
+
+
+ ) : (
+
+
{t("install_zapier_app")}
+
+
+ {t("go_to_app_store")}
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/app-store/zapier/index.ts b/packages/app-store/zapier/index.ts
new file mode 100644
index 00000000..a698d1f5
--- /dev/null
+++ b/packages/app-store/zapier/index.ts
@@ -0,0 +1,3 @@
+export * as api from "./api";
+export * as components from "./components";
+export { metadata } from "./_metadata";
diff --git a/packages/app-store/zapier/package.json b/packages/app-store/zapier/package.json
new file mode 100644
index 00000000..1fcd4ab0
--- /dev/null
+++ b/packages/app-store/zapier/package.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "private": true,
+ "name": "@calcom/zapier",
+ "version": "0.0.0",
+ "main": "./index.ts",
+ "description": "Workflow automation for everyone. Use the Cal.com Zapier app to trigger your workflows when a booking was created, rescheduled or cancled.",
+ "dependencies": {
+ "@calcom/lib": "*"
+ },
+ "devDependencies": {
+ "@calcom/types": "*"
+ }
+}
diff --git a/packages/app-store/zapier/static/icon.svg b/packages/app-store/zapier/static/icon.svg
new file mode 100644
index 00000000..237b13e5
--- /dev/null
+++ b/packages/app-store/zapier/static/icon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/packages/core/builders/CalendarEvent/builder.ts b/packages/core/builders/CalendarEvent/builder.ts
index 11c55cdc..44697080 100644
--- a/packages/core/builders/CalendarEvent/builder.ts
+++ b/packages/core/builders/CalendarEvent/builder.ts
@@ -1,4 +1,4 @@
-import { Prisma } from "@prisma/client";
+import { Prisma, Booking } from "@prisma/client";
import dayjs from "dayjs";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
@@ -80,7 +80,7 @@ export class CalendarEventBuilder implements ICalendarEventBuilder {
}
users.push(eventTypeUser);
}
- this.users = users;
+ this.setUsers(users);
}
public buildAttendeesList() {
@@ -273,17 +273,54 @@ export class CalendarEventBuilder implements ICalendarEventBuilder {
this.calendarEvent.cancellationReason = cancellationReason;
}
- public buildRescheduleLink(originalBookingUId: string) {
- if (!this.eventType) {
- throw new Error("Run buildEventObjectFromInnerClass before this function");
- }
- const isTeam = !!this.eventType.teamId;
+ public setUsers(users: User[]) {
+ this.users = users;
+ }
- const queryParams = new URLSearchParams();
- queryParams.set("rescheduleUid", `${originalBookingUId}`);
- const rescheduleLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/${
- isTeam ? `/team/${this.eventType.team?.slug}` : this.users[0].username
- }/${this.eventType.slug}?${queryParams.toString()}`;
- this.rescheduleLink = rescheduleLink;
+ public async setUsersFromId(userId: User["id"]) {
+ let resultUser: User | null;
+ try {
+ resultUser = await prisma.user.findUnique({
+ rejectOnNotFound: true,
+ where: {
+ id: userId,
+ },
+ ...userSelect,
+ });
+ this.setUsers([resultUser]);
+ } catch (error) {
+ throw new Error("getUsersById.users.notFound");
+ }
+ }
+
+ public buildRescheduleLink(booking: Partial, eventType?: CalendarEventBuilder["eventType"]) {
+ try {
+ if (!booking) {
+ throw new Error("Parameter booking is required to build reschedule link");
+ }
+ const isTeam = !!eventType && !!eventType.teamId;
+ const isDynamic = booking?.dynamicEventSlugRef && booking?.dynamicGroupSlugRef;
+
+ let slug = "";
+ if (isTeam && eventType?.team?.slug) {
+ slug = `/team/${eventType.team?.slug}`;
+ } else if (isDynamic) {
+ const dynamicSlug = isDynamic ? `${booking.dynamicGroupSlugRef}/${booking.dynamicEventSlugRef}` : "";
+ slug = dynamicSlug;
+ } else if (eventType?.slug) {
+ slug = `${this.users[0].username}/${eventType.slug}`;
+ }
+
+ const queryParams = new URLSearchParams();
+ queryParams.set("rescheduleUid", `${booking.uid}`);
+ slug = `${slug}?${queryParams.toString()}`;
+
+ const rescheduleLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/${slug}`;
+ this.rescheduleLink = rescheduleLink;
+ } catch (error) {
+ if (error instanceof Error) {
+ throw new Error(`buildRescheduleLink.error: ${error.message}`);
+ }
+ }
}
}
diff --git a/packages/core/builders/CalendarEvent/class.ts b/packages/core/builders/CalendarEvent/class.ts
index 29e8b963..c79d7efb 100644
--- a/packages/core/builders/CalendarEvent/class.ts
+++ b/packages/core/builders/CalendarEvent/class.ts
@@ -22,6 +22,7 @@ class CalendarEventClass implements CalendarEvent {
rejectionReason?: string | null;
hideCalendarNotes?: boolean;
additionalNotes?: string | null | undefined;
+ recurrence?: string;
constructor(initProps?: CalendarEvent) {
// If more parameters are given we update this
diff --git a/packages/core/builders/CalendarEvent/director.ts b/packages/core/builders/CalendarEvent/director.ts
index b33311b8..3b9e7a3c 100644
--- a/packages/core/builders/CalendarEvent/director.ts
+++ b/packages/core/builders/CalendarEvent/director.ts
@@ -11,7 +11,21 @@ export class CalendarEventDirector {
this.builder = builder;
}
- public setExistingBooking(booking: Booking) {
+ public setExistingBooking(
+ booking: Pick<
+ Booking,
+ | "id"
+ | "uid"
+ | "title"
+ | "startTime"
+ | "endTime"
+ | "eventTypeId"
+ | "userId"
+ | "dynamicEventSlugRef"
+ | "dynamicGroupSlugRef"
+ | "location"
+ >
+ ) {
this.existingBooking = booking;
}
@@ -29,9 +43,23 @@ export class CalendarEventDirector {
this.builder.setCancellationReason(this.cancellationReason);
this.builder.setDescription(this.builder.eventType.description);
this.builder.setNotes(this.existingBooking.description);
- this.builder.buildRescheduleLink(this.existingBooking.uid);
+ this.builder.buildRescheduleLink(this.existingBooking, this.builder.eventType);
} else {
throw new Error("buildForRescheduleEmail.missing.params.required");
}
}
+
+ public async buildWithoutEventTypeForRescheduleEmail() {
+ if (this.existingBooking && this.existingBooking.userId && this.existingBooking.uid) {
+ await this.builder.setUsersFromId(this.existingBooking.userId);
+ this.builder.buildAttendeesList();
+ this.builder.setLocation(this.existingBooking.location);
+ this.builder.setUId(this.existingBooking.uid);
+ this.builder.setCancellationReason(this.cancellationReason);
+ this.builder.setDescription(this.existingBooking.description);
+ await this.builder.buildRescheduleLink(this.existingBooking);
+ } else {
+ throw new Error("buildWithoutEventTypeForRescheduleEmail.missing.params.required");
+ }
+ }
}
diff --git a/packages/ee/lib/api/findValidApiKey.ts b/packages/ee/lib/api/findValidApiKey.ts
new file mode 100644
index 00000000..2aeeb7c7
--- /dev/null
+++ b/packages/ee/lib/api/findValidApiKey.ts
@@ -0,0 +1,32 @@
+import { hashAPIKey } from "@calcom/ee/lib/api/apiKeys";
+import prisma from "@calcom/prisma";
+
+const findValidApiKey = async (apiKey: string, appId?: string) => {
+ const hashedKey = hashAPIKey(apiKey.substring(process.env.API_KEY_PREFIX?.length || 0));
+
+ const validKey = await prisma.apiKey.findFirst({
+ where: {
+ AND: [
+ {
+ hashedKey,
+ },
+ {
+ appId,
+ },
+ ],
+ OR: [
+ {
+ expiresAt: {
+ gte: new Date(Date.now()),
+ },
+ },
+ {
+ expiresAt: null,
+ },
+ ],
+ },
+ });
+ return validKey;
+};
+
+export default findValidApiKey;
diff --git a/packages/embeds/embed-core/README.md b/packages/embeds/embed-core/README.md
index 23795f3b..9b68c9a9 100644
--- a/packages/embeds/embed-core/README.md
+++ b/packages/embeds/embed-core/README.md
@@ -56,6 +56,7 @@ Make `dist/embed.umd.js` servable on URL
- Automation Tests
- Run automation tests in CI
+ - Automation Tests are using snapshots of Booking Page which has current month which requires us to regenerate snapshots every month.
- Bundling Related
- Comments in CSS aren't stripped off
@@ -72,13 +73,8 @@ Make `dist/embed.umd.js` servable on URL
- Dev Experience/Ease of Installation
- Do we need a one liner(like `window.dataLayer.push`) to inform SDK of something even if snippet is not yet on the page but would be there e.g. through GTM it would come late on the page ?
-- Might be better to pass all configuration using a single base64encoded query param to booking page.
-
-- Performance Improvements
- - Custom written Tailwind CSS is sent multiple times for different custom elements.
-
-- Embed Code Generator
- Option to disable redirect banner and let parent handle redirect.
+
- Release Issues
- Compatibility Issue - When embed-iframe.js is updated in such a way that it is not compatible with embed.js, doing a release might break the embed for some time. e.g. iframeReady event let's say get's changed to something else
- Best Case scenario - App and Website goes live at the same time. A website using embed loads the same updated and thus compatible versions of embed.js and embed-iframe.js
@@ -87,8 +83,7 @@ Make `dist/embed.umd.js` servable on URL
- Quick Solution: Serve embed.js also from app, so that they go live together and there is only a slight chance of compatibility issues on going live. Note, that they can still occur as 2 different requests are sent at different times to fetch the libraries and deployments can go live in between,
- UI Config Features
- - Theme switch dynamically - If user switches the theme on website, he should be able to do it on embed. Add a demo for the API. Also, test system theme handling.
- - How would the user add on hover styles just using style attribute ?
+ - How would the user add on hover styles just using style attribute ?
- If just iframe refreshes due to some reason, embed script can't replay the applied instructions.
diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html
index cf7842da..cf631379 100644
--- a/packages/embeds/embed-core/index.html
+++ b/packages/embeds/embed-core/index.html
@@ -1,6 +1,7 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts b/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts
index 01d3e25b..fbba8459 100644
--- a/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts
+++ b/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts
@@ -1,11 +1,73 @@
import { CalWindow } from "@calcom/embed-snippet";
-import floatingButtonHtml from "./FloatingButtonHtml";
+import getFloatingButtonHtml from "./FloatingButtonHtml";
export class FloatingButton extends HTMLElement {
+ static updatedClassString(position: string, classString: string) {
+ return [
+ classString.replace(/hidden|md:right-10|md:left-10|left-4|right-4/g, ""),
+ position === "bottom-right" ? "md:right-10 right-4" : "md:left-10 left-4",
+ ].join(" ");
+ }
+
+ //@ts-ignore
+ static get observedAttributes() {
+ return [
+ "data-button-text",
+ "data-hide-button-icon",
+ "data-button-position",
+ "data-button-color",
+ "data-button-text-color",
+ ];
+ }
+
+ attributeChangedCallback(name: string, oldValue: string, newValue: string) {
+ if (name === "data-button-text") {
+ const buttonEl = this.shadowRoot?.querySelector("#button");
+ if (!buttonEl) {
+ throw new Error("Button not found");
+ }
+ buttonEl.innerHTML = newValue;
+ } else if (name === "data-hide-button-icon") {
+ const buttonIconEl = this.shadowRoot?.querySelector("#button-icon") as HTMLElement;
+ if (!buttonIconEl) {
+ throw new Error("Button not found");
+ }
+ buttonIconEl.style.display = newValue == "true" ? "none" : "block";
+ } else if (name === "data-button-position") {
+ const buttonEl = this.shadowRoot?.querySelector("button") as HTMLElement;
+ if (!buttonEl) {
+ throw new Error("Button not found");
+ }
+ buttonEl.className = FloatingButton.updatedClassString(newValue, buttonEl.className);
+ } else if (name === "data-button-color") {
+ const buttonEl = this.shadowRoot?.querySelector("button") as HTMLElement;
+ if (!buttonEl) {
+ throw new Error("Button not found");
+ }
+ buttonEl.style.backgroundColor = newValue;
+ } else if (name === "data-button-text-color") {
+ const buttonEl = this.shadowRoot?.querySelector("button") as HTMLElement;
+ if (!buttonEl) {
+ throw new Error("Button not found");
+ }
+ buttonEl.style.color = newValue;
+ }
+ }
constructor() {
super();
- const buttonHtml = ` ${floatingButtonHtml}`;
+ const buttonText = this.dataset["buttonText"];
+ const buttonPosition = this.dataset["buttonPosition"];
+ const buttonColor = this.dataset["buttonColor"];
+ const buttonTextColor = this.dataset["buttonTextColor"];
+
+ //TODO: Logic is duplicated over HTML generation and attribute change, keep it at one place
+ const buttonHtml = ` ${getFloatingButtonHtml({
+ buttonText: buttonText!,
+ buttonClasses: [FloatingButton.updatedClassString(buttonPosition!, "")],
+ buttonColor: buttonColor!,
+ buttonTextColor: buttonTextColor!,
+ })}`;
this.attachShadow({ mode: "open" });
this.shadowRoot!.innerHTML = buttonHtml;
}
diff --git a/packages/embeds/embed-core/src/FloatingButton/FloatingButtonHtml.ts b/packages/embeds/embed-core/src/FloatingButton/FloatingButtonHtml.ts
index 792032d8..e015f9c6 100644
--- a/packages/embeds/embed-core/src/FloatingButton/FloatingButtonHtml.ts
+++ b/packages/embeds/embed-core/src/FloatingButton/FloatingButtonHtml.ts
@@ -1,8 +1,23 @@
-const html = `
-
+cus:ring-4 focus:ring-gray-600 focus:ring-opacity-50 active:scale-95"
+style="background-color:${buttonColor}; color:${buttonTextColor} z-index: 10001">
+
-
Book my Cal
+
${buttonText}
`;
+};
-export default html;
+export default getHtml;
diff --git a/packages/embeds/embed-core/src/Inline/inline.ts b/packages/embeds/embed-core/src/Inline/inline.ts
index e17e9291..f0e101e6 100644
--- a/packages/embeds/embed-core/src/Inline/inline.ts
+++ b/packages/embeds/embed-core/src/Inline/inline.ts
@@ -1,6 +1,7 @@
import { CalWindow } from "@calcom/embed-snippet";
import loaderCss from "../loader.css";
+import { getErrorString } from "../utils";
import inlineHtml from "./inlineHtml";
export class Inline extends HTMLElement {
@@ -9,8 +10,16 @@ export class Inline extends HTMLElement {
return ["loading"];
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
- if (name === "loading" && newValue == "done") {
- (this.shadowRoot!.querySelector("#loader")! as HTMLElement).style.display = "none";
+ if (name === "loading") {
+ if (newValue == "done") {
+ (this.shadowRoot!.querySelector(".loader")! as HTMLElement).style.display = "none";
+ } else if (newValue === "failed") {
+ (this.shadowRoot!.querySelector(".loader")! as HTMLElement).style.display = "none";
+ (this.shadowRoot!.querySelector("#error")! as HTMLElement).style.display = "block";
+ (this.shadowRoot!.querySelector("slot")! as HTMLElement).style.visibility = "hidden";
+ const errorString = getErrorString(this.dataset.errorCode);
+ (this.shadowRoot!.querySelector("#error")! as HTMLElement).innerText = errorString;
+ }
}
}
constructor() {
diff --git a/packages/embeds/embed-core/src/Inline/inlineHtml.ts b/packages/embeds/embed-core/src/Inline/inlineHtml.ts
index 80a87b2a..44c4c7e3 100644
--- a/packages/embeds/embed-core/src/Inline/inlineHtml.ts
+++ b/packages/embeds/embed-core/src/Inline/inlineHtml.ts
@@ -1,7 +1,10 @@
-const html = `
+const html = `
+
+Something went wrong.
+
`;
export default html;
diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts
index a6e6a2b2..9a7b4dfa 100644
--- a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts
+++ b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts
@@ -1,6 +1,7 @@
import { CalWindow } from "@calcom/embed-snippet";
import loaderCss from "../loader.css";
+import { getErrorString } from "../utils";
import modalBoxHtml from "./ModalBoxHtml";
export class ModalBox extends HTMLElement {
@@ -28,11 +29,16 @@ export class ModalBox extends HTMLElement {
}
if (newValue == "loaded") {
- (this.shadowRoot!.querySelector("#loader")! as HTMLElement).style.display = "none";
+ (this.shadowRoot!.querySelector(".loader")! as HTMLElement).style.display = "none";
} else if (newValue === "started") {
this.show(true);
} else if (newValue == "closed") {
this.show(false);
+ } else if (newValue === "failed") {
+ (this.shadowRoot!.querySelector(".loader")! as HTMLElement).style.display = "none";
+ (this.shadowRoot!.querySelector("#error")! as HTMLElement).style.display = "inline-block";
+ const errorString = getErrorString(this.dataset.errorCode);
+ (this.shadowRoot!.querySelector("#error")! as HTMLElement).innerText = errorString;
}
}
diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts b/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts
index 6222e91a..9e2178cd 100644
--- a/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts
+++ b/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts
@@ -59,11 +59,12 @@ const html = `