From 3c6ac395cc4d3dd13f809ca8852b45ec3d3c06c4 Mon Sep 17 00:00:00 2001 From: alannnc Date: Thu, 14 Apr 2022 15:25:24 -0600 Subject: [PATCH] Feature/reschedule bookings (#2351) * WIP bookings page ui changes, created api endpoint * Ui changes mobile/desktop * Added translations * Fix lib import and common names * WIP reschedule * WIP * Save wip * [WIP] builder and class for CalendarEvent, email for attende * update rescheduled emails, booking view and availability page view * Working version reschedule * Fix for req.user as array * Added missing translation and refactor dialog to self component * Test for reschedule * update on types * Update lib no required * Update type on createBooking * fix types * remove preview stripe sub * remove unused file * remove unused import * Fix reschedule test * Refactor and cleaning up code * Email reschedule title fixes * Adding calendar delete and recreate placeholder of cancelled * Add translation * Removed logs, notes, fixed types * Fixes process.env types * Use strict compare * Fixes type inference * Type fixing is my middle name * Update apps/web/components/booking/BookingListItem.tsx * Update apps/web/components/dialog/RescheduleDialog.tsx * Update packages/core/builders/CalendarEvent/director.ts * Update apps/web/pages/success.tsx * Updates rescheduling labels * Update packages/core/builders/CalendarEvent/builder.ts * Type fixes * Update packages/core/builders/CalendarEvent/builder.ts * Only validating input blocked once * E2E fixes * Stripe tests fixes Co-authored-by: Peer Richelsen Co-authored-by: zomars --- apps/api | 2 +- .../components/booking/BookingListItem.tsx | 52 +++- .../booking/pages/AvailabilityPage.tsx | 59 ++-- .../components/booking/pages/BookingPage.tsx | 159 ++++++---- .../components/dialog/RescheduleDialog.tsx | 102 +++++++ apps/web/components/ui/TableActions.tsx | 95 +++--- apps/web/components/ui/form/PhoneInput.tsx | 3 +- apps/web/ee/components/stripe/PaymentPage.tsx | 6 +- .../web/lib/attendeeToPersonConversionType.ts | 16 + apps/web/lib/emails/email-manager.ts | 33 ++ .../attendee-request-reschedule-email.ts | 210 +++++++++++++ .../templates/common/scheduling-body-head.ts | 2 +- .../organizer-request-reschedule-email.ts | 189 ++++++++++++ apps/web/lib/getBooking.tsx | 33 ++ apps/web/lib/locationOptions.tsx | 30 ++ apps/web/lib/parseDate.ts | 14 + apps/web/pages/[user]/[type].tsx | 8 + apps/web/pages/[user]/book.tsx | 26 +- apps/web/pages/api/book/confirm.ts | 8 +- apps/web/pages/api/book/event.ts | 166 +++++++--- apps/web/pages/api/book/request-reschedule.ts | 217 ++++++++++++++ apps/web/pages/bookings/[status].tsx | 2 +- apps/web/pages/cancel/[uid].tsx | 4 +- apps/web/pages/cancel/success.tsx | 4 +- apps/web/pages/success.tsx | 23 +- apps/web/pages/team/[slug]/[type].tsx | 8 + apps/web/pages/team/[slug]/book.tsx | 25 +- apps/web/playwright/booking-pages.test.ts | 1 + .../playwright/dynamic-booking-pages.test.ts | 1 + .../playwright/integrations-stripe.test.ts | 1 + apps/web/playwright/lib/dbSetup.ts | 80 +++++ apps/web/playwright/lib/teardown.ts | 25 +- apps/web/playwright/reschedule.test.ts | 246 +++++++++++++++ apps/web/public/static/locales/en/common.json | 20 +- apps/web/server/routers/viewer.tsx | 1 + apps/web/styles/globals.css | 30 +- apps/website | 2 +- packages/core/EventManager.ts | 36 ++- .../core/builders/CalendarEvent/builder.ts | 283 ++++++++++++++++++ packages/core/builders/CalendarEvent/class.ts | 31 ++ .../core/builders/CalendarEvent/director.ts | 35 +++ packages/core/tsconfig.json | 2 +- packages/prisma/index.ts | 4 + .../prisma/middleware/bookingReference.ts | 51 ++++ packages/prisma/middleware/index.ts | 1 + .../migration.sql | 3 + .../migration.sql | 2 + .../migration.sql | 5 + packages/prisma/schema.prisma | 7 +- packages/types/Calendar.d.ts | 7 +- 50 files changed, 2129 insertions(+), 241 deletions(-) create mode 100644 apps/web/components/dialog/RescheduleDialog.tsx create mode 100644 apps/web/lib/attendeeToPersonConversionType.ts create mode 100644 apps/web/lib/emails/templates/attendee-request-reschedule-email.ts create mode 100644 apps/web/lib/emails/templates/organizer-request-reschedule-email.ts create mode 100644 apps/web/lib/getBooking.tsx create mode 100644 apps/web/lib/locationOptions.tsx create mode 100644 apps/web/lib/parseDate.ts create mode 100644 apps/web/pages/api/book/request-reschedule.ts create mode 100644 apps/web/playwright/lib/dbSetup.ts create mode 100644 apps/web/playwright/reschedule.test.ts create mode 100644 packages/core/builders/CalendarEvent/builder.ts create mode 100644 packages/core/builders/CalendarEvent/class.ts create mode 100644 packages/core/builders/CalendarEvent/director.ts create mode 100644 packages/prisma/middleware/bookingReference.ts create mode 100644 packages/prisma/middleware/index.ts create mode 100644 packages/prisma/migrations/20220323033335_reschedule_fields_to_bookings_table/migration.sql create mode 100644 packages/prisma/migrations/20220328185001_soft_delete_booking_references/migration.sql create mode 100644 packages/prisma/migrations/20220412172742_payment_on_delete_cascade/migration.sql diff --git a/apps/api b/apps/api index a1dcfa59..378cbf8f 160000 --- a/apps/api +++ b/apps/api @@ -1 +1 @@ -Subproject commit a1dcfa59bc43d3f71af62ae438f96a667e807913 +Subproject commit 378cbf8f3a67ea7877296f1da02edb2b6e3efbce diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 8036bb4b..62c370e1 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -1,18 +1,21 @@ import { BanIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline"; +import { PaperAirplaneIcon } from "@heroicons/react/outline"; import { BookingStatus } from "@prisma/client"; import dayjs from "dayjs"; import { useState } from "react"; import { useMutation } from "react-query"; +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 { TextArea } from "@calcom/ui/form/fields"; import { HttpError } from "@lib/core/http/error"; -import { useLocale } from "@lib/hooks/useLocale"; import { inferQueryOutput, trpc } from "@lib/trpc"; import { useMeQuery } from "@components/Shell"; +import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; import TableActions, { ActionType } from "@components/ui/TableActions"; type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number]; @@ -80,15 +83,42 @@ function BookingListItem(booking: BookingItem) { { id: "reschedule", label: t("reschedule"), - href: `/reschedule/${booking.uid}`, icon: ClockIcon, + actions: [ + { + id: "edit", + label: t("reschedule_booking"), + href: `/reschedule/${booking.uid}`, + }, + { + id: "reschedule_request", + label: t("send_reschedule_request"), + onClick: () => setIsOpenRescheduleDialog(true), + }, + ], }, ]; - const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY"); + const RequestSentMessage = () => { + return ( +
+ +

{t("reschedule_request_sent")}

+
+ ); + }; + const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY"); + const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false); return ( <> + + + {/* NOTE: Should refactor this dialog component as is being rendered multiple times */} @@ -146,7 +176,10 @@ function BookingListItem(booking: BookingItem) {
+ className={classNames( + "max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max", + isCancelled ? "line-through" : "" + )}> {booking.eventType?.team && {booking.eventType.team.name}: } {booking.title} {!!booking?.eventType?.price && !booking.paid && ( @@ -161,11 +194,17 @@ function BookingListItem(booking: BookingItem) { "{booking.description}"
)} + {booking.attendees.length !== 0 && ( )} + {isCancelled && booking.rescheduled && ( +
+ +
+ )} @@ -180,6 +219,11 @@ function BookingListItem(booking: BookingItem) { )} ) : null} + {isCancelled && booking.rescheduled && ( +
+ +
+ )} diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index cd2c2813..c561bad5 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -1,11 +1,13 @@ // Get router variables import { ArrowLeftIcon, + CalendarIcon, ChevronDownIcon, ChevronUpIcon, ClockIcon, CreditCardIcon, GlobeIcon, + InformationCircleIcon, } from "@heroicons/react/solid"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useContracts } from "contexts/contractsContext"; @@ -18,14 +20,15 @@ import { FormattedNumber, IntlProvider } from "react-intl"; import { useEmbedStyles, useIsEmbed, useIsBackgroundTransparent, sdkActionManager } from "@calcom/embed-core"; import classNames from "@calcom/lib/classNames"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { asStringOrNull } from "@lib/asStringOrNull"; import { timeZone } from "@lib/clock"; import { BASE_URL } from "@lib/config/constants"; import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally"; -import { useLocale } from "@lib/hooks/useLocale"; import useTheme from "@lib/hooks/useTheme"; import { isBrandingHidden } from "@lib/isBrandingHidden"; +import { parseDate } from "@lib/parseDate"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { detectBrowserTimeFormat } from "@lib/timeFormat"; @@ -45,12 +48,12 @@ dayjs.extend(customParseFormat); type Props = AvailabilityTeamPageProps | AvailabilityPageProps; -const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage }: Props) => { +const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage, booking }: Props) => { const router = useRouter(); const isEmbed = useIsEmbed(); const { rescheduleUid } = router.query; const { isReady, Theme } = useTheme(profile.theme); - const { t } = useLocale(); + const { t, i18n } = useLocale(); const { contracts } = useContracts(); const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker"); let isBackgroundTransparent = useIsBackgroundTransparent(); @@ -179,15 +182,21 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage />

