From 2421653de3db4f105210daf52646525723bb219e Mon Sep 17 00:00:00 2001 From: Tomas Beran Date: Tue, 5 May 2026 16:16:48 +0200 Subject: [PATCH] feat: add prebuilt templates to support form Adds a template picker to the Contact Support dialog so customers submit structured first messages instead of vague ones, reducing the back-and-forth needed to triage. Selected template prefills the textarea (without clobbering user edits), drives the Plain thread title, and is captured in PostHog as `template_id` for adoption tracking. Default behavior ("Something else") is unchanged. --- src/core/modules/support/repository.server.ts | 4 +- src/core/modules/support/templates.ts | 146 ++++++++++++++++++ src/core/server/api/routers/support.ts | 11 ++ .../dashboard/navbar/report-issue-dialog.tsx | 72 ++++++++- 4 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 src/core/modules/support/templates.ts diff --git a/src/core/modules/support/repository.server.ts b/src/core/modules/support/repository.server.ts index 91fa45a97..d383f587f 100644 --- a/src/core/modules/support/repository.server.ts +++ b/src/core/modules/support/repository.server.ts @@ -38,6 +38,7 @@ export interface SupportRepository { customerEmail: string accountOwnerEmail: string customerTier: string + titleOverride?: string }): Promise> } @@ -170,6 +171,7 @@ export function createSupportRepository( customerEmail, accountOwnerEmail, customerTier, + titleOverride, } = input const client = deps.createPlainClient() @@ -224,7 +226,7 @@ export function createSupportRepository( } } - const title = `Support Request [${teamName}]` + const title = `${titleOverride ?? 'Support Request'} [${teamName}]` const threadText = formatThreadText({ description, teamId, diff --git a/src/core/modules/support/templates.ts b/src/core/modules/support/templates.ts new file mode 100644 index 000000000..e4cc79ef7 --- /dev/null +++ b/src/core/modules/support/templates.ts @@ -0,0 +1,146 @@ +export const SUPPORT_TEMPLATES = [ + { + id: 'sandbox_issue', + label: 'Issue with a sandbox', + title: 'Sandbox issue', + prefill: `Sandbox ID: +What you were trying to do: +What went wrong (error message, logs, or unexpected behavior): +Approximate time it happened (UTC if possible): +Steps to reproduce, if known: `, + }, + { + id: 'snapshot_pause_resume', + label: 'Snapshot, pause, or resume failed', + title: 'Snapshot / pause / resume issue', + prefill: `Sandbox ID: +Snapshot or template ID (if applicable): +Which action failed (snapshot creation, pause, resume): +Error message returned: +Approximate time it happened (UTC if possible): `, + }, + { + id: 'slow_performance', + label: 'Performance is slow', + title: 'Performance issue', + prefill: `Sandbox ID(s): +What is slow (sandbox start, command execution, network, file system, etc.): +How long it currently takes: +How long you expect it to take: +Approximate time of a recent slow run (UTC if possible): `, + }, + { + id: 'cancel_subscription', + label: 'Cancel my subscription', + title: 'Subscription cancellation', + prefill: `Team ID: +Email on the payment method: +Reason for canceling (optional, helps us improve): `, + }, + { + id: 'refund_or_credit', + label: 'Refund or credit request', + title: 'Refund / credit request', + prefill: `Team ID: +Invoice number or charge date: +Amount: +Reason for the request: `, + }, + { + id: 'delete_account', + label: 'Delete my account', + title: 'Account deletion request', + prefill: `Team ID: +Confirm you want all account data permanently deleted (yes / no): +Anything else we should know: `, + }, + { + id: 'change_account_owner', + label: 'Change account owner or team email', + title: 'Account owner change', + prefill: `Team ID: +Current owner email: +New owner email: +Confirm the new owner has already created an E2B account with the new email (yes / no): `, + }, + { + id: 'increase_limit', + label: 'Increase a limit (concurrency, RAM, disk, build)', + title: 'Limit increase request', + prefill: `Team ID: +Which limit you want raised (concurrent sandboxes, RAM per sandbox, disk per sandbox, concurrent builds): +Current value: +Target value: +Brief use case: `, + }, + { + id: 'volumes_access', + label: 'Volumes access', + title: 'Volumes access request', + prefill: `Team ID: +What you plan to use volumes for: +Approximate volume size and access pattern (e.g. 50 GB read-mostly, shared across sandboxes): +Region preference (currently us-west1 only): `, + }, + { + id: 'eu_cluster', + label: 'EU cluster or data residency', + title: 'EU cluster / data residency request', + prefill: `Team ID: +Specific data residency requirement (region, regulation, customer ask): +Expected sandbox volume per month: +Timeline: `, + }, + { + id: 'enterprise_inquiry', + label: 'Enterprise inquiry', + title: 'Enterprise inquiry', + prefill: `Company: +Use case: +Current scale (sandboxes per day or month): +Expected scale at 6 months: +Timeline: `, + }, + { + id: 'self_hosting', + label: 'Self-hosting / BYOC', + title: 'Self-hosting / BYOC inquiry', + prefill: `Company: +Cloud provider you want to deploy on: +Why self-hosted is required (compliance, data residency, latency, etc.): +Expected scale: `, + }, + { + id: 'startup_program', + label: 'Startup program', + title: 'Startup program inquiry', + prefill: `Company: +Stage (pre-seed, seed, Series A, etc.): +Current E2B usage (if any): +What you are building: `, + }, + { + id: 'something_else', + label: 'Something else', + title: 'Support Request', + prefill: '', + }, +] as const + +export type SupportTemplate = (typeof SUPPORT_TEMPLATES)[number] +export type SupportTemplateId = SupportTemplate['id'] + +export const SUPPORT_TEMPLATE_IDS = SUPPORT_TEMPLATES.map((t) => t.id) as [ + SupportTemplateId, + ...SupportTemplateId[], +] + +export const DEFAULT_SUPPORT_TEMPLATE_ID: SupportTemplateId = 'something_else' + +export function getSupportTemplate(id: SupportTemplateId): SupportTemplate { + const template = SUPPORT_TEMPLATES.find((t) => t.id === id) + if (!template) { + throw new Error(`Unknown support template id: ${id}`) + } + return template +} diff --git a/src/core/server/api/routers/support.ts b/src/core/server/api/routers/support.ts index 5184f814e..11cd8b764 100644 --- a/src/core/server/api/routers/support.ts +++ b/src/core/server/api/routers/support.ts @@ -1,6 +1,10 @@ import { TRPCError } from '@trpc/server' import { z } from 'zod' import { createSupportRepository } from '@/core/modules/support/repository.server' +import { + getSupportTemplate, + SUPPORT_TEMPLATE_IDS, +} from '@/core/modules/support/templates' import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' import { createTRPCRouter } from '@/core/server/trpc/init' @@ -24,6 +28,7 @@ export const supportRouter = createTRPCRouter({ z.object({ description: z.string().min(1), files: z.array(FileSchema).max(5).optional(), + templateId: z.enum(SUPPORT_TEMPLATE_IDS).optional(), }) ) .mutation(async ({ ctx, input }) => { @@ -47,6 +52,11 @@ export const supportRouter = createTRPCRouter({ throwTRPCErrorFromRepoError(teamResult.error) } + const templateTitle = + input.templateId && input.templateId !== 'something_else' + ? getSupportTemplate(input.templateId).title + : undefined + const createResult = await ctx.supportRepository.createSupportThread({ description: input.description, files: input.files, @@ -55,6 +65,7 @@ export const supportRouter = createTRPCRouter({ customerEmail: email, accountOwnerEmail: teamResult.data.email, customerTier: teamResult.data.tier, + titleOverride: templateTitle, }) if (!createResult.ok) { throwTRPCErrorFromRepoError(createResult.error) diff --git a/src/features/dashboard/navbar/report-issue-dialog.tsx b/src/features/dashboard/navbar/report-issue-dialog.tsx index b65db71cd..f3496c54d 100644 --- a/src/features/dashboard/navbar/report-issue-dialog.tsx +++ b/src/features/dashboard/navbar/report-issue-dialog.tsx @@ -4,10 +4,17 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useMutation } from '@tanstack/react-query' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' +import { + DEFAULT_SUPPORT_TEMPLATE_ID, + getSupportTemplate, + SUPPORT_TEMPLATE_IDS, + SUPPORT_TEMPLATES, + type SupportTemplateId, +} from '@/core/modules/support/templates' import { useDashboard } from '@/features/dashboard/context' import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' @@ -29,6 +36,13 @@ import { FormMessage, } from '@/ui/primitives/form' import { CloseIcon, FileIcon } from '@/ui/primitives/icons' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/ui/primitives/select' import { Textarea } from '@/ui/primitives/textarea' import FileDropZone from './file-drop-zone' @@ -41,6 +55,7 @@ const ACCEPTED_FILE_TYPES = const supportFormSchema = z.object({ description: z.string().min(1, 'Please describe how we can help'), + templateId: z.enum(SUPPORT_TEMPLATE_IDS), }) type SupportFormValues = z.infer @@ -75,6 +90,7 @@ export default function ContactSupportDialog({ const [isOpen, setIsOpen] = useState(false) const [wasSubmitted, setWasSubmitted] = useState(false) const [files, setFiles] = useState([]) + const lastPrefillRef = useRef('') // Auto-open dialog when ?support=true is in the URL useEffect(() => { @@ -93,17 +109,19 @@ export default function ContactSupportDialog({ resolver: zodResolver(supportFormSchema), defaultValues: { description: '', + templateId: DEFAULT_SUPPORT_TEMPLATE_ID, }, }) const contactSupportMutation = useMutation( trpc.support.contactSupport.mutationOptions({ - onSuccess: (data) => { + onSuccess: (data, variables) => { posthog.capture('support_request_submitted', { thread_id: data?.threadId, team_id: team.id, tier: team.tier, attachment_count: files.length, + template_id: variables.templateId ?? DEFAULT_SUPPORT_TEMPLATE_ID, }) setWasSubmitted(true) toast.success( @@ -126,8 +144,25 @@ export default function ContactSupportDialog({ const resetForm = useCallback(() => { form.reset() setFiles([]) + lastPrefillRef.current = '' }, [form]) + const handleTemplateChange = useCallback( + (nextId: SupportTemplateId) => { + const nextPrefill = getSupportTemplate(nextId).prefill + const current = form.getValues('description') + if (current === '' || current === lastPrefillRef.current) { + form.setValue('description', nextPrefill, { + shouldValidate: true, + shouldDirty: nextPrefill.length > 0, + }) + } + lastPrefillRef.current = nextPrefill + form.setValue('templateId', nextId, { shouldValidate: true }) + }, + [form] + ) + const handleOpenChange = useCallback( (open: boolean) => { if (open) { @@ -184,6 +219,7 @@ export default function ContactSupportDialog({ teamSlug: team.slug, description, files: filePayloads.length > 0 ? filePayloads : undefined, + templateId: values.templateId, }) } @@ -225,6 +261,38 @@ export default function ContactSupportDialog({ )} /> + ( + + + What's this about? (optional) + + + + )} + /> +