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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user