update rescheduled emails, booking view and availability page view

This commit is contained in:
Alan 2022-04-08 12:52:03 -06:00
parent aed26340f1
commit 41af612355
17 changed files with 494 additions and 123 deletions

View File

@ -173,7 +173,7 @@ function BookingListItem(booking: BookingItem) {
{
id: "edit",
label: t("edit_booking"),
href: "",
href: `/reschedule/${booking.uid}`,
},
{
id: "reschedule_request",

View File

@ -6,6 +6,7 @@ import {
ClockIcon,
CreditCardIcon,
GlobeIcon,
InformationCircleIcon,
} from "@heroicons/react/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useContracts } from "contexts/contractsContext";
@ -16,11 +17,12 @@ import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
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 { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
@ -42,7 +44,7 @@ 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 { rescheduleUid } = router.query;
const { isReady, Theme } = useTheme(profile.theme);
@ -155,9 +157,13 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
truncateAfter={5}
/>
<div className="mt-4 sm:-mt-2">
<p className="text-sm font-medium text-black dark:text-white">{profile.name}</p>
<div className="flex gap-2 text-xs font-medium text-gray-600 dark:text-gray-100">
<p className="text-sm font-medium text-gray-600 dark:text-white">{profile.name}</p>
<div className="flex gap-2 text-xs font-medium text-gray-900 dark:text-gray-100">
{eventType.title}
<p className="mb-2 text-gray-600 dark:text-white">
<InformationCircleIcon className="mr-[10px] -mt-1 inline-block h-4 w-4" />
{eventType.description}
</p>
<div>
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{eventType.length} {t("minutes")}
@ -177,7 +183,6 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
</div>
</div>
</div>
<p className="mt-3 text-gray-600 dark:text-gray-200">{eventType.description}</p>
</div>
<div className="px-4 sm:flex sm:p-4 sm:py-5">
@ -204,16 +209,20 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
truncateAfter={3}
/>
<h2 className="mt-3 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
<h1 className="font-cal mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
<h1 className="font-cal mb-4 text-xl font-semibold text-gray-900 dark:text-white">
{eventType.title}
</h1>
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<p className="mb-2 text-gray-600 dark:text-white">
<InformationCircleIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-400" />
{eventType.description}
</p>
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600">
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4 text-gray-400" />
{eventType.length} {t("minutes")}
</p>
{eventType.price > 0 && (
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600">
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4 text-gray-400" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
@ -225,8 +234,6 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
)}
<TimezoneDropdown />
<p className="mt-3 mb-8 text-gray-600 dark:text-gray-200">{eventType.description}</p>
{previousPage === `${BASE_URL}/${profile.slug}` && (
<div className="flex h-full flex-col justify-end">
<ArrowLeftIcon
@ -281,8 +288,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
function TimezoneDropdown() {
return (
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Collapsible.Trigger className="min-w-32 mb-1 -ml-2 px-2 py-1 text-left text-gray-500">
<GlobeIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<Collapsible.Trigger className="min-w-32 mb-1 -ml-2 px-2 py-1 text-left text-gray-600">
<GlobeIcon className="mr-1 -mt-1 inline-block h-4 w-4 text-gray-400" />
{timeZone()}
{isTimeOptionsOpen ? (
<ChevronUpIcon className="ml-1 -mt-1 inline-block h-4 w-4" />

View File

@ -1,7 +1,14 @@
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 classNames from "classnames";
import { useContracts } from "contexts/contractsContext";
import dayjs from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import { useSession } from "next-auth/react";
import dynamic from "next/dynamic";
import Head from "next/head";
@ -12,6 +19,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
import { useMutation } from "react-query";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { createPaymentLink } from "@calcom/stripe/client";
import { Button } from "@calcom/ui/Button";
@ -20,7 +28,6 @@ import { EmailInput, Form } from "@calcom/ui/form/fields";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { ensureArray } from "@lib/ensureArray";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking";
@ -56,6 +63,7 @@ type BookingFormValues = {
};
const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPageProps) => {
console.log({ booking });
const { t, i18n } = useLocale();
const router = useRouter();
const { contracts } = useContracts();
@ -161,6 +169,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
name: primaryAttendee.name || "",
email: primaryAttendee.email || "",
guests: booking.attendees.slice(1).map((attendee) => attendee.email),
notes: booking.description || "",
};
};
@ -199,7 +208,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
}
};
const parseDate = (date: string | null) => {
const parseDate = (date: string | null | Dayjs) => {
if (!date) return "No date";
const parsedZone = parseZone(date);
if (!parsedZone?.isValid()) return "Invalid date";
@ -255,6 +264,13 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
})),
});
};
const userOwnerIds = eventType.users.map((user) => user.id);
const isUserOwnerRescheduling = !!(
session?.user.id &&
rescheduleUid &&
userOwnerIds.indexOf(session?.user.id) > -1
);
const disableInput = isUserOwnerRescheduling;
return (
<div>
@ -296,13 +312,17 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
<h1 className="mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
{eventType.title}
</h1>
<p className="mb-2 text-gray-600 dark:text-white">
<InformationCircleIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-400" />
{eventType.description}
</p>
<p className="mb-2 text-gray-500">
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<ClockIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-400" />
{eventType.length} {t("minutes")}
</p>
{eventType.price > 0 && (
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<CreditCardIcon className="mr-[10px] -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
@ -313,7 +333,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
</p>
)}
<p className="mb-4 text-green-500">
<CalendarIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4" />
{parseDate(date)}
</p>
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
@ -321,7 +341,16 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
</p>
)}
<p className="mb-8 text-gray-600 dark:text-white">{eventType.description}</p>
{booking?.startTime && rescheduleUid && (
<div>
{/* Add translation */}
<p className="mt-8 mb-2 text-gray-600 dark:text-white">Former time</p>
<p className="text-gray-500 line-through dark:text-white">
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-400" />
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime))}
</p>
</div>
)}
</div>
<div className="sm:w-1/2 sm:pl-8 sm:pr-4">
<Form form={bookingForm} handleSubmit={bookEvent}>
@ -336,8 +365,12 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
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}
/>
</div>
</div>
@ -351,9 +384,13 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
<EmailInput
{...bookingForm.register("email")}
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="you@example.com"
type="search" // Disables annoying 1password intrusive popup (non-optimal, I know I know...)
disabled={disableInput}
/>
</div>
</div>
@ -370,6 +407,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
{...bookingForm.register("locationType", { required: true })}
value={location.type}
defaultChecked={selectedLocation === location.type}
disabled={disableInput}
/>
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-gray-500">
{locationLabels[location.type]}
@ -392,6 +430,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
placeholder={t("enter_phone_number")}
id="phone"
required
disabled={disableInput}
/>
</div>
</div>
@ -414,8 +453,12 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
})}
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 && (
@ -427,6 +470,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
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={isUserOwnerRescheduling}
/>
)}
{input.type === EventTypeCustomInputType.NUMBER && (
@ -518,8 +562,12 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
{...bookingForm.register("notes")}
id="notes"
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={t("share_additional_notes")}
disabled={disableInput}
/>
</div>
<div className="flex items-start space-x-2 rtl:space-x-reverse">

View File

@ -12,7 +12,9 @@ export type ActionType = {
label: string;
disabled?: boolean;
color?: "primary" | "secondary";
} & ({ href?: never; onClick: () => any } | { href?: string; onClick?: never }) & { actions?: ActionType[] };
} & ({ href?: never; onClick: () => any } | { href?: string; onClick?: never }) & {
actions?: ActionType[];
};
interface Props {
actions: ActionType[];

View File

@ -20,7 +20,8 @@ function PhoneInput<FormValues>({ control, name, ...rest }: PhoneInputProps<Form
name={name}
control={control}
className={classNames(
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white"
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white",
rest.disabled ? "bg-gray-200 dark:text-gray-500" : ""
)}
/>
);

View File

@ -4,6 +4,7 @@ import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaitin
import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email";
import AttendeeDeclinedEmail from "@lib/emails/templates/attendee-declined-email";
import AttendeeRequestEmail from "@lib/emails/templates/attendee-request-email";
import AttendeeRequestRescheduledEmail from "@lib/emails/templates/attendee-request-reschedule-email";
import AttendeeRescheduledEmail from "@lib/emails/templates/attendee-rescheduled-email";
import AttendeeScheduledEmail from "@lib/emails/templates/attendee-scheduled-email";
import ForgotPasswordEmail, { PasswordReset } from "@lib/emails/templates/forgot-password-email";
@ -11,12 +12,11 @@ import OrganizerCancelledEmail from "@lib/emails/templates/organizer-cancelled-e
import OrganizerPaymentRefundFailedEmail from "@lib/emails/templates/organizer-payment-refund-failed-email";
import OrganizerRequestEmail from "@lib/emails/templates/organizer-request-email";
import OrganizerRequestReminderEmail from "@lib/emails/templates/organizer-request-reminder-email";
import OrganizerRequestRescheduleEmail from "@lib/emails/templates/organizer-request-reschedule-email";
import OrganizerRescheduledEmail from "@lib/emails/templates/organizer-rescheduled-email";
import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email";
import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email";
import OrganizerRequestRescheduledEmail from "./templates/organizer-request-reschedule-email";
export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
const emailsToSend: Promise<unknown>[] = [];
@ -211,41 +211,33 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
});
};
export const sendRequestRescheduleEmail = async (calEvent: CalendarEvent) => {
await new Promise((resolve, reject) => {
try {
const rescheduleEmail = new OrganizerRequestRescheduledEmail(calEvent);
resolve(rescheduleEmail.sendEmail());
} catch (e) {
reject(console.error("RescheduleEmail.sendEmail failed", e));
}
});
export const sendRequestRescheduleEmail = async (
calEvent: CalendarEvent,
metadata: { rescheduleLink: string }
) => {
const emailsToSend: Promise<unknown>[] = [];
// emailsToSend.push(
// ...calEvent.attendees.map((attendee) => {
// return new Promise((resolve, reject) => {
// try {
// const requestRescheduleEmail = new AttendeeRequesRescheduledEmail(calEvent, attendee);
// resolve(requestRescheduleEmail.sendEmail());
// } catch (e) {
// reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
// }
// });
// })
// );
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(calEvent, metadata);
resolve(requestRescheduleEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
}
})
);
// emailsToSend.push(
// new Promise((resolve, reject) => {
// try {
// const requestRescheduleEmail = new OrganizerRequestRescheduledEmail(calEvent);
// resolve(requestRescheduleEmail.sendEmail());
// } catch (e) {
// reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));
// }
// })
// );
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(calEvent, metadata);
resolve(requestRescheduleEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));
}
})
);
await Promise.all(emailsToSend);
};

