feat: improve Intercom app (#19687)

* fix: don't POST to cal.com/[user]/[type]

* fix: return html with cal calendar embed

* fix: intercom app team events types

* feat: improve Intercom UX integration

* Merge branch 'main' of https://github.com/calcom/cal.com
This commit is contained in:
Vachmara
2025-03-04 10:48:31 +05:30
committed by GitHub
parent a3ce957f00
commit df598bf281
9 changed files with 352 additions and 141 deletions
+18 -125
View File
@@ -1,136 +1,29 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import type {
NewCanvas,
ListComponent,
ListItem,
SpacerComponent,
TextComponent,
InputComponent,
} from "../lib";
import { isValidCalURL } from "../lib/isValidCalURL";
import { handleButtonAndInvitationStep } from "../lib/configure/button";
import { handleLinkStep } from "../lib/configure/link";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { admin, input_values, component_id } = req.body;
const { input_values, current_canvas } = req.body;
let isValid: boolean | TextComponent = true;
if (component_id || input_values?.submit_booking_url) {
const url = component_id === "submit_booking_url" ? input_values?.submit_booking_url : component_id;
isValid = await isValidCalURL(url);
console.dir(req.body);
if (isValid === true) return res.status(200).json({ results: { submit_booking_url: url } });
const linkStepResult = current_canvas?.stored_data?.submit_booking_url
? current_canvas.stored_data.submit_booking_url
: await handleLinkStep(req);
if (typeof linkStepResult !== "string") {
return res.status(200).json(linkStepResult);
}
const buttonAndInvitationStepResult = await handleButtonAndInvitationStep(req);
console.log(buttonAndInvitationStepResult);
if (buttonAndInvitationStepResult) {
return res.status(200).json(buttonAndInvitationStepResult);
}
const input: InputComponent = {
type: "input",
id: "submit_booking_url",
label: "Enter your Cal.com link",
placeholder: "https://cal.com/valentinchmara/30min",
save_state: "unsaved",
action: {
type: "submit",
},
aria_label: "Enter your Cal.com link",
};
const defaultCanvasData: NewCanvas = {
canvas: {
content: {
components: isValid === true ? [input] : [isValid, input],
},
},
};
if (!admin?.id) return res.status(200).json(defaultCanvasData);
const credential = await prisma.credential.findFirst({
where: {
appId: "intercom",
key: {
string_contains: admin.id,
},
return res.status(200).json({
results: {
...input_values,
submit_booking_url: current_canvas?.stored_data?.submit_booking_url,
},
});
if (!credential) return res.status(200).json(defaultCanvasData);
const team = credential.teamId
? await prisma.team.findUnique({
where: {
id: credential.teamId,
},
})
: null;
const userId = credential.userId;
const user = userId
? await prisma.user.findUnique({
where: {
id: userId,
},
})
: null;
const eventTypes = await prisma.eventType.findMany({
where: {
userId,
hidden: false,
},
});
if (!eventTypes) return res.status(200).json(defaultCanvasData);
if (!user && !team) return res.status(200).json(defaultCanvasData);
const list: ListItem[] = eventTypes.map((eventType) => {
let slug;
if (team && team.slug) {
slug = `team/${team.slug}`;
} else if (user && user.username) {
slug = user.username;
}
return {
id: `${WEBAPP_URL}/${slug}/${eventType.slug}`,
type: "item",
title: eventType.title,
subtitle: `${slug}/${eventType.slug}`,
rounded_image: false,
disabled: false,
action: {
type: "submit",
},
};
});
const components: ListComponent = {
type: "list",
items: list,
};
const spacer: SpacerComponent = {
type: "spacer",
size: "m",
};
const text: TextComponent = {
type: "text",
text: "Or choose another Cal.com link:",
style: "muted",
align: "left",
};
const canvasData: NewCanvas = {
canvas: {
content: {
components:
isValid === true ? [components, spacer, text, input] : [components, spacer, text, input, isValid],
},
},
};
return res.status(200).json(canvasData);
}
+11 -4
View File
@@ -66,11 +66,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
/* Not functionnal
Cal("on", {
action: "eventTypeSelected",
callback: (e) => INTERCOM_MESSENGER_SHEET_LIBRARY.submitSheet({...e.detail}),
});*/
action: "bookingSuccessful",
callback: (e) => {
console.log("bookingSuccessful", e)
try {
INTERCOM_MESSENGER_SHEET_LIBRARY.submitSheet(e.detail.data)
} catch(error) {
console.log("Error Intercom sheet", error)
}
}
});
</script>
<!-- Cal inline embed code ends -->
</body>
+1
View File
@@ -3,3 +3,4 @@ export { default as callback } from "./callback";
export { default as initialize } from "./initialize";
export { default as configure } from "./configure";
export { default as get } from "./get";
export { default as sheet } from "./sheet";
@@ -15,10 +15,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
canvas: {
content: {
components: [
{
type: "text",
text: card_creation_options?.invitation_input ?? "Schedule a meeting with me",
align: "left",
style: "header",
},
{
type: "button",
id: "submit-issue-form",
label: "Book a meeting",
label: card_creation_options?.booking_button_input ?? "Booking button text",
style: "primary",
action: {
type: "sheet",
+58
View File
@@ -0,0 +1,58 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type { NewCanvas, TextComponent, SpacerComponent } from "../lib";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { sheet_values } = req.body;
if (!sheet_values) {
return res.status(400).json({ error: "Invalid input data" });
}
const { booking, eventType, organizer } = sheet_values;
// Prepare date and time information
const startTime = new Date(booking.startTime);
const options = {
weekday: "long" as const,
year: "numeric" as const,
month: "long" as const,
day: "numeric" as const,
hour: "numeric" as const,
minute: "numeric" as const,
timeZone: organizer.timeZone,
timeZoneName: "short" as const,
};
const formattedDate = startTime.toLocaleDateString("en-US", options);
// Create text components for the recap
const confirmedText: TextComponent = {
type: "text",
text: `Confirmed: ${eventType.title}`,
style: "header",
align: "left",
};
const detailsText: TextComponent = {
type: "text",
text: `You are scheduled with ${organizer.name} on ${formattedDate}`,
style: "paragraph",
align: "left",
};
const spacer: SpacerComponent = {
type: "spacer",
size: "m",
};
// Build the canvas data
const canvasData: NewCanvas = {
canvas: {
content: {
components: [confirmedText, spacer, detailsText],
},
},
};
return res.status(200).json(canvasData);
}
@@ -0,0 +1,82 @@
import type { NextApiRequest } from "next";
import type { NewCanvas, InputComponent, SpacerComponent, TextComponent, ButtonComponent } from "../../lib";
export async function handleButtonAndInvitationStep(req: NextApiRequest): Promise<NewCanvas | undefined> {
const { input_values, current_canvas, component_id } = req.body;
const errors = [];
if (input_values?.invitation_text && input_values.invitation_text.length > 100) {
errors.push("Invitation text must be less than 100 characters.");
}
if (input_values?.booking_button_text && input_values.booking_button_text.length > 20) {
errors.push("Booking button text must be less than 20 characters.");
}
if (errors.length === 0 && Object.keys(input_values).includes("booking_button_input")) return;
const invitationTextInput: InputComponent = {
type: "input",
id: "invitation_input",
label: "Invitation text",
placeholder: "Schedule a meeting with me",
value: "Schedule a meeting with me",
aria_label: "Enter invitation text",
};
const bookingButtonTextInput: InputComponent = {
type: "input",
id: "booking_button_input",
label: "Booking button text",
placeholder: "Book now",
value: "Book now",
aria_label: "Enter booking button text",
};
const spacer: SpacerComponent = {
type: "spacer",
size: "m",
};
const button: ButtonComponent = {
type: "button",
id: "booking_button",
label: "Create an invite",
action: {
type: "submit",
},
};
const submit_booking_url = current_canvas?.stored_data?.submit_booking_url
? current_canvas?.stored_data?.submit_booking_url
: component_id === "submit_booking_url"
? input_values?.submit_booking_url
: component_id;
return {
canvas: {
content: {
components: [
spacer,
invitationTextInput,
bookingButtonTextInput,
button,
...errors?.map(
(e) =>
({
type: "text",
text: e,
style: "error",
align: "left",
} as TextComponent)
),
],
},
stored_data: {
submit_booking_url,
},
},
};
}
@@ -0,0 +1,134 @@
import type { NextApiRequest } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import type {
NewCanvas,
ListComponent,
ListItem,
SpacerComponent,
TextComponent,
InputComponent,
} from "../../lib";
import { isValidCalURL } from "../../lib/isValidCalURL";
export async function handleLinkStep(req: NextApiRequest): Promise<NewCanvas | string> {
const { admin, component_id, input_values } = req.body;
const url = component_id === "submit_booking_url" ? input_values?.submit_booking_url : component_id;
const { isValid, error } = url ? await isValidCalURL(url) : { isValid: false, error: undefined };
if (isValid) return url;
const input: InputComponent = {
type: "input",
id: "submit_booking_url",
label: "Enter your Cal.com link",
placeholder: "https://cal.com/valentinchmara/30min",
save_state: "unsaved",
action: {
type: "submit",
},
aria_label: "Enter your Cal.com link",
};
const defaultCanvasData: NewCanvas = {
canvas: {
content: {
components: !error ? [input] : [error, input],
},
},
};
if (!admin?.id) return defaultCanvasData;
const credential = await prisma.credential.findFirst({
where: {
appId: "intercom",
key: {
string_contains: admin.id,
},
},
});
if (!credential) return defaultCanvasData;
const team = credential.teamId
? await prisma.team.findUnique({
where: {
id: credential.teamId,
},
})
: null;
const userId = credential.userId;
const user = userId
? await prisma.user.findUnique({
where: {
id: userId,
},
})
: null;
const eventTypes = await prisma.eventType.findMany({
where: {
userId,
hidden: false,
teamId: team ? team.id : undefined,
},
});
if (eventTypes && eventTypes?.length === 0) return defaultCanvasData;
if (!user && !team) return defaultCanvasData;
// Limit to 10 Events types
const list: ListItem[] = eventTypes.slice(0, 10).map((eventType) => {
let slug;
if (team && team.slug) {
slug = `team/${team.slug}`;
} else if (user && user.username) {
slug = user.username;
}
return {
id: `${WEBAPP_URL}/${slug}/${eventType.slug}`,
type: "item",
title: eventType.title,
subtitle: `${slug}/${eventType.slug}`,
rounded_image: false,
disabled: false,
action: {
type: "submit",
},
};
});
const components: ListComponent = {
type: "list",
items: list,
};
const spacer: SpacerComponent = {
type: "spacer",
size: "m",
};
const text: TextComponent = {
type: "text",
text: "Or choose another Cal.com link:",
style: "muted",
align: "left",
};
const canvasData: NewCanvas = {
canvas: {
content: {
components: !error ? [components, spacer, text, input] : [components, spacer, text, input, error],
},
},
};
return canvasData;
}
+12 -4
View File
@@ -3,23 +3,29 @@ export interface CanvasComponent {
disabled?: boolean;
}
export interface IsValid {
isValid: boolean;
error?: TextComponent;
}
export interface InputComponent extends CanvasComponent {
type: "input";
id: string;
label: string;
placeholder: string;
save_state: "unsaved" | "saved";
action: {
value?: string;
save_state?: "unsaved" | "saved";
action?: {
type: "submit";
};
aria_label: string;
}
interface ButtonComponent extends CanvasComponent {
export interface ButtonComponent extends CanvasComponent {
type: "button";
id: string;
label: string;
style: "primary" | "secondary" | "link";
style?: "primary" | "secondary" | "link";
action: {
type: "submit" | "sheet" | "url";
url?: string;
@@ -62,5 +68,7 @@ export interface CanvasContent {
export interface NewCanvas {
canvas: {
content: CanvasContent;
content_url?: string;
stored_data?: Record<string, unknown>;
};
}
@@ -6,7 +6,7 @@ import type { TextComponent } from "../lib";
/**
* Check if the url is a valid cal.com url
* @param url
* @returns boolean
* @returns IsValid
*/
export async function isValidCalURL(url: string) {
const regex = new RegExp(`^${WEBAPP_URL}/`, `i`);
@@ -18,7 +18,11 @@ export async function isValidCalURL(url: string) {
align: "left",
};
if (!regex.test(url)) return error;
if (!regex.test(url))
return {
isValid: false,
error,
};
const urlWithoutCal = url.replace(regex, "");
@@ -26,7 +30,11 @@ export async function isValidCalURL(url: string) {
const usernameOrTeamSlug = urlParts[0];
const eventTypeSlug = urlParts[1];
if (!usernameOrTeamSlug || !eventTypeSlug) return error;
if (!usernameOrTeamSlug || !eventTypeSlug)
return {
isValid: false,
error,
};
// Find all potential users with the given username
const potentialUsers = await prisma.user.findMany({
@@ -64,11 +72,19 @@ export async function isValidCalURL(url: string) {
// Check if any team has the matching eventTypeSlug
const matchingTeam = potentialTeams.find((team) => team.eventTypes.length > 0);
if (!matchingUser && !matchingTeam) return error;
if (!matchingUser && !matchingTeam)
return {
isValid: false,
error,
};
const userOrTeam = matchingUser || matchingTeam;
if (!userOrTeam) return error;
if (!userOrTeam)
return {
isValid: false,
error,
};
// Retrieve the correct user or team
const userOrTeamId = userOrTeam.id;
@@ -81,7 +97,13 @@ export async function isValidCalURL(url: string) {
},
});
if (!eventType) return error;
if (!eventType)
return {
isValid: false,
error,
};
return true;
return {
isValid: true,
};
}