mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
Refactor SupportForm to use react hook form + new form components (#28836)
* Dont hide org field when project is selected * Refactor SupportForm to use new form components and fix UX oddities identified * Add scroll area to docs search results * Address feedback * Small padding nudge * Add check --------- Co-authored-by: Terry Sutton <saltcod@gmail.com>
This commit is contained in:
1 parent
553af18172
commit
454fda86bf
13 files changed
+1164
-1162
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle, ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Button, IconAlertCircle, IconExternalLink } from 'ui'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
|
||||
@@ -16,19 +17,19 @@ const DisabledStateForFreeTier = ({
|
||||
<div className="px-6">
|
||||
<InformationBox
|
||||
hideCollapse
|
||||
defaultVisibility={true}
|
||||
icon={<IconAlertCircle className="text-foreground" size="large" strokeWidth={1.5} />}
|
||||
defaultVisibility
|
||||
icon={<AlertCircle className="text-foreground" size={18} strokeWidth={1.5} />}
|
||||
title={`Support for ${category} is only available on the Pro Plan`}
|
||||
description={
|
||||
<div className="space-y-4 mb-1">
|
||||
<p>Upgrade your project to the Pro Plan for support in this area</p>
|
||||
<p>Upgrade your organization to the Pro Plan for support in this area</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button asChild>
|
||||
<Link href={`/org/${organizationSlug}/billing?panel=subscriptionPlan`}>
|
||||
Upgrade project
|
||||
Upgrade plan
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild type="default" icon={<IconExternalLink size={14} />}>
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link href="https://supabase.com/pricing" target="_blank" rel="noreferrer">
|
||||
About the Pro Plan
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { type DocsSearchResult as Page, type DocsSearchResultSection as PageSection } from 'common'
|
||||
import { cn } from 'ui'
|
||||
import { TextHighlighterBase as TextHighlighter } from 'ui-patterns/CommandMenu'
|
||||
import { formatSectionUrl, generateLink, getPageIcon } from './SupportForm.utils'
|
||||
|
||||
const cardBaseClasses =
|
||||
'bg-200 rounded-lg hover:bg-surface-200 p-3 transition-colors hover:border-overlay hover:shadow-sm flex items-center justify-between'
|
||||
|
||||
interface DocsLinkGroup {
|
||||
page: Page
|
||||
}
|
||||
|
||||
export const DocsLinkGroup = ({ page }: DocsLinkGroup) => {
|
||||
const link = generateLink(page.type, page.path)
|
||||
|
||||
return (
|
||||
<ul key={page.id} className="grid gap-2">
|
||||
<li key={`${page.path}-group`} className="px-2">
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={link}
|
||||
className={cn(cardBaseClasses, 'flex items-center justify-between pr-5')}
|
||||
>
|
||||
<div className="grow flex gap-3 items-center">
|
||||
<div>{getPageIcon(page)}</div>
|
||||
<div className="flex flex-col gap-0 pr-6">
|
||||
<span className="text-sm">
|
||||
<TextHighlighter text={page.title} query="test" />
|
||||
</span>
|
||||
{(page.description || page.subtitle) && (
|
||||
<div className="text-xs text">
|
||||
<TextHighlighter text={page.description || page.subtitle || ''} query="test" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={18} />
|
||||
</Link>
|
||||
{page.sections.length > 0 && (
|
||||
<ul className="border-l border-default ml-3 pt-3 grid gap-2">
|
||||
{page.sections.map((section: PageSection, i) => (
|
||||
<DocsLinkSection
|
||||
key={`${page.path}__${section.heading}-item-${i}`}
|
||||
page={page}
|
||||
section={section}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
interface DocsLinkSection {
|
||||
page: Page
|
||||
section: PageSection
|
||||
}
|
||||
|
||||
const DocsLinkSection = ({ page, section }: DocsLinkSection) => {
|
||||
const sectionLink = formatSectionUrl(page, section)
|
||||
|
||||
return (
|
||||
<ul key={`${section.heading}-group`} className="grid gap-2">
|
||||
<li key={`${section.heading}-item`} className="p-2 mb-2">
|
||||
<Link target="_blank" href={sectionLink} className={cn(cardBaseClasses)}>
|
||||
<div className="grow flex gap-3 items-center">
|
||||
<div>{getPageIcon(page)}</div>
|
||||
<div className="grid gap-1.5">
|
||||
{page.type !== 'github-discussions' && (
|
||||
<span>
|
||||
<TextHighlighter
|
||||
className="not-italic text-xs rounded-full px-3 py-1 bg-surface-300 "
|
||||
text={section.heading}
|
||||
query="test"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{section.heading && (
|
||||
<div className="text text-xs ">
|
||||
<TextHighlighter text={section.heading} query="test" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={18} />
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ExternalLink, HelpCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui'
|
||||
|
||||
export const IPV4SuggestionAlert = () => {
|
||||
return (
|
||||
<Alert_Shadcn_ variant="default">
|
||||
<HelpCircle strokeWidth={2} />
|
||||
<AlertTitle_Shadcn_>Connection issues?</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="grid gap-3">
|
||||
<p>
|
||||
Having trouble connecting to your project? It could be related to our migration from
|
||||
PGBouncer and IPv4.
|
||||
</p>
|
||||
<p>
|
||||
Please review this GitHub discussion. It's up to date and covers many frequently asked
|
||||
questions.
|
||||
</p>
|
||||
<p>
|
||||
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/orgs/supabase/discussions/17817"
|
||||
>
|
||||
PGBouncer and IPv4 Deprecation #17817
|
||||
</Link>
|
||||
</Button>
|
||||
</p>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { CLIENT_LIBRARIES } from 'common'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Button } from 'ui'
|
||||
|
||||
interface LibrarySuggestionsProps {
|
||||
library: string
|
||||
}
|
||||
|
||||
export const LibrarySuggestions = ({ library }: LibrarySuggestionsProps) => {
|
||||
const selectedLibrary = CLIENT_LIBRARIES.find((lib) => lib.language === library)
|
||||
const selectedClientLibraries = selectedLibrary?.libraries.filter((library) =>
|
||||
library.name.includes('supabase-')
|
||||
)
|
||||
return (
|
||||
<div className="px-6 flex flex-col gap-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Found an issue or a bug? Try searching our Github issues or submit a new one.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 overflow-x-auto">
|
||||
{selectedClientLibraries?.map((lib) => {
|
||||
const libraryLanguage = library === 'Dart (Flutter)' ? lib.name.split('-')[1] : library
|
||||
return (
|
||||
<div
|
||||
key={lib.name}
|
||||
className="w-[230px] min-w-[230px] min-h-[128px] rounded border border-control bg-surface-100 space-y-3 px-4 py-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">{lib.name}</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
For issues regarding the {libraryLanguage} client library
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button asChild type="default" icon={<ExternalLink size={14} strokeWidth={1.5} />}>
|
||||
<Link href={lib.url} target="_blank" rel="noreferrer">
|
||||
View Github issues
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
className={[
|
||||
'px-4 py-3 rounded border border-control bg-surface-100',
|
||||
'w-[230px] min-w-[230px] min-h-[128px] flex flex-col justify-between space-y-3',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">supabase</p>
|
||||
<p className="text-sm text-foreground-light">For any issues about our API</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button asChild type="default" icon={<ExternalLink size={14} strokeWidth={1.5} />}>
|
||||
<Link href="https://github.com/supabase/supabase" target="_blank" rel="noreferrer">
|
||||
View Github issues
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { AlertCircle, ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
|
||||
interface PlanExpectationInfoBoxProps {
|
||||
orgSlug: string
|
||||
projectRef: string
|
||||
planId?: string
|
||||
}
|
||||
|
||||
export const PlanExpectationInfoBox = ({
|
||||
orgSlug,
|
||||
projectRef,
|
||||
planId,
|
||||
}: PlanExpectationInfoBoxProps) => {
|
||||
return (
|
||||
<InformationBox
|
||||
icon={<AlertCircle size={18} strokeWidth={2} />}
|
||||
defaultVisibility={true}
|
||||
hideCollapse={true}
|
||||
title={
|
||||
projectRef === 'no-project'
|
||||
? 'Please note that no project has been selected'
|
||||
: "Expected response times are based on your organization's plan"
|
||||
}
|
||||
{...(projectRef !== 'no-project' && {
|
||||
description: (
|
||||
<div className="flex flex-col gap-y-4 mb-1">
|
||||
{planId === 'free' && (
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{planId === 'pro' && (
|
||||
<p>
|
||||
Pro Plan includes email-based support. You can expect an answer within 1 business
|
||||
day in most situations 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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{planId === 'team' && (
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-y-2 sm:gap-x-2">
|
||||
<Button asChild>
|
||||
<Link href={`/org/${orgSlug}/billing?panel=subscriptionPlan`}>Upgrade project</Link>
|
||||
</Button>
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
href="https://supabase.com/contact/enterprise"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Enquire about Enterprise
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Check, ExternalLink, Mail, Search } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { Button, IconCheck, IconExternalLink, IconMail, IconSearch, Input, Separator } from 'ui'
|
||||
|
||||
import { CATEGORY_OPTIONS } from './Support.constants'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { Button, Input, Separator } from 'ui'
|
||||
import { CATEGORY_OPTIONS } from './Support.constants'
|
||||
|
||||
interface SuccessProps {
|
||||
sentCategory?: string
|
||||
@@ -30,12 +31,12 @@ const Success = ({
|
||||
return (
|
||||
<div className="mt-10 w-[620px] flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
<IconMail strokeWidth={1.5} size={60} className="text-brand" />
|
||||
<Mail strokeWidth={1.5} size={60} className="text-brand" />
|
||||
<div className="h-6 w-6 rounded-full bg-brand absolute bottom-1 -right-1.5 flex items-center justify-center">
|
||||
<IconCheck strokeWidth={4} size={18} />
|
||||
<Check strokeWidth={4} size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center flex-col space-y-2 text-center">
|
||||
<div className="flex items-center flex-col space-y-2 text-center p-4">
|
||||
<h3 className="text-xl">Support request successfully sent!</h3>
|
||||
<p className="text-sm text-foreground-light">
|
||||
We will reach out to you at <span className="text-foreground">{respondToEmail}</span>.
|
||||
@@ -64,14 +65,14 @@ const Success = ({
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
icon={<IconSearch size={16} strokeWidth={1.5} />}
|
||||
icon={<Search size={16} strokeWidth={1.5} />}
|
||||
actions={[
|
||||
<Button
|
||||
key="search"
|
||||
asChild
|
||||
className="mr-1"
|
||||
type="default"
|
||||
icon={<IconExternalLink size={16} strokeWidth={1.5} />}
|
||||
icon={<ExternalLink />}
|
||||
>
|
||||
<Link
|
||||
href={`https://github.com/supabase/supabase/discussions?discussions_q=${searchValue}`}
|
||||
|
||||
@@ -124,3 +124,18 @@ export const SERVICE_OPTIONS = [
|
||||
disabled: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const IPV4_MIGRATION_STRINGS = [
|
||||
'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',
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,57 +0,0 @@
|
||||
import { compact } from 'lodash'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
|
||||
const SUPPORT_API_URL = process.env.NEXT_PUBLIC_SUPPORT_API_URL || ''
|
||||
const SUPPORT_API_KEY = process.env.NEXT_PUBLIC_SUPPORT_ANON_KEY || ''
|
||||
|
||||
export const uploadAttachments = async (ref: string, files: File[]) => {
|
||||
const supportSupabaseClient = createClient(SUPPORT_API_URL, SUPPORT_API_KEY, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
// @ts-ignore
|
||||
multiTab: false,
|
||||
detectSessionInUrl: false,
|
||||
localStorage: {
|
||||
getItem: (key: string) => undefined,
|
||||
setItem: (key: string, value: string) => {},
|
||||
removeItem: (key: string) => {},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const filesToUpload = Array.from(files)
|
||||
const uploadedFiles = await Promise.all(
|
||||
filesToUpload.map(async (file) => {
|
||||
const suffix = file.type.split('/')[1]
|
||||
const prefix = `${ref}/${uuidv4()}.${suffix}`
|
||||
const options = { cacheControl: '3600' }
|
||||
|
||||
const { data, error } = await supportSupabaseClient.storage
|
||||
.from('support-attachments')
|
||||
.upload(prefix, file, options)
|
||||
|
||||
if (error) console.error('Failed to upload:', file.name, error)
|
||||
return data
|
||||
})
|
||||
)
|
||||
const keys = compact(uploadedFiles).map((file) => file.path)
|
||||
|
||||
const { data, error } = await supportSupabaseClient.storage
|
||||
.from('support-attachments')
|
||||
.createSignedUrls(keys, 10 * 365 * 24 * 60 * 60)
|
||||
if (error) {
|
||||
console.error('Failed to retrieve URLs for attachments', error)
|
||||
}
|
||||
return data ? data.map((file) => file.signedUrl) : []
|
||||
}
|
||||
|
||||
export const formatMessage = (message: string, attachments: string[]) => {
|
||||
if (attachments.length > 0) {
|
||||
const attachmentsImg = attachments.map((url) => `\n${url}`)
|
||||
return `${message}\n${attachmentsImg.join('')}`
|
||||
} else {
|
||||
return message
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { compact } from 'lodash'
|
||||
import { Book, Github, Hash, MessageSquare } from 'lucide-react'
|
||||
|
||||
import {
|
||||
DocsSearchResultType as PageType,
|
||||
type DocsSearchResult as Page,
|
||||
type DocsSearchResultSection as PageSection,
|
||||
} from 'common'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
|
||||
const SUPPORT_API_URL = process.env.NEXT_PUBLIC_SUPPORT_API_URL || ''
|
||||
const SUPPORT_API_KEY = process.env.NEXT_PUBLIC_SUPPORT_ANON_KEY || ''
|
||||
|
||||
export const uploadAttachments = async (ref: string, files: File[]) => {
|
||||
const supportSupabaseClient = createClient(SUPPORT_API_URL, SUPPORT_API_KEY, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
// @ts-ignore
|
||||
multiTab: false,
|
||||
detectSessionInUrl: false,
|
||||
localStorage: {
|
||||
getItem: (key: string) => undefined,
|
||||
setItem: (key: string, value: string) => {},
|
||||
removeItem: (key: string) => {},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const filesToUpload = Array.from(files)
|
||||
const uploadedFiles = await Promise.all(
|
||||
filesToUpload.map(async (file) => {
|
||||
const suffix = file.type.split('/')[1]
|
||||
const prefix = `${ref}/${uuidv4()}.${suffix}`
|
||||
const options = { cacheControl: '3600' }
|
||||
|
||||
const { data, error } = await supportSupabaseClient.storage
|
||||
.from('support-attachments')
|
||||
.upload(prefix, file, options)
|
||||
|
||||
if (error) console.error('Failed to upload:', file.name, error)
|
||||
return data
|
||||
})
|
||||
)
|
||||
const keys = compact(uploadedFiles).map((file) => file.path)
|
||||
|
||||
if (keys.length === 0) return []
|
||||
|
||||
const { data, error } = await supportSupabaseClient.storage
|
||||
.from('support-attachments')
|
||||
.createSignedUrls(keys, 10 * 365 * 24 * 60 * 60)
|
||||
if (error) {
|
||||
console.error('Failed to retrieve URLs for attachments', error)
|
||||
}
|
||||
return data ? data.map((file) => file.signedUrl) : []
|
||||
}
|
||||
|
||||
export const formatMessage = (message: string, attachments: string[]) => {
|
||||
if (attachments.length > 0) {
|
||||
const attachmentsImg = attachments.map((url) => `\n${url}`)
|
||||
return `${message}\n${attachmentsImg.join('')}`
|
||||
} else {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
export function getPageIcon(page: Page) {
|
||||
switch (page.type) {
|
||||
case PageType.Markdown:
|
||||
case PageType.Reference:
|
||||
case PageType.Integration:
|
||||
return <Book strokeWidth={1.5} className="!mr-0 !w-4 !h-4" />
|
||||
case PageType.GithubDiscussion:
|
||||
return <Github strokeWidth={1.5} className="!mr-0 !w-4 !h-4" />
|
||||
default:
|
||||
throw new Error(`Unknown page type '${page.type}'`)
|
||||
}
|
||||
}
|
||||
|
||||
export function getPageSectionIcon(page: Page) {
|
||||
switch (page.type) {
|
||||
case PageType.Markdown:
|
||||
case PageType.Reference:
|
||||
case PageType.Integration:
|
||||
return <Hash strokeWidth={1.5} className="!mr-0 !w-4 !h-4" />
|
||||
case PageType.GithubDiscussion:
|
||||
return <MessageSquare strokeWidth={1.5} className="!mr-0 !w-4 !h-4" />
|
||||
default:
|
||||
throw new Error(`Unknown page type '${page.type}'`)
|
||||
}
|
||||
}
|
||||
|
||||
export function generateLink(pageType: PageType, link: string): string {
|
||||
switch (pageType) {
|
||||
case PageType.Markdown:
|
||||
case PageType.Reference:
|
||||
return `https://supabase.com/docs${link}`
|
||||
case PageType.Integration:
|
||||
return `https://supabase.com${link}`
|
||||
case PageType.GithubDiscussion:
|
||||
return link
|
||||
default:
|
||||
throw new Error(`Unknown page type '${pageType}'`)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSectionUrl(page: Page, section: PageSection): string {
|
||||
switch (page.type) {
|
||||
case PageType.Markdown:
|
||||
case PageType.GithubDiscussion:
|
||||
return `${generateLink(page.type, page.path)}#${section.slug ?? ''}`
|
||||
case PageType.Reference:
|
||||
return `${generateLink(page.type, page.path)}/${section.slug ?? ''}`
|
||||
case PageType.Integration:
|
||||
return generateLink(page.type, page.path) // Assuming no section slug for Integration pages
|
||||
default:
|
||||
throw new Error(`Unknown page type '${page.type}'`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
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<Page[]>([])
|
||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
|
||||
const [uploadedDataUrls, setUploadedDataUrls] = useState<string[]>([])
|
||||
|
||||
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<z.infer<typeof FormSchema>>({
|
||||
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<Project>,
|
||||
]
|
||||
const hasResults =
|
||||
state.status === 'fullResults' ||
|
||||
state.status === 'partialResults' ||
|
||||
(state.status === 'loading' && state.staleResults.length > 0)
|
||||
|
||||
const onFilesUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
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<z.infer<typeof FormSchema>> = 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) {
|
||||
form.setValue('organizationSlug', organizations[0].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 (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id="support-form"
|
||||
className={cn('flex flex-col gap-y-8')}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<h3 className={cn(CONTAINER_CLASSES, 'text-xl')}>How can we help?</h3>
|
||||
|
||||
<FormField_Shadcn_
|
||||
name="organizationSlug"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className={cn(CONTAINER_CLASSES)}
|
||||
layout="vertical"
|
||||
label="Which organization is affected?"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_
|
||||
{...field}
|
||||
disabled={isLoadingOrganizations}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger_Shadcn_ className="w-full">
|
||||
<SelectValue_Shadcn_ asChild placeholder="Select an organization">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{(organizations ?? []).find((o) => o.slug === field.value)?.name}
|
||||
{isLoadingSubscription && <Loader2 size={14} className="animate-spin" />}
|
||||
{isSuccessSubscription && (
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{subscriptionPlanId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectValue_Shadcn_>
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
{organizations?.map((org) => (
|
||||
<SelectItem_Shadcn_ value={org.slug}>{org.name}</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className={cn(CONTAINER_CLASSES, 'flex flex-col gap-y-2')}>
|
||||
<FormField_Shadcn_
|
||||
name="projectRef"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout layout="vertical" label="Which project is affected?">
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_
|
||||
{...field}
|
||||
disabled={isLoadingProjects}
|
||||
defaultValue={field.value}
|
||||
onValueChange={(val) => {
|
||||
if (val.length > 0) field.onChange(val)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger_Shadcn_ className="w-full">
|
||||
<SelectValue_Shadcn_ placeholder="Select a project" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
{projects?.map((project) => (
|
||||
<SelectItem_Shadcn_ value={project.ref as string}>
|
||||
{project.name}
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
{organizationSlug &&
|
||||
subscriptionPlanId !== 'enterprise' &&
|
||||
category !== 'Login_issues' && (
|
||||
<PlanExpectationInfoBox
|
||||
orgSlug={organizationSlug}
|
||||
projectRef={projectRef}
|
||||
planId={subscriptionPlanId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
CONTAINER_CLASSES,
|
||||
'grid sm:grid-cols-2 sm:grid-rows-1 gap-4 grid-cols-1 grid-rows-2'
|
||||
)}
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
name="category"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout layout="vertical" label="What areas are you having problems with?">
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_
|
||||
{...field}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger_Shadcn_ className="w-full">
|
||||
<SelectValue_Shadcn_>
|
||||
{CATEGORY_OPTIONS.find((o) => o.value === field.value)?.label}
|
||||
</SelectValue_Shadcn_>
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
{CATEGORY_OPTIONS.map((option) => (
|
||||
<SelectItem_Shadcn_ value={option.value}>
|
||||
{option.label}
|
||||
<span className="block text-xs text-foreground-lighter">
|
||||
{option.description}
|
||||
</span>
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
name="severity"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout layout="vertical" label="Severity">
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_
|
||||
{...field}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger_Shadcn_ className="w-full">
|
||||
<SelectValue_Shadcn_ placeholder="Select a project">
|
||||
{field.value}
|
||||
</SelectValue_Shadcn_>
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
{SEVERITY_OPTIONS.map((option) => (
|
||||
<SelectItem_Shadcn_ value={option.value}>
|
||||
{option.label}
|
||||
<span className="block text-xs text-foreground-lighter">
|
||||
{option.description}
|
||||
</span>
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
{(severity === 'Urgent' || severity === 'High') && (
|
||||
<p className="text-sm text-foreground-light mt-2 sm:col-span-2">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{!enableFreeSupport || (category === 'Performance' && subscriptionPlanId === 'free') ? (
|
||||
<DisabledStateForFreeTier
|
||||
category={CATEGORY_OPTIONS.find((o) => o.value === category)?.label ?? ''}
|
||||
organizationSlug={organizationSlug}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={cn(CONTAINER_CLASSES, 'flex flex-col gap-y-2')}>
|
||||
<FormField_Shadcn_
|
||||
name="subject"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="vertical"
|
||||
label="Subject"
|
||||
description={
|
||||
field.value.length > 0 &&
|
||||
INCLUDE_DISCUSSIONS.includes(category) && (
|
||||
<p className="flex items-center gap-x-1">
|
||||
<span>Check our </span>
|
||||
<Link
|
||||
key="gh-discussions"
|
||||
href={`https://github.com/orgs/supabase/discussions?discussions_q=${field.value}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-x-1 text-foreground-light underline hover:text-foreground transition"
|
||||
>
|
||||
Github discussions
|
||||
<ExternalLink size={14} strokeWidth={2} />
|
||||
</Link>
|
||||
<span> for a quick answer</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Summary of the problem you have" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
{docsResults.length > 0 && hasResults && (
|
||||
<div className="pt-4 px-4 border rounded-md">
|
||||
<h2 className="text-sm text-foreground-light px-2 mb-4">
|
||||
Suggested resources ({Math.min(docsResults.length, 5)})
|
||||
</h2>
|
||||
<ScrollArea className={docsResults.length > 3 ? 'h-[300px]' : ''}>
|
||||
{docsResults.slice(0, 5).map((page, i) => (
|
||||
<DocsLinkGroup key={`${page.id}-group`} page={page} />
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{category === 'Problem' && (
|
||||
<FormField_Shadcn_
|
||||
name="library"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className={cn(CONTAINER_CLASSES)}
|
||||
layout="vertical"
|
||||
label="Which library are you having issues with"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_
|
||||
{...field}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger_Shadcn_ className="w-full">
|
||||
<SelectValue_Shadcn_ placeholder="Please select a library" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
{CLIENT_LIBRARIES.map((option) => (
|
||||
<SelectItem_Shadcn_ value={option.language}>
|
||||
{option.language}
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{library.length > 0 && <LibrarySuggestions library={library} />}
|
||||
|
||||
{category !== 'Login_issues' && (
|
||||
<FormField_Shadcn_
|
||||
name="affectedServices"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className={cn(CONTAINER_CLASSES)}
|
||||
layout="vertical"
|
||||
label="Which services are affected?"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<MultiSelectV2
|
||||
options={SERVICE_OPTIONS}
|
||||
value={field.value.length === 0 ? [] : field.value?.split(', ')}
|
||||
placeholder="No particular service"
|
||||
searchPlaceholder="Search for a service"
|
||||
onChange={(services) =>
|
||||
form.setValue('affectedServices', services.join(', '))
|
||||
}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField_Shadcn_
|
||||
name="message"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className={cn(CONTAINER_CLASSES)}
|
||||
layout="vertical"
|
||||
label="Message"
|
||||
labelOptional="5000 character limit"
|
||||
description={
|
||||
IPV4_MIGRATION_STRINGS.some((str) => field.value.includes(str)) && (
|
||||
<IPV4SuggestionAlert />
|
||||
)
|
||||
}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<TextArea_Shadcn_
|
||||
{...field}
|
||||
rows={4}
|
||||
maxLength={5000}
|
||||
placeholder="Describe the issue you're facing, along with any relevant information. Please be as detailed and specific as possible."
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
{['Problem', 'Database_unresponsive', 'Performance'].includes(category) && (
|
||||
<FormField_Shadcn_
|
||||
name="allowSupportAccess"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex"
|
||||
className={cn(CONTAINER_CLASSES)}
|
||||
label="Allow Supabase Support to access your project temporarily"
|
||||
description="In some cases, we may require temporary access to your project to complete troubleshooting, or to answer questions related specifically to your project"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Checkbox_Shadcn_
|
||||
{...field}
|
||||
value={String(field.value)}
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => field.onChange(value)}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={cn(CONTAINER_CLASSES)}>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<p className="text-sm text-foreground-light">Attachments</p>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Upload up to {MAX_ATTACHMENTS} screenshots that might be relevant to the issue
|
||||
that you're facing
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
multiple
|
||||
type="file"
|
||||
ref={uploadButtonRef}
|
||||
className="hidden"
|
||||
accept="image/png, image/jpeg"
|
||||
onChange={onFilesUpload}
|
||||
/>
|
||||
<div className="flex items-center gap-x-2 mt-4">
|
||||
{uploadedDataUrls.map((x: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{ backgroundImage: `url("${x}")` }}
|
||||
className="relative h-14 w-14 rounded bg-cover bg-center bg-no-repeat"
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
|
||||
'absolute -top-1 -right-1 cursor-pointer',
|
||||
].join(' ')}
|
||||
onClick={() => removeUploadedFile(idx)}
|
||||
>
|
||||
<X size={12} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{uploadedFiles.length < MAX_ATTACHMENTS && (
|
||||
<div
|
||||
className={[
|
||||
'border border-stronger opacity-50 transition hover:opacity-100',
|
||||
'group flex h-14 w-14 cursor-pointer items-center justify-center rounded',
|
||||
].join(' ')}
|
||||
onClick={() => {
|
||||
if (uploadButtonRef.current) (uploadButtonRef.current as any).click()
|
||||
}}
|
||||
>
|
||||
<Plus strokeWidth={2} size={20} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(CONTAINER_CLASSES)}>
|
||||
<div className="flex items-center space-x-1 justify-end block text-sm mt-0 mb-2">
|
||||
<p className="text-foreground-light">We will contact you at</p>
|
||||
<p className="text-foreground font-medium">{respondToEmail}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 justify-end block text-sm mt-0 mb-2">
|
||||
<p className="text-foreground-light">
|
||||
Please ensure you haven't blocked Hubspot in your emails
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
htmlType="submit"
|
||||
size="small"
|
||||
icon={<Mail />}
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Send support request
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ExternalLink, Maximize2, Minimize2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { Button, IconExternalLink, IconMaximize2, IconMinimize2 } from 'ui'
|
||||
|
||||
import { Button } from 'ui'
|
||||
|
||||
interface InformationBoxProps {
|
||||
icon?: ReactNode
|
||||
@@ -48,9 +50,9 @@ const InformationBox = ({
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<IconMinimize2 size={14} strokeWidth={1.5} />
|
||||
<Minimize2 size={14} strokeWidth={1.5} />
|
||||
) : (
|
||||
<IconMaximize2 size={14} strokeWidth={1.5} />
|
||||
<Maximize2 size={14} strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -66,7 +68,7 @@ const InformationBox = ({
|
||||
|
||||
{url && (
|
||||
<div>
|
||||
<Button asChild type="default" icon={<IconExternalLink />}>
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link href={url} target="_blank" rel="noreferrer">
|
||||
{urlLabel}
|
||||
</Link>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Loader2, Wrench } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import SVG from 'react-inlinesvg'
|
||||
|
||||
import Success from 'components/interfaces/Support/Success'
|
||||
import SupportForm from 'components/interfaces/Support/SupportForm'
|
||||
import { SupportFormV2 } from 'components/interfaces/Support/SupportFormV2'
|
||||
import { usePlatformStatusQuery } from 'data/platform/platform-status-query'
|
||||
import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Button, IconLoader, IconTool } from 'ui'
|
||||
import { Button, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui'
|
||||
|
||||
const SupportPage = () => {
|
||||
const [sentCategory, setSentCategory] = useState<string>()
|
||||
@@ -28,8 +28,8 @@ const SupportPage = () => {
|
||||
<SVG src={`${BASE_PATH}/img/supabase-logo.svg`} className="h-4 w-4" />
|
||||
<h1 className="m-0 text-lg">Supabase support</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button asChild type="default" icon={<IconTool />}>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Button asChild type="default" icon={<Wrench />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/platform/troubleshooting"
|
||||
target="_blank"
|
||||
@@ -38,14 +38,14 @@ const SupportPage = () => {
|
||||
Troubleshooting
|
||||
</Link>
|
||||
</Button>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
icon={
|
||||
isLoading ? (
|
||||
<IconLoader className="animate-spin" />
|
||||
<Loader2 className="animate-spin" />
|
||||
) : isHealthy ? (
|
||||
<div className="h-2 w-2 bg-brand rounded-full" />
|
||||
) : (
|
||||
@@ -61,23 +61,14 @@ const SupportPage = () => {
|
||||
: 'Active incident ongoing'}
|
||||
</Link>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-alternative py-1 px-2 leading-none shadow',
|
||||
'border border-background',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-foreground">Check Supabase status page</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom" align="center">
|
||||
Check Supabase status page
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={[
|
||||
'min-w-full w-full space-y-12 rounded border bg-panel-body-light shadow-md',
|
||||
@@ -92,7 +83,7 @@ const SupportPage = () => {
|
||||
projects={projectsData}
|
||||
/>
|
||||
) : (
|
||||
<SupportForm
|
||||
<SupportFormV2
|
||||
setSentCategory={setSentCategory}
|
||||
setSelectedProject={setSelectedProject}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user