Skip to content

Commit baa9045

Browse files
anikdhabalibex088
andauthored
feat: ability to add guests via app.cal.com/bookings (#14740)
* feat: ability to add guests via app.cal.com/bookings * fix: some update * fix: minor issue * fix: final update * update * update * add requested changes * fix type error * small update * final update * fix type error * fix location * update calender event --------- Co-authored-by: Somay Chauhan <[email protected]>
1 parent b004587 commit baa9045

File tree

15 files changed

+555
-0
lines changed

15 files changed

+555
-0
lines changed

apps/web/components/booking/BookingListItem.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
Tooltip,
4444
} from "@calcom/ui";
4545

46+
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
4647
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
4748
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
4849
import { ReassignDialog } from "@components/dialog/ReassignDialog";
@@ -189,6 +190,14 @@ function BookingListItem(booking: BookingItemProps) {
189190
},
190191
icon: "map-pin" as const,
191192
},
193+
{
194+
id: "add_members",
195+
label: t("additional_guests"),
196+
onClick: () => {
197+
setIsOpenAddGuestsDialog(true);
198+
},
199+
icon: "user-plus" as const,
200+
},
192201
];
193202

194203
if (booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
@@ -256,6 +265,7 @@ function BookingListItem(booking: BookingItemProps) {
256265
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
257266
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
258267
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
268+
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
259269
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
260270
onSuccess: () => {
261271
showToast(t("location_updated"), "success");
@@ -344,6 +354,11 @@ function BookingListItem(booking: BookingItemProps) {
344354
setShowLocationModal={setIsOpenLocationDialog}
345355
teamId={booking.eventType?.team?.id}
346356
/>
357+
<AddGuestsDialog
358+
isOpenDialog={isOpenAddGuestsDialog}
359+
setIsOpenDialog={setIsOpenAddGuestsDialog}
360+
bookingId={booking.id}
361+
/>
347362
{booking.paid && booking.payment[0] && (
348363
<ChargeCardDialog
349364
isOpenDialog={chargeCardDialogIsOpen}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { Dispatch, SetStateAction } from "react";
2+
import { useState } from "react";
3+
import { z } from "zod";
4+
5+
import { useLocale } from "@calcom/lib/hooks/useLocale";
6+
import { trpc } from "@calcom/trpc/react";
7+
import {
8+
Button,
9+
Dialog,
10+
DialogContent,
11+
DialogFooter,
12+
DialogHeader,
13+
MultiEmail,
14+
Icon,
15+
showToast,
16+
} from "@calcom/ui";
17+
18+
interface IAddGuestsDialog {
19+
isOpenDialog: boolean;
20+
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
21+
bookingId: number;
22+
}
23+
24+
export const AddGuestsDialog = (props: IAddGuestsDialog) => {
25+
const { t } = useLocale();
26+
const ZAddGuestsInputSchema = z.array(z.string().email()).refine((emails) => {
27+
const uniqueEmails = new Set(emails);
28+
return uniqueEmails.size === emails.length;
29+
});
30+
const { isOpenDialog, setIsOpenDialog, bookingId } = props;
31+
const utils = trpc.useUtils();
32+
const [multiEmailValue, setMultiEmailValue] = useState<string[]>([""]);
33+
const [isInvalidEmail, setIsInvalidEmail] = useState(false);
34+
35+
const addGuestsMutation = trpc.viewer.bookings.addGuests.useMutation({
36+
onSuccess: async () => {
37+
showToast(t("guests_added"), "success");
38+
setIsOpenDialog(false);
39+
setMultiEmailValue([""]);
40+
utils.viewer.bookings.invalidate();
41+
},
42+
onError: (err) => {
43+
const message = `${err.data?.code}: ${t(err.message)}`;
44+
showToast(message || t("unable_to_add_guests"), "error");
45+
},
46+
});
47+
48+
const handleAdd = () => {
49+
if (multiEmailValue.length === 0) {
50+
return;
51+
}
52+
const validationResult = ZAddGuestsInputSchema.safeParse(multiEmailValue);
53+
if (validationResult.success) {
54+
addGuestsMutation.mutate({ bookingId, guests: multiEmailValue });
55+
} else {
56+
setIsInvalidEmail(true);
57+
}
58+
};
59+
60+
return (
61+
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
62+
<DialogContent enableOverflow>
63+
<div className="flex flex-row space-x-3">
64+
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
65+
<Icon name="user-plus" className="m-auto h-6 w-6" />
66+
</div>
67+
<div className="w-full pt-1">
68+
<DialogHeader title={t("additional_guests")} />
69+
<MultiEmail
70+
label={t("add_emails")}
71+
value={multiEmailValue}
72+
readOnly={false}
73+
setValue={setMultiEmailValue}
74+
/>
75+
76+
{isInvalidEmail && (
77+
<div className="my-4 flex text-sm text-red-700">
78+
<div className="flex-shrink-0">
79+
<Icon name="triangle-alert" className="h-5 w-5" />
80+
</div>
81+
<div className="ml-3">
82+
<p className="font-medium">{t("emails_must_be_unique_valid")}</p>
83+
</div>
84+
</div>
85+
)}
86+
87+
<DialogFooter>
88+
<Button
89+
onClick={() => {
90+
setMultiEmailValue([""]);
91+
setIsInvalidEmail(false);
92+
setIsOpenDialog(false);
93+
}}
94+
type="button"
95+
color="secondary">
96+
{t("cancel")}
97+
</Button>
98+
<Button data-testid="add_members" loading={addGuestsMutation.isPending} onClick={handleAdd}>
99+
{t("add")}
100+
</Button>
101+
</DialogFooter>
102+
</div>
103+
</div>
104+
</DialogContent>
105+
</Dialog>
106+
);
107+
};

apps/web/public/static/locales/en/common.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,7 +1119,9 @@
11191119
"impersonating_user_warning": "Impersonating username \"{{user}}\".",
11201120
"impersonating_stop_instructions": "Click here to stop",
11211121
"event_location_changed": "Updated - Your event changed the location",
1122+
"new_guests_added": "Added - New guests added to your event",
11221123
"location_changed_event_type_subject": "Location Changed: {{eventType}} with {{name}} at {{date}}",
1124+
"guests_added_event_type_subject": "Guests Added: {{eventType}} with {{name}} at {{date}}",
11231125
"current_location": "Current Location",
11241126
"new_location": "New Location",
11251127
"session": "Session",
@@ -1131,7 +1133,10 @@
11311133
"set_location": "Set Location",
11321134
"update_location": "Update Location",
11331135
"location_updated": "Location updated",
1136+
"guests_added": "Guests added",
1137+
"unable_to_add_guests": "Unable to add guests",
11341138
"email_validation_error": "That doesn't look like an email address",
1139+
"emails_must_be_unique_valid": "Emails must be unique and valid",
11351140
"url_validation_error": "That doesn't look like a URL",
11361141
"place_where_cal_widget_appear": "Place this code in your HTML where you want your {{appName}} widget to appear.",
11371142
"create_update_react_component": "Create or update an existing React component as shown below.",
@@ -2426,6 +2431,7 @@
24262431
"primary": "Primary",
24272432
"make_primary": "Make primary",
24282433
"add_email": "Add Email",
2434+
"add_emails": "Add Emails",
24292435
"add_email_description": "Add an email address to replace your primary or to use as an alternative email on your event types.",
24302436
"confirm_email": "Confirm your email",
24312437
"scheduler_first_name": "The first name of the person booking",

packages/emails/email-manager.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { EmailVerifyLink } from "./templates/account-verify-email";
1818
import AccountVerifyEmail from "./templates/account-verify-email";
1919
import type { OrganizationNotification } from "./templates/admin-organization-notification";
2020
import AdminOrganizationNotification from "./templates/admin-organization-notification";
21+
import AttendeeAddGuestsEmail from "./templates/attendee-add-guests-email";
2122
import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email";
2223
import AttendeeCancelledEmail from "./templates/attendee-cancelled-email";
2324
import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email";
@@ -48,6 +49,7 @@ import type { OrganizationCreation } from "./templates/organization-creation-ema
4849
import OrganizationCreationEmail from "./templates/organization-creation-email";
4950
import type { OrganizationEmailVerify } from "./templates/organization-email-verification";
5051
import OrganizationEmailVerification from "./templates/organization-email-verification";
52+
import OrganizerAddGuestsEmail from "./templates/organizer-add-guests-email";
5153
import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email";
5254
import OrganizerCancelledEmail from "./templates/organizer-cancelled-email";
5355
import OrganizerDailyVideoDownloadRecordingEmail from "./templates/organizer-daily-video-download-recording-email";
@@ -520,6 +522,32 @@ export const sendLocationChangeEmails = async (
520522

521523
await Promise.all(emailsToSend);
522524
};
525+
export const sendAddGuestsEmails = async (calEvent: CalendarEvent, newGuests: string[]) => {
526+
const calendarEvent = formatCalEvent(calEvent);
527+
528+
const emailsToSend: Promise<unknown>[] = [];
529+
emailsToSend.push(sendEmail(() => new OrganizerAddGuestsEmail({ calEvent: calendarEvent })));
530+
531+
if (calendarEvent.team?.members) {
532+
for (const teamMember of calendarEvent.team.members) {
533+
emailsToSend.push(
534+
sendEmail(() => new OrganizerAddGuestsEmail({ calEvent: calendarEvent, teamMember }))
535+
);
536+
}
537+
}
538+
539+
emailsToSend.push(
540+
...calendarEvent.attendees.map((attendee) => {
541+
if (newGuests.includes(attendee.email)) {
542+
return sendEmail(() => new AttendeeScheduledEmail(calendarEvent, attendee));
543+
} else {
544+
return sendEmail(() => new AttendeeAddGuestsEmail(calendarEvent, attendee));
545+
}
546+
})
547+
);
548+
549+
await Promise.all(emailsToSend);
550+
};
523551
export const sendFeedbackEmail = async (feedback: Feedback) => {
524552
await sendEmail(() => new FeedbackEmail(feedback));
525553
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
2+
3+
export const AttendeeAddGuestsEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => (
4+
<AttendeeScheduledEmail
5+
title="new_guests_added"
6+
headerType="calendarCircle"
7+
subject="guests_added_event_type_subject"
8+
{...props}
9+
/>
10+
);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
2+
3+
export const OrganizerAddGuestsEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => (
4+
<OrganizerScheduledEmail
5+
title="new_guests_added"
6+
headerType="calendarCircle"
7+
subject="guests_added_event_type_subject"
8+
callToAction={null}
9+
{...props}
10+
/>
11+
);

packages/emails/src/templates/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,6 @@ export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificat
3535
export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
3636
export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail";
3737
export { OrganizationCreationEmail } from "./OrganizationCreationEmail";
38+
export { OrganizerAddGuestsEmail } from "./OrganizerAddGuestsEmail";
39+
export { AttendeeAddGuestsEmail } from "./AttendeeAddGuestsEmail";
3840
export { OrganizationAdminNoSlotsEmail } from "./OrganizationAdminNoSlots";
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { renderEmail } from "../";
2+
import generateIcsString from "../lib/generateIcsString";
3+
import AttendeeScheduledEmail from "./attendee-scheduled-email";
4+
5+
export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
6+
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
7+
return {
8+
icalEvent: {
9+
filename: "event.ics",
10+
content: generateIcsString({
11+
event: this.calEvent,
12+
title: this.t("new_guests_added"),
13+
subtitle: this.t("emailed_you_and_any_other_attendees"),
14+
role: "attendee",
15+
status: "CONFIRMED",
16+
}),
17+
method: "REQUEST",
18+
},
19+
to: `${this.attendee.name} <${this.attendee.email}>`,
20+
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
21+
replyTo: this.calEvent.organizer.email,
22+
subject: `${this.t("guests_added_event_type_subject", {
23+
eventType: this.calEvent.type,
24+
name: this.calEvent.team?.name || this.calEvent.organizer.name,
25+
date: this.getFormattedDate(),
26+
})}`,
27+
html: await renderEmail("AttendeeAddGuestsEmail", {
28+
calEvent: this.calEvent,
29+
attendee: this.attendee,
30+
}),
31+
text: this.getTextBody("new_guests_added"),
32+
};
33+
}
34+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { APP_NAME } from "@calcom/lib/constants";
2+
3+
import { renderEmail } from "../";
4+
import generateIcsString from "../lib/generateIcsString";
5+
import OrganizerScheduledEmail from "./organizer-scheduled-email";
6+
7+
export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
8+
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
9+
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
10+
11+
return {
12+
icalEvent: {
13+
filename: "event.ics",
14+
content: generateIcsString({
15+
event: this.calEvent,
16+
title: this.t("new_guests_added"),
17+
subtitle: this.t("emailed_you_and_any_other_attendees"),
18+
role: "organizer",
19+
status: "CONFIRMED",
20+
}),
21+
method: "REQUEST",
22+
},
23+
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
24+
to: toAddresses.join(","),
25+
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
26+
subject: `${this.t("guests_added_event_type_subject", {
27+
eventType: this.calEvent.type,
28+
name: this.calEvent.attendees[0].name,
29+
date: this.getFormattedDate(),
30+
})}`,
31+
html: await renderEmail("OrganizerAddGuestsEmail", {
32+
attendee: this.calEvent.organizer,
33+
calEvent: this.calEvent,
34+
}),
35+
text: this.getTextBody("new_guests_added"),
36+
};
37+
}
38+
}

packages/trpc/server/routers/viewer/bookings/_router.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import authedProcedure from "../../../procedures/authedProcedure";
22
import publicProcedure from "../../../procedures/publicProcedure";
33
import { router } from "../../../trpc";
4+
import { ZAddGuestsInputSchema } from "./addGuests.schema";
45
import { ZConfirmInputSchema } from "./confirm.schema";
56
import { ZEditLocationInputSchema } from "./editLocation.schema";
67
import { ZFindInputSchema } from "./find.schema";
@@ -14,6 +15,7 @@ type BookingsRouterHandlerCache = {
1415
get?: typeof import("./get.handler").getHandler;
1516
requestReschedule?: typeof import("./requestReschedule.handler").requestRescheduleHandler;
1617
editLocation?: typeof import("./editLocation.handler").editLocationHandler;
18+
addGuests?: typeof import("./addGuests.handler").addGuestsHandler;
1719
confirm?: typeof import("./confirm.handler").confirmHandler;
1820
getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler;
1921
find?: typeof import("./find.handler").getHandler;
@@ -74,6 +76,23 @@ export const bookingsRouter = router({
7476
input,
7577
});
7678
}),
79+
addGuests: authedProcedure.input(ZAddGuestsInputSchema).mutation(async ({ input, ctx }) => {
80+
if (!UNSTABLE_HANDLER_CACHE.addGuests) {
81+
UNSTABLE_HANDLER_CACHE.addGuests = await import("./addGuests.handler").then(
82+
(mod) => mod.addGuestsHandler
83+
);
84+
}
85+
86+
// Unreachable code but required for type safety
87+
if (!UNSTABLE_HANDLER_CACHE.addGuests) {
88+
throw new Error("Failed to load handler");
89+
}
90+
91+
return UNSTABLE_HANDLER_CACHE.addGuests({
92+
ctx,
93+
input,
94+
});
95+
}),
7796

7897
confirm: authedProcedure.input(ZConfirmInputSchema).mutation(async ({ input, ctx }) => {
7998
if (!UNSTABLE_HANDLER_CACHE.confirm) {

0 commit comments

Comments
 (0)