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/**
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
name: Testing ${{ matrix.node }} and ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
|
|
|
@ -190,8 +190,10 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||
|
||||
### 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
|
||||
# In a terminal. Just run:
|
||||
# In a terminal just run:
|
||||
yarn test-e2e
|
||||
|
||||
# To open last HTML report run:
|
||||
|
|
|
@ -11,6 +11,10 @@ export default function BookingsShell({ children }: { children: React.ReactNode
|
|||
name: t("upcoming"),
|
||||
href: "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: t("recurring"),
|
||||
href: "/bookings/recurring",
|
||||
},
|
||||
{
|
||||
name: t("past"),
|
||||
href: "/bookings/past",
|
||||
|
|
|
@ -21,6 +21,7 @@ type AvailableTimesProps = {
|
|||
afterBufferTime: number;
|
||||
eventTypeId: number;
|
||||
eventLength: number;
|
||||
recurringCount: number | undefined;
|
||||
eventTypeSlug: string;
|
||||
slotInterval: number | null;
|
||||
date: Dayjs;
|
||||
|
@ -37,6 +38,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||
eventTypeSlug,
|
||||
slotInterval,
|
||||
minimumBookingNotice,
|
||||
recurringCount,
|
||||
timeFormat,
|
||||
users,
|
||||
schedulingType,
|
||||
|
@ -90,6 +92,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||
date: slot.time.format(),
|
||||
type: eventTypeId,
|
||||
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 { PaperAirplaneIcon } from "@heroicons/react/outline";
|
||||
import { RefreshIcon } from "@heroicons/react/solid";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { TextArea } from "@calcom/ui/form/fields";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
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 TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
|
||||
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||
|
||||
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
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
@ -30,14 +41,22 @@ function BookingListItem(booking: BookingItem) {
|
|||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const mutation = useMutation(
|
||||
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", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
id: booking.id,
|
||||
confirmed: confirm,
|
||||
language: i18n.language,
|
||||
reason: rejectionReason,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
@ -58,14 +77,20 @@ function BookingListItem(booking: BookingItem) {
|
|||
const pendingActions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label: t("reject"),
|
||||
label:
|
||||
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
|
||||
? t("reject_all")
|
||||
: t("reject"),
|
||||
onClick: () => setRejectionDialogIsOpen(true),
|
||||
icon: BanIcon,
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
{
|
||||
id: "confirm",
|
||||
label: t("confirm"),
|
||||
label:
|
||||
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
|
||||
? t("confirm_all")
|
||||
: t("confirm"),
|
||||
onClick: () => mutation.mutate(true),
|
||||
icon: CheckIcon,
|
||||
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 [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 (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
|
@ -154,12 +192,40 @@ function BookingListItem(booking: BookingItem) {
|
|||
</Dialog>
|
||||
|
||||
<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 text-gray-500">
|
||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</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 className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}>
|
||||
<div className="sm:hidden">
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
CreditCardIcon,
|
||||
GlobeIcon,
|
||||
InformationCircleIcon,
|
||||
RefreshIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
|
@ -17,6 +18,7 @@ import utc from "dayjs/plugin/utc";
|
|||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
|
||||
import {
|
||||
useEmbedStyles,
|
||||
|
@ -27,11 +29,11 @@ import {
|
|||
useEmbedNonStylesConfig,
|
||||
} from "@calcom/embed-core";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { BASE_URL, WEBAPP_URL } from "@lib/config/constants";
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
|
@ -101,6 +103,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
}
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
|
@ -142,6 +145,15 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||
};
|
||||
|
||||
// Recurring event sidebar requires more space
|
||||
const maxWidth = selectedDate
|
||||
? recurringEventCount
|
||||
? "max-w-6xl"
|
||||
: "max-w-5xl"
|
||||
: recurringEventCount
|
||||
? "max-w-4xl"
|
||||
: "max-w-3xl";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Theme />
|
||||
|
@ -158,9 +170,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed
|
||||
? classNames(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
: "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
|
||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
? classNames(maxWidth)
|
||||
: classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
|
||||
)}>
|
||||
{isReady && (
|
||||
<div
|
||||
|
@ -168,7 +179,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
className={classNames(
|
||||
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
|
||||
"border-bookinglightest rounded-md md:border",
|
||||
isEmbed ? "mx-auto" : selectedDate ? "max-w-5xl" : "max-w-3xl"
|
||||
isEmbed ? "mx-auto" : maxWidth
|
||||
)}>
|
||||
{/* mobile: details */}
|
||||
<div className="block p-4 sm:p-8 md:hidden">
|
||||
|
@ -243,7 +254,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
<div
|
||||
className={
|
||||
"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
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
|
@ -267,15 +278,42 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
{eventType.title}
|
||||
</h1>
|
||||
{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" />
|
||||
{eventType.description}
|
||||
</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" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</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 && (
|
||||
<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" />
|
||||
|
@ -302,7 +340,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<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">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
|
@ -340,6 +378,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
eventTypeSlug={eventType.slug}
|
||||
slotInterval={eventType.slotInterval}
|
||||
eventLength={eventType.length}
|
||||
recurringCount={recurringEventCount}
|
||||
date={selectedDate}
|
||||
users={eventType.users}
|
||||
schedulingType={eventType.schedulingType ?? null}
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
CreditCardIcon,
|
||||
ExclamationIcon,
|
||||
InformationCircleIcon,
|
||||
RefreshIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
|
@ -18,6 +19,8 @@ import { Controller, useForm, useWatch } from "react-hook-form";
|
|||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { ReactMultiEmail } from "react-multi-email";
|
||||
import { useMutation } from "react-query";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
|
@ -31,7 +34,9 @@ import classNames from "@calcom/lib/classNames";
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { createPaymentLink } from "@calcom/stripe/client";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { EmailInput, Form } from "@calcom/ui/form/fields";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
|
@ -40,9 +45,11 @@ import { ensureArray } from "@lib/ensureArray";
|
|||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { LocationType } from "@lib/location";
|
||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||
import { parseDate } from "@lib/parseDate";
|
||||
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||
import slugify from "@lib/slugify";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { BookingCreateBody } from "@lib/types/booking";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
|
@ -76,6 +83,7 @@ const BookingPage = ({
|
|||
booking,
|
||||
profile,
|
||||
isDynamicGroupBooking,
|
||||
recurringEventCount,
|
||||
locationLabels,
|
||||
hasHashedBookingLink,
|
||||
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 { isReady, Theme } = useTheme(profile.theme);
|
||||
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) => {
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(
|
||||
|
@ -265,7 +318,7 @@ const BookingPage = ({
|
|||
{}
|
||||
);
|
||||
|
||||
let web3Details;
|
||||
let web3Details: Record<"userWallet" | "userSignature", string> | undefined;
|
||||
if (eventTypeDetail.metadata.smartContractAddress) {
|
||||
web3Details = {
|
||||
// @ts-ignore
|
||||
|
@ -274,28 +327,59 @@ const BookingPage = ({
|
|||
};
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
...booking,
|
||||
web3Details,
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
location: getLocationValue(
|
||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||
),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
});
|
||||
if (recurringDates.length) {
|
||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||
const recurringEventId = uuidv4();
|
||||
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||
...booking,
|
||||
web3Details,
|
||||
start: dayjs(recurringDate).format(),
|
||||
end: dayjs(recurringDate).add(eventType.length, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
recurringEventId,
|
||||
// Added to track down the number of actual occurrences selected by the user
|
||||
recurringCount: recurringDates.length,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
location: getLocationValue(
|
||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||
),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
}));
|
||||
recurringMutation.mutate(recurringBookings);
|
||||
} else {
|
||||
mutation.mutate({
|
||||
...booking,
|
||||
web3Details,
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
location: getLocationValue(
|
||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||
),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const disableInput = !!rescheduleUid;
|
||||
|
@ -375,10 +459,40 @@ const BookingPage = ({
|
|||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-bookinghighlight mb-4">
|
||||
<CalendarIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{parseDate(date, i18n)}
|
||||
</p>
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<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()}`),
|
||||
})} ${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 && (
|
||||
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
|
||||
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
|
||||
|
|
|
@ -77,7 +77,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
<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 { Prisma } from "@prisma/client";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
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 { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
||||
select: {
|
||||
|
@ -14,6 +16,7 @@ const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
|||
price: true,
|
||||
currency: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
description: true,
|
||||
},
|
||||
});
|
||||
|
@ -28,6 +31,11 @@ export type EventTypeDescriptionProps = {
|
|||
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const recurringEvent: RecurringEvent = useMemo(
|
||||
() => (eventType.recurringEvent as RecurringEvent) || [],
|
||||
[eventType.recurringEvent]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames("text-neutral-500 dark:text-white", className)}>
|
||||
|
@ -54,6 +62,12 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
|||
{t("1_on_1")}
|
||||
</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 && (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<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,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
|
|
|
@ -19,12 +19,12 @@ import Dropdown, {
|
|||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { InformationCircleIcon } from "@heroicons/react/solid";
|
||||
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
export default function InfoBadge({ content }: { content: string }) {
|
||||
return (
|
||||
|
|
|
@ -3,12 +3,12 @@ import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
|||
import classNames from "@calcom/lib/classNames";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { ListItem } from "@components/List";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
|
||||
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
|
||||
|
|
|
@ -8,11 +8,11 @@ import showToast from "@calcom/lib/notification";
|
|||
import Button from "@calcom/ui/Button";
|
||||
import { DialogFooter } from "@calcom/ui/Dialog";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { Form, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import { DatePicker } from "@components/ui/form/DatePicker";
|
||||
|
||||
import { TApiKeys } from "./ApiKeyListItem";
|
||||
|
|
|
@ -7,11 +7,11 @@ import classNames from "@calcom/lib/classNames";
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { ListItem } from "@components/List";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import EventManager from "@calcom/core/EventManager";
|
|||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import prisma from "@calcom/prisma";
|
||||
import stripe from "@calcom/stripe/server";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { IS_PRODUCTION } from "@lib/config/constants";
|
||||
import { HttpError as HttpCode } from "@lib/core/http/error";
|
||||
|
@ -49,6 +49,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
|||
confirmed: true,
|
||||
attendees: true,
|
||||
location: true,
|
||||
eventTypeId: true,
|
||||
userId: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
|
@ -70,6 +71,23 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
|||
|
||||
if (!booking) throw new Error("No booking found");
|
||||
|
||||
const eventTypeSelect = Prisma.validator<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;
|
||||
|
||||
if (!user) throw new Error("No user found");
|
||||
|
@ -137,7 +155,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
|||
|
||||
await prisma.$transaction([paymentUpdate, bookingUpdate]);
|
||||
|
||||
await sendScheduledEmails({ ...evt });
|
||||
await sendScheduledEmails({ ...evt }, eventType.recurringEvent);
|
||||
|
||||
throw new HttpCode({
|
||||
statusCode: 200,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
import { recurringEvent } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent, Person, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email";
|
||||
import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email";
|
||||
|
@ -17,14 +18,14 @@ import OrganizerRescheduledEmail from "@lib/emails/templates/organizer-reschedul
|
|||
import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email";
|
||||
import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email";
|
||||
|
||||
export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
export const sendScheduledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee);
|
||||
const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||
|
@ -36,7 +37,7 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
|
|||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new OrganizerScheduledEmail(calEvent);
|
||||
const scheduledEmail = new OrganizerScheduledEmail(calEvent, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
|
||||
|
@ -47,14 +48,14 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
|
|||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
export const sendRescheduledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee);
|
||||
const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||
|
@ -66,7 +67,7 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
|||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new OrganizerRescheduledEmail(calEvent);
|
||||
const scheduledEmail = new OrganizerRescheduledEmail(calEvent, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
|
||||
|
@ -77,10 +78,13 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
|||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
|
||||
export const sendOrganizerRequestEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const organizerRequestEmail = new OrganizerRequestEmail(calEvent);
|
||||
const organizerRequestEmail = new OrganizerRequestEmail(calEvent, recurringEvent);
|
||||
resolve(organizerRequestEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerRequestEmail.sendEmail failed", e));
|
||||
|
@ -88,10 +92,14 @@ export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee: Person) => {
|
||||
export const sendAttendeeRequestEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
attendee: Person,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee);
|
||||
const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(attendeeRequestEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendRequestEmail.sendEmail failed", e));
|
||||
|
@ -99,14 +107,14 @@ export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee
|
|||
});
|
||||
};
|
||||
|
||||
export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
|
||||
export const sendDeclinedEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee);
|
||||
const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(declinedEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||
|
@ -118,14 +126,14 @@ export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
|
|||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
|
||||
export const sendCancelledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee);
|
||||
const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeCancelledEmail.sendEmail failed", e));
|
||||
|
@ -137,7 +145,7 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
|
|||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new OrganizerCancelledEmail(calEvent);
|
||||
const scheduledEmail = new OrganizerCancelledEmail(calEvent, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerCancelledEmail.sendEmail failed", e));
|
||||
|
@ -148,10 +156,13 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
|
|||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => {
|
||||
export const sendOrganizerRequestReminderEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent);
|
||||
const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent, recurringEvent);
|
||||
resolve(organizerRequestReminderEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerRequestReminderEmail.sendEmail failed", e));
|
||||
|
@ -159,14 +170,17 @@ export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent)
|
|||
});
|
||||
};
|
||||
|
||||
export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
|
||||
export const sendAwaitingPaymentEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee);
|
||||
const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(paymentEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeAwaitingPaymentEmail.sendEmail failed", e));
|
||||
|
@ -178,10 +192,13 @@ export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
|
|||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => {
|
||||
export const sendOrganizerPaymentRefundFailedEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent);
|
||||
const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent, recurringEvent);
|
||||
resolve(paymentRefundFailedEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e));
|
||||
|
@ -213,14 +230,19 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
|
|||
|
||||
export const sendRequestRescheduleEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
metadata: { rescheduleLink: string }
|
||||
metadata: { rescheduleLink: string },
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(calEvent, metadata);
|
||||
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(
|
||||
calEvent,
|
||||
metadata,
|
||||
recurringEvent
|
||||
);
|
||||
resolve(requestRescheduleEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
|
||||
|
@ -231,7 +253,11 @@ export const sendRequestRescheduleEmail = async (
|
|||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(calEvent, metadata);
|
||||
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(
|
||||
calEvent,
|
||||
metadata,
|
||||
recurringEvent
|
||||
);
|
||||
resolve(requestRescheduleEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));
|
||||
|
|
|
@ -42,7 +42,9 @@ export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail {
|
|||
|
||||
protected getTextBody(): string {
|
||||
return `
|
||||
${this.attendee.language.translate("event_request_declined")}
|
||||
${this.attendee.language.translate(
|
||||
this.recurringEvent?.count ? "event_request_declined_recurring" : "event_request_declined"
|
||||
)}
|
||||
${this.attendee.language.translate("emailed_you_and_any_other_attendees")}
|
||||
${this.getWhat()}
|
||||
${this.getWhen()}
|
||||
|
@ -75,7 +77,9 @@ ${this.getRejectionReason()}
|
|||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("xCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.attendee.language.translate("event_request_declined"),
|
||||
this.attendee.language.translate(
|
||||
this.recurringEvent?.count ? "event_request_declined_recurring" : "event_request_declined"
|
||||
),
|
||||
this.attendee.language.translate("emailed_you_and_any_other_attendees")
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
|
|
|
@ -87,10 +87,17 @@ ${this.getAdditionalNotes()}
|
|||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("calendarCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.calEvent.organizer.language.translate("booking_submitted"),
|
||||
this.calEvent.organizer.language.translate("user_needs_to_confirm_or_reject_booking", {
|
||||
user: this.calEvent.organizer.name,
|
||||
})
|
||||
this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent?.count ? "booking_submitted_recurring" : "booking_submitted"
|
||||
),
|
||||
this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent.count
|
||||
? "user_needs_to_confirm_or_reject_booking_recurring"
|
||||
: "user_needs_to_confirm_or_reject_booking",
|
||||
{
|
||||
user: this.calEvent.organizer.name,
|
||||
}
|
||||
)
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
<!--[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 { getCancelLink } from "@calcom/lib/CalEventParser";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
|
@ -24,8 +24,8 @@ dayjs.extend(toArray);
|
|||
|
||||
export default class AttendeeRequestRescheduledEmail extends OrganizerScheduledEmail {
|
||||
private metadata: { rescheduleLink: string };
|
||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
|
||||
super(calEvent);
|
||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
|
||||
super(calEvent, recurringEvent);
|
||||
this.metadata = metadata;
|
||||
}
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
|
|
|
@ -53,7 +53,7 @@ export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
|
|||
${this.attendee.language.translate("event_has_been_rescheduled")}
|
||||
${this.attendee.language.translate("emailed_you_and_any_other_attendees")}
|
||||
${this.getWhat()}
|
||||
${this.getWhen()}
|
||||
${this.getWhen()}
|
||||
${this.getLocation()}
|
||||
${this.getDescription()}
|
||||
${this.getAdditionalNotes()}
|
||||
|
|
|
@ -4,13 +4,15 @@ import timezone from "dayjs/plugin/timezone";
|
|||
import toArray from "dayjs/plugin/toArray";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray } from "ics";
|
||||
import { DatasetJsonLdProps } from "next-seo";
|
||||
import nodemailer from "nodemailer";
|
||||
import rrule from "rrule";
|
||||
|
||||
import { getAppName } from "@calcom/app-store/utils";
|
||||
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import type { Person, CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { Person, CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
|
@ -29,10 +31,12 @@ dayjs.extend(toArray);
|
|||
export default class AttendeeScheduledEmail {
|
||||
calEvent: CalendarEvent;
|
||||
attendee: Person;
|
||||
recurringEvent: RecurringEvent;
|
||||
|
||||
constructor(calEvent: CalendarEvent, attendee: Person) {
|
||||
constructor(calEvent: CalendarEvent, attendee: Person, recurringEvent: RecurringEvent) {
|
||||
this.calEvent = calEvent;
|
||||
this.attendee = attendee;
|
||||
this.recurringEvent = recurringEvent;
|
||||
}
|
||||
|
||||
public sendEmail() {
|
||||
|
@ -53,6 +57,11 @@ export default class AttendeeScheduledEmail {
|
|||
}
|
||||
|
||||
protected getiCalEventAsString(): string | undefined {
|
||||
// Taking care of recurrence rule beforehand
|
||||
let recurrenceRule: string | undefined = undefined;
|
||||
if (this.recurringEvent?.count) {
|
||||
recurrenceRule = new rrule(this.recurringEvent).toString();
|
||||
}
|
||||
const icsEvent = createEvent({
|
||||
start: dayjs(this.calEvent.startTime)
|
||||
.utc()
|
||||
|
@ -72,6 +81,7 @@ export default class AttendeeScheduledEmail {
|
|||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
})),
|
||||
...{ recurrenceRule },
|
||||
status: "CONFIRMED",
|
||||
});
|
||||
if (icsEvent.error) {
|
||||
|
@ -125,7 +135,9 @@ export default class AttendeeScheduledEmail {
|
|||
}
|
||||
protected getTextBody(): string {
|
||||
return `
|
||||
${this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled")}
|
||||
${this.calEvent.attendees[0].language.translate(
|
||||
this.recurringEvent?.count ? "your_event_has_been_scheduled_recurring" : "your_event_has_been_scheduled"
|
||||
)}
|
||||
${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")}
|
||||
|
||||
${getRichDescription(this.calEvent)}
|
||||
|
@ -157,7 +169,11 @@ ${getRichDescription(this.calEvent)}
|
|||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("checkCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled"),
|
||||
this.calEvent.attendees[0].language.translate(
|
||||
this.recurringEvent?.count
|
||||
? "your_event_has_been_scheduled_recurring"
|
||||
: "your_event_has_been_scheduled"
|
||||
),
|
||||
this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
|
@ -250,12 +266,30 @@ ${getRichDescription(this.calEvent)}
|
|||
</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 {
|
||||
return `
|
||||
<p style="height: 6px"></p>
|
||||
<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;">
|
||||
${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
|
||||
${this.calEvent.attendees[0].language.translate(
|
||||
this.getInviteeStart().format("dddd").toLowerCase()
|
||||
)}, ${this.calEvent.attendees[0].language.translate(
|
||||
|
|
|
@ -86,7 +86,9 @@ ${process.env.NEXT_PUBLIC_WEBAPP_URL} + "/bookings/upcoming"
|
|||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("calendarCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.calEvent.organizer.language.translate("event_awaiting_approval"),
|
||||
this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent?.count ? "event_awaiting_approval_recurring" : "event_awaiting_approval"
|
||||
),
|
||||
this.calEvent.organizer.language.translate("someone_requested_an_event")
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
|
|
|
@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
|
|||
import { createEvent, DateArray, Person } from "ics";
|
||||
|
||||
import { getCancelLink } from "@calcom/lib/CalEventParser";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
|
@ -24,8 +24,8 @@ dayjs.extend(toArray);
|
|||
|
||||
export default class OrganizerRequestRescheduledEmail extends OrganizerScheduledEmail {
|
||||
private metadata: { rescheduleLink: string };
|
||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
|
||||
super(calEvent);
|
||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
|
||||
super(calEvent, recurringEvent);
|
||||
this.metadata = metadata;
|
||||
}
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
|
|
|
@ -5,12 +5,13 @@ import toArray from "dayjs/plugin/toArray";
|
|||
import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray, Person } from "ics";
|
||||
import nodemailer from "nodemailer";
|
||||
import rrule from "rrule";
|
||||
|
||||
import { getAppName } from "@calcom/app-store/utils";
|
||||
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
|
@ -28,9 +29,11 @@ dayjs.extend(toArray);
|
|||
|
||||
export default class OrganizerScheduledEmail {
|
||||
calEvent: CalendarEvent;
|
||||
recurringEvent: RecurringEvent;
|
||||
|
||||
constructor(calEvent: CalendarEvent) {
|
||||
constructor(calEvent: CalendarEvent, recurringEvent: RecurringEvent) {
|
||||
this.calEvent = calEvent;
|
||||
this.recurringEvent = recurringEvent;
|
||||
}
|
||||
|
||||
public sendEmail() {
|
||||
|
@ -51,6 +54,11 @@ export default class OrganizerScheduledEmail {
|
|||
}
|
||||
|
||||
protected getiCalEventAsString(): string | undefined {
|
||||
// Taking care of recurrence rule beforehand
|
||||
let recurrenceRule: string | undefined = undefined;
|
||||
if (this.recurringEvent?.count) {
|
||||
recurrenceRule = new rrule(this.recurringEvent).toString();
|
||||
}
|
||||
const icsEvent = createEvent({
|
||||
start: dayjs(this.calEvent.startTime)
|
||||
.utc()
|
||||
|
@ -66,6 +74,7 @@ export default class OrganizerScheduledEmail {
|
|||
description: this.getTextBody(),
|
||||
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
||||
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
||||
...{ recurrenceRule },
|
||||
attendees: this.calEvent.attendees.map((attendee: Person) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
|
@ -121,7 +130,9 @@ export default class OrganizerScheduledEmail {
|
|||
|
||||
protected getTextBody(): string {
|
||||
return `
|
||||
${this.calEvent.organizer.language.translate("new_event_scheduled")}
|
||||
${this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
|
||||
)}
|
||||
${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")}
|
||||
|
||||
${getRichDescription(this.calEvent)}
|
||||
|
@ -153,7 +164,9 @@ ${getRichDescription(this.calEvent)}
|
|||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("checkCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.calEvent.organizer.language.translate("new_event_scheduled"),
|
||||
this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
|
||||
),
|
||||
this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
|
@ -240,12 +253,30 @@ ${getRichDescription(this.calEvent)}
|
|||
</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 {
|
||||
return `
|
||||
<p style="height: 6px"></p>
|
||||
<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;">
|
||||
${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
|
||||
${this.calEvent.organizer.language.translate(
|
||||
this.getOrganizerStart().format("dddd").toLowerCase()
|
||||
)}, ${this.calEvent.organizer.language.translate(
|
||||
|
|
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 { I18n } from "next-i18next";
|
||||
import { RRule } from "rrule";
|
||||
|
||||
import { recurringEvent } from "@calcom/prisma/zod-utils";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
||||
|
||||
import { parseZone } from "./parseZone";
|
||||
|
||||
export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
|
||||
if (!date) return "No date";
|
||||
const processDate = (date: string | null | Dayjs, i18n: I18n) => {
|
||||
const parsedZone = parseZone(date);
|
||||
if (!parsedZone?.isValid()) return "Invalid date";
|
||||
const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
|
||||
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
|
||||
};
|
||||
|
||||
export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
|
||||
if (!date) return ["No date"];
|
||||
return processDate(date, i18n);
|
||||
};
|
||||
|
||||
export const parseRecurringDates = (
|
||||
{
|
||||
startDate,
|
||||
recurringEvent,
|
||||
recurringCount,
|
||||
}: { startDate: string | null | Dayjs; recurringEvent: RecurringEvent; recurringCount: number },
|
||||
i18n: I18n
|
||||
): [string[], Date[]] => {
|
||||
const { count, ...restRecurringEvent } = recurringEvent;
|
||||
const rule = new RRule({
|
||||
...restRecurringEvent,
|
||||
count: recurringCount,
|
||||
dtstart: dayjs(startDate).toDate(),
|
||||
});
|
||||
const dateStrings = rule.all().map((r) => {
|
||||
return processDate(dayjs(r), i18n);
|
||||
});
|
||||
return [dateStrings, rule.all()];
|
||||
};
|
||||
|
|
|
@ -44,6 +44,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
|
|||
length: true,
|
||||
slug: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
users: {
|
||||
|
|
|
@ -19,6 +19,7 @@ export type BookingCreateBody = {
|
|||
name: string;
|
||||
notes?: string;
|
||||
rescheduleUid?: string;
|
||||
recurringEventId?: string;
|
||||
start: string;
|
||||
timeZone: string;
|
||||
user?: string | string[];
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"react-use-intercom": "1.4.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.6",
|
||||
"rrule": "^2.6.9",
|
||||
"short-uuid": "^4.2.0",
|
||||
"stripe": "^8.191.0",
|
||||
"superjson": "1.8.1",
|
||||
|
|
|
@ -18,6 +18,7 @@ import defaultEvents, {
|
|||
getUsernameSlugLink,
|
||||
} from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
|
@ -272,6 +273,7 @@ const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) =>
|
|||
description: true,
|
||||
hidden: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
metadata: true,
|
||||
|
|
|
@ -5,6 +5,7 @@ import { JSONObject } from "superjson/dist/types";
|
|||
|
||||
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
|
@ -84,6 +85,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
schedule: {
|
||||
select: {
|
||||
availability: true,
|
||||
|
@ -256,6 +258,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
metadata: (eventType.metadata || {}) as JSONObject,
|
||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||
recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent,
|
||||
});
|
||||
|
||||
const schedule = eventType.schedule
|
||||
|
|
|
@ -12,8 +12,9 @@ import {
|
|||
getUsernameList,
|
||||
} from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
|
||||
import getBooking, { GetBookingType } from "@lib/getBooking";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
@ -69,6 +70,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
const ssr = await ssrInit(context);
|
||||
const usernameList = getUsernameList(asStringOrThrow(context.query.user as string));
|
||||
const eventTypeSlug = context.query.slug as string;
|
||||
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
|
@ -111,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
periodDays: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
recurringEvent: true,
|
||||
metadata: true,
|
||||
periodCountCalendarDays: true,
|
||||
price: true,
|
||||
|
@ -150,6 +153,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
||||
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
|
||||
isWeb3Active:
|
||||
web3Credentials && web3Credentials.key
|
||||
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
|
||||
|
@ -204,6 +208,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
|
||||
const t = await getTranslation(context.locale ?? "en", "common");
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
const recurringEventCount =
|
||||
(eventType.recurringEvent?.count &&
|
||||
recurringEventCountQuery &&
|
||||
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
|
||||
? recurringEventCountQuery
|
||||
: eventType.recurringEvent.count)) ||
|
||||
null;
|
||||
|
||||
return {
|
||||
props: {
|
||||
away: user.away,
|
||||
|
@ -211,6 +224,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
profile,
|
||||
eventType: eventTypeObject,
|
||||
booking,
|
||||
recurringEventCount,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isDynamicGroupBooking,
|
||||
hasHashedBookingLink: false,
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { Prisma, User, Booking, SchedulingType, BookingStatus } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import rrule from "rrule";
|
||||
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { AdditionInformation } from "@calcom/types/Calendar";
|
||||
import type { AdditionInformation, RecurringEvent } from "@calcom/types/Calendar";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { refund } from "@ee/lib/stripe/server";
|
||||
|
||||
|
@ -94,12 +95,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
confirmed: true,
|
||||
attendees: true,
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: {
|
||||
recurringEvent: true,
|
||||
},
|
||||
},
|
||||
location: true,
|
||||
userId: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
payment: true,
|
||||
destinationCalendar: true,
|
||||
recurringEventId: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -147,6 +154,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar,
|
||||
};
|
||||
|
||||
const recurringEvent = booking.eventType?.recurringEvent as RecurringEvent;
|
||||
|
||||
if (req.body.recurringEventId && recurringEvent) {
|
||||
const groupedRecurringBookings = await prisma.booking.groupBy({
|
||||
where: {
|
||||
recurringEventId: booking.recurringEventId,
|
||||
},
|
||||
by: [Prisma.BookingScalarFieldEnum.recurringEventId],
|
||||
_count: true,
|
||||
});
|
||||
// Overriding the recurring event configuration count to be the actual number of events booked for
|
||||
// the recurring event (equal or less than recurring event configuration count)
|
||||
recurringEvent.count = groupedRecurringBookings[0]._count;
|
||||
}
|
||||
|
||||
if (reqBody.confirmed) {
|
||||
const eventManager = new EventManager(currentUser);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
|
@ -170,43 +192,93 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||
}
|
||||
try {
|
||||
await sendScheduledEmails({ ...evt, additionInformation: metadata });
|
||||
await sendScheduledEmails(
|
||||
{ ...evt, additionInformation: metadata },
|
||||
req.body.recurringEventId ? recurringEvent : {} // Send email with recurring event info only on recurring event context
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
|
||||
// Should perform update on booking (confirm) -> then trigger the rest handlers
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
confirmed: true,
|
||||
references: {
|
||||
create: scheduleResult.referencesToCreate,
|
||||
if (req.body.recurringEventId) {
|
||||
// The booking to confirm is a recurring event and comes from /booking/upcoming, proceeding to mark all related
|
||||
// bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now.
|
||||
const unconfirmedRecurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: req.body.recurringEventId,
|
||||
confirmed: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
unconfirmedRecurringBookings.map(async (recurringBooking) => {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: recurringBooking.id,
|
||||
},
|
||||
data: {
|
||||
confirmed: true,
|
||||
references: {
|
||||
create: scheduleResult.referencesToCreate,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
|
||||
// Should perform update on booking (confirm) -> then trigger the rest handlers
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
confirmed: true,
|
||||
references: {
|
||||
create: scheduleResult.referencesToCreate,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.status(204).end();
|
||||
} else {
|
||||
await refund(booking, evt);
|
||||
const rejectionReason = asStringOrNull(req.body.reason) || "";
|
||||
evt.rejectionReason = rejectionReason;
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
rejected: true,
|
||||
status: BookingStatus.REJECTED,
|
||||
rejectionReason: rejectionReason,
|
||||
},
|
||||
});
|
||||
if (req.body.recurringEventId) {
|
||||
// The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related
|
||||
// bookings as rejected. Prisma updateMany does not support relations, so doing this in two steps for now.
|
||||
const unconfirmedRecurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: req.body.recurringEventId,
|
||||
confirmed: false,
|
||||
},
|
||||
});
|
||||
unconfirmedRecurringBookings.map(async (recurringBooking) => {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: recurringBooking.id,
|
||||
},
|
||||
data: {
|
||||
rejected: true,
|
||||
status: BookingStatus.REJECTED,
|
||||
rejectionReason: rejectionReason,
|
||||
},
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await refund(booking, evt); // No payment integration for recurring events for v1
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
rejected: true,
|
||||
status: BookingStatus.REJECTED,
|
||||
rejectionReason: rejectionReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await sendDeclinedEmails(evt);
|
||||
await sendDeclinedEmails(evt, req.body.recurringEventId ? recurringEvent : {}); // Send email with recurring event info only on recurring event context
|
||||
|
||||
res.status(204).end();
|
||||
}
|
||||
|
|
|
@ -1,11 +1,4 @@
|
|||
import {
|
||||
BookingStatus,
|
||||
Credential,
|
||||
Payment,
|
||||
Prisma,
|
||||
SchedulingType,
|
||||
WebhookTriggerEvents,
|
||||
} from "@prisma/client";
|
||||
import { BookingStatus, Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
|
||||
import async from "async";
|
||||
import dayjs from "dayjs";
|
||||
import dayjsBusinessTime from "dayjs-business-time";
|
||||
|
@ -13,18 +6,24 @@ import isBetween from "dayjs/plugin/isBetween";
|
|||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import rrule from "rrule";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { getBusyVideoTimes } from "@calcom/core/videoClient";
|
||||
import { getDefaultEvent, getUsernameList, getGroupName } from "@calcom/lib/defaultEvents";
|
||||
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
|
||||
import type { AdditionInformation, CalendarEvent, EventBusyDate, Person } from "@calcom/types/Calendar";
|
||||
import type {
|
||||
AdditionInformation,
|
||||
CalendarEvent,
|
||||
EventBusyDate,
|
||||
RecurringEvent,
|
||||
} from "@calcom/types/Calendar";
|
||||
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
|
||||
import { handlePayment } from "@ee/lib/stripe/server";
|
||||
|
||||
|
@ -83,7 +82,7 @@ async function refreshCredentials(credentials: Array<Credential>): Promise<Array
|
|||
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
|
||||
let t = true;
|
||||
|
||||
|
@ -190,7 +189,7 @@ const getUserNameWithBookingCounts = async (eventTypeId: number, selectedUserNam
|
|||
};
|
||||
|
||||
const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
return await prisma.eventType.findUnique({
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
|
@ -220,14 +219,22 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
|||
metadata: true,
|
||||
destinationCalendar: true,
|
||||
hideCalendarNotes: true,
|
||||
recurringEvent: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...eventType,
|
||||
recurringEvent: (eventType.recurringEvent || undefined) as RecurringEvent,
|
||||
};
|
||||
};
|
||||
|
||||
type User = Prisma.UserGetPayload<typeof userSelect>;
|
||||
|
||||
type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number };
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const reqBody = req.body as BookingCreateBody;
|
||||
const { recurringCount, noEmail, ...reqBody } = req.body as ExtendedBookingCreateBody;
|
||||
|
||||
// handle dynamic user
|
||||
const dynamicUserList = Array.isArray(reqBody.user)
|
||||
|
@ -382,6 +389,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
}; // used for invitee emails
|
||||
}
|
||||
|
||||
if (reqBody.recurringEventId && eventType.recurringEvent) {
|
||||
// Overriding the recurring event configuration count to be the actual number of events booked for
|
||||
// the recurring event (equal or less than recurring event configuration count)
|
||||
eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { count: recurringCount });
|
||||
}
|
||||
|
||||
// Initialize EventManager with credentials
|
||||
const rescheduleUid = reqBody.rescheduleUid;
|
||||
async function getOriginalRescheduledBooking(uid: string) {
|
||||
|
@ -481,6 +494,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
}
|
||||
: undefined,
|
||||
};
|
||||
if (reqBody.recurringEventId) {
|
||||
newBookingData.recurringEventId = reqBody.recurringEventId;
|
||||
}
|
||||
if (originalRescheduledBooking) {
|
||||
newBookingData["paid"] = originalRescheduledBooking.paid;
|
||||
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
|
||||
|
@ -573,7 +589,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
|
||||
let isAvailableToBeBooked = true;
|
||||
try {
|
||||
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length);
|
||||
if (eventType.recurringEvent) {
|
||||
const allBookingDates = new rrule({
|
||||
dtstart: new Date(reqBody.start),
|
||||
...eventType.recurringEvent,
|
||||
}).all();
|
||||
// Go through each date for the recurring event and check if each one's availability
|
||||
isAvailableToBeBooked = allBookingDates
|
||||
.map((aDate) => isAvailable(bufferedBusyTimes, aDate, eventType.length)) // <-- array of booleans
|
||||
.reduce((acc, value) => acc && value, true); // <-- checks boolean array applying "AND" to each value and the current one, starting in true
|
||||
} else {
|
||||
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length);
|
||||
}
|
||||
} catch {
|
||||
log.debug({
|
||||
message: "Unable set isAvailableToBeBooked. Using true. ",
|
||||
|
@ -674,11 +701,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
}
|
||||
}
|
||||
|
||||
await sendRescheduledEmails({
|
||||
...evt,
|
||||
additionInformation: metadata,
|
||||
additionalNotes, // Resets back to the addtionalNote input and not the overriden value
|
||||
});
|
||||
if (noEmail !== true) {
|
||||
await sendRescheduledEmails(
|
||||
{
|
||||
...evt,
|
||||
additionInformation: metadata,
|
||||
additionalNotes, // Resets back to the addtionalNote input and not the overriden value
|
||||
},
|
||||
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
|
||||
);
|
||||
}
|
||||
}
|
||||
// If it's not a reschedule, doesn't require confirmation and there's no price,
|
||||
// Create a booking
|
||||
|
@ -708,17 +740,29 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
metadata.conferenceData = results[0].createdEvent?.conferenceData;
|
||||
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||
}
|
||||
await sendScheduledEmails({
|
||||
...evt,
|
||||
additionInformation: metadata,
|
||||
additionalNotes,
|
||||
});
|
||||
if (noEmail !== true) {
|
||||
await sendScheduledEmails(
|
||||
{
|
||||
...evt,
|
||||
additionInformation: metadata,
|
||||
additionalNotes,
|
||||
},
|
||||
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType.requiresConfirmation && !rescheduleUid) {
|
||||
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
|
||||
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
|
||||
if (eventType.requiresConfirmation && !rescheduleUid && noEmail !== true) {
|
||||
await sendOrganizerRequestEmail(
|
||||
{ ...evt, additionalNotes },
|
||||
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
|
||||
);
|
||||
await sendAttendeeRequestEmail(
|
||||
{ ...evt, additionalNotes },
|
||||
attendeesList[0],
|
||||
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof eventType.price === "number" && eventType.price > 0 && !originalRescheduledBooking?.paid) {
|
||||
|
|
|
@ -6,7 +6,6 @@ import type { TFunction } from "next-i18next";
|
|||
import { z, ZodError } from "zod";
|
||||
|
||||
import { getCalendar } from "@calcom/core/CalendarManager";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
|
||||
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
|
||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||
|
@ -100,6 +99,7 @@ const handler = async (
|
|||
title: true,
|
||||
users: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
},
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
|
|
|
@ -8,7 +8,7 @@ import { Alert } from "@calcom/ui/Alert";
|
|||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
|
||||
import { inferQueryInput, trpc } from "@lib/trpc";
|
||||
import { inferQueryInput, inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import BookingsShell from "@components/BookingsShell";
|
||||
import EmptyScreen from "@components/EmptyScreen";
|
||||
|
@ -17,6 +17,8 @@ import BookingListItem from "@components/booking/BookingListItem";
|
|||
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
||||
|
||||
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||
type BookingOutput = inferQueryOutput<"viewer.bookings">["bookings"][0];
|
||||
type BookingPage = inferQueryOutput<"viewer.bookings">;
|
||||
|
||||
export default function Bookings() {
|
||||
const router = useRouter();
|
||||
|
@ -26,6 +28,7 @@ export default function Bookings() {
|
|||
|
||||
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
upcoming: t("upcoming_bookings"),
|
||||
recurring: t("recurring_bookings"),
|
||||
past: t("past_bookings"),
|
||||
cancelled: t("cancelled_bookings"),
|
||||
};
|
||||
|
@ -44,6 +47,18 @@ export default function Bookings() {
|
|||
|
||||
const isEmpty = !query.data?.pages[0]?.bookings.length;
|
||||
|
||||
// Get the recurrentCount value from the grouped recurring bookings
|
||||
// created with the same recurringEventId
|
||||
const defineRecurrentCount = (booking: BookingOutput, page: BookingPage) => {
|
||||
let recurringCount = undefined;
|
||||
if (booking.recurringEventId !== null) {
|
||||
recurringCount = page.groupedRecurringBookings.filter(
|
||||
(group) => group.recurringEventId === booking.recurringEventId
|
||||
)[0]._count; // If found, only one object exists, just assing the needed _count value
|
||||
}
|
||||
return { recurringCount };
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell
|
||||
heading={t("bookings")}
|
||||
|
@ -66,7 +81,12 @@ export default function Bookings() {
|
|||
{query.data.pages.map((page, index) => (
|
||||
<Fragment key={index}>
|
||||
{page.bookings.map((booking) => (
|
||||
<BookingListItem key={booking.id} {...booking} />
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
{...defineRecurrentCount(booking, page)}
|
||||
{...booking}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
|
|
|
@ -2,6 +2,8 @@ import { Prisma } from "@prisma/client";
|
|||
import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
import { GetBookingType } from "@lib/getBooking";
|
||||
|
@ -37,6 +39,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
periodEndDate: true,
|
||||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
recurringEvent: true,
|
||||
schedulingType: true,
|
||||
userId: true,
|
||||
schedule: {
|
||||
|
@ -131,6 +134,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
const [user] = users;
|
||||
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
|
||||
metadata: {} as JSONObject,
|
||||
recurringEvent: (eventTypeSelect.recurringEvent || {}) as RecurringEvent,
|
||||
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
|
||||
slug,
|
||||
|
|
|
@ -6,8 +6,9 @@ import { GetServerSidePropsContext } from "next";
|
|||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { getLocationLabels } from "@calcom/app-store/utils";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
|
@ -28,6 +29,7 @@ export default function Book(props: HashLinkPageProps) {
|
|||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const link = asStringOrThrow(context.query.link as string);
|
||||
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||
const slug = context.query.slug as string;
|
||||
|
||||
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
|
@ -41,6 +43,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
periodType: true,
|
||||
periodDays: true,
|
||||
periodStartDate: true,
|
||||
recurringEvent: true,
|
||||
periodEndDate: true,
|
||||
metadata: true,
|
||||
periodCountCalendarDays: true,
|
||||
|
@ -122,6 +125,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
||||
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
|
||||
isWeb3Active:
|
||||
web3Credentials && web3Credentials.key
|
||||
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
|
||||
|
@ -148,6 +152,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
|
||||
const t = await getTranslation(context.locale ?? "en", "common");
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
const recurringEventCount =
|
||||
(eventTypeObject?.recurringEvent?.count &&
|
||||
recurringEventCountQuery &&
|
||||
(parseInt(recurringEventCountQuery) <= eventTypeObject.recurringEvent.count
|
||||
? recurringEventCountQuery
|
||||
: eventType.recurringEvent.count)) ||
|
||||
null;
|
||||
|
||||
return {
|
||||
props: {
|
||||
locationLabels: getLocationLabels(t),
|
||||
|
@ -155,6 +168,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
eventType: eventTypeObject,
|
||||
booking: null,
|
||||
trpcState: ssr.dehydrate(),
|
||||
recurringEventCount,
|
||||
isDynamicGroupBooking: false,
|
||||
hasHashedBookingLink: true,
|
||||
hashedLink: link,
|
||||
|
|
|
@ -34,9 +34,11 @@ import getApps, { getLocationOptions } from "@calcom/app-store/utils";
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { StripeData } from "@calcom/stripe/server";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
@ -55,9 +57,9 @@ import DestinationCalendarSelector from "@components/DestinationCalendarSelector
|
|||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
import Loader from "@components/Loader";
|
||||
import Shell from "@components/Shell";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import RecurringEventController from "@components/eventtype/RecurringEventController";
|
||||
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
|
||||
import Badge from "@components/ui/Badge";
|
||||
import InfoBadge from "@components/ui/InfoBadge";
|
||||
|
@ -65,7 +67,7 @@ import CheckboxField from "@components/ui/form/CheckboxField";
|
|||
import CheckedSelect from "@components/ui/form/CheckedSelect";
|
||||
import { DateRangePicker } from "@components/ui/form/DateRangePicker";
|
||||
import MinutesField from "@components/ui/form/MinutesField";
|
||||
import Select, { SelectProps } from "@components/ui/form/Select";
|
||||
import Select from "@components/ui/form/Select";
|
||||
import * as RadioArea from "@components/ui/form/radio-area";
|
||||
import WebhookListContainer from "@components/webhook/WebhookListContainer";
|
||||
|
||||
|
@ -272,8 +274,12 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
PERIOD_TYPES.find((s) => s.type === eventType.periodType) ||
|
||||
PERIOD_TYPES.find((s) => s.type === "UNLIMITED");
|
||||
|
||||
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
|
||||
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
|
||||
|
||||
const [requirePayment, setRequirePayment] = useState(
|
||||
eventType.price > 0 && eventType.recurringEvent?.count !== undefined
|
||||
);
|
||||
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -483,6 +489,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
description: string;
|
||||
disableGuests: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
recurringEvent: RecurringEvent;
|
||||
schedulingType: SchedulingType | null;
|
||||
price: number;
|
||||
currency: string;
|
||||
|
@ -510,6 +517,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
}>({
|
||||
defaultValues: {
|
||||
locations: eventType.locations || [],
|
||||
recurringEvent: eventType.recurringEvent || {},
|
||||
schedule: eventType.schedule?.id,
|
||||
periodDates: {
|
||||
startDate: periodDates.startDate,
|
||||
|
@ -928,15 +936,15 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
giphyThankYouPage,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
recurringEvent,
|
||||
locations,
|
||||
...input
|
||||
} = values;
|
||||
|
||||
if (requirePayment) input.currency = currency;
|
||||
|
||||
updateMutation.mutate({
|
||||
...input,
|
||||
locations,
|
||||
recurringEvent,
|
||||
periodStartDate: periodDates.startDate,
|
||||
periodEndDate: periodDates.endDate,
|
||||
periodCountCalendarDays: periodCountCalendarDays === "1",
|
||||
|
@ -1334,6 +1342,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
)}
|
||||
/>
|
||||
|
||||
<RecurringEventController
|
||||
recurringEvent={eventType.recurringEvent}
|
||||
formMethods={formMethods}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="disableGuests"
|
||||
control={formMethods.control}
|
||||
|
@ -1641,7 +1654,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
<SuccessRedirectEdit<typeof formMethods>
|
||||
formMethods={formMethods}
|
||||
eventType={eventType}></SuccessRedirectEdit>
|
||||
{hasPaymentIntegration && (
|
||||
{hasPaymentIntegration && eventType.recurringEvent?.count !== undefined && (
|
||||
<>
|
||||
<hr className="border-neutral-200" />
|
||||
<div className="block sm:flex">
|
||||
|
@ -2054,6 +2067,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
periodEndDate: true,
|
||||
periodCountCalendarDays: true,
|
||||
requiresConfirmation: true,
|
||||
recurringEvent: true,
|
||||
hideCalendarNotes: true,
|
||||
disableGuests: true,
|
||||
minimumBookingNotice: true,
|
||||
|
@ -2118,6 +2132,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
const { locations, metadata, ...restEventType } = rawEventType;
|
||||
const eventType = {
|
||||
...restEventType,
|
||||
recurringEvent: (restEventType.recurringEvent || {}) as RecurringEvent,
|
||||
locations: locations as unknown as Location[],
|
||||
metadata: (metadata || {}) as JSONObject,
|
||||
isWeb3Active:
|
||||
|
|
|
@ -31,6 +31,7 @@ import Dropdown, {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
import { withQuery } from "@lib/QueryCell";
|
||||
import classNames from "@lib/classNames";
|
||||
|
@ -40,7 +41,6 @@ import { inferQueryOutput, trpc } from "@lib/trpc";
|
|||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
import EmptyScreen from "@components/EmptyScreen";
|
||||
import Shell from "@components/Shell";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
|
||||
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { CheckIcon } from "@heroicons/react/outline";
|
||||
import { ArrowLeftIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
|
@ -11,6 +12,7 @@ import { useSession } from "next-auth/react";
|
|||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import rrule from "rrule";
|
||||
|
||||
import { SpaceBookingSuccessPage } from "@calcom/app-store/spacebooking/components";
|
||||
import {
|
||||
|
@ -21,6 +23,7 @@ import {
|
|||
} from "@calcom/embed-core";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import Button from "@calcom/ui/Button";
|
||||
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 router = useRouter();
|
||||
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);
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
const titleSuffix = props.recurringBookings ? "_recurring" : "";
|
||||
if (needsConfirmation) {
|
||||
if (props.profile.name !== null) {
|
||||
return t("user_needs_to_confirm_or_reject_booking" + titleSuffix, {
|
||||
user: props.profile.name,
|
||||
});
|
||||
}
|
||||
return t("needs_to_be_confirmed_or_rejected" + titleSuffix);
|
||||
}
|
||||
return t("emailed_you_and_attendees" + titleSuffix);
|
||||
}
|
||||
const userIsOwner = !!(session?.user?.id && eventType.users.find((user) => (user.id = session.user.id)));
|
||||
const title = t(
|
||||
`booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}`
|
||||
);
|
||||
return (
|
||||
(isReady && (
|
||||
<>
|
||||
|
@ -220,10 +241,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
|||
className={isEmbed ? "" : "h-screen bg-neutral-100 dark:bg-neutral-900"}
|
||||
data-testid="success-page">
|
||||
<Theme />
|
||||
<HeadSeo
|
||||
title={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
|
||||
description={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
|
||||
/>
|
||||
<HeadSeo title={title} description={title} />
|
||||
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
|
||||
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
|
||||
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
|
||||
|
@ -263,29 +281,29 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
|||
<h3
|
||||
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
|
||||
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>
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-neutral-600 dark:text-gray-300">
|
||||
{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>
|
||||
<p className="text-sm text-neutral-600 dark:text-gray-300">{getTitle()}</p>
|
||||
</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="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6">{eventName}</div>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
<div className="col-span-2">
|
||||
{date.format("dddd, DD MMMM YYYY")}
|
||||
<br />
|
||||
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
|
||||
<span className="text-bookinglight">
|
||||
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||
</span>
|
||||
<RecurringBookings
|
||||
isReschedule={reschedule === "true"}
|
||||
eventType={props.eventType}
|
||||
recurringBookings={props.recurringBookings}
|
||||
date={date}
|
||||
is24h={is24h}
|
||||
/>
|
||||
</div>
|
||||
{location && (
|
||||
<>
|
||||
|
@ -322,6 +340,10 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
|||
}` +
|
||||
(typeof location === "string"
|
||||
? "&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">
|
||||
|
@ -447,21 +469,15 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
|||
{props.userHasSpaceBooking && (
|
||||
<SpaceBookingSuccessPage
|
||||
open={props.userHasSpaceBooking}
|
||||
what={`
|
||||
what={`
|
||||
${needsConfirmation ? t("submitted") : `${t("meeting_is_scheduled")}.`}
|
||||
${
|
||||
needsConfirmation
|
||||
? props.profile.name !== null
|
||||
? t("user_needs_to_confirm_or_reject_booking", { user: props.profile.name })
|
||||
: t("needs_to_be_confirmed_or_rejected")
|
||||
: t("emailed_you_and_attendees")
|
||||
} ${t("what")}: ${eventName}`}
|
||||
${getTitle()} ${t("what")}: ${eventName}`}
|
||||
where={`${t("where")}: ${
|
||||
location ? (location?.startsWith("http") ? { location } : location) : "Far far a way galaxy"
|
||||
}`}
|
||||
when={`${t("when")}: ${date.format("dddd, DD MMMM YYYY")} ${date.format(
|
||||
is24h ? "H:mm" : "h:mma"
|
||||
)} - ${props.eventType.length} mins (${
|
||||
when={`${t("when")}: ${props.recurringBookings ? t("starting") : ""} ${date.format(
|
||||
"dddd, DD MMMM YYYY"
|
||||
)} ${date.format(is24h ? "H:mm" : "h:mma")} - ${props.eventType.length} mins (${
|
||||
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) => {
|
||||
return await prisma.eventType.findUnique({
|
||||
where: {
|
||||
|
@ -483,6 +564,7 @@ const getEventTypesFromDB = async (typeId: number) => {
|
|||
description: true,
|
||||
length: true,
|
||||
eventName: true,
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
userId: true,
|
||||
successRedirectUrl: true,
|
||||
|
@ -513,6 +595,7 @@ const getEventTypesFromDB = async (typeId: number) => {
|
|||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
|
||||
const recurringEventIdQuery = asStringOrNull(context.query.recur);
|
||||
const typeSlug = asStringOrNull(context.query.eventSlug) ?? "15min";
|
||||
const dynamicEventName = asStringOrNull(context.query.eventName) ?? "";
|
||||
|
||||
|
@ -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 {
|
||||
notFound: true,
|
||||
};
|
||||
|
@ -532,11 +615,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
let spaceBookingAvailable = false;
|
||||
|
||||
let userHasSpaceBooking = false;
|
||||
if (eventType.users[0] && eventType.users[0].id) {
|
||||
if (eventTypeRaw.users[0] && eventTypeRaw.users[0].id) {
|
||||
const credential = await prisma.credential.findFirst({
|
||||
where: {
|
||||
type: "spacebooking_other",
|
||||
userId: eventType.users[0].id,
|
||||
userId: eventTypeRaw.users[0].id,
|
||||
},
|
||||
});
|
||||
if (credential && credential.type === "spacebooking_other") {
|
||||
|
@ -544,11 +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
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: eventType.userId,
|
||||
id: eventTypeRaw.userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
@ -563,17 +646,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
},
|
||||
});
|
||||
if (user) {
|
||||
eventType.users.push(user);
|
||||
eventTypeRaw.users.push(user as any);
|
||||
}
|
||||
}
|
||||
|
||||
if (!eventType.users.length) {
|
||||
if (!eventTypeRaw.users.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
// if (!typeId) eventType["eventName"] = getDynamicEventName(users, typeSlug);
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
|
||||
};
|
||||
|
||||
const profile = {
|
||||
name: eventType.team?.name || eventType.users[0]?.name || null,
|
||||
|
@ -583,11 +669,25 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
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 {
|
||||
props: {
|
||||
hideBranding: eventType.team ? eventType.team.hideBranding : isBrandingHidden(eventType.users[0]),
|
||||
profile,
|
||||
eventType,
|
||||
recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null,
|
||||
trpcState: ssr.dehydrate(),
|
||||
dynamicEventName,
|
||||
userHasSpaceBooking,
|
||||
|
|
|
@ -2,6 +2,8 @@ import { UserPlan } from "@prisma/client";
|
|||
import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
import getBooking, { GetBookingType } from "@lib/getBooking";
|
||||
|
@ -68,6 +70,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
minimumBookingNotice: true,
|
||||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
recurringEvent: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
timeZone: true,
|
||||
|
@ -107,6 +110,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
metadata: (eventType.metadata || {}) as JSONObject,
|
||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||
recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent,
|
||||
});
|
||||
|
||||
eventTypeObject.availability = [];
|
||||
|
|
|
@ -3,8 +3,9 @@ import { GetServerSidePropsContext } from "next";
|
|||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { getLocationLabels } from "@calcom/app-store/utils";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
|
||||
import getBooking, { GetBookingType } from "@lib/getBooking";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
@ -21,13 +22,14 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
|
|||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
|
||||
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
const eventTypeRaw = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
|
@ -44,6 +46,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
periodCountCalendarDays: true,
|
||||
recurringEvent: true,
|
||||
disableGuests: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
|
@ -65,7 +68,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
},
|
||||
});
|
||||
|
||||
if (!eventType) return { notFound: true };
|
||||
if (!eventTypeRaw) return { notFound: true };
|
||||
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
|
||||
};
|
||||
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
return {
|
||||
|
@ -83,6 +91,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
|
||||
const t = await getTranslation(context.locale ?? "en", "common");
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
const recurringEventCount =
|
||||
(eventType.recurringEvent?.count &&
|
||||
recurringEventCountQuery &&
|
||||
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
|
||||
? recurringEventCountQuery
|
||||
: eventType.recurringEvent.count)) ||
|
||||
null;
|
||||
|
||||
return {
|
||||
props: {
|
||||
locationLabels: getLocationLabels(t),
|
||||
|
@ -96,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
eventName: null,
|
||||
},
|
||||
eventType: eventTypeObject,
|
||||
recurringEventCount,
|
||||
booking,
|
||||
isDynamicGroupBooking: false,
|
||||
hasHashedBookingLink: false,
|
||||
|
|
|
@ -49,6 +49,45 @@ test.describe("Event Types tests", () => {
|
|||
isCreated = await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("enabling recurring event comes with default options", async ({ page }) => {
|
||||
await page.click("[data-testid=new-event-type]");
|
||||
const nonce = randomString(3);
|
||||
eventTitle = `my recurring event ${nonce}`;
|
||||
|
||||
await page.fill("[name=title]", eventTitle);
|
||||
await page.fill("[name=length]", "15");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname !== "/event-types";
|
||||
},
|
||||
});
|
||||
|
||||
await page.click("[data-testid=show-advanced-settings]");
|
||||
await expect(await page.locator("[data-testid=recurring-event-collapsible] > *")).not.toBeVisible();
|
||||
await page.click("[data-testid=recurring-event-check]");
|
||||
isCreated = await expect(
|
||||
await page.locator("[data-testid=recurring-event-collapsible] > *")
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
await page
|
||||
.locator("[data-testid=recurring-event-collapsible] input[type=number]")
|
||||
.nth(0)
|
||||
.getAttribute("value")
|
||||
).toBe("1");
|
||||
await expect(
|
||||
await page.locator("[data-testid=recurring-event-collapsible] div[class$=singleValue]").textContent()
|
||||
).toBe("week");
|
||||
await expect(
|
||||
await page
|
||||
.locator("[data-testid=recurring-event-collapsible] input[type=number]")
|
||||
.nth(1)
|
||||
.getAttribute("value")
|
||||
).toBe("12");
|
||||
});
|
||||
|
||||
test("can duplicate an existing event type", async ({ page }) => {
|
||||
const firstTitle = await page.locator("[data-testid=event-type-title-3]").innerText();
|
||||
const firstFullSlug = await page.locator("[data-testid=event-type-slug-3]").innerText();
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
"event_declined_subject": "Declined: {{eventType}} with {{name}} at {{date}}",
|
||||
"event_cancelled_subject": "Cancelled: {{eventType}} with {{name}} at {{date}}",
|
||||
"event_request_declined": "Your event request has been declined",
|
||||
"event_request_declined_recurring": "Your recurring event request has been declined",
|
||||
"event_request_cancelled": "Your scheduled event was cancelled",
|
||||
"organizer": "Organizer",
|
||||
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
|
||||
|
@ -23,6 +24,7 @@
|
|||
"rejection_confirmation": "Reject the booking",
|
||||
"manage_this_event": "Manage this event",
|
||||
"your_event_has_been_scheduled": "Your event has been scheduled",
|
||||
"your_event_has_been_scheduled_recurring": "Your recurring event has been scheduled",
|
||||
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_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}}'.",
|
||||
"error_message": "The error message was: '{{errorMessage}}'",
|
||||
|
@ -57,6 +59,7 @@
|
|||
"confirm_or_reject_request": "Confirm or reject the request",
|
||||
"check_bookings_page_to_confirm_or_reject": "Check your bookings page to confirm or reject the booking.",
|
||||
"event_awaiting_approval": "An event is waiting for your approval",
|
||||
"event_awaiting_approval_recurring": "A recurring event is waiting for your approval",
|
||||
"someone_requested_an_event": "Someone has requested to schedule an event on your calendar.",
|
||||
"someone_requested_password_reset": "Someone has requested a link to change your password.",
|
||||
"password_reset_instructions": "If you didn't request this, you can safely ignore this email and your password will not be changed.",
|
||||
|
@ -79,6 +82,7 @@
|
|||
"manage_my_bookings": "Manage my bookings",
|
||||
"need_to_make_a_change": "Need to make a change?",
|
||||
"new_event_scheduled": "A new event has been scheduled.",
|
||||
"new_event_scheduled_recurring": "A new recurring event has been scheduled.",
|
||||
"invitee_email": "Invitee Email",
|
||||
"invitee_timezone": "Invitee Time Zone",
|
||||
"event_type": "Event Type",
|
||||
|
@ -128,6 +132,7 @@
|
|||
"ping_test": "Ping test",
|
||||
"add_to_homescreen": "Add this app to your home screen for faster access and improved experience.",
|
||||
"upcoming": "Upcoming",
|
||||
"recurring": "Recurring",
|
||||
"past": "Past",
|
||||
"choose_a_file": "Choose a file...",
|
||||
"upload_image": "Upload image",
|
||||
|
@ -232,13 +237,20 @@
|
|||
"add_to_calendar": "Add to calendar",
|
||||
"other": "Other",
|
||||
"emailed_you_and_attendees": "We emailed you and the other attendees a calendar invitation with all the details.",
|
||||
"emailed_you_and_attendees_recurring": "We emailed you and the other attendees a calendar invitation for the first of these recurring events.",
|
||||
"emailed_you_and_any_other_attendees": "You and any other attendees have been emailed with this information.",
|
||||
"needs_to_be_confirmed_or_rejected": "Your booking still needs to be confirmed or rejected.",
|
||||
"needs_to_be_confirmed_or_rejected_recurring": "Your recurring meeting still needs to be confirmed or rejected.",
|
||||
"user_needs_to_confirm_or_reject_booking": "{{user}} still needs to confirm or reject the booking.",
|
||||
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} still needs to confirm or reject each booking of the recurring meeting.",
|
||||
"meeting_is_scheduled": "This meeting is scheduled",
|
||||
"meeting_is_scheduled_recurring": "The recurring events are scheduled",
|
||||
"submitted": "Your booking has been submitted",
|
||||
"submitted_recurring": "Your recurring meeting has been submitted",
|
||||
"booking_submitted": "Your booking has been submitted",
|
||||
"booking_submitted_recurring": "Your recurring meeting has been submitted",
|
||||
"booking_confirmed": "Your booking has been confirmed",
|
||||
"booking_confirmed_recurring": "Your recurring meeting has been confirmed",
|
||||
"enter_new_password": "Enter the new password you'd like for your account.",
|
||||
"reset_password": "Reset Password",
|
||||
"change_your_password": "Change your password",
|
||||
|
@ -282,6 +294,7 @@
|
|||
"bookings": "Bookings",
|
||||
"bookings_description": "See upcoming and past events booked through your event type links.",
|
||||
"upcoming_bookings": "As soon as someone books a time with you it will show up here.",
|
||||
"recurring_bookings": "As soon as someone books a recurring meeting with you it will show up here.",
|
||||
"past_bookings": "Your past bookings will show up here.",
|
||||
"cancelled_bookings": "Your cancelled bookings will show up here.",
|
||||
"on": "on",
|
||||
|
@ -432,6 +445,7 @@
|
|||
"edit_role": "Edit Role",
|
||||
"edit_team": "Edit team",
|
||||
"reject": "Reject",
|
||||
"reject_all": "Reject all",
|
||||
"accept": "Accept",
|
||||
"leave": "Leave",
|
||||
"profile": "Profile",
|
||||
|
@ -460,6 +474,7 @@
|
|||
"cancel_event": "Cancel this event",
|
||||
"continue": "Continue",
|
||||
"confirm": "Confirm",
|
||||
"confirm_all": "Confirm all",
|
||||
"disband_team": "Disband Team",
|
||||
"disband_team_confirmation_message": "Are you sure you want to disband this team? Anyone who you've shared this team link with will no longer be able to book using it.",
|
||||
"remove_member_confirmation_message": "Are you sure you want to remove this member from the team?",
|
||||
|
@ -526,6 +541,18 @@
|
|||
"language": "Language",
|
||||
"timezone": "Timezone",
|
||||
"first_day_of_week": "First Day of Week",
|
||||
"repeats_up_to": "Repeats up to {{count}} time",
|
||||
"repeats_up_to_plural": "Repeats up to {{count}} times",
|
||||
"every_for_freq": "Every {{freq}} for",
|
||||
"repeats_every": "Repeats every",
|
||||
"weekly": "week",
|
||||
"weekly_plural": "weeks",
|
||||
"monthly": "month",
|
||||
"monthly_plural": "months",
|
||||
"yearly": "year",
|
||||
"yearly_plural": "years",
|
||||
"plus_more": "+ {{count}} more",
|
||||
"max": "Max",
|
||||
"single_theme": "Single Theme",
|
||||
"brand_color": "Brand Color",
|
||||
"light_brand_color": "Brand Color (Light Theme)",
|
||||
|
@ -582,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.",
|
||||
"opt_in_booking": "Opt-in Booking",
|
||||
"opt_in_booking_description": "The booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.",
|
||||
"recurring_event": "Recurring Event",
|
||||
"recurring_event_description": "People can subscribe for recurring events",
|
||||
"starting": "Starting",
|
||||
"disable_guests": "Disable Guests",
|
||||
"disable_guests_description": "Disable adding additional guests while booking.",
|
||||
"hashed_link": "Generate hashed URL",
|
||||
|
|
|
@ -6,6 +6,7 @@ import { z } from "zod";
|
|||
import getApps from "@calcom/app-store/utils";
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
||||
import jackson from "@lib/jackson";
|
||||
|
@ -127,6 +128,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
description: true,
|
||||
length: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
slug: true,
|
||||
hidden: true,
|
||||
price: true,
|
||||
|
@ -298,7 +300,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
})
|
||||
.query("bookings", {
|
||||
input: z.object({
|
||||
status: z.enum(["upcoming", "past", "cancelled"]),
|
||||
status: z.enum(["upcoming", "recurring", "past", "cancelled"]),
|
||||
limit: z.number().min(1).max(100).nullish(),
|
||||
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
|
||||
}),
|
||||
|
@ -311,9 +313,30 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
const bookingListingByStatus = input.status;
|
||||
const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput[]> = {
|
||||
upcoming: [
|
||||
{
|
||||
endTime: { gte: new Date() },
|
||||
// These changes are needed to not show confirmed recurring events,
|
||||
// as rescheduling or cancel for recurring event bookings should be
|
||||
// handled separately for each occurrence
|
||||
OR: [
|
||||
{
|
||||
AND: [{ NOT: { recurringEventId: { equals: null } } }, { confirmed: false }],
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{ recurringEventId: { equals: null } },
|
||||
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
|
||||
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
recurring: [
|
||||
{
|
||||
endTime: { gte: new Date() },
|
||||
AND: [
|
||||
{ NOT: { recurringEventId: { equals: null } } },
|
||||
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
|
||||
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
|
||||
],
|
||||
|
@ -342,11 +365,22 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
Prisma.BookingOrderByWithAggregationInput
|
||||
> = {
|
||||
upcoming: { startTime: "asc" },
|
||||
recurring: { startTime: "asc" },
|
||||
past: { 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 orderBy = bookingListingOrderby[bookingListingByStatus];
|
||||
const distinct = bookingListingDistinct[bookingListingByStatus];
|
||||
|
||||
const bookingsQuery = await prisma.booking.findMany({
|
||||
where: {
|
||||
|
@ -373,10 +407,12 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
rejected: true,
|
||||
id: true,
|
||||
startTime: true,
|
||||
recurringEventId: true,
|
||||
endTime: true,
|
||||
eventType: {
|
||||
select: {
|
||||
price: true,
|
||||
recurringEvent: true,
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
|
@ -394,13 +430,23 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
rescheduled: true,
|
||||
},
|
||||
orderBy,
|
||||
distinct,
|
||||
take: take + 1,
|
||||
skip,
|
||||
});
|
||||
|
||||
const groupedRecurringBookings = await prisma.booking.groupBy({
|
||||
by: [Prisma.BookingScalarFieldEnum.recurringEventId],
|
||||
_count: true,
|
||||
});
|
||||
|
||||
const bookings = bookingsQuery.map((booking) => {
|
||||
return {
|
||||
...booking,
|
||||
eventType: {
|
||||
...booking.eventType,
|
||||
recurringEvent: ((booking.eventType && booking.eventType.recurringEvent) || {}) as RecurringEvent,
|
||||
},
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
};
|
||||
|
@ -416,6 +462,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
|
||||
return {
|
||||
bookings,
|
||||
groupedRecurringBookings,
|
||||
nextCursor,
|
||||
};
|
||||
},
|
||||
|
|
|
@ -7,6 +7,7 @@ import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
|
|||
import { _DestinationCalendarModel, _EventTypeCustomInputModel, _EventTypeModel } from "@calcom/prisma/zod";
|
||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { createProtectedRouter } from "@server/createRouter";
|
||||
import { viewerRouter } from "@server/routers/viewer";
|
||||
|
@ -254,6 +255,7 @@ export const eventTypesRouter = createProtectedRouter()
|
|||
locations,
|
||||
destinationCalendar,
|
||||
customInputs,
|
||||
recurringEvent,
|
||||
users,
|
||||
id,
|
||||
hashedLink,
|
||||
|
@ -266,6 +268,17 @@ export const eventTypesRouter = createProtectedRouter()
|
|||
data.periodType = handlePeriodType(periodType);
|
||||
}
|
||||
|
||||
if (recurringEvent) {
|
||||
data.recurringEvent = {
|
||||
dstart: recurringEvent.dtstart as unknown as Prisma.InputJsonObject,
|
||||
interval: recurringEvent.interval,
|
||||
count: recurringEvent.count,
|
||||
freq: recurringEvent.freq,
|
||||
until: recurringEvent.until as unknown as Prisma.InputJsonObject,
|
||||
tzid: recurringEvent.tzid,
|
||||
};
|
||||
}
|
||||
|
||||
if (destinationCalendar) {
|
||||
/** We connect or create a destination calendar to the event type instead of the user */
|
||||
await viewerRouter.createCaller(ctx).mutation("setDestinationCalendar", {
|
||||
|
|
|
@ -9,9 +9,9 @@
|
|||
--brand-text-color-dark-mode: #292929;
|
||||
}
|
||||
|
||||
/*
|
||||
/*
|
||||
* Override the default tailwindcss-forms styling (default is: 'colors.blue.600')
|
||||
* @see: https://github.com/tailwindlabs/tailwindcss-forms/issues/14#issuecomment-1005376006
|
||||
* @see: https://github.com/tailwindlabs/tailwindcss-forms/issues/14#issuecomment-1005376006
|
||||
*/
|
||||
[type="text"]:focus,
|
||||
[type="email"]:focus,
|
||||
|
|
|
@ -7,7 +7,7 @@ import { ConfirmDialog } from "./confirmDialog";
|
|||
interface IWipeMyCalActionButtonProps {
|
||||
trpc: any;
|
||||
bookingsEmpty: boolean;
|
||||
bookingStatus: "upcoming" | "past" | "cancelled";
|
||||
bookingStatus: "upcoming" | "recurring" | "past" | "cancelled";
|
||||
}
|
||||
|
||||
const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
|
||||
|
|
|
@ -6,8 +6,8 @@ import { useState } from "react";
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import Loader from "@calcom/web/components/Loader";
|
||||
import { Tooltip } from "@calcom/web/components/Tooltip";
|
||||
|
||||
import Icon from "./icon";
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ class CalendarEventClass implements CalendarEvent {
|
|||
rejectionReason?: string | null;
|
||||
hideCalendarNotes?: boolean;
|
||||
additionalNotes?: string | null | undefined;
|
||||
recurrence?: string;
|
||||
|
||||
constructor(initProps?: CalendarEvent) {
|
||||
// If more parameters are given we update this
|
||||
|
|
|
@ -55,6 +55,7 @@ const commons = {
|
|||
},
|
||||
isWeb3Active: false,
|
||||
hideCalendarNotes: false,
|
||||
recurringEvent: {},
|
||||
destinationCalendar: null,
|
||||
team: null,
|
||||
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?
|
||||
periodCountCalendarDays Boolean?
|
||||
requiresConfirmation Boolean @default(false)
|
||||
/// @zod.custom(imports.recurringEvent)
|
||||
recurringEvent Json?
|
||||
disableGuests Boolean @default(false)
|
||||
hideCalendarNotes Boolean @default(false)
|
||||
minimumBookingNotice Int @default(120)
|
||||
|
@ -278,6 +280,7 @@ model Booking {
|
|||
dynamicGroupSlugRef String?
|
||||
rescheduled Boolean?
|
||||
fromReschedule String?
|
||||
recurringEventId String?
|
||||
}
|
||||
|
||||
model Schedule {
|
||||
|
|
|
@ -277,6 +277,111 @@ async function main() {
|
|||
length: 60,
|
||||
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 { 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 stringToDate = z.string().transform((a) => new Date(a));
|
||||
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 { Time } from "ical.js";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import type { Frequency as RRuleFrequency } from "rrule";
|
||||
|
||||
import type { Event } from "./Event";
|
||||
import type { Ensure } from "./utils";
|
||||
|
@ -72,6 +73,15 @@ export interface AdditionInformation {
|
|||
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
|
||||
export interface CalendarEvent {
|
||||
type: string;
|
||||
|
@ -96,6 +106,7 @@ export interface CalendarEvent {
|
|||
cancellationReason?: string | null;
|
||||
rejectionReason?: string | null;
|
||||
hideCalendarNotes?: boolean;
|
||||
recurrence?: string;
|
||||
}
|
||||
|
||||
export interface EntryPoint {
|
||||
|
|
|
@ -23,7 +23,7 @@ export function Tooltip({
|
|||
onOpenChange={onOpenChange}>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<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"
|
||||
align="center"
|
||||
{...props}>
|
Loading…
Reference in a new issue