import { zodResolver } from '@hookform/resolvers/zod' import * as Sentry from '@sentry/nextjs' import { useSupabaseClient } from '@supabase/auth-helpers-react' import { ExternalLink, Loader2, Mail, Plus, X } from 'lucide-react' import Link from 'next/link' import { ChangeEvent, useEffect, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' import { useDocsSearch, useParams, type DocsSearchResult as Page } from 'common' import { CLIENT_LIBRARIES } from 'common/constants' import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import type { Project } from 'data/projects/project-detail-query' import { useProjectsQuery } from 'data/projects/projects-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useFlag } from 'hooks/ui/useFlag' import { useProfile } from 'lib/profile' import { Badge, Button, Checkbox_Shadcn_, cn, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_, ScrollArea, Select_Shadcn_, SelectContent_Shadcn_, SelectGroup_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, Separator, TextArea_Shadcn_, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import DisabledStateForFreeTier from './DisabledStateForFreeTier' import { DocsLinkGroup } from './DocsLink' import { PlanExpectationInfoBox } from './PlanExpectationInfoBox' import { CATEGORY_OPTIONS, IPV4_MIGRATION_STRINGS, SERVICE_OPTIONS, SEVERITY_OPTIONS, } from './Support.constants' import { LibrarySuggestions } from './LibrarySuggestions' import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2' import { IPV4SuggestionAlert } from './IPV4SuggestionAlert' import { formatMessage, uploadAttachments } from './SupportForm.utils' import { detectBrowser } from 'lib/helpers' import { getProjectAuthConfig } from 'data/auth/auth-config-query' const MAX_ATTACHMENTS = 5 const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive'] const CONTAINER_CLASSES = 'px-6' interface SupportFormV2Props { setSentCategory: (value: string) => void setSelectedProject: (value: string) => void } // [Joshen] Just naming it as V2 for now for PR review purposes so its easier to view // This is a rewrite of the old SupportForm to use the new form components export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFormV2Props) => { const { profile } = useProfile() const supabaseClient = useSupabaseClient() const { ref, slug, category: urlCategory, subject: urlSubject, message: urlMessage } = useParams() const enableFreeSupport = useFlag('enableFreeSupport') const uploadButtonRef = useRef(null) const [isSubmitting, setIsSubmitting] = useState(false) const [docsResults, setDocsResults] = useState([]) const [uploadedFiles, setUploadedFiles] = useState([]) const [uploadedDataUrls, setUploadedDataUrls] = useState([]) const FormSchema = z .object({ organizationSlug: z.string().min(1, 'Please select an organization'), projectRef: z.string().min(1, 'Please select a project'), category: z.string(), severity: z.string(), library: z.string(), subject: z.string().min(1, 'Please add a subject heading'), message: z.string().min(1, "Please add a message about the issue that you're facing"), affectedServices: z.string(), allowSupportAccess: z.boolean(), }) .refine( (data) => { return !(data.category === 'Problem' && data.library === '') }, { message: "Please select the library that you're facing issues with", path: ['library'], } ) const defaultValues = { organizationSlug: '', projectRef: 'no-project', category: CATEGORY_OPTIONS[0].value, severity: 'Low', library: '', subject: '', message: '', affectedServices: '', allowSupportAccess: false, } const form = useForm>({ mode: 'onBlur', reValidateMode: 'onBlur', resolver: zodResolver(FormSchema), defaultValues, }) const { organizationSlug, projectRef, category, severity, subject, library } = form.watch() const { handleDocsSearchDebounced, searchState, searchState: state, } = useDocsSearch(supabaseClient) const { data: organizations, isLoading: isLoadingOrganizations, isSuccess: isSuccessOrganizations, } = useOrganizationsQuery() const { data: subscription, isLoading: isLoadingSubscription, isSuccess: isSuccessSubscription, } = useOrgSubscriptionQuery({ orgSlug: organizationSlug, }) const { data: allProjects, isLoading: isLoadingProjects, isSuccess: isSuccessProjects, } = useProjectsQuery() const { mutate: submitSupportTicket } = useSendSupportTicketMutation({ onSuccess: (res, variables) => { toast.success('Support request sent. Thank you!') setSentCategory(variables.category) setSelectedProject(variables.projectRef ?? 'no-project') }, onError: (error) => { toast.error(`Failed to submit support ticket: ${error.message}`) Sentry.captureMessage('Failed to submit Support Form: ' + error.message) setIsSubmitting(false) }, }) const respondToEmail = profile?.primary_email ?? 'your email' const subscriptionPlanId = subscription?.plan.id const projects = [ ...(allProjects ?? []).filter((project) => project.organization_slug === organizationSlug), { ref: 'no-project', name: 'No specific project' } as Partial, ] const hasResults = state.status === 'fullResults' || state.status === 'partialResults' || (state.status === 'loading' && state.staleResults.length > 0) const onFilesUpload = async (event: ChangeEvent) => { event.persist() const items = event.target.files || (event as any).dataTransfer.items const itemsCopied = Array.prototype.map.call(items, (item) => item) as File[] const itemsToBeUploaded = itemsCopied.slice(0, MAX_ATTACHMENTS - uploadedFiles.length) setUploadedFiles(uploadedFiles.concat(itemsToBeUploaded)) if (items.length + uploadedFiles.length > MAX_ATTACHMENTS) { toast(`Only up to ${MAX_ATTACHMENTS} attachments are allowed`) } event.target.value = '' } const removeUploadedFile = (idx: number) => { const updatedFiles = uploadedFiles?.slice() updatedFiles.splice(idx, 1) setUploadedFiles(updatedFiles) const updatedDataUrls = uploadedDataUrls.slice() uploadedDataUrls.splice(idx, 1) setUploadedDataUrls(updatedDataUrls) } const onSubmit: SubmitHandler> = async (values) => { setIsSubmitting(true) const attachments = uploadedFiles.length > 0 ? await uploadAttachments(values.projectRef, uploadedFiles) : [] const selectedLibrary = CLIENT_LIBRARIES.find((library) => library.language === values.library) const payload = { ...values, library: values.category === 'Problem' && selectedLibrary !== undefined ? selectedLibrary.key : '', message: formatMessage(values.message, attachments), verified: true, tags: ['dashboard-support-form'], siteUrl: '', additionalRedirectUrls: '', affectedServices: values.affectedServices .split(',') .map((x) => x.trim().replace(/ /g, '_').toLowerCase()) .join(';'), browserInformation: detectBrowser(), } if (values.projectRef !== 'no-project') { try { const authConfig = await getProjectAuthConfig({ projectRef: values.projectRef }) payload.siteUrl = authConfig.SITE_URL payload.additionalRedirectUrls = authConfig.URI_ALLOW_LIST } catch (error) { // [Joshen] No error handler required as fetching these info are nice to haves, not necessary } } submitSupportTicket(payload) } useEffect(() => { if (subject !== urlSubject && subject.trim().length > 0) { handleDocsSearchDebounced(subject.trim()) } else { setDocsResults([]) } }, [subject, urlSubject]) useEffect(() => { if (subject.trim().length > 0 && searchState.status === 'fullResults') { setDocsResults(searchState.results) } else if (searchState.status === 'noResults' || searchState.status === 'error') { setDocsResults([]) } }, [searchState]) useEffect(() => { if (!uploadedFiles) return const objectUrls = uploadedFiles.map((file) => URL.createObjectURL(file)) setUploadedDataUrls(objectUrls) return () => { objectUrls.forEach((url: any) => URL.revokeObjectURL(url)) } }, [uploadedFiles]) useEffect(() => { // For prefilling form fields via URL, project ref will taking higher precedence than org slug if (isSuccessOrganizations && isSuccessProjects) { if (ref) { const selectedProject = allProjects.find((p) => p.ref === ref) if (selectedProject !== undefined) { form.setValue('organizationSlug', selectedProject.organization_slug) form.setValue('projectRef', selectedProject.ref) } } else if (slug) { const selectedOrganization = organizations?.find((org) => org.slug === slug) if (selectedOrganization !== undefined) { form.setValue('organizationSlug', selectedOrganization.slug) } } else if (ref === undefined && slug === undefined) { const firstOrganization = organizations?.[0] if (firstOrganization !== undefined) { form.setValue('organizationSlug', firstOrganization.slug) } } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ref, slug, isSuccessOrganizations, isSuccessProjects]) useEffect(() => { if (urlCategory) { const validCategory = CATEGORY_OPTIONS.find((option) => { if (option.value.toLowerCase() === ((urlCategory as string) ?? '').toLowerCase()) return option }) if (validCategory !== undefined) form.setValue('category', validCategory.value) } }, [urlCategory]) useEffect(() => { if (urlSubject) form.setValue('subject', urlSubject) }, [urlSubject]) useEffect(() => { if (urlMessage) form.setValue('message', urlMessage) }, [urlMessage]) return (

