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. +

+ +
+ ) : ( +
+
+ +