feat: instant meeting improvements (#13258)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Udit Takkar
2024-01-22 19:48:23 +00:00
committed by GitHub
co-authored by Peer Richelsen Omar López
parent ede2357203
commit 850e222f0e
13 changed files with 315 additions and 37 deletions
@@ -1,3 +1,4 @@
import type { Webhook } from "@prisma/client";
import { useSession } from "next-auth/react";
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
import { useState } from "react";
@@ -5,10 +6,16 @@ import { useFormContext } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { WebhookForm } from "@calcom/features/webhooks/components";
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Button, EmptyScreen, SettingsToggle } from "@calcom/ui";
import { PhoneCall } from "@calcom/ui/components/icon";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmptyScreen, SettingsToggle, Dialog, DialogContent, showToast } from "@calcom/ui";
import { PhoneCall, Plus, Lock, Webhook as TbWebhook } from "@calcom/ui/components/icon";
type InstantEventControllerProps = {
eventType: EventTypeSetup;
@@ -82,11 +89,7 @@ export default function InstantEventController({
}
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
{instantEventState && (
<div data-testid="instant-event-collapsible" className="flex flex-col gap-2 text-sm">
<p>{t("warning_payment_instant_meeting_event")}</p>
</div>
)}
{instantEventState && <InstantMeetingWebhooks eventType={eventType} />}
</div>
</SettingsToggle>
</>
@@ -97,3 +100,201 @@ export default function InstantEventController({
</LicenseRequired>
);
}
const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: webhooks } = trpc.viewer.webhook.list.useQuery({
eventTypeId: eventType.id,
eventTriggers: [WebhookTriggerEvents.INSTANT_MEETING],
});
const { data: installedApps, isLoading } = trpc.viewer.integrations.useQuery({
variant: "other",
onlyInstalled: true,
});
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [webhookToEdit, setWebhookToEdit] = useState<Webhook>();
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
async onSuccess() {
setEditModalOpen(false);
await utils.viewer.webhook.list.invalidate();
showToast(t("webhook_updated_successfully"), "success");
},
onError(error) {
showToast(`${error.message}`, "error");
},
});
const createWebhookMutation = trpc.viewer.webhook.create.useMutation({
async onSuccess() {
showToast(t("webhook_created_successfully"), "success");
await utils.viewer.webhook.list.invalidate();
setCreateModalOpen(false);
},
onError(error) {
showToast(`${error.message}`, "error");
},
});
const onCreateWebhook = async (values: WebhookFormSubmitData) => {
if (
subscriberUrlReserved({
subscriberUrl: values.subscriberUrl,
id: values.id,
webhooks,
eventTypeId: eventType.id,
})
) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}
if (!values.payloadTemplate) {
values.payloadTemplate = null;
}
createWebhookMutation.mutate({
subscriberUrl: values.subscriberUrl,
eventTriggers: values.eventTriggers,
active: values.active,
payloadTemplate: values.payloadTemplate,
secret: values.secret,
eventTypeId: eventType.id,
});
};
const NewWebhookButton = () => {
const { t } = useLocale();
return (
<Button
color="secondary"
data-testid="new_webhook"
StartIcon={Plus}
onClick={() => setCreateModalOpen(true)}>
{t("new_webhook")}
</Button>
);
};
const { shouldLockDisableProps, isChildrenManagedEventType, isManagedEventType } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const webhookLockedStatus = shouldLockDisableProps("webhooks");
return (
<div>
{webhooks && !isLoading && (
<>
<div>
{webhooks.length ? (
<>
<div className="border-subtle my-2 rounded-md border">
{webhooks.map((webhook, index) => {
return (
<WebhookListItem
key={webhook.id}
webhook={webhook}
lastItem={webhooks.length === index + 1}
canEditWebhook={!webhookLockedStatus.disabled}
onEditWebhook={() => {
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
/>
);
})}
</div>
<p className="text-default text-sm font-normal">
{t("warning_payment_instant_meeting_event")}
</p>
</>
) : (
<>
<p className="text-default mb-4 text-sm font-normal">
{t("warning_payment_instant_meeting_event")}
</p>
<EmptyScreen
Icon={TbWebhook}
headline={t("create_your_first_webhook")}
description={t("create_instant_meeting_webhook_description")}
buttonRaw={
isChildrenManagedEventType && !isManagedEventType ? (
<Button StartIcon={Lock} color="secondary" disabled>
{t("locked_by_admin")}
</Button>
) : (
<NewWebhookButton />
)
}
/>
</>
)}
</div>
{/* New webhook dialog */}
<Dialog open={createModalOpen} onOpenChange={(isOpen) => !isOpen && setCreateModalOpen(false)}>
<DialogContent
enableOverflow
title={t("create_webhook")}
description={t("create_webhook_team_event_type")}>
<WebhookForm
noRoutingFormTriggers={true}
onSubmit={onCreateWebhook}
onCancel={() => setCreateModalOpen(false)}
apps={installedApps?.items.map((app) => app.slug)}
selectOnlyInstantMeetingOption={true}
/>
</DialogContent>
</Dialog>
{/* Edit webhook dialog */}
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
<DialogContent enableOverflow title={t("edit_webhook")}>
<WebhookForm
noRoutingFormTriggers={true}
webhook={webhookToEdit}
apps={installedApps?.items.map((app) => app.slug)}
onCancel={() => setEditModalOpen(false)}
onSubmit={(values: WebhookFormSubmitData) => {
if (
subscriberUrlReserved({
subscriberUrl: values.subscriberUrl,
id: webhookToEdit?.id,
webhooks,
eventTypeId: eventType.id,
})
) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}
if (values.changeSecret) {
values.secret = values.newSecret.length ? values.newSecret : null;
}
if (!values.payloadTemplate) {
values.payloadTemplate = null;
}
editWebhookMutation.mutate({
id: webhookToEdit?.id || "",
subscriberUrl: values.subscriberUrl,
eventTriggers: values.eventTriggers,
active: values.active,
payloadTemplate: values.payloadTemplate,
secret: values.secret,
eventTypeId: webhookToEdit?.eventTypeId || undefined,
});
}}
/>
</DialogContent>
</Dialog>
</>
)}
</div>
);
};
@@ -112,7 +112,7 @@
"requested_to_reschedule_subject_attendee": "Action Required Reschedule: Please book a new time for {{eventType}} with {{name}}",
"hi_user_name": "Hi {{name}}",
"ics_event_title": "{{eventType}} with {{name}}",
"please_book_a_time_sometime_later":"No one was available at the moment. Please book for sometime later",
"please_book_a_time_sometime_later":"Oops, we couldn't connect you this time. Please schedule a future call instead",
"new_event_subject": "New event: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Join by {{entryPoint}}",
"notes": "Notes",
@@ -440,7 +440,7 @@
"browse_api_documentation": "Browse our API documentation",
"leverage_our_api": "Leverage our API for full control and customizability.",
"create_webhook": "Create Webhook",
"instant_meeting_created":"Instant Meeting Created",
"instant_meeting":"Instant Meeting Created",
"booking_cancelled": "Booking Canceled",
"booking_rescheduled": "Booking Rescheduled",
"recording_ready": "Recording Download Link Ready",
@@ -555,6 +555,7 @@
"enter_number_between_range": "Please enter a number between 1 and {{maxOccurences}}",
"email_address": "Email address",
"enter_valid_email": "Please enter a valid email",
"please_schedule_future_call":" Please schedule a future call if we're not available in {{seconds}} seconds",
"location": "Location",
"address": "Address",
"enter_address": "Enter address",
@@ -1979,6 +1980,7 @@
"connect_google_workspace": "Connect Google Workspace",
"google_workspace_admin_tooltip": "You must be a Workspace Admin to use this feature",
"first_event_type_webhook_description": "Create your first webhook for this event type",
"create_instant_meeting_webhook_description":"Create your first webhook with 'Instant Meeting Created' as trigger for this event type",
"install_app_on": "Install app on",
"create_for": "Create for",
"currency": "Currency",
@@ -440,7 +440,7 @@
"browse_api_documentation": "עיון במסמכי ממשק תכנות היישומים (API) שלנו",
"leverage_our_api": "מומלץ להיעזר בממשק תכנות היישומים (API) שלנו לקבלת שליטה מלאה ויכולת התאמה אישית.",
"create_webhook": "יצירת Webhook",
"instant_meeting_created": "נוצרה פגישה מיידית",
"instant_meeting": "נוצרה פגישה מיידית",
"booking_cancelled": "ההזמנה בוטלה",
"booking_rescheduled": "מועד ההזמנה השתנה",
"recording_ready": "הקישור להורדת ההקלטה מוכן",
+2
View File
@@ -2,6 +2,7 @@
import dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-days2";
import customParseFormat from "dayjs/plugin/customParseFormat";
import duration from "dayjs/plugin/duration";
import isBetween from "dayjs/plugin/isBetween";
import isToday from "dayjs/plugin/isToday";
import localizedFormat from "dayjs/plugin/localizedFormat";
@@ -21,6 +22,7 @@ dayjs.extend(timeZone);
dayjs.extend(toArray);
dayjs.extend(utc);
dayjs.extend(minmax);
dayjs.extend(duration);
export type Dayjs = dayjs.Dayjs;
+5 -2
View File
@@ -179,7 +179,7 @@ const BookerComponent = ({
event,
});
const { handleBookEvent, hasInstantMeetingTokenExpired, errors, loadingStates } = useBookings({
const { handleBookEvent, hasInstantMeetingTokenExpired, errors, loadingStates, expiryTime } = useBookings({
event,
hashedLink,
bookingForm,
@@ -245,6 +245,7 @@ const BookerComponent = ({
isUserSessionRequiredToVerify={false}
/>
<RedirectToInstantMeetingModal
expiryTime={expiryTime}
hasInstantMeetingTokenExpired={hasInstantMeetingTokenExpired}
bookingId={parseInt(getQueryParam("bookingId") || "0")}
onGoBack={() => {
@@ -279,6 +280,7 @@ const BookerComponent = ({
setSeatedEventData,
setSelectedTimeslot,
setVerifiedEmail,
expiryTime,
]
);
@@ -301,8 +303,9 @@ const BookerComponent = ({
{bookerState !== "booking" && event.data?.isInstantEvent && (
<div
className="animate-fade-in-up fixed bottom-2 z-40 my-2 opacity-0"
style={{ animationDelay: "2s" }}>
style={{ animationDelay: "1s" }}>
<InstantBooking
event={event.data}
onConnectNow={() => {
const newPath = `${pathname}?isInstantMeeting=true`;
router.push(newPath);
@@ -51,6 +51,7 @@ export const BookEventForm = ({
const timeslot = useBookerStore((state) => state.selectedTimeslot);
const username = useBookerStore((state) => state.username);
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
const [expiryTime, setExpiryTime] = useState<Date | undefined>();
const [responseVercelIdHeader] = useState<string | null>(null);
const { t } = useLocale();
@@ -1,21 +1,30 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { type RouterOutputs } from "@calcom/trpc";
import { Button } from "@calcom/ui";
import { UserAvatarGroupWithOrg } from "@calcom/web/components/ui/avatar/UserAvatarGroupWithOrg";
interface IInstantBookingProps {
onConnectNow: () => void;
}
{
/* TODO: max. show 4 people here */
/* use AvatarGroup */
event: NonNullable<RouterOutputs["viewer"]["public"]["event"]>;
}
export const InstantBooking = ({ onConnectNow }: IInstantBookingProps) => {
export const InstantBooking = ({ onConnectNow, event }: IInstantBookingProps) => {
const { t } = useLocale();
return (
<div className=" bg-default border-subtle mx-2 block items-center gap-3 rounded-xl border p-[6px] text-sm shadow-sm delay-1000 sm:flex">
<div className="flex items-center gap-3 ps-1">
<div className="relative">
<UserAvatarGroupWithOrg
size="sm"
className="border-muted"
organization={{
slug: event.entity.orgSlug,
name: event.entity.name || "",
}}
users={event.schedulingType !== SchedulingType.ROUND_ROBIN ? event.users : []}
/>
<div className="border-muted absolute -bottom-0.5 -right-1 h-2 w-2 rounded-full border bg-green-500" />
</div>
<div>{t("dont_want_to_wait")}</div>
@@ -1,3 +1,6 @@
import { useState, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Dialog, DialogContent } from "@calcom/ui";
import { Button } from "@calcom/ui";
@@ -6,15 +9,60 @@ export const RedirectToInstantMeetingModal = ({
hasInstantMeetingTokenExpired,
bookingId,
onGoBack,
expiryTime,
}: {
hasInstantMeetingTokenExpired: boolean;
bookingId: number;
onGoBack: () => void;
expiryTime?: Date;
}) => {
const { t } = useLocale();
const [timeRemaining, setTimeRemaining] = useState(calculateTimeRemaining());
function calculateTimeRemaining() {
const now = dayjs();
const expiration = dayjs(expiryTime);
const duration = expiration.diff(now);
return Math.max(duration, 0);
}
useEffect(() => {
if (!expiryTime) return;
const timer = setInterval(() => {
setTimeRemaining(calculateTimeRemaining());
}, 1000);
return () => {
clearInterval(timer);
};
}, [expiryTime]);
const formatTime = (milliseconds: number) => {
const duration = dayjs.duration(milliseconds);
const seconds = duration.seconds();
const minutes = duration.minutes();
return `${minutes}m ${seconds}s`;
};
useEffect(() => {
if (hasInstantMeetingTokenExpired) return;
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
const message = "/o";
event.returnValue = message; // Standard for most browsers
return message; // For some older browsers
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [hasInstantMeetingTokenExpired]);
return (
<Dialog open={!!bookingId}>
<Dialog open={!!bookingId && !!expiryTime}>
<DialogContent enableOverflow className="py-8">
<div>
{hasInstantMeetingTokenExpired ? (
@@ -32,18 +80,14 @@ export const RedirectToInstantMeetingModal = ({
) : (
<div className="text-center">
<p className="font-medium">{t("connecting_you_to_someone")}</p>
{/* TODO: Add countdown from 60 seconds
We are connecting you!
Please schedule a future call if we're not available in XX seconds.
*/}
{/* Once countdown ends:
Oops, we couldn't connect you this time.
Please schedule a future call instead. We value your time.
*/}
<p className="font-medium">{t("please_do_not_close_this_tab")}</p>
<div className="h-[414px]">
<p className="mt-2 font-medium">
{t("please_schedule_future_call", {
seconds: formatTime(timeRemaining),
})}
</p>
<div className="mt-4 h-[414px]">
<iframe className="mx-auto h-full w-[276px] rounded-lg" src="https://cal.games/" />
</div>
</div>
@@ -73,6 +73,7 @@ export const useBookings = ({ event, hashedLink, bookingForm }: IUseBookings) =>
{
enabled: !!bookingId && !hasInstantMeetingTokenExpired,
refetchInterval: 2000,
refetchIntervalInBackground: true,
onSuccess: (data) => {
try {
showToast(t("something_went_wrong_on_our_end"), "error");
@@ -179,7 +179,8 @@ async function handler(req: NextApiRequest) {
const instantMeetingToken = await prisma.instantMeetingToken.create({
data: {
token,
expires: new Date(new Date().getTime() + 1000 * 60 * 5),
// 90 Seconds
expires: new Date(new Date().getTime() + 1000 * 90),
team: {
connect: {
id: eventType.team.id,
@@ -41,7 +41,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
{ value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" },
{ value: WebhookTriggerEvents.MEETING_STARTED, label: "meeting_started" },
{ value: WebhookTriggerEvents.RECORDING_READY, label: "recording_ready" },
{ value: WebhookTriggerEvents.INSTANT_MEETING, label: "instant_meeting_created" },
{ value: WebhookTriggerEvents.INSTANT_MEETING, label: "instant_meeting" },
],
"routing-forms": [{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" }],
} as const;
@@ -52,8 +52,9 @@ const WebhookForm = (props: {
onSubmit: (event: WebhookFormSubmitData) => void;
onCancel?: () => void;
noRoutingFormTriggers: boolean;
selectOnlyInstantMeetingOption?: boolean;
}) => {
const { apps = [] } = props;
const { apps = [], selectOnlyInstantMeetingOption = false } = props;
const { t } = useLocale();
const triggerOptions = [...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2["core"]];
@@ -67,15 +68,21 @@ const WebhookForm = (props: {
}
const translatedTriggerOptions = triggerOptions.map((option) => ({ ...option, label: t(option.label) }));
const getEventTriggers = () => {
if (props.webhook) return props.webhook.eventTriggers;
return (
selectOnlyInstantMeetingOption
? translatedTriggerOptions.filter((option) => option.value === WebhookTriggerEvents.INSTANT_MEETING)
: translatedTriggerOptions.filter((option) => option.value !== WebhookTriggerEvents.INSTANT_MEETING)
).map((option) => option.value);
};
const formMethods = useForm({
defaultValues: {
subscriberUrl: props.webhook?.subscriberUrl || "",
active: props.webhook ? props.webhook.active : true,
eventTriggers: !props.webhook
? translatedTriggerOptions
.filter((option) => option.value !== WebhookTriggerEvents.INSTANT_MEETING)
.map((option) => option.value)
: props.webhook.eventTriggers,
eventTriggers: getEventTriggers(),
secret: props?.webhook?.secret || "",
payloadTemplate: props?.webhook?.payloadTemplate || undefined,
},
@@ -35,6 +35,10 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
OR: [{ userId: ctx.user.id }, { teamId: { in: user?.teams.map((membership) => membership.teamId) } }],
});
}
if (input?.eventTriggers) {
where.AND?.push({ eventTriggers: { hasEvery: input.eventTriggers } });
}
}
return await prisma.webhook.findMany({
@@ -1,5 +1,7 @@
import { z } from "zod";
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
import { webhookIdAndEventTypeIdSchema } from "./types";
export const ZListInputSchema = webhookIdAndEventTypeIdSchema
@@ -7,6 +9,7 @@ export const ZListInputSchema = webhookIdAndEventTypeIdSchema
appId: z.string().optional(),
teamId: z.number().optional(),
eventTypeId: z.number().optional(),
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
})
.optional();