How can we help?

(
{(organizations ?? []).find((o) => o.slug === field.value)?.name} {isLoadingSubscription && } {isSuccessSubscription && ( {subscriptionPlanId} )}
{organizations?.map((org) => ( {org.name} ))}
)} />
( { if (val.length > 0) field.onChange(val) }} > {projects?.map((project) => ( {project.name} ))} )} /> {organizationSlug && subscriptionPlanId !== 'enterprise' && category !== 'Login_issues' && ( )}
( {CATEGORY_OPTIONS.find((o) => o.value === field.value)?.label} {CATEGORY_OPTIONS.map((option) => ( {option.label} {option.description} ))} )} /> ( {field.value} {SEVERITY_OPTIONS.map((option) => ( {option.label} {option.description} ))} )} /> {(severity === 'Urgent' || severity === 'High') && (

We do our best to respond to everyone as quickly as possible; however, prioritization will be based on production status. We ask that you reserve High and Urgent severity for production-impacting issues only.

)}
{!enableFreeSupport || (category === 'Performance' && subscriptionPlanId === 'free') ? ( o.value === category)?.label ?? ''} organizationSlug={organizationSlug} /> ) : ( <>
( 0 && INCLUDE_DISCUSSIONS.includes(category) && (

Check our Github discussions for a quick answer

) } >
)} /> {docsResults.length > 0 && hasResults && (

Suggested resources ({Math.min(docsResults.length, 5)})

3 ? 'h-[300px]' : ''}> {docsResults.slice(0, 5).map((page, i) => ( ))}
)}
{category === 'Problem' && ( ( {CLIENT_LIBRARIES.map((option) => ( {option.language} ))} )} /> )} {library.length > 0 && } {category !== 'Login_issues' && ( ( form.setValue('affectedServices', services.join(', ')) } /> )} /> )} ( field.value.includes(str)) && ( ) } > )} /> {['Problem', 'Database_unresponsive', 'Performance'].includes(category) && ( ( field.onChange(value)} /> )} /> )}

Attachments

Upload up to {MAX_ATTACHMENTS} screenshots that might be relevant to the issue that you're facing

{uploadedDataUrls.map((x: any, idx: number) => (
removeUploadedFile(idx)} >
))} {uploadedFiles.length < MAX_ATTACHMENTS && (
{ if (uploadButtonRef.current) (uploadButtonRef.current as any).click() }} >
)}

We will contact you at

{respondToEmail}

Please ensure you haven't blocked Hubspot in your emails

)}
) }