| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447 |
- // @ts-nocheck
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { useDebounce } from '@uidotdev/usehooks'
- import { useParams } from 'common'
- import { StudioPricingSidePanelOpenedEvent } from 'common/telemetry-constants'
- import { isArray } from 'lodash'
- import { Check, ExternalLink } from 'lucide-react'
- import { useRouter } from 'next/router'
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
- import { plans as subscriptionsPlans } from 'shared-data/plans'
- import { Button, cn, SidePanel } from 'ui'
- import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
- import DowngradeModal from './DowngradeModal'
- import { EnterpriseCard } from './EnterpriseCard'
- import { ExitSurveyModal } from './ExitSurveyModal'
- import MembersExceedLimitModal from './MembersExceedLimitModal'
- import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog'
- import UpgradeSurveyModal from './UpgradeModal'
- import { STRIPE_PROJECTS_DOCS_URL } from '@/components/interfaces/Billing/Payment/PaymentMethods/StripePaymentConnection'
- import { getPlanChangeType } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import PartnerManagedResource from '@/components/ui/PartnerManagedResource'
- import { RequestUpgradeToBillingOwners } from '@/components/ui/RequestUpgradeToBillingOwners'
- import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query'
- import { isPartnerBillingOrganization } from '@/data/organizations/managed-by-utils'
- import { useOrganizationBillingSubscriptionPreview } from '@/data/organizations/organization-billing-subscription-preview'
- import { useOrganizationQuery } from '@/data/organizations/organization-query'
- import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
- import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
- import { useOrgPlansQuery } from '@/data/subscriptions/org-plans-query'
- import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
- import type { OrgPlan } from '@/data/subscriptions/types'
- import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { MANAGED_BY } from '@/lib/constants/infrastructure'
- import { formatCurrency } from '@/lib/helpers'
- import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings'
- import { Organization } from '@/types/base'
- const getPartnerManagedResourceCta = (selectedOrganization: Organization) => {
- if (selectedOrganization.managed_by === MANAGED_BY.VERCEL_MARKETPLACE) {
- return {
- installationId: selectedOrganization?.partner_id,
- path: '/settings',
- message: 'Change plan on Vercel Marketplace',
- }
- }
- if (selectedOrganization.managed_by === MANAGED_BY.AWS_MARKETPLACE) {
- return {
- organizationSlug: selectedOrganization?.slug,
- }
- }
- }
- const getStripeProjectsUpgradeCommand = (planId: string | null | undefined) => {
- const currentTier = planId ?? 'free'
- const action = currentTier === 'team' ? 'downgrade' : 'upgrade'
- return `stripe projects ${action} briven/${currentTier}`
- }
- export const PlanUpdateSidePanel = () => {
- const router = useRouter()
- const { slug } = useParams()
- const { data: selectedOrganization } = useSelectedOrganizationQuery()
- const isPartnerBilledOrganization = isPartnerBillingOrganization(
- selectedOrganization?.billing_partner
- )
- const isStripeManagedOrganization =
- selectedOrganization?.managed_by === MANAGED_BY.STRIPE_PROJECTS
- const { mutate: sendEvent } = useSendEventMutation()
- const originalPlanRef = useRef<string>(undefined)
- const [showExitSurvey, setShowExitSurvey] = useState(false)
- const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false)
- const [showDowngradeError, setShowDowngradeError] = useState(false)
- const [selectedTier, setSelectedTier] = useState<'tier_free' | 'tier_pro' | 'tier_team'>()
- const [latestAddress, setLatestAddress] = useState<CustomerAddress>()
- const [latestTaxId, setLatestTaxId] = useState<CustomerTaxId | null>()
- const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
- const billingAddress = useAsDefaultBillingAddress ? latestAddress : undefined
- const billingTaxId = useAsDefaultBillingAddress ? latestTaxId : null
- const debouncedAddress = useDebounce(billingAddress, 1000)
- const debouncedTaxId = useDebounce(billingTaxId, 1000)
- const handleAddressChange = useCallback(
- (address: CustomerAddress) => setLatestAddress(address),
- []
- )
- const handleTaxIdChange = useCallback((taxId: CustomerTaxId | null) => setLatestTaxId(taxId), [])
- const handleUseAsDefaultBillingAddressChange = useCallback(
- (useAsDefault: boolean) => setUseAsDefaultBillingAddress(useAsDefault),
- []
- )
- const { can: canUpdateSubscription } = useAsyncCheckPermissions(
- PermissionAction.BILLING_WRITE,
- 'stripe.subscriptions'
- )
- const snap = useOrgSettingsPageStateSnapshot()
- const visible = snap.panelKey === 'subscriptionPlan'
- const { data: orgProjectsData } = useOrgProjectsInfiniteQuery({ slug }, { enabled: visible })
- const orgProjects =
- useMemo(
- () => orgProjectsData?.pages.flatMap((page) => page.projects),
- [orgProjectsData?.pages]
- ) || []
- const { data } = useOrganizationQuery({ slug })
- const hasOrioleProjects = !!data?.has_oriole_project
- const onClose = () => {
- const { panel, ...queryWithoutPanel } = router.query
- router.push({ pathname: router.pathname, query: queryWithoutPanel }, undefined, {
- shallow: true,
- })
- snap.setPanelKey(undefined)
- }
- const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({
- orgSlug: slug,
- })
- const { data: plans, isPending: isLoadingPlans } = useOrgPlansQuery(
- { orgSlug: slug },
- { enabled: visible }
- )
- const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery(
- { slug },
- { enabled: visible }
- )
- const subscriptionPreviewData = useOrganizationBillingSubscriptionPreview({
- tier: selectedTier,
- organizationSlug: slug,
- address: debouncedAddress,
- taxId: debouncedTaxId ?? undefined,
- })
- const availablePlans: OrgPlan[] = plans?.plans ?? []
- const hasMembersExceedingFreeTierLimit =
- (membersExceededLimit || []).length > 0 &&
- // [Joshen] Note that orgProjects is paginated so there's a chance this may omit certain projects
- // Although I don't foresee this affecting a majority of users. Ideally perhaps we could return
- // this data from the organization query
- orgProjects.filter((it) => it.status !== 'INACTIVE' && it.status !== 'GOING_DOWN').length > 0
- useEffect(() => {
- if (visible) {
- setSelectedTier(undefined)
- setLatestAddress(undefined)
- setLatestTaxId(undefined)
- setUseAsDefaultBillingAddress(true)
- const source = Array.isArray(router.query.source)
- ? router.query.source[0]
- : router.query.source
- const properties: StudioPricingSidePanelOpenedEvent['properties'] = {
- currentPlan: subscription?.plan?.name,
- }
- if (source) {
- properties.origin = source
- }
- sendEvent({
- action: 'studio_pricing_side_panel_opened',
- properties,
- groups: { organization: slug ?? 'Unknown' },
- })
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [visible])
- useEffect(() => {
- if (visible && isSuccessSubscription && subscription.plan.id) {
- originalPlanRef.current = subscription.plan.id
- }
- }, [visible, isSuccessSubscription, subscription?.plan.id])
- const onConfirmDowngrade = () => {
- setSelectedTier(undefined)
- if (hasMembersExceedingFreeTierLimit) {
- setShowDowngradeError(true)
- } else {
- setShowExitSurvey(true)
- }
- }
- const planMeta = selectedTier
- ? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1])
- : null
- const currentPlanMeta = {
- ...availablePlans.find((p) => p.id === subscription?.plan?.id),
- features:
- subscriptionsPlans.find((plan) => plan.id === `tier_${subscription?.plan?.id}`)?.features ||
- [],
- }
- const stripeProjectsUpgradeCommand = getStripeProjectsUpgradeCommand(
- selectedOrganization?.plan?.id ?? subscription?.plan?.id
- )
- return (
- <>
- <SidePanel
- hideFooter
- size="xxlarge"
- visible={visible}
- onCancel={() => onClose()}
- header={
- <div className="flex items-center justify-between w-full">
- <h4>Change subscription plan for {selectedOrganization?.name}</h4>
- <Button asChild type="default" icon={<ExternalLink />}>
- <a href="https://supabase.com/pricing" target="_blank" rel="noreferrer">
- Pricing
- </a>
- </Button>
- </div>
- }
- >
- {selectedOrganization &&
- (isStripeManagedOrganization ? (
- <PartnerManagedResource
- managedBy={MANAGED_BY.STRIPE_PROJECTS}
- resource="Organization plans"
- title="Organization plans are managed through Stripe."
- details={
- <>
- Run <code className="text-code-inline">{stripeProjectsUpgradeCommand}</code> in
- your project directory.
- </>
- }
- cta={{
- overrideUrl: `${STRIPE_PROJECTS_DOCS_URL}#upgrade-a-service-tier`,
- message: 'Stripe Projects docs',
- }}
- />
- ) : isPartnerBilledOrganization ? (
- <PartnerManagedResource
- managedBy={selectedOrganization.managed_by}
- resource="Organization plans"
- cta={getPartnerManagedResourceCta(selectedOrganization)}
- />
- ) : null)}
- <SidePanel.Content>
- <div className="py-6 grid grid-cols-12 gap-3">
- {subscriptionsPlans.map((plan) => {
- const planMeta = availablePlans.find((p) => p.id === plan.id.split('tier_')[1])
- const price = planMeta?.price ?? 0
- const isDowngradeOption =
- getPlanChangeType(subscription?.plan.id, plan?.planId) === 'downgrade'
- const isCurrentPlan = planMeta?.id === subscription?.plan?.id
- const features = plan.features
- const footer = plan.footer
- const source = Array.isArray(router.query.source)
- ? router.query.source[0]
- : router.query.source
- // TODO this panel should allow direct configuration of the highlighting rather than indirectly via the source param
- const shouldHighlight = source === 'log-drains-empty-state' && plan.id === 'tier_pro'
- if (plan.id === 'tier_enterprise') {
- return <EnterpriseCard key={plan.id} plan={plan} isCurrentPlan={isCurrentPlan} />
- }
- return (
- <div
- key={plan.id}
- className={cn(
- 'px-4 py-4 flex flex-col items-start justify-between',
- 'border rounded-md col-span-12 md:col-span-4 bg-surface-200',
- shouldHighlight &&
- 'ring-4 ring-brand animate-[pulse_1.5s_ease-in-out_1] shadow-md shadow-brand/40'
- )}
- >
- <div className="w-full">
- <div className="flex items-center space-x-2">
- <p className="text-brand-link text-sm uppercase">{plan.name}</p>
- {isCurrentPlan ? (
- <div className="text-xs bg-surface-300 text-foreground-light rounded-sm px-2 py-0.5">
- Current plan
- </div>
- ) : plan.nameBadge ? (
- <div className="text-xs bg-brand-300 dark:bg-brand-400 text-brand-600 rounded-sm px-2 py-0.5">
- {plan.nameBadge}
- </div>
- ) : null}
- </div>
- <div className="mt-4 flex items-center space-x-1 mb-4">
- {(price ?? 0) > 0 && <p className="text-foreground-light text-sm">From</p>}
- {isLoadingPlans ? (
- <div className="h-[28px] flex items-center justify-center">
- <ShimmeringLoader className="w-[30px] h-[24px]" />
- </div>
- ) : (
- <p className="text-foreground text-lg" translate="no">
- {formatCurrency(price)}
- </p>
- )}
- <p className="text-foreground-light text-sm">{plan.costUnit}</p>
- </div>
- {isCurrentPlan ? (
- <Button block disabled type="default">
- Current plan
- </Button>
- ) : !canUpdateSubscription && !isDowngradeOption ? (
- <RequestUpgradeToBillingOwners block plan={plan.name as 'Pro' | 'Team'} />
- ) : (
- <ButtonTooltip
- block
- type={isDowngradeOption ? 'default' : 'primary'}
- disabled={
- (!canUpdateSubscription && isDowngradeOption) ||
- subscription?.plan?.id === 'enterprise' ||
- subscription?.plan?.id === 'platform' ||
- // 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
- (isPartnerBilledOrganization && plan.id !== 'tier_free') ||
- // Orgs managed by AWS marketplace are not allowed to change the plan
- selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE ||
- hasOrioleProjects
- }
- onClick={() => {
- setSelectedTier(plan.id as 'tier_free' | 'tier_pro' | 'tier_team')
- sendEvent({
- action: 'studio_pricing_plan_cta_clicked',
- properties: {
- selectedPlan: plan.name,
- currentPlan: subscription?.plan?.name,
- },
- groups: { organization: slug ?? 'Unknown' },
- })
- }}
- tooltip={{
- content: {
- side: 'bottom',
- className: hasOrioleProjects ? 'w-96 text-center' : '',
- text:
- !canUpdateSubscription && isDowngradeOption
- ? "You need additional permissions to change your organization's plan"
- : subscription?.plan?.id === 'enterprise' ||
- subscription?.plan?.id === 'platform'
- ? 'Reach out to us via support to update your plan'
- : hasOrioleProjects
- ? '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.'
- : selectedOrganization?.managed_by ===
- MANAGED_BY.AWS_MARKETPLACE
- ? 'You cannot change the plan for an organization managed by AWS Marketplace'
- : undefined,
- },
- }}
- >
- {isDowngradeOption ? 'Downgrade' : 'Upgrade'} to {plan.name}
- </ButtonTooltip>
- )}
- <div className="border-t my-4" />
- <ul role="list">
- {features.map((feature) => (
- <li
- key={typeof feature === 'string' ? feature : feature[0]}
- className="flex py-2"
- >
- <div className="w-[12px]">
- <Check
- className="h-3 w-3 text-brand translate-y-[2.5px]"
- aria-hidden="true"
- strokeWidth={3}
- />
- </div>
- <div>
- <p className="ml-3 text-xs text-foreground-light">
- {typeof feature === 'string' ? feature : feature[0]}
- </p>
- {isArray(feature) && (
- <p className="ml-3 text-xs text-foreground-lighter">{feature[1]}</p>
- )}
- </div>
- </li>
- ))}
- </ul>
- </div>
- {footer && (
- <div className="border-t pt-4 mt-4">
- <p className="text-foreground-light text-xs">{footer}</p>
- </div>
- )}
- </div>
- )
- })}
- </div>
- </SidePanel.Content>
- </SidePanel>
- <DowngradeModal
- visible={selectedTier === 'tier_free'}
- subscription={subscription}
- onClose={() => setSelectedTier(undefined)}
- onConfirm={onConfirmDowngrade}
- projects={orgProjects}
- />
- <SubscriptionPlanUpdateDialog
- selectedTier={selectedTier}
- onClose={() => setSelectedTier(undefined)}
- planMeta={planMeta}
- subscriptionPreviewQueryResult={subscriptionPreviewData}
- projects={orgProjects}
- currentPlanMeta={currentPlanMeta}
- onAddressChange={handleAddressChange}
- onTaxIdChange={handleTaxIdChange}
- useAsDefaultBillingAddress={useAsDefaultBillingAddress}
- onUseAsDefaultBillingAddressChange={handleUseAsDefaultBillingAddressChange}
- />
- <MembersExceedLimitModal
- visible={showDowngradeError}
- onClose={() => setShowDowngradeError(false)}
- />
- <ExitSurveyModal
- visible={showExitSurvey}
- projects={orgProjects}
- onClose={(success?: boolean) => {
- setShowExitSurvey(false)
- if (success) onClose()
- }}
- />
- <UpgradeSurveyModal
- visible={showUpgradeSurvey}
- originalPlan={originalPlanRef.current}
- subscription={subscription}
- onClose={(success?: boolean) => {
- setShowUpgradeSurvey(false)
- if (success) onClose()
- }}
- />
- </>
- )
- }
|