View File

@ -0,0 +1,219 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray, Person } from "ics";
import { getCancelLink } from "@calcom/lib/CalEventParser";
import { Attendee } from "@calcom/prisma/client";
import { CalendarEvent } from "@calcom/types/Calendar";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
} from "./common";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class AttendeeRequestRescheduledEmail extends OrganizerScheduledEmail {
private metadata: { rescheduleLink: string };
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
super(calEvent);
this.metadata = metadata;
}
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.attendees[0].email];
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
// @OVERRIDE
protected getiCalEventAsString(): string | undefined {
console.log("overriding");
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
startInputType: "utc",
productId: "calendso/ics",
title: this.calEvent.organizer.language.translate("ics_event_title", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
}),
description: this.getTextBody(),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
attendees: this.calEvent.attendees.map((attendee: Person) => ({
name: attendee.name,
email: attendee.email,
})),
status: "CANCELLED",
method: "CANCEL",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
// @OVERRIDE
protected getWhen(): string {
return `
<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; font-weight: 400; line-height: 24px;text-decoration: line-through;">
${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format(
"YYYY"
)} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)} <span style="color: #888888">(${this.getTimezone()})</span>
</p>
</div>`;
}
protected getTextBody(): string {
return `
${this.calEvent.organizer.language.translate("request_reschedule_title_attendee")}
${this.calEvent.organizer.language.translate("request_reschedule_subtitle", {
organizer: this.calEvent.organizer.name,
})},
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")}
${getCancelLink(this.calEvent)}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.organizer.language.translate("request_reschedule_title_attendee"),
this.calEvent.organizer.language.translate("request_reschedule_subtitle", {
organizer: this.calEvent.organizer.name,
})
)}
${emailSchedulingBodyDivider()}
<!--[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]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 40px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${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]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;text-align:center;color:#3E3E3E;">
<a style="padding: 8px 16px;background-color: #292929;color: white;border-radius: 2px;display: inline-block;margin-bottom: 16px;"
href="${this.metadata.rescheduleLink}" target="_blank"
>
Book a new time
<img src="https://app.cal.com/emails/linkIcon.png" style="width:16px; margin-left: 5px;filter: brightness(0) invert(1); vertical-align: top;" />
</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
}

View File

@ -40,7 +40,7 @@ export const emailSchedulingBodyHeader = (headerType: BodyHeadType): string => {
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;border-top:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:30px 20px 0 20px;text-align:center;">
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;border-top:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:30px 30px 0 30px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:558px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">

View File

@ -6,6 +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 {
emailHead,
@ -22,16 +23,13 @@ dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class OrganizerRequestRescheduledEmail extends OrganizerScheduledEmail {
private metadata: { rescheduleLink: string };
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
super(calEvent);
this.metadata = metadata;
}
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
icalEvent: {
@ -86,7 +84,7 @@ export default class OrganizerRequestRescheduledEmail extends OrganizerScheduled
}
return icsEvent.value;
}
// @OVERRIDe
// @OVERRIDE
protected getWhen(): string {
return `
<p style="height: 6px"></p>
@ -108,9 +106,11 @@ export default class OrganizerRequestRescheduledEmail extends OrganizerScheduled
protected getTextBody(): string {
return `
${this.calEvent.organizer.language.translate("request_reschedule_title_attendee")}
${this.calEvent.organizer.language.translate("request_reschedule_subtitle", {
organizer: this.calEvent.attendees[0],
${this.calEvent.organizer.language.translate("request_reschedule_title_organizer", {
attendee: this.calEvent.attendees[0].name,
})}
${this.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
attendee: this.calEvent.attendees[0].name,
})},
${this.getWhat()}
${this.getWhen()}
@ -142,9 +142,11 @@ ${getCancelLink(this.calEvent)}
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.organizer.language.translate("request_reschedule_title_attendee"),
this.calEvent.organizer.language.translate("request_reschedule_subtitle", {
organizer: this.calEvent.attendees[0],
this.calEvent.organizer.language.translate("request_reschedule_title_organizer", {
attendee: this.calEvent.attendees[0].name,
}),
this.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
attendee: this.calEvent.attendees[0].name,
})
)}
${emailSchedulingBodyDivider()}
@ -159,7 +161,7 @@ ${getCancelLink(this.calEvent)}
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<td align="left" style="font-size:0px;padding:10px 40px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
@ -178,33 +180,6 @@ ${getCancelLink(this.calEvent)}
</tbody>
</table>
</div>
${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]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:0px;text-align:left;color:#3E3E3E;">
${this.getManageLink()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>

View File

@ -24,6 +24,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const userParam = asStringOrNull(context.query.user);
const typeParam = asStringOrNull(context.query.type);
const dateParam = asStringOrNull(context.query.date);
const rescheduleUid = asStringOrNull(context.query.rescheduleUid);
if (!userParam || !typeParam) {
throw new Error(`File is not named [type]/[user]`);
@ -214,6 +215,43 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventTypeObject.schedule = null;
eventTypeObject.availability = [];
type Booking = {
startTime: Date | string;
description: string | null;
attendees?: {
email: string;
name: string;
}[];
} | null;
// @NOTE: being used several times refactor to exported function
async function getBooking(rescheduleUid: string): Promise<Booking> {
return await prisma.booking.findFirst({
where: {
uid: rescheduleUid,
},
select: {
startTime: true,
description: true,
attendees: {
select: {
email: true,
name: true,
},
},
},
});
}
let booking: Booking | null = null;
if (rescheduleUid) {
booking = await getBooking(rescheduleUid);
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
booking["startTime"] = (booking?.startTime as Date)?.toISOString();
}
}
return {
props: {
profile: {
@ -231,6 +269,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
workingHours,
trpcState: ssr.dehydrate(),
previousPage: context.req.headers.referer ?? null,
booking,
},
};
};

View File

@ -69,6 +69,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
disableGuests: true,
users: {
select: {
id: true,
username: true,
name: true,
email: true,
@ -112,12 +113,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
})[0];
async function getBooking() {
return prisma.booking.findFirst({
type Booking = {
startTime: Date | string;
description: string | null;
attendees: {
email: string;
name: string;
}[];
} | null;
// @NOTE: being used several times refactor to exported function
async function getBooking(): Promise<Booking> {
return await prisma.booking.findFirst({
where: {
uid: asStringOrThrow(context.query.rescheduleUid),
},
select: {
startTime: true,
description: true,
attendees: {
select: {
@ -129,15 +141,18 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
});
}
type Booking = Prisma.PromiseReturnType<typeof getBooking>;
let booking: Booking | null = null;
if (context.query.rescheduleUid) {
booking = await getBooking();
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
booking["startTime"] = (booking?.startTime as Date)?.toISOString();
}
}
const t = await getTranslation(context.locale ?? "en", "common");
console.log({ booking });
return {
props: {
locationLabels: getLocationLabels(t),

View File

@ -21,8 +21,8 @@ const rescheduleSchema = z.object({
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req });
const { bookingId, rescheduleReason: cancellationReason } = req.body;
console.log({ bookingId });
let userOwner: Pick<User, "id" | "email" | "name" | "locale" | "timeZone"> | null;
type PersonAttendee = Pick<User, "id" | "email" | "name" | "locale" | "timeZone" | "username">;
let userOwner: PersonAttendee | null;
try {
if (session?.user?.id) {
userOwner = await prisma.user.findUnique({
@ -82,15 +82,13 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// @NOTE: Lets assume all guests are the same language
const [firstAttendee] = bookingToReschedule.attendees;
const tAttendees = await getTranslation(firstAttendee.locale ?? "en", "common");
const usersToPeopleType = (
users: Pick<User, "id" | "email" | "name" | "locale" | "timeZone">[],
selectedLanguage: TFunction
): Person[] => {
const usersToPeopleType = (users: PersonAttendee[], selectedLanguage: TFunction): Person[] => {
return users?.map((user) => {
return {
id: user.id || "",
email: user.email || "",
name: user.name || "",
username: user?.username || "",
language: { translate: selectedLanguage, locale: user.locale || "en" },
timeZone: user?.timeZone,
};
@ -105,7 +103,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
type: event?.title || "Nameless Event",
startTime: bookingToReschedule.startTime.toISOString(),
endTime: bookingToReschedule.endTime.toISOString(),
attendees: usersToPeopleType(bookingToReschedule.attendees, tAttendees),
attendees: usersToPeopleType(
// username field doesn't exists on attendee but could be in the future
bookingToReschedule.attendees as unknown as PersonAttendee[],
tAttendees
),
organizer: userOwnerAsPeopleType,
});
await calendarEventBuilder.buildEventObjectFromInnerClass(bookingToReschedule.eventTypeId);
@ -117,9 +119,19 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await calendarEventBuilder.buildLuckyUsers();
}
await calendarEventBuilder.buildAttendeesList();
calendarEventBuilder.setLocation(bookingToReschedule.location);
calendarEventBuilder.setUId(bookingToReschedule.uid);
calendarEventBuilder.setCancellationReason(cancellationReason);
console.log({ calendarEventBuilder });
// Send email =================
await sendRequestRescheduleEmail(calendarEventBuilder.calendarEvent);
const queryParams = new URLSearchParams();
queryParams.set("rescheduleUid", `${bookingToReschedule.uid}`);
const rescheduleLink = `${process.env.WEBSITE_BASE_URL}/${userOwner.username}/${
event?.slug
}?${queryParams.toString()}`;
await sendRequestRescheduleEmail(calendarEventBuilder.calendarEvent, {
rescheduleLink,
});
}
return res.status(200).json(bookingToReschedule);

View File

@ -20,6 +20,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const slugParam = asStringOrNull(context.query.slug);
const typeParam = asStringOrNull(context.query.type);
const dateParam = asStringOrNull(context.query.date);
const rescheduleUid = asStringOrNull(context.query.rescheduleUid);
if (!slugParam || !typeParam) {
throw new Error(`File is not named [idOrSlug]/[user]`);
@ -109,6 +110,43 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventTypeObject.availability = [];
type Booking = {
startTime: Date | string;
description: string | null;
attendees?: {
email: string;
name: string;
}[];
} | null;
// @NOTE: being used several times refactor to exported function
async function getBooking(rescheduleUid: string): Promise<Booking> {
return await prisma.booking.findFirst({
where: {
uid: rescheduleUid,
},
select: {
startTime: true,
description: true,
attendees: {
select: {
email: true,
name: true,
},
},
},
});
}
let booking: Booking | null = null;
if (rescheduleUid) {
booking = await getBooking(rescheduleUid);
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
booking["startTime"] = (booking?.startTime as Date)?.toISOString();
}
}
return {
props: {
// Team is always pro
@ -126,6 +164,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventType: eventTypeObject,
workingHours,
previousPage: context.req.headers.referer ?? null,
booking,
},
};
};

View File

@ -56,6 +56,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
users: {
select: {
id: true,
avatar: true,
name: true,
},
@ -74,12 +75,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
})[0];
async function getBooking() {
type Booking = {
startTime: Date | string;
description: string | null;
attendees: {
email: string;
name: string;
}[];
} | null;
// @NOTE: being used several times refactor to exported function
async function getBooking(): Promise<Booking> {
return prisma.booking.findFirst({
where: {
uid: asStringOrThrow(context.query.rescheduleUid),
},
select: {
startTime: true,
description: true,
attendees: {
select: {
@ -91,11 +103,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
});
}
type Booking = Prisma.PromiseReturnType<typeof getBooking>;
let booking: Booking | null = null;
if (context.query.rescheduleUid) {
booking = await getBooking();
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
booking["startTime"] = (booking?.startTime as Date)?.toISOString();
}
}
const t = await getTranslation(context.locale ?? "en", "common");

View File

@ -66,6 +66,8 @@
"event_has_been_rescheduled": "Updated - Your event has been rescheduled",
"request_reschedule_title_attendee": "Request to reschedule your booking",
"request_reschedule_subtitle": "{{organizer}} has cancelled the booking and requested you to pick another time.",
"request_reschedule_title_organizer": "You have requested {{attendee}} to reschedule",
"request_reschedule_subtitle_organizer": "You have cancelled the booking and {{attendee}} should be pick a new booking time with you.",
"reschedule_reason": "Reason for reschedule",
"hi_user_name": "Hi {{name}}",
"ics_event_title": "{{eventType}} with {{name}}",

View File

@ -248,4 +248,8 @@ export class CalendarEventBuilder implements ICalendarEventBuilder {
public setDescription(description: CalendarEventClass["description"]) {
this.calendarEvent.description = description;
}
public setCancellationReason(cancellationReason: CalendarEventClass["cancellationReason"]) {
this.calendarEvent.cancellationReason = cancellationReason;
}
}

View File

@ -12,6 +12,7 @@ export type Person = {
email: string;
timeZone: string;
language: { translate: TFunction; locale: string };
username?: string;
};
export type EventBusyDate = Record<"start" | "end", Date | string>;