import { CLIENT_LIBRARIES } from 'common/constants' import { HelpCircle } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { ChangeEvent, useEffect, useRef, useState } from 'react' import toast from 'react-hot-toast' import { useParams } from 'common' import Divider from 'components/ui/Divider' import InformationBox from 'components/ui/InformationBox' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { getProjectAuthConfig } from 'data/auth/auth-config-query' 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' import useLatest from 'hooks/misc/useLatest' import { detectBrowser } from 'lib/helpers' import { useProfile } from 'lib/profile' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, Checkbox, Form, IconAlertCircle, IconExternalLink, IconLoader, IconMail, IconPlus, IconX, Input, Listbox, } from 'ui' import MultiSelect from 'ui-patterns/MultiSelect' import DisabledStateForFreeTier from './DisabledStateForFreeTier' import { CATEGORY_OPTIONS, SERVICE_OPTIONS, SEVERITY_OPTIONS } from './Support.constants' import { formatMessage, uploadAttachments } from './SupportForm.utils' const MAX_ATTACHMENTS = 5 const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive'] export interface SupportFormProps { setSentCategory: (value: string) => void } const SupportForm = ({ setSentCategory }: SupportFormProps) => { const { isReady } = useRouter() const { ref, slug, subject, category, message } = useParams() const uploadButtonRef = useRef() const enableFreeSupport = useFlag('enableFreeSupport') const [isSubmitting, setIsSubmitting] = useState(false) const [uploadedFiles, setUploadedFiles] = useState([]) const [uploadedDataUrls, setUploadedDataUrls] = useState([]) const [selectedServices, setSelectedServices] = useState([]) const [textAreaValue, setTextAreaValue] = useState('') const { data: organizations, isLoading: isLoadingOrganizations, isError: isErrorOrganizations, isSuccess: isSuccessOrganizations, } = useOrganizationsQuery() // for use in useEffect const organizationsRef = useLatest(organizations) const { data: allProjects, isLoading: isLoadingProjects, isError: isErrorProjects, isSuccess: isSuccessProjects, } = useProjectsQuery() const { mutate: submitSupportTicket } = useSendSupportTicketMutation({ onSuccess: (res, variables) => { toast.success('Support request sent. Thank you!') setSentCategory(variables.category) }, onError: (error) => { toast.error(`Failed to submit support ticket: ${error.message}`) setIsSubmitting(false) }, }) const projectDefaults: Partial[] = [{ ref: 'no-project', name: 'No specific project' }] const projects = [...(allProjects ?? []), ...projectDefaults] const selectedProjectFromUrl = projects.find((project) => project.ref === ref) const selectedOrganizationFromUrl = organizations?.find((org) => org.slug === slug) const selectedCategoryFromUrl = CATEGORY_OPTIONS.find((option) => { if (option.value.toLowerCase() === ((category as string) ?? '').toLowerCase()) return option }) const [selectedProjectRef, setSelectedProjectRef] = useState( selectedProjectFromUrl !== undefined ? selectedProjectFromUrl.ref : projects.length > 0 ? projects[0].ref : 'no-project' ) const selectedOrganizationSlug = selectedOrganizationFromUrl !== undefined ? selectedOrganizationFromUrl.slug : selectedProjectRef !== 'no-project' ? organizations?.find((org) => { const project = projects.find((project) => project.ref === selectedProjectRef) return org.id === project?.organization_id })?.slug : organizations?.[0]?.slug const { data: subscription, isLoading: isLoadingSubscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganizationSlug, }) const { profile } = useProfile() const respondToEmail = profile?.primary_email ?? 'your email' const initialValues = { category: selectedCategoryFromUrl !== undefined ? selectedCategoryFromUrl.value : CATEGORY_OPTIONS[0].value, severity: 'Low', projectRef: selectedProjectRef, organizationSlug: selectedOrganizationSlug, library: 'no-library', subject: subject ?? '', message: message || '', allowSupportAccess: false, } 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 onValidate = (values: any) => { const errors: any = {} if (!values.subject) errors.subject = 'Please add a subject heading' if (!values.message) errors.message = "Please add a message about the issue that you're facing" if (values.category === 'Problem' && values.library === 'no-library') errors.library = "Please select the library that you're facing issues with" return errors } const onSubmit = async (values: any) => { 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: selectedServices .map((service) => service.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 } finally { } } submitSupportTicket(payload) } const handleTextMessageChange = (event: React.ChangeEvent) => { setTextAreaValue(event.target.value) } const ipv4MigrationStrings = [ 'ipv4', 'ipv6', 'supavisor', 'pgbouncer', '5432', 'ENETUNREACH', 'ECONNREFUSED', 'P1001', 'connect: no route to', 'network is unreac', 'could not translate host name', 'address family not supported by protocol', ] const ipv4MigrationStringMatched = ipv4MigrationStrings.some((str) => textAreaValue.includes(str)) useEffect(() => { if (!uploadedFiles) return const objectUrls = uploadedFiles.map((file) => URL.createObjectURL(file)) setUploadedDataUrls(objectUrls) return () => { objectUrls.forEach((url: any) => URL.revokeObjectURL(url)) } }, [uploadedFiles]) useEffect(() => { if (isSuccessProjects && ref !== undefined) { const selectedProjectFromUrl = projects.find((project) => project.ref === ref) if (selectedProjectFromUrl !== undefined) setSelectedProjectRef(selectedProjectFromUrl.ref) } }, [isSuccessProjects]) return (
{({ resetForm, values }: any) => { const selectedCategory = CATEGORY_OPTIONS.find( (category) => category.value === values.category ) const selectedLibrary = CLIENT_LIBRARIES.find( (library) => library.language === values.library ) const selectedClientLibraries = selectedLibrary?.libraries.filter((library) => library.name.includes('supabase-') ) const selectedProject = projects.find((project) => project.ref === values.projectRef) const isFreeProject = (subscription?.plan.id ?? 'free') === 'free' const isDisabled = !enableFreeSupport && isFreeProject && ['Performance', 'Problem'].includes(values.category) // [Alaister] although this "technically" is breaking the rules of React hooks // it won't error because the hooks are always rendered in the same order // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { if (values.projectRef === 'no-project') { const updatedValues = { ...values, organizationSlug: organizationsRef.current?.[0]?.slug, } resetForm({ values: updatedValues, initialValues: updatedValues }) } else if (selectedProject) { const organization = organizationsRef.current?.find( (org) => org.id === selectedProject.organization_id ) if (organization) { const updatedValues = { ...values, organizationSlug: organization.slug } resetForm({ values: updatedValues, initialValues: updatedValues }) } } }, [values.projectRef]) // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { if ( isSuccessProjects && isSuccessOrganizations && allProjects.length > 0 && organizations.length > 0 ) { const updatedValues = { ...values, projectRef: selectedProjectRef, organizationSlug: selectedOrganizationSlug, } resetForm({ values: updatedValues, initialValues: updatedValues }) } }, [ isSuccessProjects, isSuccessOrganizations, selectedProjectRef, selectedOrganizationSlug, ]) // Populate fields when router is ready, required when navigating to // support form on a refresh browser session // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { if (isReady) { const updatedValues = { ...initialValues, projectRef: ref ?? initialValues.projectRef, subject: subject ?? initialValues.subject, category: selectedCategoryFromUrl?.value ?? initialValues.category, message: message ?? initialValues.message, } resetForm({ values: updatedValues, initialValues: updatedValues }) } }, [isReady]) return (

How can we help?

{CATEGORY_OPTIONS.map((option, i) => { return ( {option.label} {option.description} ) })}
{values.category !== 'Login_issues' && (
{isLoadingProjects && (

Which project is affected?

)} {isErrorProjects && (

Which project is affected?

Failed to retrieve projects

)} {isSuccessProjects && ( { setSelectedProjectRef(val) }} > {projects.map((option) => { const organization = organizations?.find( (x) => x.id === option.organization_id ) return ( {option.name} {organization?.name} ) })} )} {SEVERITY_OPTIONS.map((option: any) => { return ( {option.label} {option.description} ) })}
{values.projectRef !== 'no-project' && subscription && isSuccessProjects ? (

This project is on the{' '} {subscription.plan.name} plan

) : isLoadingSubscription && selectedProjectRef !== 'no-project' ? (

Checking project's plan

) : ( <> )} {(values.severity === 'Urgent' || values.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.

)}
)} {isSuccessProjects && values.projectRef === 'no-project' && values.category !== 'Login_issues' && (
{isLoadingOrganizations && (

Which organization is affected?

)} {isErrorOrganizations && (

Which organization is affected?

Failed to retrieve organizations

)} {isSuccessOrganizations && ( {organizations?.map((option) => { return ( {option.name} ) })} )}
)} {subscription?.plan.id !== 'enterprise' && values.category !== 'Login_issues' && (
} defaultVisibility={true} hideCollapse={true} title="Expected response times are based on your project's plan" description={
{subscription?.plan.id === 'free' && (

Free plan support is available within the community and officially by the team on a best efforts basis. For a guaranteed response we recommend upgrading to the Pro plan. Enhanced SLAs for support are available on our Enterprise Plan.

)} {subscription?.plan.id === 'pro' && (

Pro Plan includes email-based support. You can expect an answer within 1 business day in most situation for all severities. We recommend upgrading to the Team plan for prioritized ticketing on all issues and prioritized escalation to product engineering teams. Enhanced SLAs for support are available on our Enterprise Plan.

)} {subscription?.plan.id === 'team' && (

Team plan includes email-based support. You get prioritized ticketing on all issues and prioritized escalation to product engineering teams. Low, Normal, and High severity tickets will generally be handled within 1 business day, while Urgent issues, we respond within 1 day, 365 days a year. Enhanced SLAs for support are available on our Enterprise Plan.

)}
} />
)} {!isDisabled ? ( <> {['Performance'].includes(values.category) && isFreeProject ? ( ) : ( <>
0 && INCLUDE_DISCUSSIONS.includes(values.category) ? (

Check our Github discussions for a quick answer

) : null } />
{values.category === 'Problem' && (
Please select a library {CLIENT_LIBRARIES.map((option, i) => { return ( {option.language} ) })}
)} {selectedLibrary !== undefined && (

Found an issue or a bug? Try searching our Github issues or submit a new one.

{selectedClientLibraries?.map((library) => { const libraryLanguage = values.library === 'Dart (Flutter)' ? library.name.split('-')[1] : values.library return (

{library.name}

For issues regarding the {libraryLanguage} client library

) })}

supabase

For any issues about our API

)} {values.category !== 'Login_issues' && (

Which services are affected?

)}
handleTextMessageChange(e)} /> {ipv4MigrationStringMatched && ( Connection issues?

Having trouble connecting to your project? It could be related to our migration from PGBouncer and IPv4.

Please review this GitHub discussion. It's up to date and covers many frequently asked questions.

)}
{['Problem', 'Database_unresponsive', 'Performance'].includes( values.category ) && (
)}

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

)} ) : ( <> )}
) }}
) } export default SupportForm