From 9bd3a362a21c671a67b1ce51e6b8d87f050aa6fc Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 4 Apr 2024 17:58:52 +0100 Subject: [PATCH] feat: added Rating Template to Workflows (#14215) Co-authored-by: alishaz-polymath Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> --- .../bookings/views/bookings-single-view.tsx | 984 +++++++++++------- apps/web/public/static/locales/en/common.json | 8 + apps/web/styles/globals.css | 2 + packages/config/tailwind-preset.js | 1 + .../workflows/api/scheduleEmailReminders.ts | 27 + .../components/WorkflowStepContainer.tsx | 10 + .../ee/workflows/lib/actionHelperFunctions.ts | 4 + .../features/ee/workflows/lib/constants.ts | 9 + .../features/ee/workflows/lib/getOptions.ts | 8 +- .../lib/reminders/emailReminderManager.ts | 17 + .../lib/reminders/templates/customTemplate.ts | 6 +- .../templates/emailRatingTemplate.ts | 79 ++ packages/lib/test/builder.ts | 3 + .../migration.sql | 2 + .../migration.sql | 4 + packages/prisma/schema.prisma | 4 + .../server/routers/publicViewer/_router.tsx | 10 + .../routers/publicViewer/noShow.handler.ts | 21 + .../routers/publicViewer/noShow.schema.ts | 7 + .../publicViewer/submitRating.handler.ts | 22 + .../publicViewer/submitRating.schema.ts | 9 + .../components/empty-screen/EmptyScreen.tsx | 16 +- 22 files changed, 845 insertions(+), 408 deletions(-) create mode 100644 packages/features/ee/workflows/lib/reminders/templates/emailRatingTemplate.ts create mode 100644 packages/prisma/migrations/20240327121006_added_rating_workflow_template/migration.sql create mode 100644 packages/prisma/migrations/20240327130910_added_rating_feedback_and_noshowhost_for_booking/migration.sql create mode 100644 packages/trpc/server/routers/publicViewer/noShow.handler.ts create mode 100644 packages/trpc/server/routers/publicViewer/noShow.schema.ts create mode 100644 packages/trpc/server/routers/publicViewer/submitRating.handler.ts create mode 100644 packages/trpc/server/routers/publicViewer/submitRating.schema.ts diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index d4932d94d6..3d1367bb02 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -7,6 +7,7 @@ import { useSession } from "next-auth/react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { Toaster } from "react-hot-toast"; import { RRule } from "rrule"; import { z } from "zod"; @@ -45,7 +46,19 @@ import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/t import { localStorage } from "@calcom/lib/webstorage"; import { BookingStatus } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; -import { Alert, Badge, Button, EmailInput, HeadSeo, Icon, useCalcomTheme } from "@calcom/ui"; +import { trpc } from "@calcom/trpc/react"; +import { + Alert, + Badge, + Button, + EmailInput, + HeadSeo, + useCalcomTheme, + TextArea, + showToast, + EmptyScreen, + Icon, +} from "@calcom/ui"; import { timeZone } from "@lib/clock"; @@ -71,6 +84,8 @@ const querySchema = z.object({ isSuccessBookingPage: stringToBoolean, formerTime: z.string().optional(), seatReferenceUid: z.string().optional(), + rating: z.string().optional(), + noShow: stringToBoolean, }); const useBrandColors = ({ @@ -102,10 +117,12 @@ export default function Success(props: PageProps) { formerTime, email, seatReferenceUid, + noShow, + rating, } = querySchema.parse(routerQuery); - const attendeeTimeZone = bookingInfo?.attendees.find((attendee) => attendee.email === email)?.timeZone; + const isFeedbackMode = !!(noShow || rating); const tz = props.tz ? props.tz : isSuccessBookingPage && attendeeTimeZone ? attendeeTimeZone : timeZone(); const location = bookingInfo.location as ReturnType; @@ -141,6 +158,42 @@ export default function Success(props: PageProps) { const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed; const [calculatedDuration, setCalculatedDuration] = useState(undefined); + const [comment, setComment] = useState(""); + const parsedRating = rating ? parseInt(rating, 10) : 3; + + const defaultRating = isNaN(parsedRating) ? 3 : parsedRating > 5 ? 5 : parsedRating < 1 ? 1 : parsedRating; + const [rateValue, setRateValue] = useState(defaultRating); + const [isFeedbackSubmitted, setIsFeedbackSubmitted] = useState(false); + + const mutation = trpc.viewer.public.submitRating.useMutation({ + onSuccess: async () => { + setIsFeedbackSubmitted(true); + showToast("Thank you, feedback submitted", "success"); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + const noShowMutation = trpc.viewer.public.noShow.useMutation({ + onSuccess: async () => { + showToast("Thank you, feedback submitted", "success"); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + useEffect(() => { + if (noShow) { + noShowMutation.mutate({ bookingUid: bookingInfo.uid }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const sendFeedback = async (rating: string, comment: string) => { + mutation.mutate({ bookingUid: bookingInfo.uid, rating: rateValue, comment: comment }); + }; function setIsCancellationMode(value: boolean) { const _searchParams = new URLSearchParams(searchParams ?? undefined); @@ -293,7 +346,7 @@ export default function Success(props: PageProps) { return (
- {!isEmbed && ( + {!isEmbed && !isFeedbackMode && ( )} - {isLoggedIn && !isEmbed && ( + {isLoggedIn && !isEmbed && !isFeedbackMode && (
-
- {giphyImage && !needsConfirmation && !isCancelled && ( - // eslint-disable-next-line @next/next/no-img-element - Gif from Giphy - )} - {!giphyImage && !needsConfirmation && !isCancelled && ( - - )} - {needsConfirmation && !isCancelled && ( - - )} - {isCancelled && } -
-
- -
-

{getTitle()}

-
- {props.paymentStatus && - (bookingInfo.status === BookingStatus.CANCELLED || - bookingInfo.status === BookingStatus.REJECTED) && ( -

- {!props.paymentStatus.success && - !props.paymentStatus.refunded && - t("booking_with_payment_cancelled")} - {props.paymentStatus.success && - !props.paymentStatus.refunded && - t("booking_with_payment_cancelled_already_paid")} - {props.paymentStatus.refunded && t("booking_with_payment_cancelled_refunded")} -

- )} - -
- {(isCancelled || reschedule) && cancellationReason && ( - <> -
- {isCancelled ? t("reason") : t("reschedule_reason")} -
-
{cancellationReason}
- - )} -
{t("what")}
-
- {eventName} + {!isFeedbackMode && ( + <> +
+ {giphyImage && !needsConfirmation && !isCancelled && ( + // eslint-disable-next-line @next/next/no-img-element + Gif from Giphy + )} + {!giphyImage && !needsConfirmation && !isCancelled && ( + + )} + {needsConfirmation && !isCancelled && ( + + )} + {isCancelled && }
-
{t("when")}
-
- {reschedule && !!formerTime && ( -

+

+ +
+

{getTitle()}

+
+ {props.paymentStatus && + (bookingInfo.status === BookingStatus.CANCELLED || + bookingInfo.status === BookingStatus.REJECTED) && ( +

+ {!props.paymentStatus.success && + !props.paymentStatus.refunded && + t("booking_with_payment_cancelled")} + {props.paymentStatus.success && + !props.paymentStatus.refunded && + t("booking_with_payment_cancelled_already_paid")} + {props.paymentStatus.refunded && t("booking_with_payment_cancelled_refunded")} +

+ )} + +
+ {(isCancelled || reschedule) && cancellationReason && ( + <> +
+ {isCancelled ? t("reason") : t("reschedule_reason")} +
+
{cancellationReason}
+ + )} +
{t("what")}
+
+ {eventName} +
+
{t("when")}
+
+ {reschedule && !!formerTime && ( +

+ +

+ )} -

- )} - -
- {(bookingInfo?.user || bookingInfo?.attendees) && ( - <> -
{t("who")}
-
- {bookingInfo?.user && ( -
-
- - {bookingInfo.user.name} - - {t("Host")} -
-

- {bookingInfo?.userPrimaryEmail ?? bookingInfo.user.email} -

-
- )} - {bookingInfo?.attendees.map((attendee) => ( -
- {attendee.name && ( -

{attendee.name}

- )} -

{attendee.email}

-
- ))}
- - )} - {locationToDisplay && !isCancelled && ( - <> -
{t("where")}
-
- {!rescheduleLocation || locationToDisplay === rescheduleLocationToDisplay ? ( - - ) : ( - <> - {!!formerTime && ( + {(bookingInfo?.user || bookingInfo?.attendees) && ( + <> +
{t("who")}
+
+ {bookingInfo?.user && ( +
+
+ + {bookingInfo.user.name} + + {t("Host")} +
+

+ {bookingInfo?.userPrimaryEmail ?? bookingInfo.user.email} +

+
+ )} + {bookingInfo?.attendees.map((attendee) => ( +
+ {attendee.name && ( +

{attendee.name}

+ )} +

{attendee.email}

+
+ ))} +
+ + )} + {locationToDisplay && !isCancelled && ( + <> +
{t("where")}
+
+ {!rescheduleLocation || locationToDisplay === rescheduleLocationToDisplay ? ( + ) : ( + <> + {!!formerTime && ( + + )} + + + + )} +
+ + )} + {props.paymentStatus && ( + <> +
+ {props.paymentStatus.paymentOption === "HOLD" + ? t("complete_your_booking") + : t("payment")} +
+
+ +
+ + )} + {bookingInfo?.description && ( + <> +
{t("additional_notes")}
+
+

{bookingInfo.description}

+
+ + )} +
+
+ {Object.entries(bookingInfo.responses).map(([name, response]) => { + const field = eventType.bookingFields.find((field) => field.name === name); + // We show location in the "where" section + // We show Booker Name, Emails and guests in Who section + // We show notes in additional notes section + // We show rescheduleReason at the top + if (!field) return null; + const isSystemField = SystemField.safeParse(field.name); + // SMS_REMINDER_NUMBER_FIELD is a system field but doesn't have a dedicated place in the UI. So, it would be shown through the following responses list + // TITLE is also an identifier for booking question "What is this meeting about?" + if ( + isSystemField.success && + field.name !== SMS_REMINDER_NUMBER_FIELD && + field.name !== TITLE_FIELD + ) + return null; + + const label = field.label || t(field.defaultLabel || ""); + + return ( + <> +
{label}
+

+ {field.type === "boolean" + ? response + ? t("yes") + : t("no") + : response.toString()} +

+ + ); + })} +
+
+ {requiresLoginToUpdate && ( + <> +
+
+ + {t("need_to_make_a_change")} + + {/* Login button but redirect to here */} + + + + {t("login")} + + + +
+ + )} + {!requiresLoginToUpdate && + (!needsConfirmation || !userIsOwner) && + !isCancelled && + (!isCancellationMode ? ( + <> +
+
+ + {t("need_to_make_a_change")} + + + <> + {!props.recurringBookings && ( + + + + {t("reschedule")} + + + {t("or_lowercase")} + )} - + - )} -
- - )} - {props.paymentStatus && ( - <> -
- {props.paymentStatus.paymentOption === "HOLD" - ? t("complete_your_booking") - : t("payment")} -
-
- -
- - )} - {bookingInfo?.description && ( - <> -
{t("additional_notes")}
-
-

{bookingInfo.description}

-
- - )} -
-
- {Object.entries(bookingInfo.responses).map(([name, response]) => { - const field = eventType.bookingFields.find((field) => field.name === name); - // We show location in the "where" section - // We show Booker Name, Emails and guests in Who section - // We show notes in additional notes section - // We show rescheduleReason at the top - if (!field) return null; - const isSystemField = SystemField.safeParse(field.name); - // SMS_REMINDER_NUMBER_FIELD is a system field but doesn't have a dedicated place in the UI. So, it would be shown through the following responses list - // TITLE is also an identifier for booking question "What is this meeting about?" - if ( - isSystemField.success && - field.name !== SMS_REMINDER_NUMBER_FIELD && - field.name !== TITLE_FIELD - ) - return null; - - const label = field.label || t(field.defaultLabel || ""); - - return ( - <> -
{label}
-

- {field.type === "boolean" ? (response ? t("yes") : t("no")) : response.toString()} -

+
- ); - })} -
-
- {requiresLoginToUpdate && ( - <> -
-
- {t("need_to_make_a_change")} - {/* Login button but redirect to here */} - - - - {t("login")} - - - -
+ ) : ( + <> +
+ + + ))} + {userIsOwner && + !needsConfirmation && + !isCancellationMode && + !isCancelled && + !!calculatedDuration && ( + <> +
+
+ + {t("add_to_calendar")} + +
+ + + Google + + + + + + Microsoft Outlook + + + + + + Microsoft Office + + + + + + {t("other")} + + + +
+
+ + )} + + {session === null && !(userIsOwner || props.hideBranding) && ( + <> +
+
+ + {t("create_booking_link_with_calcom", { appName: APP_NAME })} + + +
{ + e.preventDefault(); + const target = e.target as typeof e.target & { + email: { value: string }; + }; + router.push(`https://cal.com/signup?email=${target.email.value}`); + }} + className="mt-4 flex"> + + + +
+ + )} )} - {!requiresLoginToUpdate && - (!needsConfirmation || !userIsOwner) && - !isCancelled && - (!isCancellationMode ? ( + {isFeedbackMode && + (noShow ? ( <> -
-
- {t("need_to_make_a_change")} - - <> - {!props.recurringBookings && ( - - - - {t("reschedule")} - - - {t("or_lowercase")} - - )} - - - -
+ + {t("reschedule")} + + ) : undefined + } + /> ) : ( <> -
- + + + + + +
+
+

{t("submitted_feedback")}

+

{rateValue < 4 ? t("how_can_we_improve") : t("most_liked")}

+
+