diff --git a/apps/web/app/api/support/__tests__/route.test.ts b/apps/web/app/api/support/__tests__/route.test.ts
new file mode 100644
index 0000000000..21c6f6d9d6
--- /dev/null
+++ b/apps/web/app/api/support/__tests__/route.test.ts
@@ -0,0 +1,358 @@
+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);
+ });
+});
diff --git a/apps/web/app/api/support/route.ts b/apps/web/app/api/support/route.ts
new file mode 100644
index 0000000000..e5276c8113
--- /dev/null
+++ b/apps/web/app/api/support/route.ts
@@ -0,0 +1,95 @@
+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 });
+ }
+}
diff --git a/apps/web/app/api/support/upload/route.ts b/apps/web/app/api/support/upload/route.ts
new file mode 100644
index 0000000000..f473c70a25
--- /dev/null
+++ b/apps/web/app/api/support/upload/route.ts
@@ -0,0 +1,84 @@
+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);
+}
diff --git a/apps/web/components/plain/__tests__/PlainContactForm.test.tsx b/apps/web/components/plain/__tests__/PlainContactForm.test.tsx
new file mode 100644
index 0000000000..10fce46e85
--- /dev/null
+++ b/apps/web/components/plain/__tests__/PlainContactForm.test.tsx
@@ -0,0 +1,193 @@
+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 "../../../lib/plain/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;
+
+describe("PlainContactForm", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ 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(),
+ });
+ });
+
+ it("should render contact button when closed", () => {
+ render();
+
+ const button = screen.getByRole("button");
+ expect(button).toBeInTheDocument();
+ });
+
+ it("should open contact form when button is clicked", () => {
+ render();
+
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ expect(screen.getByText("Contact support")).toBeInTheDocument();
+ expect(screen.getByLabelText("Describe the issue")).toBeInTheDocument();
+ expect(screen.getByText("Attachments (optional)")).toBeInTheDocument();
+ });
+
+ it("should show empty form initially", () => {
+ render();
+
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
+ expect(messageInput.value).toBe("");
+ });
+
+ it("should close form when X button is clicked", () => {
+ render();
+
+ const openButton = screen.getByRole("button");
+ fireEvent.click(openButton);
+
+ const buttons = screen.getAllByRole("button");
+ const closeButton = buttons.find((button) => button.querySelector('svg use[href="#x"]'));
+ expect(closeButton).toBeDefined();
+
+ fireEvent.click(closeButton!);
+
+ expect(screen.queryByText("Contact support")).not.toBeInTheDocument();
+ });
+
+ it("should handle form submission successfully", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ success: true }),
+ });
+
+ render();
+
+ const openButton = screen.getByRole("button");
+ fireEvent.click(openButton);
+
+ 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("should show loading state during submission", async () => {
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ mockFetch.mockImplementation(() => new Promise((_resolve) => {}));
+
+ render();
+
+ const openButton = screen.getByRole("button");
+ fireEvent.click(openButton);
+
+ 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("should reset form after successful submission", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ success: true }),
+ });
+
+ render();
+
+ const openButton = screen.getByRole("button");
+ fireEvent.click(openButton);
+
+ 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();
+ });
+
+ 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("should handle missing user session", () => {
+ mockUseSession.mockReturnValue({
+ data: null,
+ status: "unauthenticated",
+ update: vi.fn(),
+ });
+
+ render();
+
+ const openButton = screen.getByRole("button");
+ fireEvent.click(openButton);
+
+ const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
+ expect(messageInput.value).toBe("");
+ });
+
+ it("should require message field", async () => {
+ render();
+
+ const openButton = screen.getByRole("button");
+ fireEvent.click(openButton);
+
+ await expect(screen.getByLabelText("Describe the issue")).toHaveAttribute("required");
+ });
+});
diff --git a/apps/web/lib/plain/PlainContactForm.tsx b/apps/web/lib/plain/PlainContactForm.tsx
new file mode 100644
index 0000000000..0bf5c1aa6d
--- /dev/null
+++ b/apps/web/lib/plain/PlainContactForm.tsx
@@ -0,0 +1,243 @@
+"use client";
+
+import { 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";
+
+interface ContactFormData {
+ name: string;
+ email: string;
+ message: string;
+ attachments?: FileData[];
+}
+
+const PlainContactForm = () => {
+ const [isOpen, setIsOpen] = useState(false);
+ 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();
+ 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 (
+
+
+
+
+
+
+
+
+
Contact support
+
+
+
+ {isSubmitted ? (
+
+
Message Sent
+
+ Thank you for contacting us. We'll get back to you as soon as possible.
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+};
+
+export default PlainContactForm;
diff --git a/apps/web/lib/plain/plain.ts b/apps/web/lib/plain/plain.ts
new file mode 100644
index 0000000000..cf4d7c1088
--- /dev/null
+++ b/apps/web/lib/plain/plain.ts
@@ -0,0 +1,32 @@
+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: {
+ externalId: `${user.id}`,
+ },
+ onCreate: {
+ fullName,
+ shortName,
+ email: {
+ email,
+ isVerified: true,
+ },
+ },
+ onUpdate: {},
+ });
+};
diff --git a/apps/web/lib/plain/plainChat.tsx b/apps/web/lib/plain/plainChat.tsx
index 4c6de541dc..191f0c9a21 100644
--- a/apps/web/lib/plain/plainChat.tsx
+++ b/apps/web/lib/plain/plainChat.tsx
@@ -7,6 +7,8 @@ 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?: {
@@ -231,16 +233,14 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
},
};
- if (isPaidUser) {
- plainChatConfig.chatButtons.push({
- icon: "chat",
- text: "Ask a question",
- threadDetails: {
- labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
- tierIdentifier: { externalId: data.userTier },
- },
- });
- }
+ 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;
@@ -286,7 +286,13 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
}
`;
- if (!isAppDomain || isSmallScreen || !config || typeof window === "undefined") return null;
+ if (!isAppDomain || isSmallScreen || typeof window === "undefined") return null;
+
+ if (!isPaidUser) {
+ return ;
+ }
+
+ if (!config) return null;
return (
<>
diff --git a/apps/web/package.json b/apps/web/package.json
index d0392e990c..ca2219090b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -75,6 +75,7 @@
"@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",
"@tremor/react": "^2.11.0",
"@types/turndown": "^5.0.1",
"@unkey/ratelimit": "^0.1.1",
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index bbe2ba6358..d49eb2c8d3 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -3376,6 +3376,15 @@
"license_key_required": "License key is required",
"invalid_license_key": "Invalid license key",
"license_validation_failed": "Failed to validate license key",
+ "attachments_optional": "Attachments Optional",
+ "send_message": "Send Message",
+ "describe_the_issue": "Describe the issue",
+ "file_size_limit_exceed": "File size exceeds limit",
+ "invalid_file_type": "Invalid file type",
+ "invalid_file_type_extension": "Invalid file type. Accepted types: {{acceptedTypes}}",
+ "max_files_exceeded": "Maximum number of files exceeded",
+ "files_uploaded_successfully": "Files uploaded successfully",
+ "file_upload_instructions": "Accepted types: {{acceptedTypes}}; Max file size: {{maxSize}}",
"license_key_saved": "License key saved successfully",
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
"failed_bookings_by_field": "Failed Bookings By Field",
diff --git a/packages/ui/components/file-uploader/FileUploader.tsx b/packages/ui/components/file-uploader/FileUploader.tsx
new file mode 100644
index 0000000000..d2b50c388c
--- /dev/null
+++ b/packages/ui/components/file-uploader/FileUploader.tsx
@@ -0,0 +1,257 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import { z } from "zod";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+
+import { Button } from "../button";
+import { Input, Label } from "../form";
+import { Icon } from "../icon";
+import { showToast } from "../toast";
+
+export interface FileData {
+ file: File;
+ dataUrl: string;
+ id: string;
+}
+
+interface FileUploaderProps {
+ id: string;
+ buttonMsg?: string;
+ onFilesChange: (allFiles: FileData[], newFiles: FileData[], removedFiles: FileData[]) => void;
+ acceptedFileTypes?: TAcceptedFileTypes[];
+ showFilesList?: boolean;
+ maxFiles?: number;
+ maxFileSize?: number;
+ disabled?: boolean;
+ testId?: string;
+ multiple?: boolean;
+}
+
+const zAcceptedFileTypes = z.enum(["any", "images", "videos", "csv", "documents"]);
+type TAcceptedFileTypes = z.infer;
+
+const documentTypes = [
+ "application/pdf", // .pdf
+ "text/plain", // .txt
+ "application/msword", // .doc
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
+ "application/vnd.ms-excel", // .xls
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
+ "text/csv", // .csv
+];
+
+const acceptFileTypes: Record = {
+ any: { types: [], extensions: [] },
+ images: {
+ types: ["image/png", "image/jpeg"],
+ extensions: [".png", ".jpg", ".jpeg"],
+ },
+ csv: {
+ types: ["text/csv"],
+ extensions: [".csv"],
+ },
+ documents: {
+ types: documentTypes,
+ extensions: [".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx", ".csv"],
+ },
+ videos: {
+ types: ["video/mp4", "video/webm", "video/ogg"],
+ extensions: [".mp4", ".webm", ".ogg"],
+ },
+};
+
+export const formatFileSize = (bytes: number): string => {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
+};
+
+export default function FileUploader({
+ id,
+ buttonMsg,
+ onFilesChange,
+ acceptedFileTypes = ["any"],
+ maxFiles,
+ maxFileSize = 10 * 1024 * 1024,
+ disabled = false,
+ multiple = true,
+ showFilesList = true,
+ testId,
+}: FileUploaderProps) {
+ const { t, isLocaleReady } = useLocale();
+ const [files, setFiles] = useState([]);
+
+ const generateFileId = () => `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+
+ const readFileAsDataURL = (file: File): Promise => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(file);
+ });
+ };
+
+ const defaultT = {
+ add_files: "Add Files",
+ file_size_limit_exceed: "File size exceeds limit",
+ invalid_file_type: "Invalid file type",
+ max_files_exceeded: "Maximum files exceeded",
+ files_uploaded_successfully: "Files uploaded successfully",
+ file_upload_instructions: "Upload images or videos",
+ };
+
+ const allowedTypes = acceptedFileTypes.flatMap((type) => acceptFileTypes[type].types);
+ const allowedExtensions = acceptedFileTypes.flatMap((type) => acceptFileTypes[type].extensions);
+
+ const validateFile = (file: File): string | null => {
+ if (file.size > maxFileSize) {
+ return isLocaleReady ? t("file_size_limit_exceed") : defaultT["file_size_limit_exceed"];
+ }
+
+ if (acceptedFileTypes.includes("any")) {
+ return null;
+ }
+
+ if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
+ const extensionsString = allowedExtensions.join(",");
+ return isLocaleReady
+ ? t("invalid_file_type_with_extensions", { extensions: extensionsString })
+ : `Invalid file type. Allowed: ${extensionsString}`;
+ }
+
+ return null;
+ };
+
+ const handleFileSelect = useCallback(
+ async (selectedFiles: FileList) => {
+ const newFiles: FileData[] = [];
+ const errors: string[] = [];
+
+ if (maxFiles && files.length + selectedFiles.length > maxFiles) {
+ const maxFileText = isLocaleReady ? t("max_files_exceeded") : `${defaultT["max_files_exceeded"]}`;
+ showToast(maxFileText, "error");
+ return;
+ }
+
+ for (let i = 0; i < selectedFiles.length; i++) {
+ const file = selectedFiles[i];
+ const validationError = validateFile(file);
+
+ if (validationError) {
+ errors.push(`${file.name}: ${validationError}`);
+ continue;
+ }
+
+ try {
+ const dataUrl = await readFileAsDataURL(file);
+ newFiles.push({
+ file,
+ dataUrl,
+ id: generateFileId(),
+ });
+ } catch (error) {
+ errors.push(`${file.name}: Failed to read file`);
+ }
+ }
+
+ if (errors.length > 0) {
+ showToast(errors.join(", "), "error");
+ }
+
+ if (newFiles.length > 0) {
+ const updatedFiles = [...files, ...newFiles];
+ setFiles(updatedFiles);
+ onFilesChange(updatedFiles, newFiles, []);
+ }
+ },
+ [files, maxFiles, maxFileSize, acceptedFileTypes, onFilesChange, t]
+ );
+
+ const handleFileRemove = useCallback(
+ (fileId: string) => {
+ const updatedFiles = files.filter((file) => file.id !== fileId);
+ setFiles(updatedFiles);
+ onFilesChange(updatedFiles, [], [files.find((file) => file.id === fileId)!]);
+ },
+ [files, onFilesChange]
+ );
+
+ const getFileIcon = (fileType: string) => {
+ if (fileType.startsWith("image/")) return "file";
+ if (fileType.startsWith("video/")) return "video";
+ return "file-text";
+ };
+
+ const extensionsString = allowedExtensions.length > 0 ? allowedExtensions.join(",") : "any";
+
+ const fileInstructionsText = isLocaleReady
+ ? t("file_upload_instructions", {
+ types: extensionsString,
+ maxSize: formatFileSize(maxFileSize),
+ })
+ : `Accepted types: ${extensionsString}; Max file size: ${formatFileSize(maxFileSize)}`;
+ const buttonText = buttonMsg ? buttonMsg : isLocaleReady ? t("add_files") : defaultT["add_files"];
+
+ return (
+
+
+
+
+
+ e.target.files && handleFileSelect(e.target.files)}
+ disabled={disabled}
+ className="hidden"
+ data-testid={testId}
+ />
+ {files.length > 0 && (
+
+ {files.length}/{maxFiles} files
+
+ )}
+
+
+
+ {showFilesList && files.length > 0 && (
+
+ {files.map((fileData) => (
+
+
+
+
+
{fileData.file.name}
+
{formatFileSize(fileData.file.size)}
+
+
+
+ ))}
+
+ )}
+
{fileInstructionsText}
+
+ );
+}
diff --git a/packages/ui/components/file-uploader/index.ts b/packages/ui/components/file-uploader/index.ts
new file mode 100644
index 0000000000..b3dc74ea2e
--- /dev/null
+++ b/packages/ui/components/file-uploader/index.ts
@@ -0,0 +1,2 @@
+export { default as FileUploader } from "./FileUploader";
+export type { FileData } from "./FileUploader";
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 507346fb87..31ad2a9e0b 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -34,6 +34,7 @@
"./components/form/timezone-select": "./components/form/timezone-select/index.ts",
"./components/hover-card": "./components/hover-card/index.tsx",
"./components/image-uploader": "./components/image-uploader/index.ts",
+ "./components/file-uploader": "./components/file-uploader/index.ts",
"./components/layout": "./components/layout/index.ts",
"./components/list": "./components/list/index.ts",
"./components/logo": "./components/logo/index.ts",
diff --git a/yarn.lock b/yarn.lock
index a7ba5789ed..83a04cb37c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4060,6 +4060,7 @@ __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
"@tremor/react": ^2.11.0
"@types/accept-language-parser": 1.5.2
@@ -16749,6 +16750,20 @@ __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
+ languageName: node
+ linkType: hard
+
"@tediousjs/connection-string@npm:^0.5.0":
version: 0.5.0
resolution: "@tediousjs/connection-string@npm:0.5.0"
@@ -19872,6 +19887,18 @@ __metadata:
languageName: node
linkType: hard
+"ajv@npm:^8.12.0":
+ version: 8.17.1
+ resolution: "ajv@npm:8.17.1"
+ dependencies:
+ fast-deep-equal: ^3.1.3
+ fast-uri: ^3.0.1
+ json-schema-traverse: ^1.0.0
+ require-from-string: ^2.0.2
+ checksum: 1797bf242cfffbaf3b870d13565bd1716b73f214bb7ada9a497063aada210200da36e3ed40237285f3255acc4feeae91b1fb183625331bad27da95973f7253d9
+ languageName: node
+ linkType: hard
+
"akismet-api@npm:^6.0.0":
version: 6.0.0
resolution: "akismet-api@npm:6.0.0"
@@ -27225,6 +27252,13 @@ __metadata:
languageName: node
linkType: hard
+"fast-uri@npm:^3.0.1":
+ version: 3.0.6
+ resolution: "fast-uri@npm:3.0.6"
+ checksum: 7161ba2a7944778d679ba8e5f00d6a2bb479a2142df0982f541d67be6c979b17808f7edbb0ce78161c85035974bde3fa52b5137df31da46c0828cb629ba67c4e
+ languageName: node
+ linkType: hard
+
"fast-xml-parser@npm:4.4.1":
version: 4.4.1
resolution: "fast-xml-parser@npm:4.4.1"
@@ -29059,6 +29093,13 @@ __metadata:
languageName: node
linkType: hard
+"graphql@npm:^16.6.0":
+ version: 16.11.0
+ resolution: "graphql@npm:16.11.0"
+ checksum: 65bc206edbe980f2759a8e4cf324873f75a66ab48263961472716e50127ae446739be20f926bb7f036a8d199bd4de072f684fd147c285bd6ba965d98cebb6200
+ languageName: node
+ linkType: hard
+
"graphql@npm:^16.8.1":
version: 16.10.0
resolution: "graphql@npm:16.10.0"
@@ -48077,7 +48118,7 @@ __metadata:
languageName: node
linkType: hard
-"zod@npm:^3.20.2, zod@npm:^3.22.4":
+"zod@npm:3.22.4, zod@npm:^3.20.2, zod@npm:^3.22.4":
version: 3.22.4
resolution: "zod@npm:3.22.4"
checksum: 80bfd7f8039b24fddeb0718a2ec7c02aa9856e4838d6aa4864335a047b6b37a3273b191ef335bf0b2002e5c514ef261ffcda5a589fb084a48c336ffc4cdbab7f