feat: Add contact form for free users in Plain support widget (#22311)
Co-authored-by: peer@cal.com <peer@cal.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: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
co-authored by
peer@cal.com <peer@cal.com>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Amit Sharma
Peer Richelsen
parent
29e1dcb43c
commit
b9c49b4567
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(<PlainContactForm />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open contact form when button is clicked", () => {
|
||||
render(<PlainContactForm />);
|
||||
|
||||
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(<PlainContactForm />);
|
||||
|
||||
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(<PlainContactForm />);
|
||||
|
||||
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(<PlainContactForm />);
|
||||
|
||||
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(<PlainContactForm />);
|
||||
|
||||
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(<PlainContactForm />);
|
||||
|
||||
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(<PlainContactForm />);
|
||||
|
||||
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(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
|
||||
await expect(screen.getByLabelText("Describe the issue")).toHaveAttribute("required");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="absolute bottom-4 right-4 z-50">
|
||||
<Popover open={isOpen} 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="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;
|
||||
@@ -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: {},
|
||||
});
|
||||
};
|
||||
@@ -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 <PlainContactForm />;
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof zAcceptedFileTypes>;
|
||||
|
||||
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<TAcceptedFileTypes, { types: string[]; extensions: string[] }> = {
|
||||
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<FileData[]>([]);
|
||||
|
||||
const generateFileId = () => `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const readFileAsDataURL = (file: File): Promise<string> => {
|
||||
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 (
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={`mb-0 inline-flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
|
||||
disabled ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}>
|
||||
<Icon name="upload" className="h-4 w-4" />
|
||||
{buttonText}
|
||||
</Label>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
id={id}
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
accept={allowedTypes.join(",")}
|
||||
onChange={(e) => e.target.files && handleFileSelect(e.target.files)}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
data-testid={testId}
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<span className="text-sm">
|
||||
{files.length}/{maxFiles} files
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showFilesList && files.length > 0 && (
|
||||
<div className="mt-2 space-y-1 transition">
|
||||
{files.map((fileData) => (
|
||||
<div key={fileData.id} className="flex items-center justify-between rounded-md border">
|
||||
<div className="flex min-w-0 items-center gap-2 pl-2">
|
||||
<Icon name={getFileIcon(fileData.file.type)} className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="min-w-0 border-l py-2 pl-3">
|
||||
<p className="text-emphasis truncate text-sm font-medium">{fileData.file.name}</p>
|
||||
<p className="text-xs">{formatFileSize(fileData.file.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
StartIcon="x"
|
||||
onClick={() => handleFileRemove(fileData.id)}
|
||||
className="mx-2 h-6 w-6 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs">{fileInstructionsText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as FileUploader } from "./FileUploader";
|
||||
export type { FileData } from "./FileUploader";
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user