{profile.name}

-
+
{eventType.title} + {eventType?.description && ( +

+ + {eventType.description} +

+ )}
- + {eventType.length} {t("minutes")}
{eventType.price > 0 && ( -
- +
+
-

{eventType.description}

@@ -226,17 +234,23 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage size={10} truncateAfter={3} /> -

{profile.name}

-

+

{profile.name}

+

{eventType.title}

-

- + {eventType?.description && ( +

+ + {eventType.description} +

+ )} +

+ {eventType.length} {t("minutes")}

{eventType.price > 0 && ( -

- +

+ - -

{eventType.description}

{previousPage === `${BASE_URL}/${profile.slug}` && (
Go Back

)} + {booking?.startTime && rescheduleUid && ( +
+

+ {t("former_time")} +

+

+ + {typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)} +

+
+ )}
- - + + {timeZone()} {isTimeOptionsOpen ? ( diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index dd36b04f..fe317dd0 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -1,4 +1,10 @@ -import { CalendarIcon, ClockIcon, CreditCardIcon, ExclamationIcon } from "@heroicons/react/solid"; +import { + CalendarIcon, + ClockIcon, + CreditCardIcon, + ExclamationIcon, + InformationCircleIcon, +} from "@heroicons/react/solid"; import { EventTypeCustomInputType } from "@prisma/client"; import { useContracts } from "contexts/contractsContext"; import dayjs from "dayjs"; @@ -12,7 +18,7 @@ import { FormattedNumber, IntlProvider } from "react-intl"; import { ReactMultiEmail } from "react-multi-email"; import { useMutation } from "react-query"; -import { useIsEmbed, useEmbedStyles, useIsBackgroundTransparent } from "@calcom/embed-core"; +import { useIsEmbed, useIsBackgroundTransparent } from "@calcom/embed-core"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; @@ -26,10 +32,9 @@ 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 { parseZone } from "@lib/parseZone"; +import { parseDate } from "@lib/parseDate"; import slugify from "@lib/slugify"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; -import { detectBrowserTimeFormat } from "@lib/timeFormat"; import CustomBranding from "@components/CustomBranding"; import AvatarGroup from "@components/ui/AvatarGroup"; @@ -147,6 +152,10 @@ const BookingPage = ({ const locationInfo = (type: LocationType) => locations.find((location) => location.type === type); const loggedInIsOwner = eventType?.users[0]?.name === session?.user?.name; + const guestListEmails = !isDynamicGroupBooking + ? booking?.attendees.slice(1).map((attendee) => attendee.email) + : []; + const defaultValues = () => { if (!rescheduleUid) { return { @@ -173,7 +182,8 @@ const BookingPage = ({ return { name: primaryAttendee.name || "", email: primaryAttendee.email || "", - guests: !isDynamicGroupBooking ? booking.attendees.slice(1).map((attendee) => attendee.email) : [], + guests: guestListEmails, + notes: booking.description || "", }; }; @@ -212,14 +222,6 @@ const BookingPage = ({ } }; - const parseDate = (date: string | null) => { - if (!date) return "No date"; - 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" }); - }; - const bookEvent = (booking: BookingFormValues) => { telemetry.withJitsu((jitsu) => jitsu.track( @@ -273,6 +275,8 @@ const BookingPage = ({ }); }; + const disableInput = !!rescheduleUid; + return (
@@ -322,16 +326,22 @@ const BookingPage = ({

{profile.name}

-

+

{eventType.title}

-

- + {eventType?.description && ( +

+ + {eventType.description} +

+ )} +

+ {eventType.length} {t("minutes")}

{eventType.price > 0 && ( -

- +

+ )}

- - {parseDate(date)} + + {parseDate(date, i18n)}

{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (

{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}

)} -

{eventType.description}

+ {booking?.startTime && rescheduleUid && ( +
+

+ {t("former_time")} +

+

+ + {typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)} +

+
+ )}
@@ -365,8 +385,12 @@ const BookingPage = ({ name="name" id="name" required - className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm" + className={classNames( + "focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm", + disableInput ? "bg-gray-200 dark:text-gray-500" : "" + )} placeholder={t("example_name")} + disabled={disableInput} />
@@ -380,9 +404,13 @@ const BookingPage = ({
@@ -399,6 +427,7 @@ const BookingPage = ({ {...bookingForm.register("locationType", { required: true })} value={location.type} defaultChecked={selectedLocation === location.type} + disabled={disableInput} /> {locationLabels[location.type]} @@ -421,6 +450,7 @@ const BookingPage = ({ placeholder={t("enter_phone_number")} id="phone" required + disabled={disableInput} /> @@ -443,8 +473,12 @@ const BookingPage = ({ })} id={"custom_" + input.id} rows={3} - className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm" + className={classNames( + "focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm", + disableInput ? "bg-gray-200 dark:text-gray-500" : "" + )} placeholder={input.placeholder} + disabled={disableInput} /> )} {input.type === EventTypeCustomInputType.TEXT && ( @@ -456,6 +490,7 @@ const BookingPage = ({ id={"custom_" + input.id} className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm" placeholder={input.placeholder} + disabled={disableInput} /> )} {input.type === EventTypeCustomInputType.NUMBER && ( @@ -507,32 +542,49 @@ const BookingPage = ({ className="mb-1 block text-sm font-medium text-gray-700 dark:text-white"> {t("guests")} - ( - void - ) => { - return ( -
- {email} - removeEmail(index)}> - × - -
- ); - }} - /> - )} - /> + {!disableInput && ( + ( + void + ) => { + return ( +
+ {email} + {!disableInput && ( + removeEmail(index)}> + × + + )} +
+ ); + }} + /> + )} + /> + )} + {/* Custom code when guest emails should not be editable */} + {disableInput && guestListEmails && guestListEmails.length > 0 && ( +
+ {/* // @TODO: user owners are appearing as guest here when should be only user input */} + {guestListEmails.map((email, index) => { + return ( +
+ {email} +
+ ); + })} +
+ )} )} @@ -546,9 +598,14 @@ const BookingPage = ({