"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"; /** Make RHF generics match the resolver (Zod defaults => input can be undefined) */ type SupportFormInput = z.input; type SupportFormValues = z.output; 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( // eslint-disable-next-line @typescript-eslint/no-explicit-any 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: () => setIsSubmittingLocal(false), }); 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); // UI-side constraints const maxFiles = 5; const maxFileSize = 10 * 1024 * 1024; const maxCombined = 50 * 1024 * 1024; if ((files?.length ?? 0) > maxFiles) { throw new Error(`Please upload at most ${maxFiles} files.`); } if ((files ?? []).some((f) => f.size > maxFileSize)) { throw new Error("Each file must be ≤ 10MB."); } if (totalUploadBytes > maxCombined) { throw new Error("Total attachment size must be ≤ 50MB."); } // 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.