import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState, FC, ChangeEvent, useRef } from 'react' import { observer } from 'mobx-react-lite' import { Button, IconMail, IconPlus, IconX, Input, Listbox, Form, IconLoader, IconAlertCircle, IconExternalLink, Checkbox, } from 'ui' import { CLIENT_LIBRARIES } from 'common/constants' import { Project } from 'types' import { useStore, useFlag } from 'hooks' import { post, get } from 'lib/common/fetch' import { detectBrowser } from 'lib/helpers' import { API_URL, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' import { useProfileQuery } from 'data/profile/profile-query' import Divider from 'components/ui/Divider' import Connecting from 'components/ui/Loading' import MultiSelect from 'components/ui/MultiSelect' import InformationBox from 'components/ui/InformationBox' import { formatMessage, uploadAttachments } from './SupportForm.utils' import { CATEGORY_OPTIONS, SEVERITY_OPTIONS, SERVICE_OPTIONS } from './Support.constants' import DisabledStateForFreeTier from './DisabledStateForFreeTier' const MAX_ATTACHMENTS = 5 interface Props { setSentCategory: (value: string) => void } const SupportForm: FC = ({ setSentCategory }) => { const { ui, app } = useStore() const router = useRouter() const { ref, subject, category, message } = router.query const uploadButtonRef = useRef() const enableFreeSupport = useFlag('enableFreeSupport') const [uploadedFiles, setUploadedFiles] = useState([]) const [uploadedDataUrls, setUploadedDataUrls] = useState([]) const [selectedServices, setSelectedServices] = useState([]) // Get all orgs and projects from global store const sortedOrganizations = app.organizations.list() const sortedProjects = app.projects.list() const isInitialized = app.projects.isInitialized const projectDefaults: Partial[] = [{ ref: 'no-project', name: 'No specific project' }] const projects = [...sortedProjects, ...projectDefaults] const planNames = { [PRICING_TIER_PRODUCT_IDS.FREE]: 'Free', [PRICING_TIER_PRODUCT_IDS.PRO]: 'Pro', [PRICING_TIER_PRODUCT_IDS.PAYG]: 'Pro', [PRICING_TIER_PRODUCT_IDS.TEAM]: 'Team', [PRICING_TIER_PRODUCT_IDS.ENTERPRISE]: 'Enterprise', } useEffect(() => { if (!uploadedFiles) return const objectUrls = uploadedFiles.map((file) => URL.createObjectURL(file)) setUploadedDataUrls(objectUrls) return () => { objectUrls.forEach((url: any) => URL.revokeObjectURL(url)) } }, [uploadedFiles]) const { data: profile } = useProfileQuery() const respondToEmail = profile?.primary_email ?? 'your email' if (!isInitialized) { return (
) } const selectedProject = sortedProjects.find((project) => project.ref === ref) const selectedCategory = CATEGORY_OPTIONS.find((option) => { if (option.value.toLowerCase() === ((category as string) ?? '').toLowerCase()) return option }) const initialProjectRef = selectedProject !== undefined ? selectedProject.ref : sortedProjects.length > 0 ? sortedProjects[0].ref : 'no-project' const initialOrganizationSlug = initialProjectRef !== 'no-project' ? sortedOrganizations.find((org) => { const project = sortedProjects.find((project) => project.ref === initialProjectRef) return org.id === project?.organization_id })?.slug : sortedOrganizations[0]?.slug const initialValues = { category: selectedCategory !== undefined ? selectedCategory.value : CATEGORY_OPTIONS[0].value, severity: 'Low', projectRef: initialProjectRef, organizationSlug: initialOrganizationSlug, 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) { ui.setNotification({ category: 'info', message: `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, { setSubmitting }: any) => { setSubmitting(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') { const URL = `${API_URL}/auth/${values.projectRef}/config` const authConfig = await get(URL) if (!authConfig.error) { payload.siteUrl = authConfig.SITE_URL payload.additionalRedirectUrls = authConfig.URI_ALLOW_LIST } } const response = await post(`${API_URL}/feedback/send`, payload) if (response.error) { ui.setNotification({ category: 'error', message: `Failed to submit support ticket: ${response.error.message}`, }) setSubmitting(false) } else { ui.setNotification({ category: 'success', message: 'Support request sent. Thank you!' }) setSentCategory(values.category) } } return (
{({ isSubmitting, 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 = (selectedProject?.subscription_tier ?? PRICING_TIER_PRODUCT_IDS.FREE) === PRICING_TIER_PRODUCT_IDS.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: sortedOrganizations[0]?.slug } resetForm({ values: updatedValues, initialValues: updatedValues }) } else if (selectedProject) { if (!selectedProject.subscription_tier) { app.projects.fetchSubscriptionTier(selectedProject as Project) } const organization = sortedOrganizations.find( (org) => org.id === selectedProject.organization_id ) if (organization) { const updatedValues = { ...values, organizationSlug: organization.slug } resetForm({ values: updatedValues, initialValues: updatedValues }) } } }, [values.projectRef]) return (

How can we help?

{CATEGORY_OPTIONS.map((option, i) => { return ( {option.label} {option.description} ) })}
{projects.map((option) => { const organization = sortedOrganizations.find( (x) => x.id === option.organization_id ) return ( {option.name} {organization?.name} ) })} {SEVERITY_OPTIONS.map((option: any) => { return ( {option.label} {option.description} ) })}
{selectedProject?.subscription_tier ? (

This project is on the{' '} {planNames[selectedProject?.subscription_tier]} tier

) : selectedProject?.ref !== 'no-project' ? (

Checking project's tier

) : ( <> )}
{values.projectRef === 'no-project' && sortedOrganizations.length > 0 && (
{sortedOrganizations.map((option) => { return ( {option.name} ) })}
)} {selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.FREE && (
} title="Expected response times are based on your project's tier" description={

Free tier support is available within the community and officially by the team on a best efforts basis, though we cannot guarantee a response time. For a guaranteed response time we recommend upgrading to the Pro tier. Enhanced SLAs for support are available on our Enterprise Tier.

} />
)} {/* {values.category === 'Problem' && ( <> )} */} {!isDisabled ? ( <> {['Performance'].includes(values.category) && isFreeProject ? ( ) : ( <>
{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

)}

Which services are affected?

{['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}.

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