PlanUpdateSidePanel.tsx447 lines · main
1// @ts-nocheck
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { useDebounce } from '@uidotdev/usehooks'
4import { useParams } from 'common'
5import { StudioPricingSidePanelOpenedEvent } from 'common/telemetry-constants'
6import { isArray } from 'lodash'
7import { Check, ExternalLink } from 'lucide-react'
8import { useRouter } from 'next/router'
9import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
10import { plans as subscriptionsPlans } from 'shared-data/plans'
11import { Button, cn, SidePanel } from 'ui'
12import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
13
14import DowngradeModal from './DowngradeModal'
15import { EnterpriseCard } from './EnterpriseCard'
16import { ExitSurveyModal } from './ExitSurveyModal'
17import MembersExceedLimitModal from './MembersExceedLimitModal'
18import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog'
19import UpgradeSurveyModal from './UpgradeModal'
20import { STRIPE_PROJECTS_DOCS_URL } from '@/components/interfaces/Billing/Payment/PaymentMethods/StripePaymentConnection'
21import { getPlanChangeType } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
22import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
23import PartnerManagedResource from '@/components/ui/PartnerManagedResource'
24import { RequestUpgradeToBillingOwners } from '@/components/ui/RequestUpgradeToBillingOwners'
25import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query'
26import { isPartnerBillingOrganization } from '@/data/organizations/managed-by-utils'
27import { useOrganizationBillingSubscriptionPreview } from '@/data/organizations/organization-billing-subscription-preview'
28import { useOrganizationQuery } from '@/data/organizations/organization-query'
29import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
30import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
31import { useOrgPlansQuery } from '@/data/subscriptions/org-plans-query'
32import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
33import type { OrgPlan } from '@/data/subscriptions/types'
34import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
35import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
36import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
37import { MANAGED_BY } from '@/lib/constants/infrastructure'
38import { formatCurrency } from '@/lib/helpers'
39import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings'
40import { Organization } from '@/types/base'
41
42const getPartnerManagedResourceCta = (selectedOrganization: Organization) => {
43 if (selectedOrganization.managed_by === MANAGED_BY.VERCEL_MARKETPLACE) {
44 return {
45 installationId: selectedOrganization?.partner_id,
46 path: '/settings',
47 message: 'Change plan on Vercel Marketplace',
48 }
49 }
50 if (selectedOrganization.managed_by === MANAGED_BY.AWS_MARKETPLACE) {
51 return {
52 organizationSlug: selectedOrganization?.slug,
53 }
54 }
55}
56
57const getStripeProjectsUpgradeCommand = (planId: string | null | undefined) => {
58 const currentTier = planId ?? 'free'
59 const action = currentTier === 'team' ? 'downgrade' : 'upgrade'
60 return `stripe projects ${action} briven/${currentTier}`
61}
62
63export const PlanUpdateSidePanel = () => {
64 const router = useRouter()
65 const { slug } = useParams()
66 const { data: selectedOrganization } = useSelectedOrganizationQuery()
67 const isPartnerBilledOrganization = isPartnerBillingOrganization(
68 selectedOrganization?.billing_partner
69 )
70 const isStripeManagedOrganization =
71 selectedOrganization?.managed_by === MANAGED_BY.STRIPE_PROJECTS
72 const { mutate: sendEvent } = useSendEventMutation()
73
74 const originalPlanRef = useRef<string>(undefined)
75
76 const [showExitSurvey, setShowExitSurvey] = useState(false)
77 const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false)
78 const [showDowngradeError, setShowDowngradeError] = useState(false)
79 const [selectedTier, setSelectedTier] = useState<'tier_free' | 'tier_pro' | 'tier_team'>()
80 const [latestAddress, setLatestAddress] = useState<CustomerAddress>()
81 const [latestTaxId, setLatestTaxId] = useState<CustomerTaxId | null>()
82 const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
83
84 const billingAddress = useAsDefaultBillingAddress ? latestAddress : undefined
85 const billingTaxId = useAsDefaultBillingAddress ? latestTaxId : null
86 const debouncedAddress = useDebounce(billingAddress, 1000)
87 const debouncedTaxId = useDebounce(billingTaxId, 1000)
88
89 const handleAddressChange = useCallback(
90 (address: CustomerAddress) => setLatestAddress(address),
91 []
92 )
93
94 const handleTaxIdChange = useCallback((taxId: CustomerTaxId | null) => setLatestTaxId(taxId), [])
95
96 const handleUseAsDefaultBillingAddressChange = useCallback(
97 (useAsDefault: boolean) => setUseAsDefaultBillingAddress(useAsDefault),
98 []
99 )
100
101 const { can: canUpdateSubscription } = useAsyncCheckPermissions(
102 PermissionAction.BILLING_WRITE,
103 'stripe.subscriptions'
104 )
105
106 const snap = useOrgSettingsPageStateSnapshot()
107 const visible = snap.panelKey === 'subscriptionPlan'
108
109 const { data: orgProjectsData } = useOrgProjectsInfiniteQuery({ slug }, { enabled: visible })
110 const orgProjects =
111 useMemo(
112 () => orgProjectsData?.pages.flatMap((page) => page.projects),
113 [orgProjectsData?.pages]
114 ) || []
115
116 const { data } = useOrganizationQuery({ slug })
117 const hasOrioleProjects = !!data?.has_oriole_project
118
119 const onClose = () => {
120 const { panel, ...queryWithoutPanel } = router.query
121 router.push({ pathname: router.pathname, query: queryWithoutPanel }, undefined, {
122 shallow: true,
123 })
124 snap.setPanelKey(undefined)
125 }
126
127 const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({
128 orgSlug: slug,
129 })
130 const { data: plans, isPending: isLoadingPlans } = useOrgPlansQuery(
131 { orgSlug: slug },
132 { enabled: visible }
133 )
134 const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery(
135 { slug },
136 { enabled: visible }
137 )
138
139 const subscriptionPreviewData = useOrganizationBillingSubscriptionPreview({
140 tier: selectedTier,
141 organizationSlug: slug,
142 address: debouncedAddress,
143 taxId: debouncedTaxId ?? undefined,
144 })
145
146 const availablePlans: OrgPlan[] = plans?.plans ?? []
147 const hasMembersExceedingFreeTierLimit =
148 (membersExceededLimit || []).length > 0 &&
149 // [Joshen] Note that orgProjects is paginated so there's a chance this may omit certain projects
150 // Although I don't foresee this affecting a majority of users. Ideally perhaps we could return
151 // this data from the organization query
152 orgProjects.filter((it) => it.status !== 'INACTIVE' && it.status !== 'GOING_DOWN').length > 0
153
154 useEffect(() => {
155 if (visible) {
156 setSelectedTier(undefined)
157 setLatestAddress(undefined)
158 setLatestTaxId(undefined)
159 setUseAsDefaultBillingAddress(true)
160 const source = Array.isArray(router.query.source)
161 ? router.query.source[0]
162 : router.query.source
163 const properties: StudioPricingSidePanelOpenedEvent['properties'] = {
164 currentPlan: subscription?.plan?.name,
165 }
166 if (source) {
167 properties.origin = source
168 }
169 sendEvent({
170 action: 'studio_pricing_side_panel_opened',
171 properties,
172 groups: { organization: slug ?? 'Unknown' },
173 })
174 }
175 // eslint-disable-next-line react-hooks/exhaustive-deps
176 }, [visible])
177
178 useEffect(() => {
179 if (visible && isSuccessSubscription && subscription.plan.id) {
180 originalPlanRef.current = subscription.plan.id
181 }
182 }, [visible, isSuccessSubscription, subscription?.plan.id])
183
184 const onConfirmDowngrade = () => {
185 setSelectedTier(undefined)
186 if (hasMembersExceedingFreeTierLimit) {
187 setShowDowngradeError(true)
188 } else {
189 setShowExitSurvey(true)
190 }
191 }
192
193 const planMeta = selectedTier
194 ? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1])
195 : null
196
197 const currentPlanMeta = {
198 ...availablePlans.find((p) => p.id === subscription?.plan?.id),
199 features:
200 subscriptionsPlans.find((plan) => plan.id === `tier_${subscription?.plan?.id}`)?.features ||
201 [],
202 }
203
204 const stripeProjectsUpgradeCommand = getStripeProjectsUpgradeCommand(
205 selectedOrganization?.plan?.id ?? subscription?.plan?.id
206 )
207
208 return (
209 <>
210 <SidePanel
211 hideFooter
212 size="xxlarge"
213 visible={visible}
214 onCancel={() => onClose()}
215 header={
216 <div className="flex items-center justify-between w-full">
217 <h4>Change subscription plan for {selectedOrganization?.name}</h4>
218 <Button asChild type="default" icon={<ExternalLink />}>
219 <a href="https://supabase.com/pricing" target="_blank" rel="noreferrer">
220 Pricing
221 </a>
222 </Button>
223 </div>
224 }
225 >
226 {selectedOrganization &&
227 (isStripeManagedOrganization ? (
228 <PartnerManagedResource
229 managedBy={MANAGED_BY.STRIPE_PROJECTS}
230 resource="Organization plans"
231 title="Organization plans are managed through Stripe."
232 details={
233 <>
234 Run <code className="text-code-inline">{stripeProjectsUpgradeCommand}</code> in
235 your project directory.
236 </>
237 }
238 cta={{
239 overrideUrl: `${STRIPE_PROJECTS_DOCS_URL}#upgrade-a-service-tier`,
240 message: 'Stripe Projects docs',
241 }}
242 />
243 ) : isPartnerBilledOrganization ? (
244 <PartnerManagedResource
245 managedBy={selectedOrganization.managed_by}
246 resource="Organization plans"
247 cta={getPartnerManagedResourceCta(selectedOrganization)}
248 />
249 ) : null)}
250 <SidePanel.Content>
251 <div className="py-6 grid grid-cols-12 gap-3">
252 {subscriptionsPlans.map((plan) => {
253 const planMeta = availablePlans.find((p) => p.id === plan.id.split('tier_')[1])
254 const price = planMeta?.price ?? 0
255 const isDowngradeOption =
256 getPlanChangeType(subscription?.plan.id, plan?.planId) === 'downgrade'
257 const isCurrentPlan = planMeta?.id === subscription?.plan?.id
258 const features = plan.features
259 const footer = plan.footer
260
261 const source = Array.isArray(router.query.source)
262 ? router.query.source[0]
263 : router.query.source
264 // TODO this panel should allow direct configuration of the highlighting rather than indirectly via the source param
265 const shouldHighlight = source === 'log-drains-empty-state' && plan.id === 'tier_pro'
266
267 if (plan.id === 'tier_enterprise') {
268 return <EnterpriseCard key={plan.id} plan={plan} isCurrentPlan={isCurrentPlan} />
269 }
270
271 return (
272 <div
273 key={plan.id}
274 className={cn(
275 'px-4 py-4 flex flex-col items-start justify-between',
276 'border rounded-md col-span-12 md:col-span-4 bg-surface-200',
277 shouldHighlight &&
278 'ring-4 ring-brand animate-[pulse_1.5s_ease-in-out_1] shadow-md shadow-brand/40'
279 )}
280 >
281 <div className="w-full">
282 <div className="flex items-center space-x-2">
283 <p className="text-brand-link text-sm uppercase">{plan.name}</p>
284 {isCurrentPlan ? (
285 <div className="text-xs bg-surface-300 text-foreground-light rounded-sm px-2 py-0.5">
286 Current plan
287 </div>
288 ) : plan.nameBadge ? (
289 <div className="text-xs bg-brand-300 dark:bg-brand-400 text-brand-600 rounded-sm px-2 py-0.5">
290 {plan.nameBadge}
291 </div>
292 ) : null}
293 </div>
294 <div className="mt-4 flex items-center space-x-1 mb-4">
295 {(price ?? 0) > 0 && <p className="text-foreground-light text-sm">From</p>}
296 {isLoadingPlans ? (
297 <div className="h-[28px] flex items-center justify-center">
298 <ShimmeringLoader className="w-[30px] h-[24px]" />
299 </div>
300 ) : (
301 <p className="text-foreground text-lg" translate="no">
302 {formatCurrency(price)}
303 </p>
304 )}
305 <p className="text-foreground-light text-sm">{plan.costUnit}</p>
306 </div>
307 {isCurrentPlan ? (
308 <Button block disabled type="default">
309 Current plan
310 </Button>
311 ) : !canUpdateSubscription && !isDowngradeOption ? (
312 <RequestUpgradeToBillingOwners block plan={plan.name as 'Pro' | 'Team'} />
313 ) : (
314 <ButtonTooltip
315 block
316 type={isDowngradeOption ? 'default' : 'primary'}
317 disabled={
318 (!canUpdateSubscription && isDowngradeOption) ||
319 subscription?.plan?.id === 'enterprise' ||
320 subscription?.plan?.id === 'platform' ||
321 // Downgrades to free are still allowed through the dashboard given we have much better control about showing customers the impact + any possible issues with downgrading to free
322 (isPartnerBilledOrganization && plan.id !== 'tier_free') ||
323 // Orgs managed by AWS marketplace are not allowed to change the plan
324 selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE ||
325 hasOrioleProjects
326 }
327 onClick={() => {
328 setSelectedTier(plan.id as 'tier_free' | 'tier_pro' | 'tier_team')
329 sendEvent({
330 action: 'studio_pricing_plan_cta_clicked',
331 properties: {
332 selectedPlan: plan.name,
333 currentPlan: subscription?.plan?.name,
334 },
335 groups: { organization: slug ?? 'Unknown' },
336 })
337 }}
338 tooltip={{
339 content: {
340 side: 'bottom',
341 className: hasOrioleProjects ? 'w-96 text-center' : '',
342 text:
343 !canUpdateSubscription && isDowngradeOption
344 ? "You need additional permissions to change your organization's plan"
345 : subscription?.plan?.id === 'enterprise' ||
346 subscription?.plan?.id === 'platform'
347 ? 'Reach out to us via support to update your plan'
348 : hasOrioleProjects
349 ? 'Your organization has projects that are using the OrioleDB extension which is only available on the Free plan. Remove all OrioleDB projects before changing your plan.'
350 : selectedOrganization?.managed_by ===
351 MANAGED_BY.AWS_MARKETPLACE
352 ? 'You cannot change the plan for an organization managed by AWS Marketplace'
353 : undefined,
354 },
355 }}
356 >
357 {isDowngradeOption ? 'Downgrade' : 'Upgrade'} to {plan.name}
358 </ButtonTooltip>
359 )}
360
361 <div className="border-t my-4" />
362
363 <ul role="list">
364 {features.map((feature) => (
365 <li
366 key={typeof feature === 'string' ? feature : feature[0]}
367 className="flex py-2"
368 >
369 <div className="w-[12px]">
370 <Check
371 className="h-3 w-3 text-brand translate-y-[2.5px]"
372 aria-hidden="true"
373 strokeWidth={3}
374 />
375 </div>
376 <div>
377 <p className="ml-3 text-xs text-foreground-light">
378 {typeof feature === 'string' ? feature : feature[0]}
379 </p>
380 {isArray(feature) && (
381 <p className="ml-3 text-xs text-foreground-lighter">{feature[1]}</p>
382 )}
383 </div>
384 </li>
385 ))}
386 </ul>
387 </div>
388
389 {footer && (
390 <div className="border-t pt-4 mt-4">
391 <p className="text-foreground-light text-xs">{footer}</p>
392 </div>
393 )}
394 </div>
395 )
396 })}
397 </div>
398 </SidePanel.Content>
399 </SidePanel>
400
401 <DowngradeModal
402 visible={selectedTier === 'tier_free'}
403 subscription={subscription}
404 onClose={() => setSelectedTier(undefined)}
405 onConfirm={onConfirmDowngrade}
406 projects={orgProjects}
407 />
408
409 <SubscriptionPlanUpdateDialog
410 selectedTier={selectedTier}
411 onClose={() => setSelectedTier(undefined)}
412 planMeta={planMeta}
413 subscriptionPreviewQueryResult={subscriptionPreviewData}
414 projects={orgProjects}
415 currentPlanMeta={currentPlanMeta}
416 onAddressChange={handleAddressChange}
417 onTaxIdChange={handleTaxIdChange}
418 useAsDefaultBillingAddress={useAsDefaultBillingAddress}
419 onUseAsDefaultBillingAddressChange={handleUseAsDefaultBillingAddressChange}
420 />
421
422 <MembersExceedLimitModal
423 visible={showDowngradeError}
424 onClose={() => setShowDowngradeError(false)}
425 />
426
427 <ExitSurveyModal
428 visible={showExitSurvey}
429 projects={orgProjects}
430 onClose={(success?: boolean) => {
431 setShowExitSurvey(false)
432 if (success) onClose()
433 }}
434 />
435
436 <UpgradeSurveyModal
437 visible={showUpgradeSurvey}
438 originalPlan={originalPlanRef.current}
439 subscription={subscription}
440 onClose={(success?: boolean) => {
441 setShowUpgradeSurvey(false)
442 if (success) onClose()
443 }}
444 />
445 </>
446 )
447}