ProjectAndPlanInfo.tsx214 lines · main
| 1 | // End of third-party imports |
| 2 | |
| 3 | import { useParams } from 'common' |
| 4 | import { AnimatePresence, motion } from 'framer-motion' |
| 5 | import { Check, ChevronsUpDown, ExternalLink } from 'lucide-react' |
| 6 | import Link from 'next/link' |
| 7 | import type { UseFormReturn } from 'react-hook-form' |
| 8 | import { toast } from 'sonner' |
| 9 | import { Button, cn, CommandGroup, CommandItem, FormControl, FormField } from 'ui' |
| 10 | import { Admonition } from 'ui-patterns' |
| 11 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 12 | import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' |
| 13 | |
| 14 | import type { ExtendedSupportCategories } from './Support.constants' |
| 15 | import type { SupportFormValues } from './SupportForm.schema' |
| 16 | import { NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils' |
| 17 | import CopyButton from '@/components/ui/CopyButton' |
| 18 | import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector' |
| 19 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 20 | |
| 21 | interface ProjectAndPlanProps { |
| 22 | form: UseFormReturn<SupportFormValues> |
| 23 | orgSlug: string | null |
| 24 | projectRef: string | null |
| 25 | category: ExtendedSupportCategories |
| 26 | subscriptionPlanId: string | undefined |
| 27 | } |
| 28 | |
| 29 | export 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 | |
| 50 | interface ProjectSelectorProps { |
| 51 | form: UseFormReturn<SupportFormValues> |
| 52 | orgSlug: string | null |
| 53 | projectRef: string | null |
| 54 | } |
| 55 | |
| 56 | function 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 | |
| 125 | interface ProjectRefHighlightedProps { |
| 126 | projectRef: string | null |
| 127 | } |
| 128 | |
| 129 | function 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 | |
| 158 | interface PlanExpectationInfoContentProps { |
| 159 | orgSlug: string |
| 160 | planId?: string |
| 161 | } |
| 162 | |
| 163 | export 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 | } |