PlanUpdateSidePanel.tsx 19 KB

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