Recurring Events (#2562)
* Init dev * UI changes for recurring event + prisma * Revisiting schema + changes WIP * UI done, BE WIP * Feature completion * Unused query param removed * Invalid comment removed * Removed unused translation * Update apps/web/public/static/locales/en/common.json Thanks! Co-authored-by: Peer Richelsen <peeroke@gmail.com> * Success page changes * More progress * Email text tweaks + test + seed * Tweaking emails + Cal Apps support WIP * No app integration for now Final email and pages tweaks to avoid recurring info showed * Missing comment for clarity * Yet again, comment * Last minute fix * Missing tooltip for upcoming bookings * Fixing seed * Fixing import * Increasing timeout for e2e * Fixing any * Apply suggestions from code review Co-authored-by: Omar López <zomars@me.com> * Update apps/web/pages/d/[link]/book.tsx Co-authored-by: Omar López <zomars@me.com> * Code improvements * More code improvements * Reverting back number input arrows * Update BookingPage.tsx * Update BookingPage.tsx * Adds fallback for sendOrganizerPaymentRefundFailedEmail * Type overkill * Type fixes * Type fixes * Nitpicks * Update success.tsx * Update success.tsx * Update success.tsx * Fixing types Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
parent
26e46ff06c
commit
1a79e0624c
61 changed files with 1416 additions and 1475 deletions
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
|
@ -7,7 +7,7 @@ on:
|
||||||
- public/static/locales/**
|
- public/static/locales/**
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
timeout-minutes: 15
|
timeout-minutes: 20
|
||||||
name: Testing ${{ matrix.node }} and ${{ matrix.os }}
|
name: Testing ${{ matrix.node }} and ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
|
@ -190,8 +190,10 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||||
|
|
||||||
### E2E-Testing
|
### E2E-Testing
|
||||||
|
|
||||||
|
Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# In a terminal. Just run:
|
# In a terminal just run:
|
||||||
yarn test-e2e
|
yarn test-e2e
|
||||||
|
|
||||||
# To open last HTML report run:
|
# To open last HTML report run:
|
||||||
|
|
|
@ -11,6 +11,10 @@ export default function BookingsShell({ children }: { children: React.ReactNode
|
||||||
name: t("upcoming"),
|
name: t("upcoming"),
|
||||||
href: "/bookings/upcoming",
|
href: "/bookings/upcoming",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: t("recurring"),
|
||||||
|
href: "/bookings/recurring",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: t("past"),
|
name: t("past"),
|
||||||
href: "/bookings/past",
|
href: "/bookings/past",
|
||||||
|
|
|
@ -21,6 +21,7 @@ type AvailableTimesProps = {
|
||||||
afterBufferTime: number;
|
afterBufferTime: number;
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
eventLength: number;
|
eventLength: number;
|
||||||
|
recurringCount: number | undefined;
|
||||||
eventTypeSlug: string;
|
eventTypeSlug: string;
|
||||||
slotInterval: number | null;
|
slotInterval: number | null;
|
||||||
date: Dayjs;
|
date: Dayjs;
|
||||||
|
@ -37,6 +38,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||||
eventTypeSlug,
|
eventTypeSlug,
|
||||||
slotInterval,
|
slotInterval,
|
||||||
minimumBookingNotice,
|
minimumBookingNotice,
|
||||||
|
recurringCount,
|
||||||
timeFormat,
|
timeFormat,
|
||||||
users,
|
users,
|
||||||
schedulingType,
|
schedulingType,
|
||||||
|
@ -90,6 +92,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||||
date: slot.time.format(),
|
date: slot.time.format(),
|
||||||
type: eventTypeId,
|
type: eventTypeId,
|
||||||
slug: eventTypeSlug,
|
slug: eventTypeSlug,
|
||||||
|
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
|
||||||
|
count: recurringCount && !rescheduleUid ? recurringCount : undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,26 +1,37 @@
|
||||||
import { BanIcon, CheckIcon, ClockIcon, XIcon, PencilAltIcon } from "@heroicons/react/outline";
|
import { BanIcon, CheckIcon, ClockIcon, XIcon, PencilAltIcon } from "@heroicons/react/outline";
|
||||||
import { PaperAirplaneIcon } from "@heroicons/react/outline";
|
import { PaperAirplaneIcon } from "@heroicons/react/outline";
|
||||||
|
import { RefreshIcon } from "@heroicons/react/solid";
|
||||||
import { BookingStatus } from "@prisma/client";
|
import { BookingStatus } from "@prisma/client";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
|
import { Frequency as RRuleFrequency } from "rrule";
|
||||||
|
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||||
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
import { TextArea } from "@calcom/ui/form/fields";
|
import { TextArea } from "@calcom/ui/form/fields";
|
||||||
|
|
||||||
import { HttpError } from "@lib/core/http/error";
|
import { HttpError } from "@lib/core/http/error";
|
||||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
import { parseRecurringDates } from "@lib/parseDate";
|
||||||
|
import { inferQueryOutput, trpc, inferQueryInput } from "@lib/trpc";
|
||||||
|
|
||||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||||
|
|
||||||
|
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||||
|
|
||||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||||
|
|
||||||
function BookingListItem(booking: BookingItem) {
|
type BookingItemProps = BookingItem & {
|
||||||
|
listingStatus: BookingListingStatus;
|
||||||
|
recurringCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function BookingListItem(booking: BookingItemProps) {
|
||||||
// Get user so we can determine 12/24 hour format preferences
|
// Get user so we can determine 12/24 hour format preferences
|
||||||
const query = useMeQuery();
|
const query = useMeQuery();
|
||||||
const user = query.data;
|
const user = query.data;
|
||||||
|
@ -30,14 +41,22 @@ function BookingListItem(booking: BookingItem) {
|
||||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
async (confirm: boolean) => {
|
async (confirm: boolean) => {
|
||||||
|
let body = {
|
||||||
|
id: booking.id,
|
||||||
|
confirmed: confirm,
|
||||||
|
language: i18n.language,
|
||||||
|
reason: rejectionReason,
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||||
|
* the "Upcoming" tab, to support confirming discretionally in the "Recurring" tab.
|
||||||
|
*/
|
||||||
|
if (booking.listingStatus === "upcoming" && booking.recurringEventId !== null) {
|
||||||
|
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||||
|
}
|
||||||
const res = await fetch("/api/book/confirm", {
|
const res = await fetch("/api/book/confirm", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
id: booking.id,
|
|
||||||
confirmed: confirm,
|
|
||||||
language: i18n.language,
|
|
||||||
reason: rejectionReason,
|
|
||||||
}),
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
@ -58,14 +77,20 @@ function BookingListItem(booking: BookingItem) {
|
||||||
const pendingActions: ActionType[] = [
|
const pendingActions: ActionType[] = [
|
||||||
{
|
{
|
||||||
id: "reject",
|
id: "reject",
|
||||||
label: t("reject"),
|
label:
|
||||||
|
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
|
||||||
|
? t("reject_all")
|
||||||
|
: t("reject"),
|
||||||
onClick: () => setRejectionDialogIsOpen(true),
|
onClick: () => setRejectionDialogIsOpen(true),
|
||||||
icon: BanIcon,
|
icon: BanIcon,
|
||||||
disabled: mutation.isLoading,
|
disabled: mutation.isLoading,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "confirm",
|
id: "confirm",
|
||||||
label: t("confirm"),
|
label:
|
||||||
|
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
|
||||||
|
? t("confirm_all")
|
||||||
|
: t("confirm"),
|
||||||
onClick: () => mutation.mutate(true),
|
onClick: () => mutation.mutate(true),
|
||||||
icon: CheckIcon,
|
icon: CheckIcon,
|
||||||
disabled: mutation.isLoading,
|
disabled: mutation.isLoading,
|
||||||
|
@ -112,6 +137,19 @@ function BookingListItem(booking: BookingItem) {
|
||||||
|
|
||||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||||
|
|
||||||
|
// Calculate the booking date(s)
|
||||||
|
let recurringStrings: string[] = [];
|
||||||
|
if (booking.recurringCount && booking.eventType.recurringEvent?.freq !== null) {
|
||||||
|
[recurringStrings] = parseRecurringDates(
|
||||||
|
{
|
||||||
|
startDate: booking.startTime,
|
||||||
|
recurringEvent: booking.eventType.recurringEvent,
|
||||||
|
recurringCount: booking.recurringCount,
|
||||||
|
},
|
||||||
|
i18n
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RescheduleDialog
|
<RescheduleDialog
|
||||||
|
@ -154,12 +192,40 @@ function BookingListItem(booking: BookingItem) {
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<tr className="flex">
|
<tr className="flex">
|
||||||
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell">
|
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:w-56">
|
||||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
{booking.recurringCount &&
|
||||||
|
booking.eventType?.recurringEvent?.freq &&
|
||||||
|
booking.listingStatus === "upcoming" && (
|
||||||
|
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
||||||
|
<div className="flex">
|
||||||
|
<Tooltip
|
||||||
|
content={recurringStrings.map((aDate, key) => (
|
||||||
|
<p key={key}>{aDate}</p>
|
||||||
|
))}>
|
||||||
|
<p className="text-gray-600 dark:text-white">
|
||||||
|
<RefreshIcon className="mr-1 -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
{`${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 }
|
||||||
|
)}`}
|
||||||
|
</p>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}>
|
<td className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}>
|
||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
|
RefreshIcon,
|
||||||
} from "@heroicons/react/solid";
|
} from "@heroicons/react/solid";
|
||||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||||
import { useContracts } from "contexts/contractsContext";
|
import { useContracts } from "contexts/contractsContext";
|
||||||
|
@ -17,6 +18,7 @@ import utc from "dayjs/plugin/utc";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
|
import { Frequency as RRuleFrequency } from "rrule";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useEmbedStyles,
|
useEmbedStyles,
|
||||||
|
@ -27,11 +29,11 @@ import {
|
||||||
useEmbedNonStylesConfig,
|
useEmbedNonStylesConfig,
|
||||||
} from "@calcom/embed-core";
|
} from "@calcom/embed-core";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
import { BASE_URL, WEBAPP_URL } from "@lib/config/constants";
|
|
||||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||||
import useTheme from "@lib/hooks/useTheme";
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||||
|
@ -101,6 +103,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||||
}
|
}
|
||||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||||
|
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||||
|
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
|
@ -142,6 +145,15 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||||
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Theme />
|
<Theme />
|
||||||
|
@ -158,9 +170,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||||
className={classNames(
|
className={classNames(
|
||||||
shouldAlignCentrally ? "mx-auto" : "",
|
shouldAlignCentrally ? "mx-auto" : "",
|
||||||
isEmbed
|
isEmbed
|
||||||
? classNames(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
? classNames(maxWidth)
|
||||||
: "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
|
: classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
|
||||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
|
||||||
)}>
|
)}>
|
||||||
{isReady && (
|
{isReady && (
|
||||||
<div
|
<div
|
||||||
|
@ -168,7 +179,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||||
className={classNames(
|
className={classNames(
|
||||||
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
|
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
|
||||||
"border-bookinglightest rounded-md md:border",
|
"border-bookinglightest rounded-md md:border",
|
||||||
isEmbed ? "mx-auto" : selectedDate ? "max-w-5xl" : "max-w-3xl"
|
isEmbed ? "mx-auto" : maxWidth
|
||||||
)}>
|
)}>
|
||||||
{/* mobile: details */}
|
{/* mobile: details */}
|
||||||
<div className="block p-4 sm:p-8 md:hidden">
|
<div className="block p-4 sm:p-8 md:hidden">
|
||||||
|
@ -243,7 +254,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"hidden pr-8 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " +
|
"hidden pr-8 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " +
|
||||||
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
(selectedDate ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
|
||||||
}>
|
}>
|
||||||
<AvatarGroup
|
<AvatarGroup
|
||||||
border="border-2 dark:border-gray-800 border-white"
|
border="border-2 dark:border-gray-800 border-white"
|
||||||
|
@ -267,15 +278,42 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||||
{eventType.title}
|
{eventType.title}
|
||||||
</h1>
|
</h1>
|
||||||
{eventType?.description && (
|
{eventType?.description && (
|
||||||
<p className="text-bookinglight mb-2 dark:text-white">
|
<p className="text-bookinglight mb-3 dark:text-white">
|
||||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
{eventType.description}
|
{eventType.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-bookinglight mb-2 dark:text-white">
|
<p className="text-bookinglight mb-3 dark:text-white">
|
||||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||||
{eventType.length} {t("minutes")}
|
{eventType.length} {t("minutes")}
|
||||||
</p>
|
</p>
|
||||||
|
{!rescheduleUid && eventType.recurringEvent?.count && eventType.recurringEvent?.freq && (
|
||||||
|
<div className="mb-3 text-gray-600 dark:text-white">
|
||||||
|
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||||
|
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||||
|
{t("every_for_freq", {
|
||||||
|
freq: t(
|
||||||
|
`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max={eventType.recurringEvent.count}
|
||||||
|
className="w-16 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
|
||||||
|
defaultValue={eventType.recurringEvent.count}
|
||||||
|
onChange={(event) => {
|
||||||
|
setRecurringEventCount(parseInt(event?.target.value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="inline text-gray-600 dark:text-white">
|
||||||
|
{t(`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`, {
|
||||||
|
count: recurringEventCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{eventType.price > 0 && (
|
{eventType.price > 0 && (
|
||||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white">
|
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white">
|
||||||
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||||
|
@ -302,7 +340,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||||
{booking?.startTime && rescheduleUid && (
|
{booking?.startTime && rescheduleUid && (
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
className="mt-4 mb-2 text-gray-600 dark:text-white"
|
className="mt-4 mb-3 text-gray-600 dark:text-white"
|
||||||
data-testid="former_time_p_desktop">
|
data-testid="former_time_p_desktop">
|
||||||
{t("former_time")}
|
{t("former_time")}
|
||||||
</p>
|
</p>
|
||||||
|
@ -340,6 +378,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||||
eventTypeSlug={eventType.slug}
|
eventTypeSlug={eventType.slug}
|
||||||
slotInterval={eventType.slotInterval}
|
slotInterval={eventType.slotInterval}
|
||||||
eventLength={eventType.length}
|
eventLength={eventType.length}
|
||||||
|
recurringCount={recurringEventCount}
|
||||||
date={selectedDate}
|
date={selectedDate}
|
||||||
users={eventType.users}
|
users={eventType.users}
|
||||||
schedulingType={eventType.schedulingType ?? null}
|
schedulingType={eventType.schedulingType ?? null}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
ExclamationIcon,
|
ExclamationIcon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
|
RefreshIcon,
|
||||||
} from "@heroicons/react/solid";
|
} from "@heroicons/react/solid";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { EventTypeCustomInputType } from "@prisma/client";
|
import { EventTypeCustomInputType } from "@prisma/client";
|
||||||
|
@ -18,6 +19,8 @@ import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
import { ReactMultiEmail } from "react-multi-email";
|
import { ReactMultiEmail } from "react-multi-email";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
|
import { Frequency as RRuleFrequency } from "rrule";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -31,7 +34,9 @@ import classNames from "@calcom/lib/classNames";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { createPaymentLink } from "@calcom/stripe/client";
|
import { createPaymentLink } from "@calcom/stripe/client";
|
||||||
|
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
import { Button } from "@calcom/ui/Button";
|
import { Button } from "@calcom/ui/Button";
|
||||||
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
import { EmailInput, Form } from "@calcom/ui/form/fields";
|
import { EmailInput, Form } from "@calcom/ui/form/fields";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
|
@ -40,9 +45,11 @@ import { ensureArray } from "@lib/ensureArray";
|
||||||
import useTheme from "@lib/hooks/useTheme";
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
import { LocationType } from "@lib/location";
|
import { LocationType } from "@lib/location";
|
||||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
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 slugify from "@lib/slugify";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||||
|
import { BookingCreateBody } from "@lib/types/booking";
|
||||||
|
|
||||||
import CustomBranding from "@components/CustomBranding";
|
import CustomBranding from "@components/CustomBranding";
|
||||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||||
|
@ -76,6 +83,7 @@ const BookingPage = ({
|
||||||
booking,
|
booking,
|
||||||
profile,
|
profile,
|
||||||
isDynamicGroupBooking,
|
isDynamicGroupBooking,
|
||||||
|
recurringEventCount,
|
||||||
locationLabels,
|
locationLabels,
|
||||||
hasHashedBookingLink,
|
hasHashedBookingLink,
|
||||||
hashedLink,
|
hashedLink,
|
||||||
|
@ -140,6 +148,37 @@ const BookingPage = ({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const rescheduleUid = router.query.rescheduleUid as string;
|
const rescheduleUid = router.query.rescheduleUid as string;
|
||||||
const { isReady, Theme } = useTheme(profile.theme);
|
const { isReady, Theme } = useTheme(profile.theme);
|
||||||
const date = asStringOrNull(router.query.date);
|
const date = asStringOrNull(router.query.date);
|
||||||
|
@ -243,6 +282,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) => {
|
const bookEvent = (booking: BookingFormValues) => {
|
||||||
telemetry.withJitsu((jitsu) =>
|
telemetry.withJitsu((jitsu) =>
|
||||||
jitsu.track(
|
jitsu.track(
|
||||||
|
@ -265,7 +318,7 @@ const BookingPage = ({
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
let web3Details;
|
let web3Details: Record<"userWallet" | "userSignature", string> | undefined;
|
||||||
if (eventTypeDetail.metadata.smartContractAddress) {
|
if (eventTypeDetail.metadata.smartContractAddress) {
|
||||||
web3Details = {
|
web3Details = {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -274,28 +327,59 @@ const BookingPage = ({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation.mutate({
|
if (recurringDates.length) {
|
||||||
...booking,
|
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||||
web3Details,
|
const recurringEventId = uuidv4();
|
||||||
start: dayjs(date).format(),
|
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
...booking,
|
||||||
eventTypeId: eventType.id,
|
web3Details,
|
||||||
eventTypeSlug: eventType.slug,
|
start: dayjs(recurringDate).format(),
|
||||||
timeZone: timeZone(),
|
end: dayjs(recurringDate).add(eventType.length, "minute").format(),
|
||||||
language: i18n.language,
|
eventTypeId: eventType.id,
|
||||||
rescheduleUid,
|
eventTypeSlug: eventType.slug,
|
||||||
user: router.query.user,
|
recurringEventId,
|
||||||
location: getLocationValue(
|
// Added to track down the number of actual occurrences selected by the user
|
||||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
recurringCount: recurringDates.length,
|
||||||
),
|
timeZone: timeZone(),
|
||||||
metadata,
|
language: i18n.language,
|
||||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
rescheduleUid,
|
||||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
user: router.query.user,
|
||||||
value: booking.customInputs![inputId],
|
location: getLocationValue(
|
||||||
})),
|
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||||
hasHashedBookingLink,
|
),
|
||||||
hashedLink,
|
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;
|
const disableInput = !!rescheduleUid;
|
||||||
|
@ -375,10 +459,40 @@ const BookingPage = ({
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-bookinghighlight mb-4">
|
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||||
<CalendarIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
<div className="mb-3 text-gray-600 dark:text-white">
|
||||||
{parseDate(date, i18n)}
|
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||||
</p>
|
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||||
|
{`${t("every_for_freq", {
|
||||||
|
freq: t(`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`),
|
||||||
|
})} ${recurringEventCount} ${t(
|
||||||
|
`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`,
|
||||||
|
{ count: parseInt(recurringEventCount.toString()) }
|
||||||
|
)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-bookinghighlight mb-4 flex">
|
||||||
|
<CalendarIcon className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||||
|
<div className="-mt-1">
|
||||||
|
{(rescheduleUid || !eventType.recurringEvent.freq) && parseDate(date, i18n)}
|
||||||
|
{!rescheduleUid &&
|
||||||
|
eventType.recurringEvent.freq &&
|
||||||
|
recurringStrings.slice(0, 5).map((aDate, key) => <p key={key}>{aDate}</p>)}
|
||||||
|
{!rescheduleUid && eventType.recurringEvent.freq && recurringStrings.length > 5 && (
|
||||||
|
<div className="flex">
|
||||||
|
<Tooltip
|
||||||
|
content={recurringStrings.slice(5).map((aDate, key) => (
|
||||||
|
<p key={key}>{aDate}</p>
|
||||||
|
))}>
|
||||||
|
<p className="text-gray-600 dark:text-white">
|
||||||
|
{t("plus_more", { count: recurringStrings.length - 5 })}
|
||||||
|
</p>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
|
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
|
||||||
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
|
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
|
||||||
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
|
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
|
||||||
|
|
|
@ -77,7 +77,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose>
|
<DialogClose asChild>
|
||||||
<Button color="secondary">{t("cancel")}</Button>
|
<Button color="secondary">{t("cancel")}</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import { ClockIcon, CreditCardIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
|
import { ClockIcon, CreditCardIcon, RefreshIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
|
||||||
import { SchedulingType } from "@prisma/client";
|
import { SchedulingType } from "@prisma/client";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
||||||
select: {
|
select: {
|
||||||
|
@ -14,6 +16,7 @@ const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
||||||
price: true,
|
price: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
schedulingType: true,
|
schedulingType: true,
|
||||||
|
recurringEvent: true,
|
||||||
description: true,
|
description: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -28,6 +31,11 @@ export type EventTypeDescriptionProps = {
|
||||||
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
|
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const recurringEvent: RecurringEvent = useMemo(
|
||||||
|
() => (eventType.recurringEvent as RecurringEvent) || [],
|
||||||
|
[eventType.recurringEvent]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={classNames("text-neutral-500 dark:text-white", className)}>
|
<div className={classNames("text-neutral-500 dark:text-white", className)}>
|
||||||
|
@ -54,6 +62,12 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
||||||
{t("1_on_1")}
|
{t("1_on_1")}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
{recurringEvent?.count && recurringEvent.count > 0 && (
|
||||||
|
<li className="flex whitespace-nowrap">
|
||||||
|
<RefreshIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||||
|
{t("repeats_up_to", { count: recurringEvent.count })}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
{eventType.price > 0 && (
|
{eventType.price > 0 && (
|
||||||
<li className="flex whitespace-nowrap">
|
<li className="flex whitespace-nowrap">
|
||||||
<CreditCardIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
<CreditCardIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||||
|
|
132
apps/web/components/eventtype/RecurringEventController.tsx
Normal file
132
apps/web/components/eventtype/RecurringEventController.tsx
Normal file
|
@ -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<any, any> };
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="block items-start sm:flex">
|
||||||
|
<div className="min-w-48 mb-4 sm:mb-0">
|
||||||
|
<label htmlFor="recurringEvent" className="flex text-sm font-medium text-neutral-700">
|
||||||
|
{t("recurring_event")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="relative flex items-start">
|
||||||
|
<div className="flex h-5 items-center">
|
||||||
|
<input
|
||||||
|
onChange={(event) => {
|
||||||
|
setRecurringEventDefined(event?.target.checked);
|
||||||
|
if (!event?.target.checked) {
|
||||||
|
formMethods.setValue("recurringEvent", {});
|
||||||
|
} else {
|
||||||
|
formMethods.setValue(
|
||||||
|
"recurringEvent",
|
||||||
|
recurringEventDefined
|
||||||
|
? recurringEvent
|
||||||
|
: {
|
||||||
|
interval: 1,
|
||||||
|
count: 12,
|
||||||
|
freq: RRuleFrequency.WEEKLY,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
recurringEvent = formMethods.getValues("recurringEvent");
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
className="text-primary-600 h-4 w-4 rounded border-gray-300"
|
||||||
|
defaultChecked={recurringEventDefined}
|
||||||
|
data-testid="recurring-event-check"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||||
|
<p className="text-neutral-900">{t("recurring_event_description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Collapsible
|
||||||
|
open={recurringEventDefined}
|
||||||
|
data-testid="recurring-event-collapsible"
|
||||||
|
onOpenChange={() => setRecurringEventDefined(!recurringEventDefined)}>
|
||||||
|
<CollapsibleContent className="mt-4 text-sm">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||||
|
defaultValue={recurringEvent?.interval || 1}
|
||||||
|
onChange={(event) => {
|
||||||
|
setRecurringEventInterval(parseInt(event?.target.value));
|
||||||
|
recurringEvent.interval = parseInt(event?.target.value);
|
||||||
|
formMethods.setValue("recurringEvent", recurringEvent);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
options={recurringEventFreqOptions}
|
||||||
|
value={recurringEventFreqOptions[recurringEventFrequency]}
|
||||||
|
isSearchable={false}
|
||||||
|
className="w-18 block min-w-0 rounded-sm sm:text-sm"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e?.value) {
|
||||||
|
setRecurringEventFrequency(parseInt(e?.value));
|
||||||
|
recurringEvent.freq = parseInt(e?.value);
|
||||||
|
formMethods.setValue("recurringEvent", recurringEvent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center">
|
||||||
|
<p className="mr-2 text-neutral-900">{t("max")}</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||||
|
defaultValue={recurringEvent?.count || 12}
|
||||||
|
onChange={(event) => {
|
||||||
|
setRecurringEventCount(parseInt(event?.target.value));
|
||||||
|
recurringEvent.count = parseInt(event?.target.value);
|
||||||
|
formMethods.setValue("recurringEvent", recurringEvent);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mr-2 text-neutral-900">
|
||||||
|
{t(`${RRuleFrequency[recurringEventFrequency].toString().toLowerCase()}`, {
|
||||||
|
count: recurringEventCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -14,13 +14,13 @@ import Dropdown, {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@calcom/ui/Dropdown";
|
} from "@calcom/ui/Dropdown";
|
||||||
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
|
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
|
||||||
|
|
||||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||||
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
|
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
|
||||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import { Tooltip } from "@components/Tooltip";
|
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import ModalContainer from "@components/ui/ModalContainer";
|
import ModalContainer from "@components/ui/ModalContainer";
|
||||||
|
|
|
@ -19,12 +19,12 @@ import Dropdown, {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@calcom/ui/Dropdown";
|
} from "@calcom/ui/Dropdown";
|
||||||
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||||
|
|
||||||
import { Tooltip } from "@components/Tooltip";
|
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { InformationCircleIcon } from "@heroicons/react/solid";
|
import { InformationCircleIcon } from "@heroicons/react/solid";
|
||||||
|
|
||||||
import { Tooltip } from "@components/Tooltip";
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
|
|
||||||
export default function InfoBadge({ content }: { content: string }) {
|
export default function InfoBadge({ content }: { content: string }) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -3,12 +3,12 @@ import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||||
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import { ListItem } from "@components/List";
|
import { ListItem } from "@components/List";
|
||||||
import { Tooltip } from "@components/Tooltip";
|
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
|
|
||||||
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
|
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
|
||||||
|
|
|
@ -8,11 +8,11 @@ import showToast from "@calcom/lib/notification";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { DialogFooter } from "@calcom/ui/Dialog";
|
import { DialogFooter } from "@calcom/ui/Dialog";
|
||||||
import Switch from "@calcom/ui/Switch";
|
import Switch from "@calcom/ui/Switch";
|
||||||
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
import { Form, TextField } from "@calcom/ui/form/fields";
|
import { Form, TextField } from "@calcom/ui/form/fields";
|
||||||
|
|
||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import { Tooltip } from "@components/Tooltip";
|
|
||||||
import { DatePicker } from "@components/ui/form/DatePicker";
|
import { DatePicker } from "@components/ui/form/DatePicker";
|
||||||
|
|
||||||
import { TApiKeys } from "./ApiKeyListItem";
|
import { TApiKeys } from "./ApiKeyListItem";
|
||||||
|
|
|
@ -7,11 +7,11 @@ import classNames from "@calcom/lib/classNames";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||||
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
|
|
||||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import { ListItem } from "@components/List";
|
import { ListItem } from "@components/List";
|
||||||
import { Tooltip } from "@components/Tooltip";
|
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
import Badge from "@components/ui/Badge";
|
import Badge from "@components/ui/Badge";
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import EventManager from "@calcom/core/EventManager";
|
||||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import stripe from "@calcom/stripe/server";
|
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 { IS_PRODUCTION } from "@lib/config/constants";
|
||||||
import { HttpError as HttpCode } from "@lib/core/http/error";
|
import { HttpError as HttpCode } from "@lib/core/http/error";
|
||||||
|
@ -49,6 +49,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
attendees: true,
|
attendees: true,
|
||||||
location: true,
|
location: true,
|
||||||
|
eventTypeId: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
id: true,
|
id: true,
|
||||||
uid: true,
|
uid: true,
|
||||||
|
@ -70,6 +71,23 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||||
|
|
||||||
if (!booking) throw new Error("No booking found");
|
if (!booking) throw new Error("No booking found");
|
||||||
|
|
||||||
|
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({ recurringEvent: true });
|
||||||
|
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({ select: eventTypeSelect });
|
||||||
|
type EventTypeRaw = Prisma.EventTypeGetPayload<typeof eventTypeData>;
|
||||||
|
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;
|
const { user } = booking;
|
||||||
|
|
||||||
if (!user) throw new Error("No user found");
|
if (!user) throw new Error("No user found");
|
||||||
|
@ -137,7 +155,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||||
|
|
||||||
await prisma.$transaction([paymentUpdate, bookingUpdate]);
|
await prisma.$transaction([paymentUpdate, bookingUpdate]);
|
||||||
|
|
||||||
await sendScheduledEmails({ ...evt });
|
await sendScheduledEmails({ ...evt }, eventType.recurringEvent);
|
||||||
|
|
||||||
throw new HttpCode({
|
throw new HttpCode({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
|
|
|
@ -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 AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email";
|
||||||
import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-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 OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email";
|
||||||
import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-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<unknown>[] = [];
|
const emailsToSend: Promise<unknown>[] = [];
|
||||||
|
|
||||||
emailsToSend.push(
|
emailsToSend.push(
|
||||||
...calEvent.attendees.map((attendee) => {
|
...calEvent.attendees.map((attendee) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee);
|
const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee, recurringEvent);
|
||||||
resolve(scheduledEmail.sendEmail());
|
resolve(scheduledEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||||
|
@ -36,7 +37,7 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
|
||||||
emailsToSend.push(
|
emailsToSend.push(
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const scheduledEmail = new OrganizerScheduledEmail(calEvent);
|
const scheduledEmail = new OrganizerScheduledEmail(calEvent, recurringEvent);
|
||||||
resolve(scheduledEmail.sendEmail());
|
resolve(scheduledEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
|
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
|
||||||
|
@ -47,14 +48,14 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
|
||||||
await Promise.all(emailsToSend);
|
await Promise.all(emailsToSend);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
export const sendRescheduledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||||
const emailsToSend: Promise<unknown>[] = [];
|
const emailsToSend: Promise<unknown>[] = [];
|
||||||
|
|
||||||
emailsToSend.push(
|
emailsToSend.push(
|
||||||
...calEvent.attendees.map((attendee) => {
|
...calEvent.attendees.map((attendee) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee);
|
const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee, recurringEvent);
|
||||||
resolve(scheduledEmail.sendEmail());
|
resolve(scheduledEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||||
|
@ -66,7 +67,7 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
||||||
emailsToSend.push(
|
emailsToSend.push(
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const scheduledEmail = new OrganizerRescheduledEmail(calEvent);
|
const scheduledEmail = new OrganizerRescheduledEmail(calEvent, recurringEvent);
|
||||||
resolve(scheduledEmail.sendEmail());
|
resolve(scheduledEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
|
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
|
||||||
|
@ -77,10 +78,13 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
||||||
await Promise.all(emailsToSend);
|
await Promise.all(emailsToSend);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
|
export const sendOrganizerRequestEmail = async (
|
||||||
|
calEvent: CalendarEvent,
|
||||||
|
recurringEvent: RecurringEvent = {}
|
||||||
|
) => {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const organizerRequestEmail = new OrganizerRequestEmail(calEvent);
|
const organizerRequestEmail = new OrganizerRequestEmail(calEvent, recurringEvent);
|
||||||
resolve(organizerRequestEmail.sendEmail());
|
resolve(organizerRequestEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("OrganizerRequestEmail.sendEmail failed", 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) => {
|
await new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee);
|
const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee, recurringEvent);
|
||||||
resolve(attendeeRequestEmail.sendEmail());
|
resolve(attendeeRequestEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("AttendRequestEmail.sendEmail failed", 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<unknown>[] = [];
|
const emailsToSend: Promise<unknown>[] = [];
|
||||||
|
|
||||||
emailsToSend.push(
|
emailsToSend.push(
|
||||||
...calEvent.attendees.map((attendee) => {
|
...calEvent.attendees.map((attendee) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee);
|
const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee, recurringEvent);
|
||||||
resolve(declinedEmail.sendEmail());
|
resolve(declinedEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||||
|
@ -118,14 +126,14 @@ export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
|
||||||
await Promise.all(emailsToSend);
|
await Promise.all(emailsToSend);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
|
export const sendCancelledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||||
const emailsToSend: Promise<unknown>[] = [];
|
const emailsToSend: Promise<unknown>[] = [];
|
||||||
|
|
||||||
emailsToSend.push(
|
emailsToSend.push(
|
||||||
...calEvent.attendees.map((attendee) => {
|
...calEvent.attendees.map((attendee) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee);
|
const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee, recurringEvent);
|
||||||
resolve(scheduledEmail.sendEmail());
|
resolve(scheduledEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("AttendeeCancelledEmail.sendEmail failed", e));
|
reject(console.error("AttendeeCancelledEmail.sendEmail failed", e));
|
||||||
|
@ -137,7 +145,7 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
|
||||||
emailsToSend.push(
|
emailsToSend.push(
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const scheduledEmail = new OrganizerCancelledEmail(calEvent);
|
const scheduledEmail = new OrganizerCancelledEmail(calEvent, recurringEvent);
|
||||||
resolve(scheduledEmail.sendEmail());
|
resolve(scheduledEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("OrganizerCancelledEmail.sendEmail failed", e));
|
reject(console.error("OrganizerCancelledEmail.sendEmail failed", e));
|
||||||
|
@ -148,10 +156,13 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
|
||||||
await Promise.all(emailsToSend);
|
await Promise.all(emailsToSend);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => {
|
export const sendOrganizerRequestReminderEmail = async (
|
||||||
|
calEvent: CalendarEvent,
|
||||||
|
recurringEvent: RecurringEvent = {}
|
||||||
|
) => {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent);
|
const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent, recurringEvent);
|
||||||
resolve(organizerRequestReminderEmail.sendEmail());
|
resolve(organizerRequestReminderEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("OrganizerRequestReminderEmail.sendEmail failed", 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<unknown>[] = [];
|
const emailsToSend: Promise<unknown>[] = [];
|
||||||
|
|
||||||
emailsToSend.push(
|
emailsToSend.push(
|
||||||
...calEvent.attendees.map((attendee) => {
|
...calEvent.attendees.map((attendee) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee);
|
const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee, recurringEvent);
|
||||||
resolve(paymentEmail.sendEmail());
|
resolve(paymentEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("AttendeeAwaitingPaymentEmail.sendEmail failed", e));
|
reject(console.error("AttendeeAwaitingPaymentEmail.sendEmail failed", e));
|
||||||
|
@ -178,10 +192,13 @@ export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
|
||||||
await Promise.all(emailsToSend);
|
await Promise.all(emailsToSend);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => {
|
export const sendOrganizerPaymentRefundFailedEmail = async (
|
||||||
|
calEvent: CalendarEvent,
|
||||||
|
recurringEvent: RecurringEvent = {}
|
||||||
|
) => {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent);
|
const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent, recurringEvent);
|
||||||
resolve(paymentRefundFailedEmail.sendEmail());
|
resolve(paymentRefundFailedEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e));
|
reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e));
|
||||||
|
@ -213,14 +230,19 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
|
||||||
|
|
||||||
export const sendRequestRescheduleEmail = async (
|
export const sendRequestRescheduleEmail = async (
|
||||||
calEvent: CalendarEvent,
|
calEvent: CalendarEvent,
|
||||||
metadata: { rescheduleLink: string }
|
metadata: { rescheduleLink: string },
|
||||||
|
recurringEvent: RecurringEvent = {}
|
||||||
) => {
|
) => {
|
||||||
const emailsToSend: Promise<unknown>[] = [];
|
const emailsToSend: Promise<unknown>[] = [];
|
||||||
|
|
||||||
emailsToSend.push(
|
emailsToSend.push(
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(calEvent, metadata);
|
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(
|
||||||
|
calEvent,
|
||||||
|
metadata,
|
||||||
|
recurringEvent
|
||||||
|
);
|
||||||
resolve(requestRescheduleEmail.sendEmail());
|
resolve(requestRescheduleEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
|
reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
|
||||||
|
@ -231,7 +253,11 @@ export const sendRequestRescheduleEmail = async (
|
||||||
emailsToSend.push(
|
emailsToSend.push(
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(calEvent, metadata);
|
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(
|
||||||
|
calEvent,
|
||||||
|
metadata,
|
||||||
|
recurringEvent
|
||||||
|
);
|
||||||
resolve(requestRescheduleEmail.sendEmail());
|
resolve(requestRescheduleEmail.sendEmail());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));
|
reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));
|
||||||
|
|
|
@ -42,7 +42,9 @@ export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail {
|
||||||
|
|
||||||
protected getTextBody(): string {
|
protected getTextBody(): string {
|
||||||
return `
|
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.attendee.language.translate("emailed_you_and_any_other_attendees")}
|
||||||
${this.getWhat()}
|
${this.getWhat()}
|
||||||
${this.getWhen()}
|
${this.getWhen()}
|
||||||
|
@ -75,7 +77,9 @@ ${this.getRejectionReason()}
|
||||||
<div style="background-color:#F5F5F5;">
|
<div style="background-color:#F5F5F5;">
|
||||||
${emailSchedulingBodyHeader("xCircle")}
|
${emailSchedulingBodyHeader("xCircle")}
|
||||||
${emailScheduledBodyHeaderContent(
|
${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")
|
this.attendee.language.translate("emailed_you_and_any_other_attendees")
|
||||||
)}
|
)}
|
||||||
${emailSchedulingBodyDivider()}
|
${emailSchedulingBodyDivider()}
|
||||||
|
|
|
@ -87,10 +87,17 @@ ${this.getAdditionalNotes()}
|
||||||
<div style="background-color:#F5F5F5;">
|
<div style="background-color:#F5F5F5;">
|
||||||
${emailSchedulingBodyHeader("calendarCircle")}
|
${emailSchedulingBodyHeader("calendarCircle")}
|
||||||
${emailScheduledBodyHeaderContent(
|
${emailScheduledBodyHeaderContent(
|
||||||
this.calEvent.organizer.language.translate("booking_submitted"),
|
this.calEvent.organizer.language.translate(
|
||||||
this.calEvent.organizer.language.translate("user_needs_to_confirm_or_reject_booking", {
|
this.recurringEvent?.count ? "booking_submitted_recurring" : "booking_submitted"
|
||||||
user: this.calEvent.organizer.name,
|
),
|
||||||
})
|
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()}
|
${emailSchedulingBodyDivider()}
|
||||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
|
|
@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
|
||||||
import { createEvent, DateArray, Person } from "ics";
|
import { createEvent, DateArray, Person } from "ics";
|
||||||
|
|
||||||
import { getCancelLink } from "@calcom/lib/CalEventParser";
|
import { getCancelLink } from "@calcom/lib/CalEventParser";
|
||||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
emailHead,
|
emailHead,
|
||||||
|
@ -24,8 +24,8 @@ dayjs.extend(toArray);
|
||||||
|
|
||||||
export default class AttendeeRequestRescheduledEmail extends OrganizerScheduledEmail {
|
export default class AttendeeRequestRescheduledEmail extends OrganizerScheduledEmail {
|
||||||
private metadata: { rescheduleLink: string };
|
private metadata: { rescheduleLink: string };
|
||||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
|
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
|
||||||
super(calEvent);
|
super(calEvent, recurringEvent);
|
||||||
this.metadata = metadata;
|
this.metadata = metadata;
|
||||||
}
|
}
|
||||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||||
|
|
|
@ -53,7 +53,7 @@ export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
|
||||||
${this.attendee.language.translate("event_has_been_rescheduled")}
|
${this.attendee.language.translate("event_has_been_rescheduled")}
|
||||||
${this.attendee.language.translate("emailed_you_and_any_other_attendees")}
|
${this.attendee.language.translate("emailed_you_and_any_other_attendees")}
|
||||||
${this.getWhat()}
|
${this.getWhat()}
|
||||||
${this.getWhen()}
|
${this.getWhen()}
|
||||||
${this.getLocation()}
|
${this.getLocation()}
|
||||||
${this.getDescription()}
|
${this.getDescription()}
|
||||||
${this.getAdditionalNotes()}
|
${this.getAdditionalNotes()}
|
||||||
|
|
|
@ -4,13 +4,15 @@ import timezone from "dayjs/plugin/timezone";
|
||||||
import toArray from "dayjs/plugin/toArray";
|
import toArray from "dayjs/plugin/toArray";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { createEvent, DateArray } from "ics";
|
import { createEvent, DateArray } from "ics";
|
||||||
|
import { DatasetJsonLdProps } from "next-seo";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
import rrule from "rrule";
|
||||||
|
|
||||||
import { getAppName } from "@calcom/app-store/utils";
|
import { getAppName } from "@calcom/app-store/utils";
|
||||||
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
|
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||||
import type { Person, CalendarEvent } from "@calcom/types/Calendar";
|
import type { Person, CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
emailHead,
|
emailHead,
|
||||||
|
@ -29,10 +31,12 @@ dayjs.extend(toArray);
|
||||||
export default class AttendeeScheduledEmail {
|
export default class AttendeeScheduledEmail {
|
||||||
calEvent: CalendarEvent;
|
calEvent: CalendarEvent;
|
||||||
attendee: Person;
|
attendee: Person;
|
||||||
|
recurringEvent: RecurringEvent;
|
||||||
|
|
||||||
constructor(calEvent: CalendarEvent, attendee: Person) {
|
constructor(calEvent: CalendarEvent, attendee: Person, recurringEvent: RecurringEvent) {
|
||||||
this.calEvent = calEvent;
|
this.calEvent = calEvent;
|
||||||
this.attendee = attendee;
|
this.attendee = attendee;
|
||||||
|
this.recurringEvent = recurringEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendEmail() {
|
public sendEmail() {
|
||||||
|
@ -53,6 +57,11 @@ export default class AttendeeScheduledEmail {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getiCalEventAsString(): string | undefined {
|
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({
|
const icsEvent = createEvent({
|
||||||
start: dayjs(this.calEvent.startTime)
|
start: dayjs(this.calEvent.startTime)
|
||||||
.utc()
|
.utc()
|
||||||
|
@ -72,6 +81,7 @@ export default class AttendeeScheduledEmail {
|
||||||
name: attendee.name,
|
name: attendee.name,
|
||||||
email: attendee.email,
|
email: attendee.email,
|
||||||
})),
|
})),
|
||||||
|
...{ recurrenceRule },
|
||||||
status: "CONFIRMED",
|
status: "CONFIRMED",
|
||||||
});
|
});
|
||||||
if (icsEvent.error) {
|
if (icsEvent.error) {
|
||||||
|
@ -125,7 +135,9 @@ export default class AttendeeScheduledEmail {
|
||||||
}
|
}
|
||||||
protected getTextBody(): string {
|
protected getTextBody(): string {
|
||||||
return `
|
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")}
|
${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")}
|
||||||
|
|
||||||
${getRichDescription(this.calEvent)}
|
${getRichDescription(this.calEvent)}
|
||||||
|
@ -157,7 +169,11 @@ ${getRichDescription(this.calEvent)}
|
||||||
<div style="background-color:#F5F5F5;">
|
<div style="background-color:#F5F5F5;">
|
||||||
${emailSchedulingBodyHeader("checkCircle")}
|
${emailSchedulingBodyHeader("checkCircle")}
|
||||||
${emailScheduledBodyHeaderContent(
|
${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")
|
this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")
|
||||||
)}
|
)}
|
||||||
${emailSchedulingBodyDivider()}
|
${emailSchedulingBodyDivider()}
|
||||||
|
@ -250,12 +266,30 @@ ${getRichDescription(this.calEvent)}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
protected getWhen(): string {
|
||||||
return `
|
return `
|
||||||
<p style="height: 6px"></p>
|
<p style="height: 6px"></p>
|
||||||
<div style="line-height: 6px;">
|
<div style="line-height: 6px;">
|
||||||
<p style="color: #494949;">${this.calEvent.attendees[0].language.translate("when")}</p>
|
<p style="color: #494949;">${this.calEvent.attendees[0].language.translate("when")}${
|
||||||
|
this.recurringEvent?.count ? this.getRecurringWhen() : ""
|
||||||
|
}</p>
|
||||||
<p style="color: #494949; font-weight: 400; line-height: 24px;">
|
<p style="color: #494949; font-weight: 400; line-height: 24px;">
|
||||||
|
${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
|
||||||
${this.calEvent.attendees[0].language.translate(
|
${this.calEvent.attendees[0].language.translate(
|
||||||
this.getInviteeStart().format("dddd").toLowerCase()
|
this.getInviteeStart().format("dddd").toLowerCase()
|
||||||
)}, ${this.calEvent.attendees[0].language.translate(
|
)}, ${this.calEvent.attendees[0].language.translate(
|
||||||
|
|
|
@ -86,7 +86,9 @@ ${process.env.NEXT_PUBLIC_WEBAPP_URL} + "/bookings/upcoming"
|
||||||
<div style="background-color:#F5F5F5;">
|
<div style="background-color:#F5F5F5;">
|
||||||
${emailSchedulingBodyHeader("calendarCircle")}
|
${emailSchedulingBodyHeader("calendarCircle")}
|
||||||
${emailScheduledBodyHeaderContent(
|
${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")
|
this.calEvent.organizer.language.translate("someone_requested_an_event")
|
||||||
)}
|
)}
|
||||||
${emailSchedulingBodyDivider()}
|
${emailSchedulingBodyDivider()}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
|
||||||
import { createEvent, DateArray, Person } from "ics";
|
import { createEvent, DateArray, Person } from "ics";
|
||||||
|
|
||||||
import { getCancelLink } from "@calcom/lib/CalEventParser";
|
import { getCancelLink } from "@calcom/lib/CalEventParser";
|
||||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
emailHead,
|
emailHead,
|
||||||
|
@ -24,8 +24,8 @@ dayjs.extend(toArray);
|
||||||
|
|
||||||
export default class OrganizerRequestRescheduledEmail extends OrganizerScheduledEmail {
|
export default class OrganizerRequestRescheduledEmail extends OrganizerScheduledEmail {
|
||||||
private metadata: { rescheduleLink: string };
|
private metadata: { rescheduleLink: string };
|
||||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
|
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
|
||||||
super(calEvent);
|
super(calEvent, recurringEvent);
|
||||||
this.metadata = metadata;
|
this.metadata = metadata;
|
||||||
}
|
}
|
||||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||||
|
|
|
@ -5,12 +5,13 @@ import toArray from "dayjs/plugin/toArray";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { createEvent, DateArray, Person } from "ics";
|
import { createEvent, DateArray, Person } from "ics";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
import rrule from "rrule";
|
||||||
|
|
||||||
import { getAppName } from "@calcom/app-store/utils";
|
import { getAppName } from "@calcom/app-store/utils";
|
||||||
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
|
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
import type { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
emailHead,
|
emailHead,
|
||||||
|
@ -28,9 +29,11 @@ dayjs.extend(toArray);
|
||||||
|
|
||||||
export default class OrganizerScheduledEmail {
|
export default class OrganizerScheduledEmail {
|
||||||
calEvent: CalendarEvent;
|
calEvent: CalendarEvent;
|
||||||
|
recurringEvent: RecurringEvent;
|
||||||
|
|
||||||
constructor(calEvent: CalendarEvent) {
|
constructor(calEvent: CalendarEvent, recurringEvent: RecurringEvent) {
|
||||||
this.calEvent = calEvent;
|
this.calEvent = calEvent;
|
||||||
|
this.recurringEvent = recurringEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendEmail() {
|
public sendEmail() {
|
||||||
|
@ -51,6 +54,11 @@ export default class OrganizerScheduledEmail {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getiCalEventAsString(): string | undefined {
|
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({
|
const icsEvent = createEvent({
|
||||||
start: dayjs(this.calEvent.startTime)
|
start: dayjs(this.calEvent.startTime)
|
||||||
.utc()
|
.utc()
|
||||||
|
@ -66,6 +74,7 @@ export default class OrganizerScheduledEmail {
|
||||||
description: this.getTextBody(),
|
description: this.getTextBody(),
|
||||||
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
||||||
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
||||||
|
...{ recurrenceRule },
|
||||||
attendees: this.calEvent.attendees.map((attendee: Person) => ({
|
attendees: this.calEvent.attendees.map((attendee: Person) => ({
|
||||||
name: attendee.name,
|
name: attendee.name,
|
||||||
email: attendee.email,
|
email: attendee.email,
|
||||||
|
@ -121,7 +130,9 @@ export default class OrganizerScheduledEmail {
|
||||||
|
|
||||||
protected getTextBody(): string {
|
protected getTextBody(): string {
|
||||||
return `
|
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")}
|
${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")}
|
||||||
|
|
||||||
${getRichDescription(this.calEvent)}
|
${getRichDescription(this.calEvent)}
|
||||||
|
@ -153,7 +164,9 @@ ${getRichDescription(this.calEvent)}
|
||||||
<div style="background-color:#F5F5F5;">
|
<div style="background-color:#F5F5F5;">
|
||||||
${emailSchedulingBodyHeader("checkCircle")}
|
${emailSchedulingBodyHeader("checkCircle")}
|
||||||
${emailScheduledBodyHeaderContent(
|
${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")
|
this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")
|
||||||
)}
|
)}
|
||||||
${emailSchedulingBodyDivider()}
|
${emailSchedulingBodyDivider()}
|
||||||
|
@ -240,12 +253,30 @@ ${getRichDescription(this.calEvent)}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
protected getWhen(): string {
|
||||||
return `
|
return `
|
||||||
<p style="height: 6px"></p>
|
<p style="height: 6px"></p>
|
||||||
<div style="line-height: 6px;">
|
<div style="line-height: 6px;">
|
||||||
<p style="color: #494949;">${this.calEvent.organizer.language.translate("when")}</p>
|
<p style="color: #494949;">${this.calEvent.organizer.language.translate("when")}${
|
||||||
|
this.recurringEvent?.count ? this.getRecurringWhen() : ""
|
||||||
|
}</p>
|
||||||
<p style="color: #494949; font-weight: 400; line-height: 24px;">
|
<p style="color: #494949; font-weight: 400; line-height: 24px;">
|
||||||
|
${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
|
||||||
${this.calEvent.organizer.language.translate(
|
${this.calEvent.organizer.language.translate(
|
||||||
this.getOrganizerStart().format("dddd").toLowerCase()
|
this.getOrganizerStart().format("dddd").toLowerCase()
|
||||||
)}, ${this.calEvent.organizer.language.translate(
|
)}, ${this.calEvent.organizer.language.translate(
|
||||||
|
|
22
apps/web/lib/mutations/bookings/create-recurring-booking.ts
Normal file
22
apps/web/lib/mutations/bookings/create-recurring-booking.ts
Normal file
|
@ -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<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", booking);
|
||||||
|
} else {
|
||||||
|
return fetch.post<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", {
|
||||||
|
...booking,
|
||||||
|
noEmail: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createRecurringBooking;
|
|
@ -1,14 +1,42 @@
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import { I18n } from "next-i18next";
|
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 { detectBrowserTimeFormat } from "@lib/timeFormat";
|
||||||
|
|
||||||
import { parseZone } from "./parseZone";
|
import { parseZone } from "./parseZone";
|
||||||
|
|
||||||
export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
|
const processDate = (date: string | null | Dayjs, i18n: I18n) => {
|
||||||
if (!date) return "No date";
|
|
||||||
const parsedZone = parseZone(date);
|
const parsedZone = parseZone(date);
|
||||||
if (!parsedZone?.isValid()) return "Invalid date";
|
if (!parsedZone?.isValid()) return "Invalid date";
|
||||||
const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
|
const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
|
||||||
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
|
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()];
|
||||||
|
};
|
||||||
|
|
|
@ -44,6 +44,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
|
||||||
length: true,
|
length: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
schedulingType: true,
|
schedulingType: true,
|
||||||
|
recurringEvent: true,
|
||||||
price: true,
|
price: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
users: {
|
users: {
|
||||||
|
|
|
@ -19,6 +19,7 @@ export type BookingCreateBody = {
|
||||||
name: string;
|
name: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
rescheduleUid?: string;
|
rescheduleUid?: string;
|
||||||
|
recurringEventId?: string;
|
||||||
start: string;
|
start: string;
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
user?: string | string[];
|
user?: string | string[];
|
||||||
|
|
|
@ -104,6 +104,7 @@
|
||||||
"react-use-intercom": "1.4.0",
|
"react-use-intercom": "1.4.0",
|
||||||
"react-virtualized-auto-sizer": "^1.0.6",
|
"react-virtualized-auto-sizer": "^1.0.6",
|
||||||
"react-window": "^1.8.6",
|
"react-window": "^1.8.6",
|
||||||
|
"rrule": "^2.6.9",
|
||||||
"short-uuid": "^4.2.0",
|
"short-uuid": "^4.2.0",
|
||||||
"stripe": "^8.191.0",
|
"stripe": "^8.191.0",
|
||||||
"superjson": "1.8.1",
|
"superjson": "1.8.1",
|
||||||
|
|
|
@ -18,6 +18,7 @@ import defaultEvents, {
|
||||||
getUsernameSlugLink,
|
getUsernameSlugLink,
|
||||||
} from "@calcom/lib/defaultEvents";
|
} from "@calcom/lib/defaultEvents";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||||
import useTheme from "@lib/hooks/useTheme";
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
|
@ -272,6 +273,7 @@ const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) =>
|
||||||
description: true,
|
description: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
schedulingType: true,
|
schedulingType: true,
|
||||||
|
recurringEvent: true,
|
||||||
price: true,
|
price: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { JSONObject } from "superjson/dist/types";
|
||||||
|
|
||||||
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { getWorkingHours } from "@lib/availability";
|
import { getWorkingHours } from "@lib/availability";
|
||||||
|
@ -84,6 +85,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
periodDays: true,
|
periodDays: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
schedulingType: true,
|
schedulingType: true,
|
||||||
|
recurringEvent: true,
|
||||||
schedule: {
|
schedule: {
|
||||||
select: {
|
select: {
|
||||||
availability: true,
|
availability: true,
|
||||||
|
@ -256,6 +258,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
metadata: (eventType.metadata || {}) as JSONObject,
|
metadata: (eventType.metadata || {}) as JSONObject,
|
||||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||||
|
recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent,
|
||||||
});
|
});
|
||||||
|
|
||||||
const schedule = eventType.schedule
|
const schedule = eventType.schedule
|
||||||
|
|
|
@ -12,8 +12,9 @@ import {
|
||||||
getUsernameList,
|
getUsernameList,
|
||||||
} from "@calcom/lib/defaultEvents";
|
} from "@calcom/lib/defaultEvents";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
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 getBooking, { GetBookingType } from "@lib/getBooking";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
@ -69,6 +70,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const ssr = await ssrInit(context);
|
const ssr = await ssrInit(context);
|
||||||
const usernameList = getUsernameList(asStringOrThrow(context.query.user as string));
|
const usernameList = getUsernameList(asStringOrThrow(context.query.user as string));
|
||||||
const eventTypeSlug = context.query.slug as string;
|
const eventTypeSlug = context.query.slug as string;
|
||||||
|
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
username: {
|
username: {
|
||||||
|
@ -111,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
periodDays: true,
|
periodDays: true,
|
||||||
periodStartDate: true,
|
periodStartDate: true,
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
|
recurringEvent: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
price: true,
|
price: true,
|
||||||
|
@ -150,6 +153,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const eventType = {
|
const eventType = {
|
||||||
...eventTypeRaw,
|
...eventTypeRaw,
|
||||||
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
||||||
|
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
|
||||||
isWeb3Active:
|
isWeb3Active:
|
||||||
web3Credentials && web3Credentials.key
|
web3Credentials && web3Credentials.key
|
||||||
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
|
? (((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");
|
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 {
|
return {
|
||||||
props: {
|
props: {
|
||||||
away: user.away,
|
away: user.away,
|
||||||
|
@ -211,6 +224,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
profile,
|
profile,
|
||||||
eventType: eventTypeObject,
|
eventType: eventTypeObject,
|
||||||
booking,
|
booking,
|
||||||
|
recurringEventCount,
|
||||||
trpcState: ssr.dehydrate(),
|
trpcState: ssr.dehydrate(),
|
||||||
isDynamicGroupBooking,
|
isDynamicGroupBooking,
|
||||||
hasHashedBookingLink: false,
|
hasHashedBookingLink: false,
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { Prisma, User, Booking, SchedulingType, BookingStatus } from "@prisma/client";
|
import { Prisma, User, Booking, SchedulingType, BookingStatus } from "@prisma/client";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import rrule from "rrule";
|
||||||
|
|
||||||
import EventManager from "@calcom/core/EventManager";
|
import EventManager from "@calcom/core/EventManager";
|
||||||
import logger from "@calcom/lib/logger";
|
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 type { CalendarEvent } from "@calcom/types/Calendar";
|
||||||
import { refund } from "@ee/lib/stripe/server";
|
import { refund } from "@ee/lib/stripe/server";
|
||||||
|
|
||||||
|
@ -94,12 +95,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
attendees: true,
|
attendees: true,
|
||||||
eventTypeId: true,
|
eventTypeId: true,
|
||||||
|
eventType: {
|
||||||
|
select: {
|
||||||
|
recurringEvent: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
location: true,
|
location: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
id: true,
|
id: true,
|
||||||
uid: true,
|
uid: true,
|
||||||
payment: true,
|
payment: true,
|
||||||
destinationCalendar: true,
|
destinationCalendar: true,
|
||||||
|
recurringEventId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -147,6 +154,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar,
|
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) {
|
if (reqBody.confirmed) {
|
||||||
const eventManager = new EventManager(currentUser);
|
const eventManager = new EventManager(currentUser);
|
||||||
const scheduleResult = await eventManager.create(evt);
|
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;
|
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
log.error(error);
|
log.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
|
if (req.body.recurringEventId) {
|
||||||
// Should perform update on booking (confirm) -> then trigger the rest handlers
|
// The booking to confirm is a recurring event and comes from /booking/upcoming, proceeding to mark all related
|
||||||
await prisma.booking.update({
|
// bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now.
|
||||||
where: {
|
const unconfirmedRecurringBookings = await prisma.booking.findMany({
|
||||||
id: bookingId,
|
where: {
|
||||||
},
|
recurringEventId: req.body.recurringEventId,
|
||||||
data: {
|
confirmed: false,
|
||||||
confirmed: true,
|
|
||||||
references: {
|
|
||||||
create: scheduleResult.referencesToCreate,
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
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();
|
res.status(204).end();
|
||||||
} else {
|
} else {
|
||||||
await refund(booking, evt);
|
|
||||||
const rejectionReason = asStringOrNull(req.body.reason) || "";
|
const rejectionReason = asStringOrNull(req.body.reason) || "";
|
||||||
evt.rejectionReason = rejectionReason;
|
evt.rejectionReason = rejectionReason;
|
||||||
await prisma.booking.update({
|
if (req.body.recurringEventId) {
|
||||||
where: {
|
// The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related
|
||||||
id: bookingId,
|
// bookings as rejected. Prisma updateMany does not support relations, so doing this in two steps for now.
|
||||||
},
|
const unconfirmedRecurringBookings = await prisma.booking.findMany({
|
||||||
data: {
|
where: {
|
||||||
rejected: true,
|
recurringEventId: req.body.recurringEventId,
|
||||||
status: BookingStatus.REJECTED,
|
confirmed: false,
|
||||||
rejectionReason: rejectionReason,
|
},
|
||||||
},
|
});
|
||||||
});
|
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();
|
res.status(204).end();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,4 @@
|
||||||
import {
|
import { BookingStatus, Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
|
||||||
BookingStatus,
|
|
||||||
Credential,
|
|
||||||
Payment,
|
|
||||||
Prisma,
|
|
||||||
SchedulingType,
|
|
||||||
WebhookTriggerEvents,
|
|
||||||
} from "@prisma/client";
|
|
||||||
import async from "async";
|
import async from "async";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import dayjsBusinessTime from "dayjs-business-time";
|
import dayjsBusinessTime from "dayjs-business-time";
|
||||||
|
@ -13,18 +6,24 @@ import isBetween from "dayjs/plugin/isBetween";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import rrule from "rrule";
|
||||||
import short from "short-uuid";
|
import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
|
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
|
||||||
import EventManager from "@calcom/core/EventManager";
|
import EventManager from "@calcom/core/EventManager";
|
||||||
import { getBusyVideoTimes } from "@calcom/core/videoClient";
|
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 { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import notEmpty from "@calcom/lib/notEmpty";
|
import notEmpty from "@calcom/lib/notEmpty";
|
||||||
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
|
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 type { EventResult, PartialReference } from "@calcom/types/EventManager";
|
||||||
import { handlePayment } from "@ee/lib/stripe/server";
|
import { handlePayment } from "@ee/lib/stripe/server";
|
||||||
|
|
||||||
|
@ -83,7 +82,7 @@ async function refreshCredentials(credentials: Array<Credential>): Promise<Array
|
||||||
return await async.mapLimit(credentials, 5, refreshCredential);
|
return await async.mapLimit(credentials, 5, refreshCredential);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAvailable(busyTimes: BufferedBusyTimes, time: string, length: number): boolean {
|
function isAvailable(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, length: number): boolean {
|
||||||
// Check for conflicts
|
// Check for conflicts
|
||||||
let t = true;
|
let t = true;
|
||||||
|
|
||||||
|
@ -190,7 +189,7 @@ const getUserNameWithBookingCounts = async (eventTypeId: number, selectedUserNam
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEventTypesFromDB = async (eventTypeId: number) => {
|
const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||||
return await prisma.eventType.findUnique({
|
const eventType = await prisma.eventType.findUnique({
|
||||||
rejectOnNotFound: true,
|
rejectOnNotFound: true,
|
||||||
where: {
|
where: {
|
||||||
id: eventTypeId,
|
id: eventTypeId,
|
||||||
|
@ -220,14 +219,22 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||||
metadata: true,
|
metadata: true,
|
||||||
destinationCalendar: true,
|
destinationCalendar: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
|
recurringEvent: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...eventType,
|
||||||
|
recurringEvent: (eventType.recurringEvent || undefined) as RecurringEvent,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type User = Prisma.UserGetPayload<typeof userSelect>;
|
type User = Prisma.UserGetPayload<typeof userSelect>;
|
||||||
|
|
||||||
|
type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number };
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
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
|
// handle dynamic user
|
||||||
const dynamicUserList = Array.isArray(reqBody.user)
|
const dynamicUserList = Array.isArray(reqBody.user)
|
||||||
|
@ -382,6 +389,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
}; // used for invitee emails
|
}; // 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
|
// Initialize EventManager with credentials
|
||||||
const rescheduleUid = reqBody.rescheduleUid;
|
const rescheduleUid = reqBody.rescheduleUid;
|
||||||
async function getOriginalRescheduledBooking(uid: string) {
|
async function getOriginalRescheduledBooking(uid: string) {
|
||||||
|
@ -481,6 +494,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
if (reqBody.recurringEventId) {
|
||||||
|
newBookingData.recurringEventId = reqBody.recurringEventId;
|
||||||
|
}
|
||||||
if (originalRescheduledBooking) {
|
if (originalRescheduledBooking) {
|
||||||
newBookingData["paid"] = originalRescheduledBooking.paid;
|
newBookingData["paid"] = originalRescheduledBooking.paid;
|
||||||
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
|
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
|
||||||
|
@ -573,7 +589,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
|
|
||||||
let isAvailableToBeBooked = true;
|
let isAvailableToBeBooked = true;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
log.debug({
|
log.debug({
|
||||||
message: "Unable set isAvailableToBeBooked. Using true. ",
|
message: "Unable set isAvailableToBeBooked. Using true. ",
|
||||||
|
@ -674,11 +701,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendRescheduledEmails({
|
if (noEmail !== true) {
|
||||||
...evt,
|
await sendRescheduledEmails(
|
||||||
additionInformation: metadata,
|
{
|
||||||
additionalNotes, // Resets back to the addtionalNote input and not the overriden value
|
...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,
|
// If it's not a reschedule, doesn't require confirmation and there's no price,
|
||||||
// Create a booking
|
// Create a booking
|
||||||
|
@ -708,17 +740,29 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
metadata.conferenceData = results[0].createdEvent?.conferenceData;
|
metadata.conferenceData = results[0].createdEvent?.conferenceData;
|
||||||
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||||
}
|
}
|
||||||
await sendScheduledEmails({
|
if (noEmail !== true) {
|
||||||
...evt,
|
await sendScheduledEmails(
|
||||||
additionInformation: metadata,
|
{
|
||||||
additionalNotes,
|
...evt,
|
||||||
});
|
additionInformation: metadata,
|
||||||
|
additionalNotes,
|
||||||
|
},
|
||||||
|
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventType.requiresConfirmation && !rescheduleUid) {
|
if (eventType.requiresConfirmation && !rescheduleUid && noEmail !== true) {
|
||||||
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
|
await sendOrganizerRequestEmail(
|
||||||
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
|
{ ...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) {
|
if (typeof eventType.price === "number" && eventType.price > 0 && !originalRescheduledBooking?.paid) {
|
||||||
|
|
|
@ -6,7 +6,6 @@ import type { TFunction } from "next-i18next";
|
||||||
import { z, ZodError } from "zod";
|
import { z, ZodError } from "zod";
|
||||||
|
|
||||||
import { getCalendar } from "@calcom/core/CalendarManager";
|
import { getCalendar } from "@calcom/core/CalendarManager";
|
||||||
import EventManager from "@calcom/core/EventManager";
|
|
||||||
import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
|
import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
|
||||||
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
|
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
|
||||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||||
|
@ -100,6 +99,7 @@ const handler = async (
|
||||||
title: true,
|
title: true,
|
||||||
users: true,
|
users: true,
|
||||||
schedulingType: true,
|
schedulingType: true,
|
||||||
|
recurringEvent: true,
|
||||||
},
|
},
|
||||||
rejectOnNotFound: true,
|
rejectOnNotFound: true,
|
||||||
where: {
|
where: {
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { Alert } from "@calcom/ui/Alert";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
|
|
||||||
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
|
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 BookingsShell from "@components/BookingsShell";
|
||||||
import EmptyScreen from "@components/EmptyScreen";
|
import EmptyScreen from "@components/EmptyScreen";
|
||||||
|
@ -17,6 +17,8 @@ import BookingListItem from "@components/booking/BookingListItem";
|
||||||
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
||||||
|
|
||||||
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||||
|
type BookingOutput = inferQueryOutput<"viewer.bookings">["bookings"][0];
|
||||||
|
type BookingPage = inferQueryOutput<"viewer.bookings">;
|
||||||
|
|
||||||
export default function Bookings() {
|
export default function Bookings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -26,6 +28,7 @@ export default function Bookings() {
|
||||||
|
|
||||||
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||||
upcoming: t("upcoming_bookings"),
|
upcoming: t("upcoming_bookings"),
|
||||||
|
recurring: t("recurring_bookings"),
|
||||||
past: t("past_bookings"),
|
past: t("past_bookings"),
|
||||||
cancelled: t("cancelled_bookings"),
|
cancelled: t("cancelled_bookings"),
|
||||||
};
|
};
|
||||||
|
@ -44,6 +47,18 @@ export default function Bookings() {
|
||||||
|
|
||||||
const isEmpty = !query.data?.pages[0]?.bookings.length;
|
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 (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
heading={t("bookings")}
|
heading={t("bookings")}
|
||||||
|
@ -66,7 +81,12 @@ export default function Bookings() {
|
||||||
{query.data.pages.map((page, index) => (
|
{query.data.pages.map((page, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
{page.bookings.map((booking) => (
|
{page.bookings.map((booking) => (
|
||||||
<BookingListItem key={booking.id} {...booking} />
|
<BookingListItem
|
||||||
|
key={booking.id}
|
||||||
|
listingStatus={status}
|
||||||
|
{...defineRecurrentCount(booking, page)}
|
||||||
|
{...booking}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { Prisma } from "@prisma/client";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
import { JSONObject } from "superjson/dist/types";
|
||||||
|
|
||||||
|
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { getWorkingHours } from "@lib/availability";
|
import { getWorkingHours } from "@lib/availability";
|
||||||
import { GetBookingType } from "@lib/getBooking";
|
import { GetBookingType } from "@lib/getBooking";
|
||||||
|
@ -37,6 +39,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
periodDays: true,
|
periodDays: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
|
recurringEvent: true,
|
||||||
schedulingType: true,
|
schedulingType: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
schedule: {
|
schedule: {
|
||||||
|
@ -131,6 +134,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
const [user] = users;
|
const [user] = users;
|
||||||
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
|
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
|
||||||
metadata: {} as JSONObject,
|
metadata: {} as JSONObject,
|
||||||
|
recurringEvent: (eventTypeSelect.recurringEvent || {}) as RecurringEvent,
|
||||||
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
|
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
|
||||||
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
|
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
|
||||||
slug,
|
slug,
|
||||||
|
|
|
@ -6,8 +6,9 @@ import { GetServerSidePropsContext } from "next";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
import { JSONObject } from "superjson/dist/types";
|
||||||
|
|
||||||
import { getLocationLabels } from "@calcom/app-store/utils";
|
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 prisma from "@lib/prisma";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
|
@ -28,6 +29,7 @@ export default function Book(props: HashLinkPageProps) {
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const ssr = await ssrInit(context);
|
const ssr = await ssrInit(context);
|
||||||
const link = asStringOrThrow(context.query.link as string);
|
const link = asStringOrThrow(context.query.link as string);
|
||||||
|
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||||
const slug = context.query.slug as string;
|
const slug = context.query.slug as string;
|
||||||
|
|
||||||
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||||
|
@ -41,6 +43,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
periodType: true,
|
periodType: true,
|
||||||
periodDays: true,
|
periodDays: true,
|
||||||
periodStartDate: true,
|
periodStartDate: true,
|
||||||
|
recurringEvent: true,
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
|
@ -122,6 +125,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const eventType = {
|
const eventType = {
|
||||||
...eventTypeRaw,
|
...eventTypeRaw,
|
||||||
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
||||||
|
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
|
||||||
isWeb3Active:
|
isWeb3Active:
|
||||||
web3Credentials && web3Credentials.key
|
web3Credentials && web3Credentials.key
|
||||||
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
|
? (((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");
|
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 {
|
return {
|
||||||
props: {
|
props: {
|
||||||
locationLabels: getLocationLabels(t),
|
locationLabels: getLocationLabels(t),
|
||||||
|
@ -155,6 +168,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
eventType: eventTypeObject,
|
eventType: eventTypeObject,
|
||||||
booking: null,
|
booking: null,
|
||||||
trpcState: ssr.dehydrate(),
|
trpcState: ssr.dehydrate(),
|
||||||
|
recurringEventCount,
|
||||||
isDynamicGroupBooking: false,
|
isDynamicGroupBooking: false,
|
||||||
hasHashedBookingLink: true,
|
hasHashedBookingLink: true,
|
||||||
hashedLink: link,
|
hashedLink: link,
|
||||||
|
|
|
@ -34,9 +34,11 @@ import getApps, { getLocationOptions } from "@calcom/app-store/utils";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
import showToast from "@calcom/lib/notification";
|
||||||
import { StripeData } from "@calcom/stripe/server";
|
import { StripeData } from "@calcom/stripe/server";
|
||||||
|
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||||
import Switch from "@calcom/ui/Switch";
|
import Switch from "@calcom/ui/Switch";
|
||||||
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
import { Form } from "@calcom/ui/form/fields";
|
import { Form } from "@calcom/ui/form/fields";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
@ -55,9 +57,9 @@ import DestinationCalendarSelector from "@components/DestinationCalendarSelector
|
||||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import { Tooltip } from "@components/Tooltip";
|
|
||||||
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
|
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
|
import RecurringEventController from "@components/eventtype/RecurringEventController";
|
||||||
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
|
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
|
||||||
import Badge from "@components/ui/Badge";
|
import Badge from "@components/ui/Badge";
|
||||||
import InfoBadge from "@components/ui/InfoBadge";
|
import InfoBadge from "@components/ui/InfoBadge";
|
||||||
|
@ -65,7 +67,7 @@ import CheckboxField from "@components/ui/form/CheckboxField";
|
||||||
import CheckedSelect from "@components/ui/form/CheckedSelect";
|
import CheckedSelect from "@components/ui/form/CheckedSelect";
|
||||||
import { DateRangePicker } from "@components/ui/form/DateRangePicker";
|
import { DateRangePicker } from "@components/ui/form/DateRangePicker";
|
||||||
import MinutesField from "@components/ui/form/MinutesField";
|
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 * as RadioArea from "@components/ui/form/radio-area";
|
||||||
import WebhookListContainer from "@components/webhook/WebhookListContainer";
|
import WebhookListContainer from "@components/webhook/WebhookListContainer";
|
||||||
|
|
||||||
|
@ -272,8 +274,12 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
PERIOD_TYPES.find((s) => s.type === eventType.periodType) ||
|
PERIOD_TYPES.find((s) => s.type === eventType.periodType) ||
|
||||||
PERIOD_TYPES.find((s) => s.type === "UNLIMITED");
|
PERIOD_TYPES.find((s) => s.type === "UNLIMITED");
|
||||||
|
|
||||||
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
|
|
||||||
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
|
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
|
||||||
|
|
||||||
|
const [requirePayment, setRequirePayment] = useState(
|
||||||
|
eventType.price > 0 && eventType.recurringEvent?.count !== undefined
|
||||||
|
);
|
||||||
|
|
||||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -483,6 +489,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
description: string;
|
description: string;
|
||||||
disableGuests: boolean;
|
disableGuests: boolean;
|
||||||
requiresConfirmation: boolean;
|
requiresConfirmation: boolean;
|
||||||
|
recurringEvent: RecurringEvent;
|
||||||
schedulingType: SchedulingType | null;
|
schedulingType: SchedulingType | null;
|
||||||
price: number;
|
price: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
@ -510,6 +517,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
}>({
|
}>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
locations: eventType.locations || [],
|
locations: eventType.locations || [],
|
||||||
|
recurringEvent: eventType.recurringEvent || {},
|
||||||
schedule: eventType.schedule?.id,
|
schedule: eventType.schedule?.id,
|
||||||
periodDates: {
|
periodDates: {
|
||||||
startDate: periodDates.startDate,
|
startDate: periodDates.startDate,
|
||||||
|
@ -928,15 +936,15 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
giphyThankYouPage,
|
giphyThankYouPage,
|
||||||
beforeBufferTime,
|
beforeBufferTime,
|
||||||
afterBufferTime,
|
afterBufferTime,
|
||||||
|
recurringEvent,
|
||||||
locations,
|
locations,
|
||||||
...input
|
...input
|
||||||
} = values;
|
} = values;
|
||||||
|
|
||||||
if (requirePayment) input.currency = currency;
|
|
||||||
|
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
...input,
|
...input,
|
||||||
locations,
|
locations,
|
||||||
|
recurringEvent,
|
||||||
periodStartDate: periodDates.startDate,
|
periodStartDate: periodDates.startDate,
|
||||||
periodEndDate: periodDates.endDate,
|
periodEndDate: periodDates.endDate,
|
||||||
periodCountCalendarDays: periodCountCalendarDays === "1",
|
periodCountCalendarDays: periodCountCalendarDays === "1",
|
||||||
|
@ -1334,6 +1342,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RecurringEventController
|
||||||
|
recurringEvent={eventType.recurringEvent}
|
||||||
|
formMethods={formMethods}
|
||||||
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="disableGuests"
|
name="disableGuests"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
|
@ -1641,7 +1654,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
<SuccessRedirectEdit<typeof formMethods>
|
<SuccessRedirectEdit<typeof formMethods>
|
||||||
formMethods={formMethods}
|
formMethods={formMethods}
|
||||||
eventType={eventType}></SuccessRedirectEdit>
|
eventType={eventType}></SuccessRedirectEdit>
|
||||||
{hasPaymentIntegration && (
|
{hasPaymentIntegration && eventType.recurringEvent?.count !== undefined && (
|
||||||
<>
|
<>
|
||||||
<hr className="border-neutral-200" />
|
<hr className="border-neutral-200" />
|
||||||
<div className="block sm:flex">
|
<div className="block sm:flex">
|
||||||
|
@ -2054,6 +2067,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
|
recurringEvent: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
disableGuests: true,
|
disableGuests: true,
|
||||||
minimumBookingNotice: true,
|
minimumBookingNotice: true,
|
||||||
|
@ -2118,6 +2132,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
const { locations, metadata, ...restEventType } = rawEventType;
|
const { locations, metadata, ...restEventType } = rawEventType;
|
||||||
const eventType = {
|
const eventType = {
|
||||||
...restEventType,
|
...restEventType,
|
||||||
|
recurringEvent: (restEventType.recurringEvent || {}) as RecurringEvent,
|
||||||
locations: locations as unknown as Location[],
|
locations: locations as unknown as Location[],
|
||||||
metadata: (metadata || {}) as JSONObject,
|
metadata: (metadata || {}) as JSONObject,
|
||||||
isWeb3Active:
|
isWeb3Active:
|
||||||
|
|
|
@ -31,6 +31,7 @@ import Dropdown, {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@calcom/ui/Dropdown";
|
} from "@calcom/ui/Dropdown";
|
||||||
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
|
|
||||||
import { withQuery } from "@lib/QueryCell";
|
import { withQuery } from "@lib/QueryCell";
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
@ -40,7 +41,6 @@ import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||||
import EmptyScreen from "@components/EmptyScreen";
|
import EmptyScreen from "@components/EmptyScreen";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import { Tooltip } from "@components/Tooltip";
|
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
|
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
|
||||||
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { CheckIcon } from "@heroicons/react/outline";
|
import { CheckIcon } from "@heroicons/react/outline";
|
||||||
import { ArrowLeftIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
|
import { ArrowLeftIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
@ -11,6 +12,7 @@ import { useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import rrule from "rrule";
|
||||||
|
|
||||||
import { SpaceBookingSuccessPage } from "@calcom/app-store/spacebooking/components";
|
import { SpaceBookingSuccessPage } from "@calcom/app-store/spacebooking/components";
|
||||||
import {
|
import {
|
||||||
|
@ -21,6 +23,7 @@ import {
|
||||||
} from "@calcom/embed-core";
|
} from "@calcom/embed-core";
|
||||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { EmailInput } from "@calcom/ui/form/fields";
|
import { EmailInput } from "@calcom/ui/form/fields";
|
||||||
|
|
||||||
|
@ -133,7 +136,9 @@ function RedirectionToast({ url }: { url: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Success(props: inferSSRProps<typeof getServerSideProps>) {
|
type SuccessProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
|
export default function Success(props: SuccessProps) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { location: _location, name, reschedule } = router.query;
|
const { location: _location, name, reschedule } = router.query;
|
||||||
|
@ -212,7 +217,23 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||||
|
|
||||||
return encodeURIComponent(event.value ? event.value : false);
|
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 userIsOwner = !!(session?.user?.id && eventType.users.find((user) => (user.id = session.user.id)));
|
||||||
|
const title = t(
|
||||||
|
`booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}`
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
(isReady && (
|
(isReady && (
|
||||||
<>
|
<>
|
||||||
|
@ -220,10 +241,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||||
className={isEmbed ? "" : "h-screen bg-neutral-100 dark:bg-neutral-900"}
|
className={isEmbed ? "" : "h-screen bg-neutral-100 dark:bg-neutral-900"}
|
||||||
data-testid="success-page">
|
data-testid="success-page">
|
||||||
<Theme />
|
<Theme />
|
||||||
<HeadSeo
|
<HeadSeo title={title} description={title} />
|
||||||
title={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
|
|
||||||
description={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
|
|
||||||
/>
|
|
||||||
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
|
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
|
||||||
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
|
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
|
||||||
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
|
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
|
||||||
|
@ -263,29 +281,29 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||||
<h3
|
<h3
|
||||||
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
|
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
|
||||||
id="modal-headline">
|
id="modal-headline">
|
||||||
{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")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<p className="text-sm text-neutral-600 dark:text-gray-300">
|
<p className="text-sm text-neutral-600 dark:text-gray-300">{getTitle()}</p>
|
||||||
{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")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-bookinglightest text-bookingdark mt-4 grid grid-cols-3 border-t border-b py-4 text-left dark:border-gray-900 dark:text-gray-300">
|
<div className="border-bookinglightest text-bookingdark mt-4 grid grid-cols-3 border-t border-b py-4 text-left dark:border-gray-900 dark:text-gray-300">
|
||||||
<div className="font-medium">{t("what")}</div>
|
<div className="font-medium">{t("what")}</div>
|
||||||
<div className="col-span-2 mb-6">{eventName}</div>
|
<div className="col-span-2 mb-6">{eventName}</div>
|
||||||
<div className="font-medium">{t("when")}</div>
|
<div className="font-medium">{t("when")}</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
{date.format("dddd, DD MMMM YYYY")}
|
<RecurringBookings
|
||||||
<br />
|
isReschedule={reschedule === "true"}
|
||||||
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
|
eventType={props.eventType}
|
||||||
<span className="text-bookinglight">
|
recurringBookings={props.recurringBookings}
|
||||||
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
date={date}
|
||||||
</span>
|
is24h={is24h}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{location && (
|
{location && (
|
||||||
<>
|
<>
|
||||||
|
@ -322,6 +340,10 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||||
}` +
|
}` +
|
||||||
(typeof location === "string"
|
(typeof location === "string"
|
||||||
? "&location=" + encodeURIComponent(location)
|
? "&location=" + encodeURIComponent(location)
|
||||||
|
: "") +
|
||||||
|
(props.eventType.recurringEvent
|
||||||
|
? "&recur=" +
|
||||||
|
encodeURIComponent(new rrule(props.eventType.recurringEvent).toString())
|
||||||
: "")
|
: "")
|
||||||
}>
|
}>
|
||||||
<a className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white">
|
<a className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white">
|
||||||
|
@ -447,21 +469,15 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||||
{props.userHasSpaceBooking && (
|
{props.userHasSpaceBooking && (
|
||||||
<SpaceBookingSuccessPage
|
<SpaceBookingSuccessPage
|
||||||
open={props.userHasSpaceBooking}
|
open={props.userHasSpaceBooking}
|
||||||
what={`
|
what={`
|
||||||
${needsConfirmation ? t("submitted") : `${t("meeting_is_scheduled")}.`}
|
${needsConfirmation ? t("submitted") : `${t("meeting_is_scheduled")}.`}
|
||||||
${
|
${getTitle()} ${t("what")}: ${eventName}`}
|
||||||
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")
|
|
||||||
} ${t("what")}: ${eventName}`}
|
|
||||||
where={`${t("where")}: ${
|
where={`${t("where")}: ${
|
||||||
location ? (location?.startsWith("http") ? { location } : location) : "Far far a way galaxy"
|
location ? (location?.startsWith("http") ? { location } : location) : "Far far a way galaxy"
|
||||||
}`}
|
}`}
|
||||||
when={`${t("when")}: ${date.format("dddd, DD MMMM YYYY")} ${date.format(
|
when={`${t("when")}: ${props.recurringBookings ? t("starting") : ""} ${date.format(
|
||||||
is24h ? "H:mm" : "h:mma"
|
"dddd, DD MMMM YYYY"
|
||||||
)} - ${props.eventType.length} mins (${
|
)} ${date.format(is24h ? "H:mm" : "h:mma")} - ${props.eventType.length} mins (${
|
||||||
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
|
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
|
||||||
})`}
|
})`}
|
||||||
/>
|
/>
|
||||||
|
@ -472,6 +488,71 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<div key={idx} className="mb-2">
|
||||||
|
{dayjs(dateStr).format("dddd, DD MMMM YYYY")}
|
||||||
|
<br />
|
||||||
|
{dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
|
||||||
|
<span className="text-bookinglight">
|
||||||
|
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{recurringBookings.length > 4 && (
|
||||||
|
<Collapsible open={moreEventsVisible} onOpenChange={() => setMoreEventsVisible(!moreEventsVisible)}>
|
||||||
|
<CollapsibleTrigger
|
||||||
|
type="button"
|
||||||
|
className={classNames("flex w-full", moreEventsVisible ? "hidden" : "")}>
|
||||||
|
{t("plus_more", { count: recurringBookings.length - 4 })}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
{eventType.recurringEvent?.count &&
|
||||||
|
recurringBookings.slice(4).map((dateStr, idx) => (
|
||||||
|
<div key={idx} className="mb-2">
|
||||||
|
{dayjs(dateStr).format("dddd, DD MMMM YYYY")}
|
||||||
|
<br />
|
||||||
|
{dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
|
||||||
|
<span className="text-bookinglight">
|
||||||
|
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : !eventType.recurringEvent.freq ? (
|
||||||
|
<>
|
||||||
|
{date.format("dddd, DD MMMM YYYY")}
|
||||||
|
<br />
|
||||||
|
{date.format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
|
||||||
|
<span className="text-bookinglight">
|
||||||
|
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
const getEventTypesFromDB = async (typeId: number) => {
|
const getEventTypesFromDB = async (typeId: number) => {
|
||||||
return await prisma.eventType.findUnique({
|
return await prisma.eventType.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
@ -483,6 +564,7 @@ const getEventTypesFromDB = async (typeId: number) => {
|
||||||
description: true,
|
description: true,
|
||||||
length: true,
|
length: true,
|
||||||
eventName: true,
|
eventName: true,
|
||||||
|
recurringEvent: true,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
successRedirectUrl: true,
|
successRedirectUrl: true,
|
||||||
|
@ -513,6 +595,7 @@ const getEventTypesFromDB = async (typeId: number) => {
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const ssr = await ssrInit(context);
|
const ssr = await ssrInit(context);
|
||||||
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
|
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
|
||||||
|
const recurringEventIdQuery = asStringOrNull(context.query.recur);
|
||||||
const typeSlug = asStringOrNull(context.query.eventSlug) ?? "15min";
|
const typeSlug = asStringOrNull(context.query.eventSlug) ?? "15min";
|
||||||
const dynamicEventName = asStringOrNull(context.query.eventName) ?? "";
|
const dynamicEventName = asStringOrNull(context.query.eventName) ?? "";
|
||||||
|
|
||||||
|
@ -522,9 +605,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 {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
};
|
||||||
|
@ -532,11 +615,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
let spaceBookingAvailable = false;
|
let spaceBookingAvailable = false;
|
||||||
|
|
||||||
let userHasSpaceBooking = 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({
|
const credential = await prisma.credential.findFirst({
|
||||||
where: {
|
where: {
|
||||||
type: "spacebooking_other",
|
type: "spacebooking_other",
|
||||||
userId: eventType.users[0].id,
|
userId: eventTypeRaw.users[0].id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (credential && credential.type === "spacebooking_other") {
|
if (credential && credential.type === "spacebooking_other") {
|
||||||
|
@ -544,11 +627,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
|
// TODO we should add `user User` relation on `EventType` so this extra query isn't needed
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: eventType.userId,
|
id: eventTypeRaw.userId,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -563,17 +646,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (user) {
|
if (user) {
|
||||||
eventType.users.push(user);
|
eventTypeRaw.users.push(user as any);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!eventType.users.length) {
|
if (!eventTypeRaw.users.length) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (!typeId) eventType["eventName"] = getDynamicEventName(users, typeSlug);
|
const eventType = {
|
||||||
|
...eventTypeRaw,
|
||||||
|
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
|
||||||
|
};
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
name: eventType.team?.name || eventType.users[0]?.name || null,
|
name: eventType.team?.name || eventType.users[0]?.name || null,
|
||||||
|
@ -583,11 +669,25 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null,
|
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
props: {
|
props: {
|
||||||
hideBranding: eventType.team ? eventType.team.hideBranding : isBrandingHidden(eventType.users[0]),
|
hideBranding: eventType.team ? eventType.team.hideBranding : isBrandingHidden(eventType.users[0]),
|
||||||
profile,
|
profile,
|
||||||
eventType,
|
eventType,
|
||||||
|
recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null,
|
||||||
trpcState: ssr.dehydrate(),
|
trpcState: ssr.dehydrate(),
|
||||||
dynamicEventName,
|
dynamicEventName,
|
||||||
userHasSpaceBooking,
|
userHasSpaceBooking,
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { UserPlan } from "@prisma/client";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
import { JSONObject } from "superjson/dist/types";
|
||||||
|
|
||||||
|
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { getWorkingHours } from "@lib/availability";
|
import { getWorkingHours } from "@lib/availability";
|
||||||
import getBooking, { GetBookingType } from "@lib/getBooking";
|
import getBooking, { GetBookingType } from "@lib/getBooking";
|
||||||
|
@ -68,6 +70,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
minimumBookingNotice: true,
|
minimumBookingNotice: true,
|
||||||
beforeEventBuffer: true,
|
beforeEventBuffer: true,
|
||||||
afterEventBuffer: true,
|
afterEventBuffer: true,
|
||||||
|
recurringEvent: true,
|
||||||
price: true,
|
price: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
|
@ -107,6 +110,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
metadata: (eventType.metadata || {}) as JSONObject,
|
metadata: (eventType.metadata || {}) as JSONObject,
|
||||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||||
|
recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent,
|
||||||
});
|
});
|
||||||
|
|
||||||
eventTypeObject.availability = [];
|
eventTypeObject.availability = [];
|
||||||
|
|
|
@ -3,8 +3,9 @@ import { GetServerSidePropsContext } from "next";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
import { JSONObject } from "superjson/dist/types";
|
||||||
|
|
||||||
import { getLocationLabels } from "@calcom/app-store/utils";
|
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 getBooking, { GetBookingType } from "@lib/getBooking";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
@ -21,13 +22,14 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
|
||||||
|
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
|
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
|
||||||
|
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||||
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
|
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType = await prisma.eventType.findUnique({
|
const eventTypeRaw = await prisma.eventType.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: eventTypeId,
|
id: eventTypeId,
|
||||||
},
|
},
|
||||||
|
@ -44,6 +46,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
periodStartDate: true,
|
periodStartDate: true,
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
|
recurringEvent: true,
|
||||||
disableGuests: true,
|
disableGuests: true,
|
||||||
price: true,
|
price: true,
|
||||||
currency: 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) => {
|
const eventTypeObject = [eventType].map((e) => {
|
||||||
return {
|
return {
|
||||||
|
@ -83,6 +91,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
|
|
||||||
const t = await getTranslation(context.locale ?? "en", "common");
|
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 {
|
return {
|
||||||
props: {
|
props: {
|
||||||
locationLabels: getLocationLabels(t),
|
locationLabels: getLocationLabels(t),
|
||||||
|
@ -96,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
eventName: null,
|
eventName: null,
|
||||||
},
|
},
|
||||||
eventType: eventTypeObject,
|
eventType: eventTypeObject,
|
||||||
|
recurringEventCount,
|
||||||
booking,
|
booking,
|
||||||
isDynamicGroupBooking: false,
|
isDynamicGroupBooking: false,
|
||||||
hasHashedBookingLink: false,
|
hasHashedBookingLink: false,
|
||||||
|
|
|
@ -49,6 +49,45 @@ test.describe("Event Types tests", () => {
|
||||||
isCreated = await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
|
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 }) => {
|
test("can duplicate an existing event type", async ({ page }) => {
|
||||||
const firstTitle = await page.locator("[data-testid=event-type-title-3]").innerText();
|
const firstTitle = await page.locator("[data-testid=event-type-title-3]").innerText();
|
||||||
const firstFullSlug = await page.locator("[data-testid=event-type-slug-3]").innerText();
|
const firstFullSlug = await page.locator("[data-testid=event-type-slug-3]").innerText();
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
"event_declined_subject": "Declined: {{eventType}} with {{name}} at {{date}}",
|
"event_declined_subject": "Declined: {{eventType}} with {{name}} at {{date}}",
|
||||||
"event_cancelled_subject": "Cancelled: {{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": "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",
|
"event_request_cancelled": "Your scheduled event was cancelled",
|
||||||
"organizer": "Organizer",
|
"organizer": "Organizer",
|
||||||
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
|
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
|
||||||
|
@ -23,6 +24,7 @@
|
||||||
"rejection_confirmation": "Reject the booking",
|
"rejection_confirmation": "Reject the booking",
|
||||||
"manage_this_event": "Manage this event",
|
"manage_this_event": "Manage this event",
|
||||||
"your_event_has_been_scheduled": "Your event has been scheduled",
|
"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_CONSENT</1> to '{{agree}}'.",
|
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
|
||||||
"remove_banner_instructions": "To remove this banner, please open your .env file and change the <1>NEXT_PUBLIC_LICENSE_CONSENT</1> variable to '{{agree}}'.",
|
"remove_banner_instructions": "To remove this banner, please open your .env file and change the <1>NEXT_PUBLIC_LICENSE_CONSENT</1> variable to '{{agree}}'.",
|
||||||
"error_message": "The error message was: '{{errorMessage}}'",
|
"error_message": "The error message was: '{{errorMessage}}'",
|
||||||
|
@ -57,6 +59,7 @@
|
||||||
"confirm_or_reject_request": "Confirm or reject the request",
|
"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.",
|
"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": "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_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.",
|
"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.",
|
"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",
|
"manage_my_bookings": "Manage my bookings",
|
||||||
"need_to_make_a_change": "Need to make a change?",
|
"need_to_make_a_change": "Need to make a change?",
|
||||||
"new_event_scheduled": "A new event has been scheduled.",
|
"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_email": "Invitee Email",
|
||||||
"invitee_timezone": "Invitee Time Zone",
|
"invitee_timezone": "Invitee Time Zone",
|
||||||
"event_type": "Event Type",
|
"event_type": "Event Type",
|
||||||
|
@ -128,6 +132,7 @@
|
||||||
"ping_test": "Ping test",
|
"ping_test": "Ping test",
|
||||||
"add_to_homescreen": "Add this app to your home screen for faster access and improved experience.",
|
"add_to_homescreen": "Add this app to your home screen for faster access and improved experience.",
|
||||||
"upcoming": "Upcoming",
|
"upcoming": "Upcoming",
|
||||||
|
"recurring": "Recurring",
|
||||||
"past": "Past",
|
"past": "Past",
|
||||||
"choose_a_file": "Choose a file...",
|
"choose_a_file": "Choose a file...",
|
||||||
"upload_image": "Upload image",
|
"upload_image": "Upload image",
|
||||||
|
@ -232,13 +237,20 @@
|
||||||
"add_to_calendar": "Add to calendar",
|
"add_to_calendar": "Add to calendar",
|
||||||
"other": "Other",
|
"other": "Other",
|
||||||
"emailed_you_and_attendees": "We emailed you and the other attendees a calendar invitation with all the details.",
|
"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.",
|
"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": "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": "{{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": "This meeting is scheduled",
|
||||||
|
"meeting_is_scheduled_recurring": "The recurring events are scheduled",
|
||||||
"submitted": "Your booking has been submitted",
|
"submitted": "Your booking has been submitted",
|
||||||
|
"submitted_recurring": "Your recurring meeting has been submitted",
|
||||||
"booking_submitted": "Your booking 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": "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.",
|
"enter_new_password": "Enter the new password you'd like for your account.",
|
||||||
"reset_password": "Reset Password",
|
"reset_password": "Reset Password",
|
||||||
"change_your_password": "Change your password",
|
"change_your_password": "Change your password",
|
||||||
|
@ -282,6 +294,7 @@
|
||||||
"bookings": "Bookings",
|
"bookings": "Bookings",
|
||||||
"bookings_description": "See upcoming and past events booked through your event type links.",
|
"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.",
|
"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.",
|
"past_bookings": "Your past bookings will show up here.",
|
||||||
"cancelled_bookings": "Your cancelled bookings will show up here.",
|
"cancelled_bookings": "Your cancelled bookings will show up here.",
|
||||||
"on": "on",
|
"on": "on",
|
||||||
|
@ -432,6 +445,7 @@
|
||||||
"edit_role": "Edit Role",
|
"edit_role": "Edit Role",
|
||||||
"edit_team": "Edit team",
|
"edit_team": "Edit team",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
|
"reject_all": "Reject all",
|
||||||
"accept": "Accept",
|
"accept": "Accept",
|
||||||
"leave": "Leave",
|
"leave": "Leave",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
|
@ -460,6 +474,7 @@
|
||||||
"cancel_event": "Cancel this event",
|
"cancel_event": "Cancel this event",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
"confirm_all": "Confirm all",
|
||||||
"disband_team": "Disband Team",
|
"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.",
|
"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?",
|
"remove_member_confirmation_message": "Are you sure you want to remove this member from the team?",
|
||||||
|
@ -526,6 +541,18 @@
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
"first_day_of_week": "First Day of Week",
|
"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",
|
"single_theme": "Single Theme",
|
||||||
"brand_color": "Brand Color",
|
"brand_color": "Brand Color",
|
||||||
"light_brand_color": "Brand Color (Light Theme)",
|
"light_brand_color": "Brand Color (Light Theme)",
|
||||||
|
@ -582,6 +609,9 @@
|
||||||
"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.",
|
"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": "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.",
|
"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": "Disable Guests",
|
||||||
"disable_guests_description": "Disable adding additional guests while booking.",
|
"disable_guests_description": "Disable adding additional guests while booking.",
|
||||||
"hashed_link": "Generate hashed URL",
|
"hashed_link": "Generate hashed URL",
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { z } from "zod";
|
||||||
import getApps from "@calcom/app-store/utils";
|
import getApps from "@calcom/app-store/utils";
|
||||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||||
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
||||||
|
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
||||||
import jackson from "@lib/jackson";
|
import jackson from "@lib/jackson";
|
||||||
|
@ -127,6 +128,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
description: true,
|
description: true,
|
||||||
length: true,
|
length: true,
|
||||||
schedulingType: true,
|
schedulingType: true,
|
||||||
|
recurringEvent: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
price: true,
|
price: true,
|
||||||
|
@ -298,7 +300,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
})
|
})
|
||||||
.query("bookings", {
|
.query("bookings", {
|
||||||
input: z.object({
|
input: z.object({
|
||||||
status: z.enum(["upcoming", "past", "cancelled"]),
|
status: z.enum(["upcoming", "recurring", "past", "cancelled"]),
|
||||||
limit: z.number().min(1).max(100).nullish(),
|
limit: z.number().min(1).max(100).nullish(),
|
||||||
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
|
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 bookingListingByStatus = input.status;
|
||||||
const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput[]> = {
|
const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput[]> = {
|
||||||
upcoming: [
|
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() },
|
endTime: { gte: new Date() },
|
||||||
AND: [
|
AND: [
|
||||||
|
{ NOT: { recurringEventId: { equals: null } } },
|
||||||
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
|
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
|
||||||
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
|
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
|
||||||
],
|
],
|
||||||
|
@ -342,11 +365,22 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
Prisma.BookingOrderByWithAggregationInput
|
Prisma.BookingOrderByWithAggregationInput
|
||||||
> = {
|
> = {
|
||||||
upcoming: { startTime: "asc" },
|
upcoming: { startTime: "asc" },
|
||||||
|
recurring: { startTime: "asc" },
|
||||||
past: { startTime: "desc" },
|
past: { startTime: "desc" },
|
||||||
cancelled: { startTime: "desc" },
|
cancelled: { startTime: "desc" },
|
||||||
};
|
};
|
||||||
|
const bookingListingDistinct: Record<
|
||||||
|
typeof bookingListingByStatus,
|
||||||
|
Prisma.Enumerable<Prisma.BookingScalarFieldEnum> | undefined
|
||||||
|
> = {
|
||||||
|
upcoming: Prisma.BookingScalarFieldEnum.recurringEventId,
|
||||||
|
recurring: undefined,
|
||||||
|
past: undefined,
|
||||||
|
cancelled: undefined,
|
||||||
|
};
|
||||||
const passedBookingsFilter = bookingListingFilters[bookingListingByStatus];
|
const passedBookingsFilter = bookingListingFilters[bookingListingByStatus];
|
||||||
const orderBy = bookingListingOrderby[bookingListingByStatus];
|
const orderBy = bookingListingOrderby[bookingListingByStatus];
|
||||||
|
const distinct = bookingListingDistinct[bookingListingByStatus];
|
||||||
|
|
||||||
const bookingsQuery = await prisma.booking.findMany({
|
const bookingsQuery = await prisma.booking.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
@ -373,10 +407,12 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
rejected: true,
|
rejected: true,
|
||||||
id: true,
|
id: true,
|
||||||
startTime: true,
|
startTime: true,
|
||||||
|
recurringEventId: true,
|
||||||
endTime: true,
|
endTime: true,
|
||||||
eventType: {
|
eventType: {
|
||||||
select: {
|
select: {
|
||||||
price: true,
|
price: true,
|
||||||
|
recurringEvent: true,
|
||||||
team: {
|
team: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
|
@ -394,13 +430,23 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
rescheduled: true,
|
rescheduled: true,
|
||||||
},
|
},
|
||||||
orderBy,
|
orderBy,
|
||||||
|
distinct,
|
||||||
take: take + 1,
|
take: take + 1,
|
||||||
skip,
|
skip,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const groupedRecurringBookings = await prisma.booking.groupBy({
|
||||||
|
by: [Prisma.BookingScalarFieldEnum.recurringEventId],
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
|
||||||
const bookings = bookingsQuery.map((booking) => {
|
const bookings = bookingsQuery.map((booking) => {
|
||||||
return {
|
return {
|
||||||
...booking,
|
...booking,
|
||||||
|
eventType: {
|
||||||
|
...booking.eventType,
|
||||||
|
recurringEvent: ((booking.eventType && booking.eventType.recurringEvent) || {}) as RecurringEvent,
|
||||||
|
},
|
||||||
startTime: booking.startTime.toISOString(),
|
startTime: booking.startTime.toISOString(),
|
||||||
endTime: booking.endTime.toISOString(),
|
endTime: booking.endTime.toISOString(),
|
||||||
};
|
};
|
||||||
|
@ -416,6 +462,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookings,
|
bookings,
|
||||||
|
groupedRecurringBookings,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -7,6 +7,7 @@ import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
|
||||||
import { _DestinationCalendarModel, _EventTypeCustomInputModel, _EventTypeModel } from "@calcom/prisma/zod";
|
import { _DestinationCalendarModel, _EventTypeCustomInputModel, _EventTypeModel } from "@calcom/prisma/zod";
|
||||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||||
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
||||||
|
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import { createProtectedRouter } from "@server/createRouter";
|
import { createProtectedRouter } from "@server/createRouter";
|
||||||
import { viewerRouter } from "@server/routers/viewer";
|
import { viewerRouter } from "@server/routers/viewer";
|
||||||
|
@ -254,6 +255,7 @@ export const eventTypesRouter = createProtectedRouter()
|
||||||
locations,
|
locations,
|
||||||
destinationCalendar,
|
destinationCalendar,
|
||||||
customInputs,
|
customInputs,
|
||||||
|
recurringEvent,
|
||||||
users,
|
users,
|
||||||
id,
|
id,
|
||||||
hashedLink,
|
hashedLink,
|
||||||
|
@ -266,6 +268,17 @@ export const eventTypesRouter = createProtectedRouter()
|
||||||
data.periodType = handlePeriodType(periodType);
|
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) {
|
if (destinationCalendar) {
|
||||||
/** We connect or create a destination calendar to the event type instead of the user */
|
/** We connect or create a destination calendar to the event type instead of the user */
|
||||||
await viewerRouter.createCaller(ctx).mutation("setDestinationCalendar", {
|
await viewerRouter.createCaller(ctx).mutation("setDestinationCalendar", {
|
||||||
|
|
|
@ -9,9 +9,9 @@
|
||||||
--brand-text-color-dark-mode: #292929;
|
--brand-text-color-dark-mode: #292929;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Override the default tailwindcss-forms styling (default is: 'colors.blue.600')
|
* 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="text"]:focus,
|
||||||
[type="email"]:focus,
|
[type="email"]:focus,
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { ConfirmDialog } from "./confirmDialog";
|
||||||
interface IWipeMyCalActionButtonProps {
|
interface IWipeMyCalActionButtonProps {
|
||||||
trpc: any;
|
trpc: any;
|
||||||
bookingsEmpty: boolean;
|
bookingsEmpty: boolean;
|
||||||
bookingStatus: "upcoming" | "past" | "cancelled";
|
bookingStatus: "upcoming" | "recurring" | "past" | "cancelled";
|
||||||
}
|
}
|
||||||
|
|
||||||
const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
|
const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
|
||||||
|
|
|
@ -6,8 +6,8 @@ import { useState } from "react";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
import showToast from "@calcom/lib/notification";
|
||||||
import { Button } from "@calcom/ui";
|
import { Button } from "@calcom/ui";
|
||||||
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
import Loader from "@calcom/web/components/Loader";
|
import Loader from "@calcom/web/components/Loader";
|
||||||
import { Tooltip } from "@calcom/web/components/Tooltip";
|
|
||||||
|
|
||||||
import Icon from "./icon";
|
import Icon from "./icon";
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@ class CalendarEventClass implements CalendarEvent {
|
||||||
rejectionReason?: string | null;
|
rejectionReason?: string | null;
|
||||||
hideCalendarNotes?: boolean;
|
hideCalendarNotes?: boolean;
|
||||||
additionalNotes?: string | null | undefined;
|
additionalNotes?: string | null | undefined;
|
||||||
|
recurrence?: string;
|
||||||
|
|
||||||
constructor(initProps?: CalendarEvent) {
|
constructor(initProps?: CalendarEvent) {
|
||||||
// If more parameters are given we update this
|
// If more parameters are given we update this
|
||||||
|
|
|
@ -55,6 +55,7 @@ const commons = {
|
||||||
},
|
},
|
||||||
isWeb3Active: false,
|
isWeb3Active: false,
|
||||||
hideCalendarNotes: false,
|
hideCalendarNotes: false,
|
||||||
|
recurringEvent: {},
|
||||||
destinationCalendar: null,
|
destinationCalendar: null,
|
||||||
team: null,
|
team: null,
|
||||||
requiresConfirmation: false,
|
requiresConfirmation: false,
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "recurringEventId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "EventType" ADD COLUMN "recurringEvent" JSONB;
|
|
@ -59,6 +59,8 @@ model EventType {
|
||||||
periodDays Int?
|
periodDays Int?
|
||||||
periodCountCalendarDays Boolean?
|
periodCountCalendarDays Boolean?
|
||||||
requiresConfirmation Boolean @default(false)
|
requiresConfirmation Boolean @default(false)
|
||||||
|
/// @zod.custom(imports.recurringEvent)
|
||||||
|
recurringEvent Json?
|
||||||
disableGuests Boolean @default(false)
|
disableGuests Boolean @default(false)
|
||||||
hideCalendarNotes Boolean @default(false)
|
hideCalendarNotes Boolean @default(false)
|
||||||
minimumBookingNotice Int @default(120)
|
minimumBookingNotice Int @default(120)
|
||||||
|
@ -278,6 +280,7 @@ model Booking {
|
||||||
dynamicGroupSlugRef String?
|
dynamicGroupSlugRef String?
|
||||||
rescheduled Boolean?
|
rescheduled Boolean?
|
||||||
fromReschedule String?
|
fromReschedule String?
|
||||||
|
recurringEventId String?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Schedule {
|
model Schedule {
|
||||||
|
|
|
@ -277,6 +277,111 @@ async function main() {
|
||||||
length: 60,
|
length: 60,
|
||||||
locations: [{ type: "integrations:google:meet" }],
|
locations: [{ type: "integrations:google:meet" }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Yoga class",
|
||||||
|
slug: "yoga-class",
|
||||||
|
length: 30,
|
||||||
|
recurringEvent: { freq: 2, count: 12, interval: 1 },
|
||||||
|
_bookings: [
|
||||||
|
{
|
||||||
|
uid: uuid(),
|
||||||
|
title: "Yoga class",
|
||||||
|
recurringEventId: Buffer.from("yoga-class").toString("base64"),
|
||||||
|
startTime: dayjs().add(1, "day").toDate(),
|
||||||
|
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
|
||||||
|
confirmed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: uuid(),
|
||||||
|
title: "Yoga class",
|
||||||
|
recurringEventId: Buffer.from("yoga-class").toString("base64"),
|
||||||
|
startTime: dayjs().add(1, "day").add(1, "week").toDate(),
|
||||||
|
endTime: dayjs().add(1, "day").add(1, "week").add(30, "minutes").toDate(),
|
||||||
|
confirmed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: uuid(),
|
||||||
|
title: "Yoga class",
|
||||||
|
recurringEventId: Buffer.from("yoga-class").toString("base64"),
|
||||||
|
startTime: dayjs().add(1, "day").add(2, "week").toDate(),
|
||||||
|
endTime: dayjs().add(1, "day").add(2, "week").add(30, "minutes").toDate(),
|
||||||
|
confirmed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: uuid(),
|
||||||
|
title: "Yoga class",
|
||||||
|
recurringEventId: Buffer.from("yoga-class").toString("base64"),
|
||||||
|
startTime: dayjs().add(1, "day").add(3, "week").toDate(),
|
||||||
|
endTime: dayjs().add(1, "day").add(3, "week").add(30, "minutes").toDate(),
|
||||||
|
confirmed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: uuid(),
|
||||||
|
title: "Yoga class",
|
||||||
|
recurringEventId: Buffer.from("yoga-class").toString("base64"),
|
||||||
|
startTime: dayjs().add(1, "day").add(4, "week").toDate(),
|
||||||
|
endTime: dayjs().add(1, "day").add(4, "week").add(30, "minutes").toDate(),
|
||||||
|
confirmed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: uuid(),
|
||||||
|
title: "Yoga class",
|
||||||
|
recurringEventId: Buffer.from("yoga-class").toString("base64"),
|
||||||
|
startTime: dayjs().add(1, "day").add(5, "week").toDate(),
|
||||||
|
endTime: dayjs().add(1, "day").add(5, "week").add(30, "minutes").toDate(),
|
||||||
|
confirmed: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Tennis class",
|
||||||
|
slug: "tennis-class",
|
||||||
|
length: 60,
|
||||||
|
recurringEvent: { freq: 2, count: 10, interval: 2 },
|
||||||
|
requiresConfirmation: true,
|
||||||
|
_bookings: [
|
||||||
|
{
|
||||||
|
uid: uuid(),
|
||||||
|
title: "Tennis class",
|
||||||
|
recurringEventId: Buffer.from("tennis-class").toString("base64"),
|
||||||
|
startTime: dayjs().add(2, "day").toDate(),
|
||||||
|
endTime: dayjs().add(2, "day").add(60, "minutes").toDate(),
|
||||||
|
confirmed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: uuid(),
|
||||||
|
title: "Tennis class",
|
||||||
|
recurringEventId: Buffer.from("tennis-class").toString("base64"),
|
||||||
|
startTime: dayjs().add(2, "day").add(2, "week").toDate(),
|
||||||
|
endTime: dayjs().add(2, "day").add(2, "week").add(60, "minutes").toDate(),
|
||||||
|
confirmed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: uuid(),
|
||||||
|
title: "Tennis class",
|
||||||
|
recurringEventId: Buffer.from("tennis-class").toString("base64"),
|
||||||
|
startTime: dayjs().add(2, "day").add(4, "week").toDate(),
|
||||||
|
endTime: dayjs().add(2, "day").add(4, "week").add(60, "minutes").toDate(),
|
||||||
|
confirmed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: uuid(),
|
||||||
|
title: "Tennis class",
|
||||||
|
recurringEventId: Buffer.from("tennis-class").toString("base64"),
|
||||||
|
startTime: dayjs().add(2, "day").add(8, "week").toDate(),
|
||||||
|
endTime: dayjs().add(2, "day").add(8, "week").add(60, "minutes").toDate(),
|
||||||
|
confirmed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: uuid(),
|
||||||
|
title: "Tennis class",
|
||||||
|
recurringEventId: Buffer.from("tennis-class").toString("base64"),
|
||||||
|
startTime: dayjs().add(2, "day").add(10, "week").toDate(),
|
||||||
|
endTime: dayjs().add(2, "day").add(10, "week").add(60, "minutes").toDate(),
|
||||||
|
confirmed: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Frequency as RRuleFrequency } from "rrule";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { LocationType } from "@calcom/core/location";
|
import { LocationType } from "@calcom/core/location";
|
||||||
|
@ -11,6 +12,16 @@ export const eventTypeLocations = z.array(
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Matching RRule.Options: rrule/dist/esm/src/types.d.ts
|
||||||
|
export const recurringEvent = z.object({
|
||||||
|
dtstart: z.date().optional(),
|
||||||
|
interval: z.number().optional(),
|
||||||
|
count: z.number().optional(),
|
||||||
|
freq: z.nativeEnum(RRuleFrequency).optional(),
|
||||||
|
until: z.date().optional(),
|
||||||
|
tzid: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const eventTypeSlug = z.string().transform((val) => slugify(val.trim()));
|
export const eventTypeSlug = z.string().transform((val) => slugify(val.trim()));
|
||||||
export const stringToDate = z.string().transform((a) => new Date(a));
|
export const stringToDate = z.string().transform((a) => new Date(a));
|
||||||
export const stringOrNumber = z.union([z.string().transform((v) => parseInt(v, 10)), z.number().int()]);
|
export const stringOrNumber = z.union([z.string().transform((v) => parseInt(v, 10)), z.number().int()]);
|
||||||
|
|
11
packages/types/Calendar.d.ts
vendored
11
packages/types/Calendar.d.ts
vendored
|
@ -3,6 +3,7 @@ import type { Dayjs } from "dayjs";
|
||||||
import type { calendar_v3 } from "googleapis";
|
import type { calendar_v3 } from "googleapis";
|
||||||
import type { Time } from "ical.js";
|
import type { Time } from "ical.js";
|
||||||
import type { TFunction } from "next-i18next";
|
import type { TFunction } from "next-i18next";
|
||||||
|
import type { Frequency as RRuleFrequency } from "rrule";
|
||||||
|
|
||||||
import type { Event } from "./Event";
|
import type { Event } from "./Event";
|
||||||
import type { Ensure } from "./utils";
|
import type { Ensure } from "./utils";
|
||||||
|
@ -72,6 +73,15 @@ export interface AdditionInformation {
|
||||||
hangoutLink?: string;
|
hangoutLink?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecurringEvent {
|
||||||
|
dtstart?: Date | undefined;
|
||||||
|
interval?: number;
|
||||||
|
count?: number;
|
||||||
|
freq?: RRuleFrequency;
|
||||||
|
until?: Date | undefined;
|
||||||
|
tzid?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// If modifying this interface, probably should update builders/calendarEvent files
|
// If modifying this interface, probably should update builders/calendarEvent files
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
type: string;
|
type: string;
|
||||||
|
@ -96,6 +106,7 @@ export interface CalendarEvent {
|
||||||
cancellationReason?: string | null;
|
cancellationReason?: string | null;
|
||||||
rejectionReason?: string | null;
|
rejectionReason?: string | null;
|
||||||
hideCalendarNotes?: boolean;
|
hideCalendarNotes?: boolean;
|
||||||
|
recurrence?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntryPoint {
|
export interface EntryPoint {
|
||||||
|
|
|
@ -23,7 +23,7 @@ export function Tooltip({
|
||||||
onOpenChange={onOpenChange}>
|
onOpenChange={onOpenChange}>
|
||||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||||
<TooltipPrimitive.Content
|
<TooltipPrimitive.Content
|
||||||
className="slideInBottom -mt-2 rounded-sm bg-black px-1 py-0.5 text-xs text-white shadow-lg"
|
className="-mt-2 rounded-sm bg-black px-1 py-0.5 text-xs text-white shadow-lg"
|
||||||
side="top"
|
side="top"
|
||||||
align="center"
|
align="center"
|
||||||
{...props}>
|
{...props}>
|
Loading…
Reference in a new issue