feat: implement tiered Intercom chat system replacing Plain (#23285)
* feat: implement tiered Intercom chat system replacing Plain - Add TieredIntercomChat component with customer tier detection - Add IntercomContactForm for free users using Intercom API - Add /api/intercom-conversation endpoint for free user support - Update UserDropdown to use tiered chat logic - Replace Plain chat with Intercom in app providers - Remove all Plain chat components and related files - Use useHasPaidPlan hook for customer tier detection Paying customers see Intercom widget, free users see contact form that creates conversations via Intercom API. Co-Authored-By: peer@cal.com <peer@cal.com> * fix: add missing environment variables to turbo.json globalEnv - Add NEXT_PUBLIC_WEBAPP_URL, NEXT_PUBLIC_WEBSITE_URL, NEXT_PUBLIC_STRIPE_PUBLIC_KEY - Add NEXT_PUBLIC_IS_E2E, NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY - Add NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD, NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID - Resolves turbo/no-undeclared-env-vars ESLint errors blocking commits Co-Authored-By: peer@cal.com <peer@cal.com> * fix: resolve linting errors in platform examples base package - Fix prettier/prettier formatting (quotes and comma) - Add underscore prefix to unused variables - Resolves CI failure in @calcom/base#lint check Co-Authored-By: peer@cal.com <peer@cal.com> * remove plain usage * placement fixes * fix: pop closing immediately issue * stuff * proper error and additional user info * add key to .env.example and remove unused plain route * fix conversation route * refactor intercom dynamic provider * code rabbit fixes * feat: introduce tiered support feature flag * fix: type check * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
peer@cal.com
coderabbitai[bot]
Devin AI
Amit Sharma
sean-brydon
parent
ca7c432728
commit
20b7ac8c0d
+3
-8
@@ -103,19 +103,14 @@ NEXT_PUBLIC_INTERCOM_APP_ID=
|
||||
# Secret to enable Intercom Identity Verification
|
||||
INTERCOM_SECRET=
|
||||
|
||||
# Intercom api token for free users
|
||||
INTERCOM_API_TOKEN=
|
||||
|
||||
# Posthog Config
|
||||
NEXT_PUBLIC_POSTHOG_KEY=
|
||||
|
||||
NEXT_PUBLIC_POSTHOG_HOST=
|
||||
|
||||
# plain.com config
|
||||
|
||||
PLAIN_API_KEY=
|
||||
PLAIN_API_URL=https://api.plain.com/v1
|
||||
PLAIN_HMAC_SECRET_KEY=
|
||||
NEXT_PUBLIC_PLAIN_CHAT_ID=
|
||||
PLAIN_CHAT_HMAC_SECRET_KEY=
|
||||
NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS=
|
||||
|
||||
|
||||
# Dub Config
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { createHmac } from "crypto";
|
||||
import { headers } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { CardComponent } from "@lib/plain/card-components";
|
||||
|
||||
const customerCardDisplay = (
|
||||
name: string,
|
||||
email: string,
|
||||
id: string,
|
||||
username: string,
|
||||
timeZone: string,
|
||||
emailVerified: Date | null,
|
||||
twoFactorEnabled: boolean | null,
|
||||
identityProvider: string | null,
|
||||
lastActiveAt: Date | null,
|
||||
teamName: string | null,
|
||||
teamSlug: string | null,
|
||||
isOrganization: boolean | null,
|
||||
stripeCustomerId: string | null
|
||||
): Card => {
|
||||
return {
|
||||
key: "customer-card",
|
||||
timeToLiveSeconds: null,
|
||||
components: [
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
text: "Basic Information",
|
||||
textColor: "NORMAL",
|
||||
textSize: "L",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Name",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: name || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Email",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: email || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Username",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: username || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "User ID",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: id || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentDivider: {
|
||||
dividerSpacingSize: "M",
|
||||
},
|
||||
},
|
||||
// Account Settings Section Title
|
||||
{
|
||||
componentText: {
|
||||
text: "Account Settings",
|
||||
textColor: "NORMAL",
|
||||
textSize: "L",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Time Zone",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: timeZone || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Identity Provider",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: identityProvider || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Last Active At",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: lastActiveAt ? dayjs(lastActiveAt).fromNow() : "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentDivider: {
|
||||
dividerSpacingSize: "M",
|
||||
},
|
||||
},
|
||||
// Team Information Section Title
|
||||
{
|
||||
componentText: {
|
||||
text: "Team Information",
|
||||
textColor: "NORMAL",
|
||||
textSize: "L",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Team Name",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: teamName || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Team Slug",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: teamSlug || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Is Organization",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentBadge: {
|
||||
badgeLabel: isOrganization ? "Yes" : "No",
|
||||
badgeColor: isOrganization ? "GREEN" : "RED",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentDivider: {
|
||||
dividerSpacingSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
text: "Security & Billing",
|
||||
textColor: "NORMAL",
|
||||
textSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Stripe Customer ID",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: stripeCustomerId || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Email Verified?",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentBadge: {
|
||||
badgeLabel: emailVerified === null ? "No" : emailVerified ? "Yes" : "No",
|
||||
badgeColor: emailVerified === null ? "RED" : emailVerified ? "GREEN" : "RED",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Two Factor Enabled?",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentBadge: {
|
||||
badgeLabel: twoFactorEnabled === null ? "No" : twoFactorEnabled ? "Yes" : "No",
|
||||
badgeColor: twoFactorEnabled === null ? "RED" : twoFactorEnabled ? "GREEN" : "RED",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const Card = z.object({
|
||||
key: z.string(),
|
||||
timeToLiveSeconds: z.number().int().min(0).nullish().default(null),
|
||||
components: z.array(CardComponent).nullable(),
|
||||
});
|
||||
|
||||
type Card = z.infer<typeof Card>;
|
||||
|
||||
const cardExamples: ((
|
||||
name: string,
|
||||
email: string,
|
||||
id: string,
|
||||
username: string,
|
||||
timeZone: string,
|
||||
emailVerified: Date | null,
|
||||
twoFactorEnabled: boolean | null,
|
||||
identityProvider: string | null,
|
||||
lastActiveAt: Date | null,
|
||||
teamName: string | null,
|
||||
teamSlug: string | null,
|
||||
isOrganization: boolean | null,
|
||||
stripeCustomerId: string | null
|
||||
) => Card)[] = [customerCardDisplay];
|
||||
|
||||
const inputSchema = z.object({
|
||||
customer: z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
username: z.string().optional(),
|
||||
timeZone: timeZoneSchema.optional(),
|
||||
emailVerified: z.boolean().optional(),
|
||||
identityProvider: z.string().optional(),
|
||||
twoFactorEnabled: z.boolean().optional(),
|
||||
lastActiveAt: z.string().optional(),
|
||||
teamName: z.string().optional(),
|
||||
teamSlug: z.string().optional(),
|
||||
isOrganization: z.boolean().optional(),
|
||||
stripeCustomerId: z.string().optional(),
|
||||
}),
|
||||
cardKeys: z.array(z.string()),
|
||||
});
|
||||
|
||||
async function handler(request: NextRequest) {
|
||||
const headersList = await headers();
|
||||
const requestBody = await request.json();
|
||||
|
||||
// HMAC verification
|
||||
const incomingSignature = headersList.get("plain-request-signature");
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const expectedSignature = createHmac("sha-256", process.env.PLAIN_HMAC_SECRET_KEY!)
|
||||
.update(JSON.stringify(requestBody))
|
||||
.digest("hex");
|
||||
if (incomingSignature !== expectedSignature) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const { cardKeys, customer } = inputSchema.parse(requestBody);
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const user = await userRepo.findByEmail({ email: customer.email });
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({
|
||||
cards: [
|
||||
{
|
||||
key: "customer-card",
|
||||
timeToLiveSeconds: null,
|
||||
components: [
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
textSize: "L",
|
||||
textColor: "MUTED",
|
||||
text: "User does not exist!",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch team details including userId and team name
|
||||
const teamMemberships = await userRepo.findTeamsByUserId({ userId: user.id });
|
||||
const firstTeam = teamMemberships.teams[0] ?? null;
|
||||
|
||||
// Parse user metadata
|
||||
const parsedMetadata = userMetadata.parse(user.metadata);
|
||||
|
||||
const cards = await Promise.all(
|
||||
cardExamples.map(async (cardFn) => {
|
||||
return cardFn(
|
||||
user.name || "Unknown",
|
||||
user.email,
|
||||
user.id.toString(),
|
||||
user.username || "Unknown",
|
||||
user.timeZone,
|
||||
user.emailVerified,
|
||||
user.twoFactorEnabled,
|
||||
user.identityProvider,
|
||||
user.lastActiveAt,
|
||||
firstTeam?.name || "Unknown",
|
||||
firstTeam?.slug || "Unknown",
|
||||
firstTeam?.isOrganization || false,
|
||||
(user.metadata as { stripeCustomerId?: string })?.stripeCustomerId || "Unknown"
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const filteredCards = cards.filter((card) => {
|
||||
return cardKeys.length === 0 || cardKeys.includes(card.key);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
cards: filteredCards,
|
||||
user: {
|
||||
...user,
|
||||
metadata: parsedMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
@@ -1,88 +0,0 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { createHmac } from "crypto";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const responseSchema = z.object({
|
||||
hash: z.string(),
|
||||
email: z.string().email(),
|
||||
shortName: z.string(),
|
||||
appId: z.string(),
|
||||
fullName: z.string(),
|
||||
chatAvatarUrl: z.string(),
|
||||
userTier: z.enum(["free", "teams", "enterprise"]),
|
||||
});
|
||||
|
||||
async function handler() {
|
||||
// Early return if Plain Chat is not enabled
|
||||
if (!IS_PLAIN_CHAT_ENABLED) {
|
||||
return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 });
|
||||
}
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized - No session email found" }, { status: 401 });
|
||||
}
|
||||
|
||||
const secret = process.env.PLAIN_CHAT_HMAC_SECRET_KEY;
|
||||
if (!secret) {
|
||||
return NextResponse.json({ error: "Missing Plain Chat secret" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get user's team membership info
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
include: {
|
||||
teams: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
isOrganization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check if user is part of a team and determine tier
|
||||
let userTier = "free";
|
||||
|
||||
if (user?.teams.length) {
|
||||
const teamMemberships = user.teams;
|
||||
|
||||
const isEnterprise = teamMemberships.some((membership) => membership.team.isOrganization === true);
|
||||
const isTeams = user?.teams.length > 0;
|
||||
|
||||
userTier = isEnterprise ? "enterprise" : isTeams ? "teams" : "free";
|
||||
}
|
||||
|
||||
const hmac = createHmac("sha256", secret);
|
||||
hmac.update(session.user.email.toLowerCase().trim());
|
||||
const hash = hmac.digest("hex");
|
||||
|
||||
const shortName =
|
||||
(session.user.name?.split(" ")[0] || session.user.email).charAt(0).toUpperCase() +
|
||||
(session.user.name?.split(" ")[0] || session.user.email).slice(1) || "User";
|
||||
|
||||
const response = responseSchema.parse({
|
||||
hash,
|
||||
email: session.user.email || "user@example.com",
|
||||
shortName,
|
||||
appId: process.env.NEXT_PUBLIC_PLAIN_CHAT_ID,
|
||||
fullName: session.user.name || "User",
|
||||
chatAvatarUrl: session.user.avatarUrl || "",
|
||||
userTier,
|
||||
});
|
||||
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
@@ -1,358 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
vi.mock("@calcom/features/auth/lib/getServerSession", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/constants", async () => {
|
||||
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
|
||||
return {
|
||||
...actual,
|
||||
IS_PRODUCTION: true,
|
||||
IS_PLAIN_CHAT_ENABLED: true,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@lib/buildLegacyCtx", () => ({
|
||||
buildLegacyRequest: vi.fn(() => ({ headers: {}, cookies: {} })),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(() => new Map()),
|
||||
cookies: vi.fn(() => ({ getAll: () => [] })),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json: vi.fn((data, options) => ({
|
||||
json: () => Promise.resolve(data),
|
||||
status: options?.status || 200,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@lib/plain/plain", () => {
|
||||
const mockGetCustomerByEmail = vi.fn();
|
||||
const mockCreateThread = vi.fn();
|
||||
const mockUpsertPlainCustomer = vi.fn();
|
||||
|
||||
return {
|
||||
plain: {
|
||||
getCustomerByEmail: mockGetCustomerByEmail,
|
||||
createThread: mockCreateThread,
|
||||
},
|
||||
upsertPlainCustomer: mockUpsertPlainCustomer,
|
||||
};
|
||||
});
|
||||
|
||||
const mockGetServerSession = vi.mocked(getServerSession);
|
||||
|
||||
describe("/api/support", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.PLAIN_API_KEY = "test-api-key";
|
||||
});
|
||||
|
||||
it("should return 404 when Plain Chat is disabled", async () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("should return 401 when user is not authenticated", async () => {
|
||||
mockGetServerSession.mockResolvedValue(null);
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should return 500 for invalid form data", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: { id: 123, email: "test@example.com" },
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("should return 500 when Plain API key is not configured", async () => {
|
||||
delete process.env.PLAIN_API_KEY;
|
||||
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: { id: 123, email: "test@example.com" },
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("should successfully create customer and thread", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: { id: 123, email: "test@example.com" },
|
||||
});
|
||||
|
||||
const { plain } = vi.mocked(await import("@lib/plain/plain"));
|
||||
vi.mocked(plain.getCustomerByEmail).mockResolvedValue({
|
||||
data: {
|
||||
id: "customer-123",
|
||||
__typename: "Customer",
|
||||
fullName: "Test User",
|
||||
shortName: "Test",
|
||||
externalId: "123",
|
||||
email: {
|
||||
email: "test@example.com",
|
||||
isVerified: true,
|
||||
verifiedAt: {
|
||||
__typename: "DateTime",
|
||||
iso8601: "2025-01-01T00:00:00Z",
|
||||
unixTimestamp: "1735689600",
|
||||
},
|
||||
},
|
||||
company: null,
|
||||
createdBy: { __typename: "SystemActor" as const, systemId: "system" },
|
||||
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
markedAsSpamAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(plain.createThread).mockResolvedValue({
|
||||
data: {
|
||||
id: "thread-123",
|
||||
__typename: "Thread",
|
||||
externalId: null,
|
||||
title: "Test message",
|
||||
description: null,
|
||||
status: "Todo" as any,
|
||||
statusChangedAt: {
|
||||
__typename: "DateTime",
|
||||
iso8601: "2025-01-01T00:00:00Z",
|
||||
unixTimestamp: "1735689600",
|
||||
},
|
||||
statusDetail: null,
|
||||
customer: { id: "customer-123" },
|
||||
priority: 0,
|
||||
previewText: "Test message",
|
||||
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
} as any,
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toBeDefined();
|
||||
|
||||
expect(vi.mocked(plain.getCustomerByEmail)).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
expect(vi.mocked(plain.createThread)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle form submission with file attachments", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: { id: 123, email: "test@example.com" },
|
||||
});
|
||||
|
||||
const { plain, upsertPlainCustomer } = vi.mocked(await import("@lib/plain/plain"));
|
||||
vi.mocked(plain.getCustomerByEmail).mockResolvedValue({
|
||||
data: null,
|
||||
});
|
||||
|
||||
vi.mocked(upsertPlainCustomer).mockResolvedValue({
|
||||
data: {
|
||||
result: "Created" as any,
|
||||
customer: {
|
||||
id: "customer-456",
|
||||
__typename: "Customer",
|
||||
fullName: "Test User",
|
||||
shortName: "Test",
|
||||
externalId: "123",
|
||||
email: {
|
||||
email: "test@example.com",
|
||||
isVerified: true,
|
||||
verifiedAt: {
|
||||
__typename: "DateTime",
|
||||
iso8601: "2025-01-01T00:00:00Z",
|
||||
unixTimestamp: "1735689600",
|
||||
},
|
||||
},
|
||||
company: null,
|
||||
createdBy: { __typename: "SystemActor" as const, systemId: "system" },
|
||||
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
markedAsSpamAt: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(plain.createThread).mockResolvedValue({
|
||||
data: {
|
||||
id: "thread-456",
|
||||
__typename: "Thread",
|
||||
externalId: null,
|
||||
title: "Test message",
|
||||
description: null,
|
||||
status: "Todo" as any,
|
||||
statusChangedAt: {
|
||||
__typename: "DateTime",
|
||||
iso8601: "2025-01-01T00:00:00Z",
|
||||
unixTimestamp: "1735689600",
|
||||
},
|
||||
statusDetail: null,
|
||||
customer: { id: "customer-456" },
|
||||
priority: 0,
|
||||
previewText: "Test message",
|
||||
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
} as any,
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: ["attachment-123"],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toBeDefined();
|
||||
|
||||
expect(vi.mocked(plain.getCustomerByEmail)).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
expect(vi.mocked(upsertPlainCustomer)).toHaveBeenCalled();
|
||||
expect(vi.mocked(plain.createThread)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle Plain customer creation error", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: { id: 123, email: "test@example.com" },
|
||||
});
|
||||
|
||||
const { plain, upsertPlainCustomer } = vi.mocked(await import("@lib/plain/plain"));
|
||||
vi.mocked(plain.getCustomerByEmail).mockResolvedValue({
|
||||
data: null,
|
||||
});
|
||||
|
||||
vi.mocked(upsertPlainCustomer).mockResolvedValue({
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: "Customer creation failed",
|
||||
},
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("should handle Plain thread creation error", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: { id: 123, email: "test@example.com" },
|
||||
});
|
||||
|
||||
const { plain } = vi.mocked(await import("@lib/plain/plain"));
|
||||
vi.mocked(plain.getCustomerByEmail).mockResolvedValue({
|
||||
data: {
|
||||
id: "customer-123",
|
||||
__typename: "Customer",
|
||||
fullName: "Test User",
|
||||
shortName: "Test",
|
||||
externalId: "123",
|
||||
email: {
|
||||
email: "test@example.com",
|
||||
isVerified: true,
|
||||
verifiedAt: {
|
||||
__typename: "DateTime",
|
||||
iso8601: "2025-01-01T00:00:00Z",
|
||||
unixTimestamp: "1735689600",
|
||||
},
|
||||
},
|
||||
company: null,
|
||||
createdBy: { __typename: "SystemActor" as const, systemId: "system" },
|
||||
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
markedAsSpamAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(plain.createThread).mockResolvedValue({
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: "Thread creation failed",
|
||||
},
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { Contact } from "@calcom/features/ee/support/lib/intercom/intercom";
|
||||
import { intercom } from "@calcom/features/ee/support/lib/intercom/intercom";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: [`/api/support/conversation`] });
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const headersList = await headers();
|
||||
const cookiesList = await cookies();
|
||||
const legacyReq = buildLegacyRequest(headersList, cookiesList);
|
||||
|
||||
const session = await getServerSession({ req: legacyReq });
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized - No session found" }, { status: 401 });
|
||||
}
|
||||
|
||||
const intercomApiKey = process.env.INTERCOM_API_TOKEN;
|
||||
if (!intercomApiKey) {
|
||||
return NextResponse.json({ error: "Intercom API key not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
let contact: Contact;
|
||||
|
||||
const existingContact = await intercom.getContactByEmail(session.user.email);
|
||||
|
||||
if (existingContact.error) {
|
||||
return NextResponse.json(
|
||||
{ error: existingContact?.error ?? "Error fetching intercom contact for user" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = session;
|
||||
|
||||
if (!existingContact.data) {
|
||||
const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id });
|
||||
const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce(
|
||||
(sum, team) => sum + team.team.eventTypes.length,
|
||||
0
|
||||
);
|
||||
|
||||
const newContact = await intercom.createContact({
|
||||
email: session.user.email,
|
||||
external_id: session.user.id.toString(),
|
||||
name: session.user.name ?? session.user.email,
|
||||
type: "contact",
|
||||
custom_attributes: {
|
||||
user_name: user?.username,
|
||||
link: `${WEBSITE_URL}/${encodeURIComponent(user?.username ?? "")}`,
|
||||
admin_link: `${WEBAPP_URL}/settings/admin/users/${user?.id}/edit`,
|
||||
impersonate_user: `${WEBAPP_URL}/settings/admin/impersonation?username=${encodeURIComponent(
|
||||
user?.email ?? user?.username ?? ""
|
||||
)}`,
|
||||
locale: user?.locale,
|
||||
completed_onboarding: user?.completedOnboarding,
|
||||
is_logged_in: !!user,
|
||||
has_orgs_plan: !!user?.org,
|
||||
organization: user?.org?.slug,
|
||||
sum_of_bookings: additionalUserInfo?._count?.bookings,
|
||||
sum_of_calendars: additionalUserInfo?._count?.userLevelSelectedCalendars,
|
||||
sum_of_teams: additionalUserInfo?._count?.teams,
|
||||
sum_of_event_types: additionalUserInfo?._count?.eventTypes,
|
||||
sum_of_team_event_types: sumOfTeamEventTypes,
|
||||
},
|
||||
});
|
||||
|
||||
if (newContact.error || !newContact.data) {
|
||||
return NextResponse.json(
|
||||
{ error: newContact.error ?? "Error creating contact from email" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
contact = newContact.data;
|
||||
} else {
|
||||
contact = existingContact.data;
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
return NextResponse.json({ error: "No contactId found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { message } = await req.json();
|
||||
|
||||
if (!message.trim()) {
|
||||
return NextResponse.json({ error: "Cannot start a conversation without message" }, { status: 400 });
|
||||
}
|
||||
|
||||
const conversation = await intercom.createConversation({
|
||||
body: message,
|
||||
from: {
|
||||
id: contact.id,
|
||||
type: "user",
|
||||
},
|
||||
});
|
||||
|
||||
if (conversation.error) {
|
||||
return NextResponse.json(
|
||||
{ error: conversation.error ?? "Error creating conversation" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Created conversation in Intercom" }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
log.error(`Error creating Intercom conversation:`, safeStringify(err));
|
||||
return NextResponse.json({ error: "Unexpected error occurred" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { plain, upsertPlainCustomer } from "@lib/plain/plain";
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
message: z.string().min(1, "Message is required"),
|
||||
attachmentIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const log = logger.getSubLogger({ prefix: [`/api/support`] });
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!IS_PLAIN_CHAT_ENABLED) {
|
||||
return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 });
|
||||
}
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized - No session found" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { message, attachmentIds } = contactFormSchema.parse(body);
|
||||
|
||||
const plainApiKey = process.env.PLAIN_API_KEY;
|
||||
if (!plainApiKey) {
|
||||
return NextResponse.json({ error: "Plain API key not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
let plainCustomerId: string | null = null;
|
||||
|
||||
const plainCustomer = await plain.getCustomerByEmail({ email: session.user.email });
|
||||
|
||||
if (plainCustomer.data) {
|
||||
plainCustomerId = plainCustomer.data.id;
|
||||
} else {
|
||||
const { data, error } = await upsertPlainCustomer({
|
||||
email: session.user.email,
|
||||
id: session.user.id,
|
||||
name: session.user.name,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
log.error(`Error submitting plain contact form: `, safeStringify(error));
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: error.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (data) {
|
||||
plainCustomerId = data.customer.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!plainCustomerId) {
|
||||
return NextResponse.json({ message: "Plain customer not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { data, error } = await plain.createThread({
|
||||
customerIdentifier: {
|
||||
customerId: plainCustomerId,
|
||||
},
|
||||
components: [
|
||||
{
|
||||
componentText: {
|
||||
text: message,
|
||||
},
|
||||
},
|
||||
],
|
||||
attachmentIds,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
log.error("Error creating plain contact form thread: ", safeStringify(error));
|
||||
return NextResponse.json({ message: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (err) {
|
||||
log.error(`Error submitting plain contact form: `, safeStringify(err));
|
||||
return NextResponse.json({ message: "Unexpected error occured" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { AttachmentType } from "@team-plain/typescript-sdk";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { plain, upsertPlainCustomer } from "@lib/plain/plain";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["/api/support/upload"] });
|
||||
|
||||
/**
|
||||
* Returns a signed url from plain to upload the attachment
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!IS_PLAIN_CHAT_ENABLED) {
|
||||
return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 });
|
||||
}
|
||||
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const name = searchParams.get("name");
|
||||
const size = searchParams.get("size");
|
||||
|
||||
if (!name || !size) {
|
||||
return NextResponse.json({ error: "Missing required parameters: name and size" }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized - No session found" }, { status: 401 });
|
||||
}
|
||||
|
||||
let plainCustomerId: string | null = null;
|
||||
|
||||
const plainCustomer = await plain.getCustomerByEmail({
|
||||
email: session.user.email,
|
||||
});
|
||||
|
||||
if (plainCustomer.data) {
|
||||
plainCustomerId = plainCustomer.data.id;
|
||||
} else {
|
||||
const { data, error } = await upsertPlainCustomer({
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
id: session.user.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
log.error("Error getting customer info: ", safeStringify(error));
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (data) {
|
||||
plainCustomerId = data.customer.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!plainCustomerId) {
|
||||
return NextResponse.json({ error: "Plain customer not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { data, error } = await plain.createAttachmentUploadUrl({
|
||||
customerId: plainCustomerId,
|
||||
fileName: name,
|
||||
fileSizeBytes: parseInt(size),
|
||||
attachmentType: AttachmentType.CustomTimelineEntry,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
log.error(`Error getting signed url for attachment upload: `, safeStringify(error));
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
import { TrpcProvider } from "app/_trpc/trpc-provider";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import CacheProvider from "react-inlinesvg/provider";
|
||||
|
||||
import { WebPushProvider } from "@calcom/features/notifications/WebPushContext";
|
||||
import { NotificationSoundHandler } from "@calcom/web/components/notification-sound-handler";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import PlainChat from "@lib/plain/dynamicProvider";
|
||||
|
||||
type ProvidersProps = {
|
||||
isEmbed: boolean;
|
||||
@@ -18,13 +16,10 @@ type ProvidersProps = {
|
||||
};
|
||||
export function Providers({ isEmbed, children, nonce }: ProvidersProps) {
|
||||
const isBookingPage = useIsBookingPage();
|
||||
const pathname = usePathname();
|
||||
const isOnboardingPage = pathname?.startsWith("/getting-started");
|
||||
|
||||
return (
|
||||
<SessionProvider>
|
||||
<TrpcProvider>
|
||||
{!isBookingPage && !isOnboardingPage ? <PlainChat nonce={nonce} /> : null}
|
||||
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
|
||||
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
|
||||
<CacheProvider>
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { FileUploader, type FileData } from "@calcom/ui/components/file-uploader";
|
||||
import { Label, TextArea } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
const PlainContactForm = ({ open, setIsOpen }: { open: boolean, setIsOpen: Dispatch<SetStateAction<boolean>> }) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
|
||||
const [data, setData] = useState<{
|
||||
message: string;
|
||||
attachmentIds: string[];
|
||||
}>({
|
||||
message: "",
|
||||
attachmentIds: [],
|
||||
});
|
||||
|
||||
const [uploads, setUploads] = useState<
|
||||
{
|
||||
attachmentId?: string;
|
||||
uploading: boolean;
|
||||
file: File;
|
||||
id: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const handleUpload = async (allFiles: FileData[], newFiles: FileData[], removedFiles: FileData[]) => {
|
||||
if (newFiles.length > 0) {
|
||||
const newFile = newFiles[0];
|
||||
setUploads((prev) => [...prev, { file: newFile.file, uploading: true, id: newFile.id }]);
|
||||
setIsUploadingImage(true);
|
||||
|
||||
const { file } = newFile;
|
||||
|
||||
const res = await fetch(`/api/support/upload?name=${file.name}&size=${file.size}`);
|
||||
if (!res.ok) {
|
||||
showToast("Error uploading attachment", "error");
|
||||
setIsUploadingImage(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
uploadFormUrl,
|
||||
uploadFormData,
|
||||
attachment: { id: attachmentId },
|
||||
} = await res.json();
|
||||
setIsUploadingImage(false);
|
||||
|
||||
const formData = new FormData();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
uploadFormData.forEach(({ key, value }: any) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
formData.append("file", file);
|
||||
|
||||
const uploadRes = await fetch(uploadFormUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
showToast(`Failed while uploading file: ${uploadRes.text}`, "error");
|
||||
setIsUploadingImage(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploads((prev) =>
|
||||
prev.map((upload) => (upload.file === file ? { ...upload, uploading: false, attachmentId } : upload))
|
||||
);
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
attachmentIds: [...prev.attachmentIds, attachmentId],
|
||||
}));
|
||||
setIsUploadingImage(false);
|
||||
showToast("File uploaded successfully", "success");
|
||||
} else if (removedFiles.length > 0) {
|
||||
const removedFile = removedFiles[0];
|
||||
const file = uploads.find((upload) => upload.id === removedFile.id);
|
||||
if (!file) {
|
||||
console.warn("File not found in uploads: ", removedFile.id);
|
||||
return;
|
||||
}
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
attachmentIds: prev.attachmentIds.filter((id) => id !== file.attachmentId),
|
||||
}));
|
||||
setUploads((prev) => prev.filter((upload) => upload.id !== removedFile.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await fetch("/api/support", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
showToast(errorData.message ?? "Failed to submit contact form", "error");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitted(true);
|
||||
setIsSubmitting(false);
|
||||
setData({
|
||||
message: "",
|
||||
attachmentIds: [],
|
||||
});
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : "An error occurred", "error");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setIsSubmitted(false);
|
||||
setData({
|
||||
message: "",
|
||||
attachmentIds: [],
|
||||
});
|
||||
setUploads([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-[1rem] right-[1rem] z-50">
|
||||
<Popover open={open} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild className="enabled:hover:bg-subtle bg-subtle shadow-none">
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-subtle text-emphasis flex h-12 w-12 items-center justify-center rounded-full border-none">
|
||||
<Icon name="message-circle" className="h-6 w-6" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
style={{ maxWidth: "450px", maxHeight: "650px" }}
|
||||
className="!bg-muted no-scrollbar mb-2 mr-8 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
|
||||
<div className="flex w-full justify-between">
|
||||
<p className="mb-5 text-lg font-semibold">Contact support</p>
|
||||
<Button
|
||||
color="minimal"
|
||||
variant="button"
|
||||
StartIcon="x"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isSubmitted ? (
|
||||
<div className="py-4 text-center">
|
||||
<h4 className="mb-2 text-lg font-medium ">Message Sent</h4>
|
||||
<p className="text-subtle mb-4 text-sm">
|
||||
Thank you for contacting us. We'll get back to you as soon as possible.
|
||||
</p>
|
||||
<Button color="primary" className="my-2" onClick={resetForm} variant="button" size="base">
|
||||
Send Another Message
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="message">Describe the issue</Label>
|
||||
<TextArea
|
||||
id="message"
|
||||
name="message"
|
||||
value={data.message}
|
||||
onChange={(e) => setData((prev) => ({ ...prev, message: e.target.value }))}
|
||||
placeholder="Please describe the issue you're facing, e.g. 'Busy slots are marked available', ..., etc."
|
||||
required
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Attachments (optional)</Label>
|
||||
<FileUploader
|
||||
id="contact-attachments"
|
||||
buttonMsg="Add Files"
|
||||
onFilesChange={handleUpload}
|
||||
acceptedFileTypes={["images", "videos"]}
|
||||
multiple={false}
|
||||
showFilesList
|
||||
maxFiles={5}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
disabled={isSubmitting || isUploadingImage}
|
||||
testId="contact-form-file-upload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full items-center">
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="button"
|
||||
type="submit"
|
||||
disabled={isSubmitting || isUploadingImage}
|
||||
className="w-full">
|
||||
<div className="flex w-full justify-center">
|
||||
{isSubmitting ? (
|
||||
<div className="flex items-center">
|
||||
<Icon name="loader" className="mr-2 h-4 w-4 animate-spin rounded-full" />
|
||||
Sending
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="send" className="mr-2 h-4 w-4" />
|
||||
Send Message
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlainContactForm;
|
||||
@@ -1,181 +0,0 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import PlainContactForm from "../PlainContactForm";
|
||||
|
||||
vi.mock("next-auth/react", () => ({
|
||||
useSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/ui/components/toast", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseSession = vi.mocked(useSession);
|
||||
const mockShowToast = vi.mocked(showToast);
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
describe("PlainContactForm", () => {
|
||||
let mockSetIsOpen: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSetIsOpen = vi.fn();
|
||||
|
||||
mockUseSession.mockReturnValue({
|
||||
data: {
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: {
|
||||
id: 123,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
},
|
||||
status: "authenticated",
|
||||
update: vi.fn(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("renders the contact button when closed", () => {
|
||||
render(<PlainContactForm open={false} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens when the launcher button is clicked (calls setIsOpen(true))", () => {
|
||||
render(<PlainContactForm open={false} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockSetIsOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("shows the form when open=true", () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
expect(screen.getByText("Contact support")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Describe the issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("Attachments (optional)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty form initially", () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
|
||||
expect(messageInput.value).toBe("");
|
||||
});
|
||||
|
||||
it("closes when X button is clicked (calls setIsOpen(false))", () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const closeButton = buttons.find((button) => button.querySelector('svg use[href="#x"]'));
|
||||
expect(closeButton).toBeDefined();
|
||||
|
||||
fireEvent.click(closeButton!);
|
||||
|
||||
expect(mockSetIsOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("handles form submission successfully", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
});
|
||||
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Describe the issue"), {
|
||||
target: { value: "Test message" },
|
||||
});
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /send message/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Message Sent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/support", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state during submission", async () => {
|
||||
// never resolves → stays loading
|
||||
mockFetch.mockImplementation(() => new Promise((_resolve) => {}));
|
||||
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Describe the issue"), {
|
||||
target: { value: "Test message" },
|
||||
});
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /send message/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(screen.getByText("Sending")).toBeInTheDocument();
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("resets form after successful submission", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
});
|
||||
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Describe the issue"), {
|
||||
target: { value: "Test message" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /send message/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Message Sent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const sendAnotherButton = screen.getByRole("button", { name: /send another message/i });
|
||||
fireEvent.click(sendAnotherButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
|
||||
expect(messageInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles missing user session", () => {
|
||||
mockUseSession.mockReturnValue({
|
||||
data: null,
|
||||
status: "unauthenticated",
|
||||
update: vi.fn(),
|
||||
} as any);
|
||||
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
|
||||
expect(messageInput.value).toBe("");
|
||||
});
|
||||
|
||||
it("requires message field", async () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
await expect(screen.getByLabelText("Describe the issue")).toHaveAttribute("required");
|
||||
});
|
||||
});
|
||||
@@ -1,327 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
|
||||
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import PlainContactForm from "./PlainContactForm";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Plain?: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
init: (config: any) => void;
|
||||
open: () => void;
|
||||
};
|
||||
plainScriptLoaded?: () => void;
|
||||
__PLAIN_CONFIG__?: PlainChatConfig;
|
||||
}
|
||||
}
|
||||
|
||||
interface PlainChatConfig {
|
||||
appId: string;
|
||||
customerDetails: {
|
||||
email: string;
|
||||
emailHash: string;
|
||||
fullName: string;
|
||||
shortName: string;
|
||||
chatAvatarUrl: string;
|
||||
};
|
||||
links: Array<{
|
||||
icon: string;
|
||||
text: string;
|
||||
url: string;
|
||||
}>;
|
||||
chatButtons: Array<{
|
||||
icon: string;
|
||||
text: string;
|
||||
threadDetails?: {
|
||||
labelTypeIds: Array<string>;
|
||||
tierIdentifier: {
|
||||
externalId: string;
|
||||
};
|
||||
};
|
||||
form?: {
|
||||
fields: Array<{
|
||||
type: string;
|
||||
placeholder: string;
|
||||
options: Array<{
|
||||
icon: string;
|
||||
text: string;
|
||||
threadDetails: {
|
||||
labelTypeIds: Array<string>;
|
||||
tierIdentifier: {
|
||||
externalId: string;
|
||||
};
|
||||
};
|
||||
onClick?: () => void;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
entryPoint: {
|
||||
type: string;
|
||||
};
|
||||
hideBranding: boolean;
|
||||
theme: string;
|
||||
style: {
|
||||
brandColor: string;
|
||||
launcherBackgroundColor: string;
|
||||
launcherIconColor: string;
|
||||
};
|
||||
position: {
|
||||
zIndex: string;
|
||||
bottom: string;
|
||||
right: string;
|
||||
};
|
||||
}
|
||||
|
||||
const PlainChat = IS_PLAIN_CHAT_ENABLED
|
||||
? ({ nonce }: { nonce: string | undefined }) => {
|
||||
const [config, setConfig] = useState<PlainChatConfig | null>(null);
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const shouldOpenPlain = pathname === "/event-types" && searchParams?.has("openPlain");
|
||||
const userEmail = session?.user?.email;
|
||||
const isPaidUser = session?.user.belongsToActiveTeam || !!session?.user.org;
|
||||
|
||||
const isAppDomain = useMemo(() => {
|
||||
const restrictedPathsSet = new Set(
|
||||
(process.env.NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS?.split(",") || []).map((path) => path.trim())
|
||||
);
|
||||
|
||||
const pathSegments = pathname?.split("/").filter(Boolean) || [];
|
||||
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.location.origin === process.env.NEXT_PUBLIC_WEBAPP_URL &&
|
||||
!pathSegments.some((segment) => restrictedPathsSet.has(segment))
|
||||
);
|
||||
}, [pathname]);
|
||||
|
||||
const checkScreenSize = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const isSmall = window.innerWidth < 768;
|
||||
setIsSmallScreen(isSmall);
|
||||
|
||||
if (isSmall && window.Plain) {
|
||||
const plainElement = document.querySelector("#plain-container");
|
||||
plainElement?.remove();
|
||||
window.Plain = undefined;
|
||||
} else if (!isSmall && window.Plain === undefined) {
|
||||
window.plainScriptLoaded?.();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const initConfig = useCallback(async () => {
|
||||
if (!userEmail) return;
|
||||
|
||||
// Check if Plain Chat is enabled
|
||||
if (!IS_PLAIN_CHAT_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/plain-hash", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to generate hash: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.hash || !data.email || !data.appId) {
|
||||
throw new Error("Missing required fields in API response");
|
||||
}
|
||||
|
||||
const plainChatConfig: PlainChatConfig = {
|
||||
appId: data.appId,
|
||||
customerDetails: {
|
||||
email: data.email,
|
||||
shortName: data.shortName,
|
||||
fullName: data.fullName,
|
||||
emailHash: data.hash,
|
||||
chatAvatarUrl: data.chatAvatarUrl,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
icon: "book",
|
||||
text: "Documentation",
|
||||
url: "https://cal.com/docs",
|
||||
},
|
||||
{
|
||||
icon: "chat",
|
||||
text: "Ask the community",
|
||||
url: "https://github.com/calcom/cal.com/discussions",
|
||||
},
|
||||
],
|
||||
chatButtons: [
|
||||
{
|
||||
icon: "bulb",
|
||||
text: "Send feedback",
|
||||
threadDetails: {
|
||||
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
|
||||
tierIdentifier: { externalId: data.userTier },
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "error",
|
||||
text: "Report an issue",
|
||||
form: {
|
||||
fields: [
|
||||
{
|
||||
type: "dropdown",
|
||||
placeholder: "Select severity...",
|
||||
options: [
|
||||
{
|
||||
icon: "support",
|
||||
text: "I'm unable to use the app",
|
||||
threadDetails: {
|
||||
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
|
||||
tierIdentifier: { externalId: data.userTier },
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "error",
|
||||
text: "Major functionality degraded",
|
||||
threadDetails: {
|
||||
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
|
||||
tierIdentifier: { externalId: data.userTier },
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "bug",
|
||||
text: "Minor annoyance",
|
||||
threadDetails: {
|
||||
labelTypeIds: ["lt_01JFJWPC8ADW0PK28JHMJR6NSS"],
|
||||
tierIdentifier: { externalId: data.userTier },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
entryPoint: {
|
||||
type: "chat",
|
||||
},
|
||||
hideBranding: true,
|
||||
theme: "auto",
|
||||
style: {
|
||||
brandColor: "#FFFFFF",
|
||||
launcherBackgroundColor: "#262626",
|
||||
launcherIconColor: "#FFFFFF",
|
||||
},
|
||||
position: {
|
||||
zIndex: "1",
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
},
|
||||
};
|
||||
|
||||
plainChatConfig.chatButtons.push({
|
||||
icon: "chat",
|
||||
text: "Ask a question",
|
||||
threadDetails: {
|
||||
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
|
||||
tierIdentifier: { externalId: data.userTier },
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") {
|
||||
window.__PLAIN_CONFIG__ = plainChatConfig;
|
||||
}
|
||||
|
||||
setConfig(plainChatConfig);
|
||||
|
||||
if (shouldOpenPlain) {
|
||||
const timer = setTimeout(() => {
|
||||
if (window.Plain) {
|
||||
window.Plain.open();
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Plain Chat:", error);
|
||||
}
|
||||
}, [userEmail, shouldOpenPlain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAppDomain) return;
|
||||
|
||||
// Skip initialization if Plain Chat is not enabled
|
||||
if (!IS_PLAIN_CHAT_ENABLED) return;
|
||||
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
if (isPaidUser) {
|
||||
initConfig();
|
||||
} else if (typeof window !== "undefined" && !window.Plain) {
|
||||
window.Plain = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
init: () => {},
|
||||
open: () => {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, [isAppDomain, checkScreenSize, initConfig, userEmail, isPaidUser]);
|
||||
|
||||
const plainChatScript = `
|
||||
window.plainScriptLoaded = function() {
|
||||
if (window.Plain && ${Boolean(config)}) {
|
||||
try {
|
||||
Plain.init(${config ? JSON.stringify(config) : null});
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Plain:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
if (!isAppDomain || isSmallScreen || typeof window === "undefined") return null;
|
||||
|
||||
if (!isPaidUser) {
|
||||
return <PlainContactForm open={isOpen} setIsOpen={setIsOpen} />;
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="plain-chat"
|
||||
src="https://chat.cdn-plain.com/index.js"
|
||||
strategy="afterInteractive"
|
||||
onLoad={() => window.plainScriptLoaded?.()}
|
||||
/>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="plain-chat-init"
|
||||
strategy="afterInteractive"
|
||||
dangerouslySetInnerHTML={{ __html: plainChatScript }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
: () => null;
|
||||
|
||||
export default PlainChat;
|
||||
@@ -10,6 +10,7 @@ import { usePathname, useSearchParams } from "next/navigation";
|
||||
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
|
||||
@@ -119,7 +120,13 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
isThemeSupported={isThemeSupported}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<FeatureFlagsProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
{props.isBookingPage || isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
) : (
|
||||
<DynamicIntercomProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
</DynamicIntercomProvider>
|
||||
)}
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useEffect } from "react";
|
||||
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
|
||||
@@ -271,6 +272,10 @@ function OrgBrandProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const AppProviders = (props: AppPropsWithChildren) => {
|
||||
const isBookingPage = useIsBookingPage();
|
||||
const _isBookingPage =
|
||||
(typeof props.Component.isBookingPage === "function"
|
||||
? props.Component.isBookingPage({ router: props.router })
|
||||
: props.Component.isBookingPage) || isBookingPage;
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
@@ -282,7 +287,13 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
isBookingPage={props.Component.isBookingPage || isBookingPage}
|
||||
router={props.router}>
|
||||
<FeatureFlagsProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
{_isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
) : (
|
||||
<DynamicIntercomProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
</DynamicIntercomProvider>
|
||||
)}
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ComponentTextSize = z.enum(["S", "M", "L"]);
|
||||
export type ComponentTextSize = z.infer<typeof ComponentTextSize>;
|
||||
|
||||
export const ComponentTextColor = z.enum(["NORMAL", "MUTED", "SUCCESS", "WARNING", "ERROR"]);
|
||||
export type ComponentTextColor = z.infer<typeof ComponentTextColor>;
|
||||
|
||||
export const ComponentSpacerSize = z.enum(["XS", "S", "M", "L", "XL"]);
|
||||
export type ComponentSpacerSize = z.infer<typeof ComponentSpacerSize>;
|
||||
|
||||
export const ComponentDividerSpacingSize = z.enum(["XS", "S", "M", "L", "XL"]);
|
||||
export type ComponentDividerSpacingSize = z.infer<typeof ComponentDividerSpacingSize>;
|
||||
|
||||
export const ComponentBadgeColor = z.enum(["GREY", "GREEN", "YELLOW", "RED", "BLUE"]);
|
||||
export type ComponentBadgeColor = z.infer<typeof ComponentBadgeColor>;
|
||||
|
||||
const Text = z.object({
|
||||
textSize: ComponentTextSize.nullish(),
|
||||
textColor: ComponentTextColor.nullish(),
|
||||
text: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
const Divider = z.object({
|
||||
dividerSpacingSize: ComponentDividerSpacingSize.nullish(),
|
||||
});
|
||||
|
||||
const LinkButton = z.object({
|
||||
linkButtonUrl: z.string().url(),
|
||||
linkButtonLabel: z.string().max(500),
|
||||
});
|
||||
|
||||
const Spacer = z.object({
|
||||
spacerSize: ComponentSpacerSize,
|
||||
});
|
||||
|
||||
const Badge = z.object({
|
||||
badgeLabel: z.string().max(500),
|
||||
badgeColor: ComponentBadgeColor.nullish(),
|
||||
});
|
||||
|
||||
const CopyButton = z.object({
|
||||
copyButtonValue: z.string().max(1000),
|
||||
copyButtonTooltipLabel: z.string().max(500).nullish(),
|
||||
});
|
||||
|
||||
const RowContentUnionInput = z.object({
|
||||
componentText: Text.optional(),
|
||||
componentDivider: Divider.optional(),
|
||||
componentLinkButton: LinkButton.optional(),
|
||||
componentSpacer: Spacer.optional(),
|
||||
componentBadge: Badge.optional(),
|
||||
componentCopyButton: CopyButton.optional(),
|
||||
});
|
||||
|
||||
const Row = z.object({
|
||||
rowMainContent: z.array(RowContentUnionInput),
|
||||
rowAsideContent: z.array(RowContentUnionInput),
|
||||
});
|
||||
|
||||
const ContainerContentUnionInput = z.object({
|
||||
componentText: Text.optional(),
|
||||
componentDivider: Divider.optional(),
|
||||
componentLinkButton: LinkButton.optional(),
|
||||
componentSpacer: Spacer.optional(),
|
||||
componentBadge: Badge.optional(),
|
||||
componentCopyButton: CopyButton.optional(),
|
||||
componentRow: Row.optional(),
|
||||
});
|
||||
|
||||
const Container = z.object({
|
||||
containerContent: z.array(ContainerContentUnionInput).min(1),
|
||||
});
|
||||
|
||||
export const CardComponent = z.object({
|
||||
componentText: Text.optional(),
|
||||
componentDivider: Divider.optional(),
|
||||
componentLinkButton: LinkButton.optional(),
|
||||
componentSpacer: Spacer.optional(),
|
||||
componentBadge: Badge.optional(),
|
||||
componentCopyButton: CopyButton.optional(),
|
||||
componentRow: Row.optional(),
|
||||
componentContainer: Container.optional(),
|
||||
});
|
||||
|
||||
export type CardComponent = z.infer<typeof CardComponent>;
|
||||
@@ -1,7 +0,0 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { Fragment } from "react";
|
||||
|
||||
// Preload caused by dynamic import doesn't seem to add nonce and thus preload fails but the functionality still works - https://github.com/vercel/next.js/issues/81260
|
||||
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID
|
||||
? dynamic(() => import("../../components/plain/plainChat"))
|
||||
: Fragment;
|
||||
@@ -1,32 +0,0 @@
|
||||
import { PlainClient } from "@team-plain/typescript-sdk";
|
||||
|
||||
export const plain = new PlainClient({
|
||||
apiKey: process.env.PLAIN_API_KEY || "",
|
||||
});
|
||||
|
||||
type PlainUser = {
|
||||
name?: string | null;
|
||||
email: string;
|
||||
id: number;
|
||||
};
|
||||
|
||||
export const upsertPlainCustomer = async (user: PlainUser) => {
|
||||
const fullName = user.name ?? user.email;
|
||||
const shortName = user.name ?? user.email?.split("@")[0];
|
||||
const email = user.email;
|
||||
|
||||
return await plain.upsertCustomer({
|
||||
identifier: {
|
||||
emailAddress: email,
|
||||
},
|
||||
onCreate: {
|
||||
fullName,
|
||||
shortName,
|
||||
email: {
|
||||
email,
|
||||
isVerified: true,
|
||||
},
|
||||
},
|
||||
onUpdate: {},
|
||||
});
|
||||
};
|
||||
@@ -19,10 +19,9 @@ interface CtaRowProps {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Plain?: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
init: (config: any) => void;
|
||||
Support?: {
|
||||
open: () => void;
|
||||
shouldShowTriggerButton: (showTrigger: boolean) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -71,8 +70,8 @@ const BillingView = () => {
|
||||
: `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
|
||||
|
||||
const onContactSupportClick = async () => {
|
||||
if (window.Plain) {
|
||||
window.Plain.open();
|
||||
if (window.Support) {
|
||||
window.Support.open();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -20,10 +20,9 @@ import { CtaRow } from "~/settings/billing/billing-view";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Plain?: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
init: (config: any) => void;
|
||||
Support?: {
|
||||
open: () => void;
|
||||
shouldShowTriggerButton: (showTrigger: boolean) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -35,8 +34,8 @@ export default function PlatformBillingUpgrade() {
|
||||
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
|
||||
|
||||
const onContactSupportClick = async () => {
|
||||
if (window.Plain) {
|
||||
window.Plain.open();
|
||||
if (window.Support) {
|
||||
window.Support.open();
|
||||
}
|
||||
};
|
||||
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, userBillingData, isPaidUser, userOrgId } =
|
||||
|
||||
@@ -642,7 +642,7 @@ const nextConfig = (phase) => {
|
||||
value: nextJsOrgRewriteConfig.orgHostPath,
|
||||
},
|
||||
],
|
||||
destination: "/event-types?openPlain=true",
|
||||
destination: "/event-types?openSupport=true",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -74,7 +74,6 @@
|
||||
"@stripe/react-stripe-js": "^1.10.0",
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
"@team-plain/typescript-sdk": "^5.9.0",
|
||||
"@todesktop/client-core": "^1.20.0",
|
||||
"@types/turndown": "^5.0.1",
|
||||
"@upstash/redis": "^1.35.2",
|
||||
|
||||
@@ -3612,7 +3612,12 @@
|
||||
"you_have_one_team": "You have one team",
|
||||
"consider_consolidating_one_team_org": "Consider setting up an organization to unify billing, admin tools, and analytics across your team.",
|
||||
"consider_consolidating_multi_team_org": "Consider setting up an organization to unify billing, admin tools, and analytics across your teams.",
|
||||
"message_sent": "Message Sent",
|
||||
"support_message_sent_description": "Thank you for contacting us. We'll get back to you as soon as possible.",
|
||||
"search_variables": "Search variables",
|
||||
"support_message_placeholder": "Please describe the issue you're facing, e.g. 'Busy slots are marked available', ..., etc.",
|
||||
"send_another_message": "Send another message",
|
||||
"sending": "Sending",
|
||||
"no_variables_found": "No variables found",
|
||||
"api_key_name_too_long": "Name must not exceed {{max}} characters",
|
||||
"auto_charge_for_last_minute_cancellation": "Auto charge no-show fee for last minute cancellations",
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import type { Params } from "app/_types";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import { POST } from "../../app/api/plain-hash/route";
|
||||
|
||||
// Mock NextResponse
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json: (data: any, init?: ResponseInit) => ({
|
||||
json: async () => data,
|
||||
status: init?.status || 200,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock session
|
||||
vi.mock("@calcom/features/auth/lib/getServerSession", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: () => new Headers(),
|
||||
cookies: () => ({ get: () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("@lib/buildLegacyCtx", () => ({
|
||||
buildLegacyRequest: vi.fn().mockReturnValue({
|
||||
headers: new Headers(),
|
||||
cookies: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("app/api/defaultResponderForAppDir", () => ({
|
||||
defaultResponderForAppDir: (handler: () => Promise<Response>) => handler,
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: prismaMock,
|
||||
}));
|
||||
|
||||
describe("Plain Integration API", () => {
|
||||
beforeAll(() => {
|
||||
process.env.PLAIN_CHAT_HMAC_SECRET_KEY = "test-secret";
|
||||
process.env.NEXT_PUBLIC_PLAIN_CHAT_ID = "test-chat-id";
|
||||
// Force the feature to be enabled for tests
|
||||
vi.mock("@calcom/lib/constants", () => ({
|
||||
IS_PLAIN_CHAT_ENABLED: true,
|
||||
}));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
// Skip all tests if the feature is disabled
|
||||
if (!IS_PLAIN_CHAT_ENABLED) {
|
||||
it("skips tests when Plain Chat is disabled", () => {
|
||||
console.log("Plain Chat is disabled, skipping tests");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tiers = [
|
||||
{ tier: "free", teams: [] },
|
||||
{ tier: "teams", teams: [{ team: { id: 2 } }] },
|
||||
{ tier: "enterprise", teams: [{ team: { id: 3, isOrganization: true } }] },
|
||||
];
|
||||
|
||||
it.each(tiers)("should return $tier tier", async ({ tier, teams }) => {
|
||||
const userData = { id: 1, email: `${tier}@example.com`, name: `${tier} User` };
|
||||
(getServerSession as any).mockResolvedValue({ user: userData });
|
||||
prismaMock.user.findUnique.mockResolvedValue({ ...userData, teams });
|
||||
const params = Promise.resolve<Params>({});
|
||||
|
||||
const data = await (await POST({} as NextRequest, { params })).json();
|
||||
expect(data).toMatchObject({ userTier: tier, email: userData.email, fullName: userData.name });
|
||||
});
|
||||
|
||||
it("should return 401 when no session exists", async () => {
|
||||
(getServerSession as any).mockResolvedValue(null);
|
||||
const params = Promise.resolve<Params>({});
|
||||
|
||||
expect((await POST({} as NextRequest, { params })).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextArea } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Support?: {
|
||||
open: () => void;
|
||||
shouldShowTriggerButton: (showTrigger: boolean) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const IntercomContactForm = () => {
|
||||
const { t } = useLocale();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showTrigger, setShowTrigger] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && !window.Support) {
|
||||
window.Support = {
|
||||
open: () => {
|
||||
setIsOpen(true);
|
||||
},
|
||||
shouldShowTriggerButton: (showTrigger) => {
|
||||
setShowTrigger(showTrigger);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof window !== "undefined" && window.Support) {
|
||||
delete window.Support;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/support/conversation", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Failed to send message");
|
||||
}
|
||||
|
||||
showToast(t("success"), "success");
|
||||
setIsSubmitting(false);
|
||||
setIsSubmitted(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t("something_went_wrong");
|
||||
showToast(message, "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const contentRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node) return;
|
||||
|
||||
const isSmall = window.innerWidth < 768;
|
||||
|
||||
if (isSmall) {
|
||||
setShowTrigger(false);
|
||||
}
|
||||
|
||||
const wrapper = node.parentElement;
|
||||
if (wrapper?.hasAttribute("data-radix-popper-content-wrapper")) {
|
||||
if (!showTrigger) {
|
||||
wrapper.style.left = "auto";
|
||||
wrapper.style.right = "0px";
|
||||
wrapper.style.bottom = "5rem";
|
||||
wrapper.style.top = "auto";
|
||||
} else {
|
||||
wrapper.style.left = "0px";
|
||||
wrapper.style.right = "auto";
|
||||
wrapper.style.bottom = "auto";
|
||||
wrapper.style.top = "0px";
|
||||
}
|
||||
}
|
||||
},
|
||||
[showTrigger]
|
||||
);
|
||||
|
||||
const resetForm = () => {
|
||||
setIsSubmitted(false);
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-[1rem] right-[1rem] z-50 [&_[data-radix-popper-content-wrapper]]:!bottom-[1rem]">
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
className={classNames("enabled:hover:bg-subtle bg-subtle shadow-none", !showTrigger && "!hidden")}>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-subtle text-emphasis flex h-12 w-12 items-center justify-center rounded-full border-none">
|
||||
<Icon name="message-circle" className="h-6 w-6" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
ref={contentRef}
|
||||
style={{ maxWidth: "450px", maxHeight: "650px" }}
|
||||
className={classNames(
|
||||
"!bg-muted no-scrollbar mb-2 w-screen overflow-hidden overflow-y-scroll px-6 py-4 md:w-[450px]",
|
||||
showTrigger ? "mr-8" : "mr-0"
|
||||
)}>
|
||||
<div className="flex w-full justify-between">
|
||||
<p className="mb-5 text-lg font-semibold">{t("contact_support")}</p>
|
||||
<Button
|
||||
color="minimal"
|
||||
variant="button"
|
||||
StartIcon="x"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isSubmitted ? (
|
||||
<div className="py-4 text-center">
|
||||
<h4 className="mb-2 text-lg font-medium ">{t("message_sent")}</h4>
|
||||
<p className="text-subtle mb-4 text-sm">{t("support_message_sent_description")}</p>
|
||||
<Button color="primary" className="my-2" onClick={resetForm} variant="button" size="base">
|
||||
{t("send_another_message")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="message">{t("describe_the_issue")}</Label>
|
||||
<TextArea
|
||||
id="message"
|
||||
name="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={t("support_message_placeholder")}
|
||||
required
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full items-center">
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="button"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full">
|
||||
<div className="flex w-full justify-center">
|
||||
{isSubmitting ? (
|
||||
<div className="flex items-center">
|
||||
<Icon name="loader" className="mr-2 h-4 w-4 animate-spin rounded-full" />
|
||||
{t("sending")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="send" className="mr-2 h-4 w-4" />
|
||||
{t("send_message")}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
export interface Contact extends CreateContact {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface CreateContact {
|
||||
type: "contact" | "lead";
|
||||
external_id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
custom_attributes?: object;
|
||||
}
|
||||
|
||||
interface IntercomResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CreateConversation {
|
||||
from: {
|
||||
type: string;
|
||||
id: string;
|
||||
};
|
||||
body: string;
|
||||
}
|
||||
|
||||
const INTERCOM_API_ENDPOINT = "https://api.intercom.io";
|
||||
const INTERCOM_API_TOKEN = process.env.INTERCOM_API_TOKEN;
|
||||
export const intercom = {
|
||||
async getContactByEmail(email: string): Promise<IntercomResponse<Contact | null>> {
|
||||
try {
|
||||
const res = await fetch(`${INTERCOM_API_ENDPOINT}/contacts/search`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
query: {
|
||||
operator: "AND",
|
||||
value: [
|
||||
{
|
||||
field: "email",
|
||||
operator: "=",
|
||||
value: email,
|
||||
},
|
||||
],
|
||||
},
|
||||
pagination: {
|
||||
per_page: 1,
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${INTERCOM_API_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json();
|
||||
return {
|
||||
error: body?.errors[0]?.message,
|
||||
};
|
||||
}
|
||||
|
||||
const { data } = (await res.json()) as { type: string; data: Contact[] };
|
||||
|
||||
if (data.length === 0) {
|
||||
return {
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: data[0],
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Unexpected error while fetching contact data from email: ${safeStringify(err)}`);
|
||||
return {
|
||||
error: "Unexpected error while fetching contact data from email",
|
||||
};
|
||||
}
|
||||
},
|
||||
async createContact(data: CreateContact): Promise<IntercomResponse<Contact>> {
|
||||
try {
|
||||
const res = await fetch(`${INTERCOM_API_ENDPOINT}/contacts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${INTERCOM_API_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.error(`Error creating contact from email ${data.email}: `, safeStringify(data));
|
||||
return {
|
||||
error: data?.errors[0]?.message ?? "Error creating contact from email",
|
||||
};
|
||||
}
|
||||
|
||||
const contact = (await res.json()) as Contact;
|
||||
return {
|
||||
data: contact,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Unexpected error while creating contact from email: ${safeStringify(err)}`);
|
||||
return {
|
||||
error: "Unexpected error while creating contact from email",
|
||||
};
|
||||
}
|
||||
},
|
||||
async createConversation({ from, body }: CreateConversation): Promise<IntercomResponse<boolean>> {
|
||||
try {
|
||||
const response = await fetch(`https://api.intercom.io/conversations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${INTERCOM_API_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
body,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error("Intercom API error:", safeStringify(errorData));
|
||||
return {
|
||||
error: `Intercom Err: ${errorData?.errors?.[0].message ?? "Error creating conversation"}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: true,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Unexpected error while creating conversation from email: ${safeStringify(err)}`);
|
||||
return {
|
||||
error: "Unexpected error while creating contact from email",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,61 @@
|
||||
import { FC } from "react";
|
||||
"use client";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, type FC } from "react";
|
||||
import { IntercomProvider } from "react-use-intercom";
|
||||
|
||||
const Provider: FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<IntercomProvider appId={process.env.NEXT_PUBLIC_INTERCOM_APP_ID || ""}>{children}</IntercomProvider>
|
||||
);
|
||||
import { useBootIntercom } from "@calcom/ee/support/lib/intercom/useIntercom";
|
||||
import { IntercomContactForm } from "@calcom/features/ee/support/components/IntercomContactForm";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import useHasPaidPlan from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Support?: {
|
||||
open: () => void;
|
||||
shouldShowTriggerButton: (showTrigger: boolean) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function IntercomBootstrap() {
|
||||
useBootIntercom();
|
||||
return null;
|
||||
}
|
||||
|
||||
const Provider: FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { hasPaidPlan } = useHasPaidPlan();
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const flagMap = useFlagMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (window.Support) {
|
||||
window.Support.shouldShowTriggerButton(!isMobile);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const pathname = usePathname();
|
||||
const isOnboardingPage = pathname?.startsWith("/getting-started");
|
||||
|
||||
if (isOnboardingPage) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (flagMap["tiered-support-chat"] && !hasPaidPlan) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<IntercomContactForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntercomProvider appId={process.env.NEXT_PUBLIC_INTERCOM_APP_ID || ""}>
|
||||
<IntercomBootstrap />
|
||||
{children}
|
||||
</IntercomProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Provider;
|
||||
|
||||
@@ -2,7 +2,7 @@ import dynamic from "next/dynamic";
|
||||
import { Fragment } from "react";
|
||||
|
||||
const DynamicIntercomProvider = process.env.NEXT_PUBLIC_INTERCOM_APP_ID
|
||||
? dynamic(() => import("./provider"))
|
||||
? dynamic(() => import("./provider"), { ssr: false })
|
||||
: Fragment;
|
||||
|
||||
export default DynamicIntercomProvider;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { noop } from "lodash";
|
||||
import { useEffect } from "react";
|
||||
import type { IntercomBootProps, IntercomProps } from "react-use-intercom";
|
||||
import { useIntercom as useIntercomLib } from "react-use-intercom";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useHasTeamPlan, useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
@@ -17,14 +20,18 @@ const useIntercomHook = isInterComEnabled
|
||||
? useIntercomLib
|
||||
: () => {
|
||||
return {
|
||||
boot: noop,
|
||||
// eslint-disable-next-line
|
||||
boot: (_props: IntercomBootProps) => {},
|
||||
show: noop,
|
||||
shutdown: noop,
|
||||
// eslint-disable-next-line
|
||||
update: (_props: Partial<IntercomProps>) => {},
|
||||
};
|
||||
};
|
||||
|
||||
export const useIntercom = () => {
|
||||
const hookData = useIntercomHook();
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const { data } = trpc.viewer.me.get.useQuery();
|
||||
const { data: statsData } = trpc.viewer.me.myStats.useQuery(undefined, {
|
||||
trpc: {
|
||||
@@ -39,8 +46,7 @@ export const useIntercom = () => {
|
||||
const boot = async () => {
|
||||
if (!data || !statsData) return;
|
||||
let userHash;
|
||||
|
||||
const req = await fetch(`/api/intercom-hash`);
|
||||
const req = await fetch(`/api/support/hash`);
|
||||
const res = await req.json();
|
||||
if (res?.hash) {
|
||||
userHash = res.hash;
|
||||
@@ -52,6 +58,7 @@ export const useIntercom = () => {
|
||||
...(data && data?.id && { userId: data.id }),
|
||||
createdAt: String(dayjs(data?.createdDate).unix()),
|
||||
...(userHash && { userHash }),
|
||||
hideDefaultLauncher: isMobile,
|
||||
customAttributes: {
|
||||
//keys should be snake cased
|
||||
user_name: data?.username,
|
||||
@@ -83,7 +90,7 @@ export const useIntercom = () => {
|
||||
const open = async () => {
|
||||
let userHash;
|
||||
|
||||
const req = await fetch(`/api/intercom-hash`);
|
||||
const req = await fetch(`/api/support/hash`);
|
||||
const res = await req.json();
|
||||
if (res?.hash) {
|
||||
userHash = res.hash;
|
||||
@@ -95,6 +102,7 @@ export const useIntercom = () => {
|
||||
...(data && data?.id && { userId: data.id }),
|
||||
createdAt: String(dayjs(data?.createdDate).unix()),
|
||||
...(userHash && { userHash }),
|
||||
hideDefaultLauncher: isMobile,
|
||||
customAttributes: {
|
||||
//keys should be snake cased
|
||||
user_name: data?.username,
|
||||
@@ -126,9 +134,22 @@ export const useIntercom = () => {
|
||||
return { ...hookData, open, boot };
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Support?: {
|
||||
open: () => void;
|
||||
shouldShowTriggerButton: (showTrigger: boolean) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const useBootIntercom = () => {
|
||||
const { boot } = useIntercom();
|
||||
const { hasPaidPlan } = useHasPaidPlan();
|
||||
const flagMap = useFlagMap();
|
||||
const { boot, open, update } = useIntercom();
|
||||
|
||||
const { data: user } = trpc.viewer.me.get.useQuery();
|
||||
const isTieredSupportEnabled = flagMap["tiered-support-chat"];
|
||||
const { data: statsData } = trpc.viewer.me.myStats.useQuery(undefined, {
|
||||
trpc: {
|
||||
context: {
|
||||
@@ -139,12 +160,28 @@ export const useBootIntercom = () => {
|
||||
useEffect(() => {
|
||||
// not using useMediaQuery as it toggles between true and false
|
||||
const showIntercom = localStorage.getItem("showIntercom");
|
||||
if (!isInterComEnabled || showIntercom === "false" || window.innerWidth <= 768 || !user || !statsData)
|
||||
if (
|
||||
!isInterComEnabled ||
|
||||
showIntercom === "false" ||
|
||||
!user ||
|
||||
!statsData ||
|
||||
(!hasPaidPlan && isTieredSupportEnabled)
|
||||
)
|
||||
return;
|
||||
|
||||
boot();
|
||||
if (typeof window !== "undefined" && !window.Support) {
|
||||
window.Support = {
|
||||
open,
|
||||
shouldShowTriggerButton: (showTrigger: boolean) => {
|
||||
update({
|
||||
hideDefaultLauncher: !showTrigger,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user, statsData]);
|
||||
}, [user, statsData, hasPaidPlan, isTieredSupportEnabled]);
|
||||
};
|
||||
|
||||
export default useIntercom;
|
||||
|
||||
@@ -25,6 +25,7 @@ export type AppFlags = {
|
||||
"restriction-schedule": boolean;
|
||||
"team-booking-page-cache": boolean;
|
||||
"cal-ai-voice-agents": boolean;
|
||||
"tiered-support-chat": boolean;
|
||||
};
|
||||
|
||||
export type TeamFeatures = Record<keyof AppFlags, boolean>;
|
||||
|
||||
@@ -24,6 +24,7 @@ const initialData: AppFlags = {
|
||||
"restriction-schedule": false,
|
||||
"team-booking-page-cache": false,
|
||||
"cal-ai-voice-agents": false,
|
||||
"tiered-support-chat": false,
|
||||
};
|
||||
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { signOut } from "next-auth/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ROADMAP, DESKTOP_APP_LINK } from "@calcom/lib/constants";
|
||||
@@ -24,10 +25,9 @@ import FreshChatProvider from "../../ee/support/lib/freshchat/FreshChatProvider"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Plain?: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
init: (config: any) => void;
|
||||
Support?: {
|
||||
open: () => void;
|
||||
shouldShowTriggerButton: (showTrigger: boolean) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export function UserDropdown({ small }: UserDropdownProps) {
|
||||
const { data: user, isPending } = useMeQuery();
|
||||
const pathname = usePathname();
|
||||
const isPlatformPages = pathname?.startsWith("/settings/platform");
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
@@ -55,22 +56,25 @@ export function UserDropdown({ small }: UserDropdownProps) {
|
||||
});
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [openSupportAfterClose, setOpenSupportAfterClose] = useState(false);
|
||||
|
||||
const handleHelpClick = (e?: React.MouseEvent) => {
|
||||
const handleHelpClick = (e?: MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
|
||||
setOpenSupportAfterClose(true);
|
||||
setMenuOpen(false);
|
||||
|
||||
// Defer to next frame to avoid the originating click closing the dialog
|
||||
requestAnimationFrame(() => {
|
||||
// double RAF is extra-safe if your dropdown unmounts with a transition
|
||||
requestAnimationFrame(() => {
|
||||
if (window.Plain) window.Plain.open();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen && openSupportAfterClose) {
|
||||
setTimeout(() => {
|
||||
window.Support?.open();
|
||||
}, 0);
|
||||
setOpenSupportAfterClose(false);
|
||||
}
|
||||
}, [menuOpen, openSupportAfterClose]);
|
||||
|
||||
// Prevent rendering dropdown if user isn't available.
|
||||
// We don't want to show nameless user.
|
||||
if (!user && !isPending) {
|
||||
|
||||
@@ -219,9 +219,6 @@ export const GOOGLE_CALENDAR_SCOPES = [
|
||||
export const DIRECTORY_IDS_TO_LOG = process.env.DIRECTORY_IDS_TO_LOG?.split(",") || [];
|
||||
export const SCANNING_WORKFLOW_STEPS = !!(!IS_SELF_HOSTED && process.env.IFFY_API_KEY);
|
||||
|
||||
export const IS_PLAIN_CHAT_ENABLED =
|
||||
!!process.env.NEXT_PUBLIC_PLAIN_CHAT_ID && process.env.NEXT_PUBLIC_PLAIN_CHAT_ID !== "";
|
||||
|
||||
export const IS_DUB_REFERRALS_ENABLED =
|
||||
!!process.env.NEXT_PUBLIC_DUB_PROGRAM_ID && process.env.NEXT_PUBLIC_DUB_PROGRAM_ID !== "";
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export default function Bookings(props: { calUsername: string; calEmail: string }) {
|
||||
const [eventTypeId, setEventTypeId] = useState<number | null>(null);
|
||||
const [isTeamEvent, setIsTeamEvent] = useState<boolean>(false);
|
||||
const [_isTeamEvent, setIsTeamEvent] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
const eventTypeRef = useRef<EventSettingsFromRef>(null);
|
||||
|
||||
@@ -751,7 +751,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
|
||||
allowDelete={true}
|
||||
id={eventTypeId}
|
||||
tabs={["setup", "limits", "recurring", "advanced", "availability", "team", "payments"]}
|
||||
onSuccess={(eventType) => {
|
||||
onSuccess={(_eventType) => {
|
||||
setEventTypeId(null);
|
||||
refetch();
|
||||
refetchTeamEvents();
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
INSERT INTO
|
||||
"Feature" (slug, enabled, description, "type")
|
||||
VALUES
|
||||
(
|
||||
'tiered-support-chat',
|
||||
false,
|
||||
'Enable Tiered Support Chat - Allow users to access different levels of support chat based on their subscription tier.',
|
||||
'OPERATIONAL'
|
||||
) ON CONFLICT (slug) DO NOTHING;
|
||||
+4
-8
@@ -260,7 +260,9 @@
|
||||
"NEXT_PUBLIC_API_V2_URL",
|
||||
"NEXT_PUBLIC_WEBAPP_URL",
|
||||
"NEXT_PUBLIC_WEBSITE_URL",
|
||||
"BUILD_STANDALONE"
|
||||
"BUILD_STANDALONE",
|
||||
"INTERCOM_API_TOKEN",
|
||||
"NEXT_PUBLIC_INTERCOM_APP_ID"
|
||||
],
|
||||
"tasks": {
|
||||
"@calcom/prisma#build": {
|
||||
@@ -392,13 +394,7 @@
|
||||
"STRIPE_SUPPORT_TABLE",
|
||||
"MOTIFLAND_REST_ENDPOINT",
|
||||
"MOTIFLAND_DOCS_API_KEY",
|
||||
"MOTIFLAND_DOCS_PROJECT_ID",
|
||||
"PLAIN_HMAC_SECRET_KEY",
|
||||
"PLAIN_API_KEY",
|
||||
"PLAIN_API_URL",
|
||||
"NEXT_PUBLIC_PLAIN_CHAT_ID",
|
||||
"PLAIN_CHAT_HMAC_SECRET_KEY",
|
||||
"NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS"
|
||||
"MOTIFLAND_DOCS_PROJECT_ID"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
|
||||
@@ -4260,7 +4260,6 @@ __metadata:
|
||||
"@stripe/react-stripe-js": ^1.10.0
|
||||
"@stripe/stripe-js": ^1.35.0
|
||||
"@tanstack/react-query": ^5.17.15
|
||||
"@team-plain/typescript-sdk": ^5.9.0
|
||||
"@testing-library/react": ^13.3.0
|
||||
"@todesktop/client-core": ^1.20.0
|
||||
"@types/accept-language-parser": 1.5.2
|
||||
@@ -16880,17 +16879,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@team-plain/typescript-sdk@npm:^5.9.0":
|
||||
version: 5.9.0
|
||||
resolution: "@team-plain/typescript-sdk@npm:5.9.0"
|
||||
dependencies:
|
||||
"@graphql-typed-document-node/core": ^3.2.0
|
||||
ajv: ^8.12.0
|
||||
ajv-formats: ^2.1.1
|
||||
graphql: ^16.6.0
|
||||
lodash.get: ^4.4.2
|
||||
zod: 3.22.4
|
||||
checksum: a1eb9a7f65096d232337d081a1b56a51fc5a4cd39877debf29c0eed5ee9140aaf0f148f65681257bfe804076bff93a049d46b0cae940844c4e20e9528a6a8782
|
||||
"@tanstack/virtual-core@npm:3.13.12":
|
||||
version: 3.13.12
|
||||
resolution: "@tanstack/virtual-core@npm:3.13.12"
|
||||
checksum: bef2c3138543a2088fff7d4fa46624d07403b18d92ec5a7271e187457851535031a79bc78ac655355a221d5549ef3b5674a3b249d2531f4c17c93107498c8438
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user