"use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { type z } from "zod"; import { MESSAGE_TYPES, SEVERITIES, INTEGRATION_TYPES, TopicGroups, type MessageType, SupportFormSchema, } from "./formConstants"; import { api } from "@/src/utils/api"; import { Button } from "@/src/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/src/components/ui/form"; import { RadioGroup } from "@/src/components/ui/radio-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/src/components/ui/select"; import { Textarea } from "@/src/components/ui/textarea"; import { useQueryProjectOrOrganization } from "@/src/features/projects/hooks"; import { useMemo, useState } from "react"; import { Dropzone, DropzoneContent, DropzoneEmptyState, } from "@/src/components/ui/shadcn-io/dropzone"; import { Paperclip, Loader2, Trash2 } from "lucide-react"; import { showErrorToast } from "@/src/features/notifications/showErrorToast"; import { PLAIN_MAX_FILE_SIZE_BYTES } from "./plain/plainConstants"; /** Make RHF generics match the resolver (Zod defaults => input can be undefined) */ type SupportFormInput = z.input; type SupportFormValues = z.output; /** * File upload constraints - single source of truth for validation * Uses Plain API's file size limit */ const FILE_UPLOAD_CONSTRAINTS = { maxFiles: 5, maxFileSizeBytes: PLAIN_MAX_FILE_SIZE_BYTES, // 6MB (Plain API limit) maxCombinedBytes: 50 * 1024 * 1024, // 50MB } as const; /** * Validates files against upload constraints * @returns {isValid: boolean, error?: string} */ function validateFiles(files: File[] | undefined): { isValid: boolean; error?: string; } { if (!files || files.length === 0) { return { isValid: true }; } const { maxFiles, maxFileSizeBytes, maxCombinedBytes } = FILE_UPLOAD_CONSTRAINTS; // Check file count if (files.length > maxFiles) { return { isValid: false, error: `Please upload at most ${maxFiles} files.`, }; } // Check individual file sizes const oversizedFile = files.find((f) => f.size > maxFileSizeBytes); if (oversizedFile) { const maxMB = (maxFileSizeBytes / (1024 * 1024)).toFixed(0); return { isValid: false, error: `File "${oversizedFile.name}" is too large. Maximum file size is ${maxMB}MB per file.`, }; } // Check combined size const totalSize = files.reduce((sum, f) => sum + f.size, 0); if (totalSize > maxCombinedBytes) { const totalMB = (totalSize / (1024 * 1024)).toFixed(2); const maxMB = (maxCombinedBytes / (1024 * 1024)).toFixed(0); return { isValid: false, error: `Total attachment size (${totalMB}MB) exceeds the limit of ${maxMB}MB.`, }; } return { isValid: true }; } /** * Converts technical file error messages to user-friendly ones */ function formatFileError(error: Error): string { const msg = error.message.toLowerCase(); const { maxFiles, maxFileSizeBytes, maxCombinedBytes } = FILE_UPLOAD_CONSTRAINTS; const maxMB = (maxFileSizeBytes / (1024 * 1024)).toFixed(0); const maxCombinedMB = (maxCombinedBytes / (1024 * 1024)).toFixed(0); // File size errors if ( msg.includes("larger than") || msg.includes("10485760") || msg.includes("10mb") || msg.includes("too large") ) { return `File is too large. Maximum file size is ${maxMB}MB per file.`; } // File count errors if ( msg.includes("too many") || msg.includes("maxfiles") || msg.includes("5 files") ) { return `Too many files. Maximum ${maxFiles} files allowed.`; } // Combined size errors if (msg.includes("total") && (msg.includes("50mb") || msg.includes("size"))) { return `Total attachment size exceeds limit. Maximum combined size is ${maxCombinedMB}MB.`; } // File type errors if (msg.includes("file type") || msg.includes("accept")) { return "File type not supported. Please select a different file."; } return error.message || "File upload failed. Please try again."; } export function SupportFormSection({ onCancel, onSuccess, }: { onCancel: () => void; onSuccess: () => void; }) { const { organization, project } = useQueryProjectOrOrganization(); // Tracks whether we've already warned about a short message const [warnedShortOnce, setWarnedShortOnce] = useState(false); // Local file state from Dropzone const [files, setFiles] = useState(undefined); const totalUploadBytes = useMemo( () => (files ?? []).reduce((sum, f) => sum + f.size, 0), [files], ); // Local submit guard to avoid flicker across multiple mutations const [isSubmittingLocal, setIsSubmittingLocal] = useState(false); const form = useForm({ resolver: zodResolver(SupportFormSchema), defaultValues: { messageType: "Question" as MessageType, severity: "Question or feature request", topic: "", message: "", integrationType: "", }, mode: "onSubmit", }); const selectedTopic = form.watch("topic"); const isProductFeatureTopic = TopicGroups["Product Features"].includes( selectedTopic as any, ); const createSupportThread = api.plainRouter.createSupportThread.useMutation({ onSuccess: () => { form.reset({ messageType: "Question", severity: "Question or feature request", topic: "", message: "", }); setWarnedShortOnce(false); setFiles(undefined); onSuccess(); }, onSettled: () => setIsSubmittingLocal(false), }); const prepareUploads = api.plainRouter.prepareAttachmentUploads.useMutation({ onError: (error) => { setIsSubmittingLocal(false); showErrorToast( "Upload Preparation Failed", error.message || "Failed to prepare file uploads. Please try again.", "ERROR", ); }, }); async function uploadToPlainS3( uploadFormUrl: string, uploadFormData: { key: string; value: string }[], file: File, ) { const form = new FormData(); uploadFormData.forEach(({ key, value }) => form.append(key, value)); form.append("file", file, file.name); const res = await fetch(uploadFormUrl, { method: "POST", body: form }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error( `Attachment upload failed (${res.status} ${res.statusText}) ${text}`, ); } } const onSubmit = async (values: SupportFormInput) => { const parsed: SupportFormValues = SupportFormSchema.parse(values); const msgLen = (parsed.message ?? "").trim().length; if (msgLen < 50 && !warnedShortOnce) { setWarnedShortOnce(true); return; } try { setIsSubmittingLocal(true); // Validate files using centralized validation function const validation = validateFiles(files); if (!validation.isValid) { throw new Error(validation.error); } // 1) Request presigned S3 upload forms const uploadPlans = files && files.length ? await prepareUploads.mutateAsync({ files: files.map((f) => ({ fileName: f.name, fileSizeBytes: f.size, })), }) : { uploads: [] as any[], customerId: undefined as string | undefined, }; // 2) Upload blobs if (files && files.length) { await Promise.all( files.map(async (file, idx) => { const plan = uploadPlans.uploads[idx]; if (!plan) throw new Error("Missing upload plan for a file."); await uploadToPlainS3( plan.uploadFormUrl, plan.uploadFormData, file, ); }), ); } // 3) Create thread with attachmentIds const attachmentIds = uploadPlans.uploads?.map((u: any) => u.attachmentId) ?? []; await createSupportThread.mutateAsync({ messageType: parsed.messageType, severity: parsed.severity, topic: parsed.topic as any, integrationType: parsed.integrationType, message: parsed.message, url: window.location.href, organizationId: organization?.id, projectId: project?.id, browserMetadata: { userAgent: navigator.userAgent, platform: navigator.platform, language: navigator.language, viewport: { w: window.innerWidth, h: window.innerHeight }, }, attachmentIds, }); } catch (err: any) { console.error(err); setIsSubmittingLocal(false); form.setError("message", { type: "manual", message: err?.message ?? "Failed to submit support request.", }); } }; const messageIsShortAfterWarning = warnedShortOnce && (form.getValues("message") ?? "").trim().length < 50; // --- Compact attachment row helpers const totalMB = (totalUploadBytes / (1024 * 1024)).toFixed(2); const hasFiles = (files?.length ?? 0) > 0; return (
E-Mail a Support Engineer

Details speed things up. The clearer your request, the quicker you get the answer you need.

{/* Message Type */} ( Message Type {MESSAGE_TYPES.map((v) => ( ))} Choose the type of your message. )} /> {/* Severity */} ( Severity )} /> {/* Topic */} ( Topic )} /> {/* Integration Type */} {isProductFeatureTopic && ( ( Integration Type (optional) )} /> )} {/* Message */} ( Message
We will email you at your account address. Replies may take up to one business day.