ProjectAndPlanInfo.tsx214 lines · main
1// End of third-party imports
2
3import { useParams } from 'common'
4import { AnimatePresence, motion } from 'framer-motion'
5import { Check, ChevronsUpDown, ExternalLink } from 'lucide-react'
6import Link from 'next/link'
7import type { UseFormReturn } from 'react-hook-form'
8import { toast } from 'sonner'
9import { Button, cn, CommandGroup, CommandItem, FormControl, FormField } from 'ui'
10import { Admonition } from 'ui-patterns'
11import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
12import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
13
14import type { ExtendedSupportCategories } from './Support.constants'
15import type { SupportFormValues } from './SupportForm.schema'
16import { NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils'
17import CopyButton from '@/components/ui/CopyButton'
18import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
19import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
20
21interface ProjectAndPlanProps {
22 form: UseFormReturn<SupportFormValues>
23 orgSlug: string | null
24 projectRef: string | null
25 category: ExtendedSupportCategories
26 subscriptionPlanId: string | undefined
27}
28
29export function ProjectAndPlanInfo({
30 form,
31 orgSlug,
32 projectRef,
33 category: _category,
34 subscriptionPlanId: _subscriptionPlanId,
35}: ProjectAndPlanProps) {
36 const hasProjectSelected = projectRef && projectRef !== NO_PROJECT_MARKER
37
38 return (
39 <div className="flex flex-col gap-y-2">
40 <ProjectSelector form={form} orgSlug={orgSlug} projectRef={projectRef} />
41 <ProjectRefHighlighted projectRef={projectRef} />
42
43 {!hasProjectSelected && (
44 <Admonition type="default" description="No project has been selected." />
45 )}
46 </div>
47 )
48}
49
50interface ProjectSelectorProps {
51 form: UseFormReturn<SupportFormValues>
52 orgSlug: string | null
53 projectRef: string | null
54}
55
56function ProjectSelector({ form, orgSlug, projectRef }: ProjectSelectorProps) {
57 const { projectRef: urlProjectRef } = useParams()
58
59 return (
60 <FormField
61 name="projectRef"
62 control={form.control}
63 render={({ field }) => (
64 <FormItemLayout hideMessage layout="vertical" label="Which project is affected?">
65 <FormControl>
66 <OrganizationProjectSelector
67 key={orgSlug}
68 sameWidthAsTrigger
69 fetchOnMount
70 checkPosition="left"
71 slug={!orgSlug || orgSlug === NO_ORG_MARKER ? undefined : orgSlug}
72 selectedRef={field.value}
73 onInitialLoad={(projects) => {
74 if (!urlProjectRef && (!projectRef || projectRef === NO_PROJECT_MARKER))
75 field.onChange(projects[0]?.ref ?? NO_PROJECT_MARKER)
76 }}
77 onSelect={(project) => field.onChange(project.ref)}
78 renderTrigger={({ isLoading, project, listboxId, open }) => {
79 return (
80 <Button
81 block
82 type="default"
83 role="combobox"
84 aria-label="Select a project"
85 aria-expanded={open}
86 aria-controls={listboxId}
87 size="small"
88 className="justify-between"
89 iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
90 >
91 {!!orgSlug && isLoading ? (
92 <ShimmeringLoader className="w-44 py-2" />
93 ) : !field.value || field.value === NO_PROJECT_MARKER ? (
94 'No specific project'
95 ) : (
96 (project?.name ?? 'Unknown project')
97 )}
98 </Button>
99 )
100 }}
101 renderActions={(setOpen) => (
102 <CommandGroup>
103 <CommandItem
104 className="w-full gap-x-2"
105 onSelect={() => {
106 field.onChange(NO_PROJECT_MARKER)
107 setOpen(false)
108 }}
109 >
110 {field.value === NO_PROJECT_MARKER && <Check size={16} />}
111 <p className={cn(field.value !== NO_PROJECT_MARKER && 'ml-6')}>
112 No specific project
113 </p>
114 </CommandItem>
115 </CommandGroup>
116 )}
117 />
118 </FormControl>
119 </FormItemLayout>
120 )}
121 />
122 )
123}
124
125interface ProjectRefHighlightedProps {
126 projectRef: string | null
127}
128
129function ProjectRefHighlighted({ projectRef }: ProjectRefHighlightedProps) {
130 const isVisible = !!projectRef && projectRef !== NO_PROJECT_MARKER
131
132 return (
133 <AnimatePresence>
134 {isVisible && (
135 <motion.div
136 initial={{ opacity: 0, height: 0 }}
137 animate={{ opacity: 1, height: 'auto' }}
138 exit={{ opacity: 0, height: 0 }}
139 transition={{ duration: 0.3 }}
140 className="flex items-center gap-x-1"
141 >
142 <p className="text-sm transition text-foreground-lighter">
143 Project ID:{' '}
144 <code className="text-code-inline text-foreground-light!">{projectRef}</code>
145 </p>
146 <CopyButton
147 iconOnly
148 type="text"
149 text={projectRef}
150 onClick={() => toast.success('Copied project ID to clipboard')}
151 />
152 </motion.div>
153 )}
154 </AnimatePresence>
155 )
156}
157
158interface PlanExpectationInfoContentProps {
159 orgSlug: string
160 planId?: string
161}
162
163export const PlanExpectationInfoContent = ({
164 orgSlug,
165 planId,
166}: PlanExpectationInfoContentProps) => {
167 const { billingAll } = useIsFeatureEnabled(['billing:all'])
168 const shouldShowUpgradeActions = billingAll && planId !== 'enterprise'
169
170 return (
171 <div className="flex flex-col gap-y-3 text-sm text-foreground-light">
172 {planId === 'free' && (
173 <p>
174 Support on the Free plan is provided through the community and by the team on a
175 best-effort basis. For a guaranteed response time, we recommend upgrading to the Pro plan.
176 Enhanced support SLAs are available on the Enterprise plan.
177 </p>
178 )}
179
180 {planId === 'pro' && (
181 <p>
182 Pro includes email support with typical 1-business-day responses; upgrade to Team for
183 prioritized ticketing and engineering escalation, or Enterprise for enhanced SLAs.
184 </p>
185 )}
186
187 {planId === 'team' && (
188 <p>
189 The Team plan includes email support with prioritized ticketing and escalation to product
190 engineering. Low, normal, and high-severity tickets are typically handled within 1
191 business day. Urgent issues are handled within 1 day, 365 days a year. Enhanced support
192 SLAs are available on the Enterprise plan.
193 </p>
194 )}
195
196 {shouldShowUpgradeActions && (
197 <div className="flex flex-wrap gap-2 pt-1">
198 <Button asChild size="tiny">
199 <Link
200 href={`/org/${orgSlug}/billing?panel=subscriptionPlan&source=planSupportExpectationInfoBox`}
201 >
202 Upgrade plan
203 </Link>
204 </Button>
205 <Button asChild type="default" size="tiny" icon={<ExternalLink />}>
206 <Link href="https://supabase.com/contact/enterprise" target="_blank" rel="noreferrer">
207 Enquire about Enterprise
208 </Link>
209 </Button>
210 </div>
211 )}
212 </div>
213 )
214}