diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanCard.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanCard.tsx index ba86f00b8f2..f10474ccab3 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanCard.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanCard.tsx @@ -1,27 +1,15 @@ import { motion, type Variants } from 'framer-motion' import { isArray } from 'lodash' -import { Check, X } from 'lucide-react' +import { Check } from 'lucide-react' import type { PricingInformation } from 'shared-data' import { Button, cn } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import type { GapFeature, PlanPresentationVariant } from './plan-presentation' -import { - FREE_PLAN_GAPS, - hasPlanGaps, - isParityPresentation, - PRO_PLAN_GAPS, -} from './plan-presentation' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { RequestUpgradeToBillingOwners } from '@/components/ui/RequestUpgradeToBillingOwners' import { MANAGED_BY } from '@/lib/constants/infrastructure' import { formatCurrency } from '@/lib/helpers' -const GAPS_BY_PLAN_ID: Record = { - tier_free: FREE_PLAN_GAPS, - tier_pro: PRO_PLAN_GAPS, -} - export interface PlanCardProps { plan: PricingInformation price: number @@ -34,7 +22,6 @@ export interface PlanCardProps { currentSubscriptionPlanId: string | undefined managedBy: string | undefined shouldHighlight: boolean - variant: PlanPresentationVariant variants?: Variants onSelectTier: () => void onTrackCtaClick: () => void @@ -52,17 +39,13 @@ export function PlanCard({ currentSubscriptionPlanId, managedBy, shouldHighlight, - variant, variants, onSelectTier, onTrackCtaClick, }: PlanCardProps) { - const isParity = isParityPresentation(variant) const features = plan.features const footer = plan.footer - const gaps: GapFeature[] = hasPlanGaps(variant) ? (GAPS_BY_PLAN_ID[plan.id] ?? []) : [] - const ctaButton = isCurrentPlan ? ( - - } - > - {notice} - {children} - - ) -} - export function PlanUpdateFullScreenShell({ organizationName, notice, @@ -48,7 +20,7 @@ export function PlanUpdateFullScreenShell({ contentDelay, onClose, children, -}: PlanUpdatePanelShellProps & { skipOverlayFade: boolean; contentDelay: number }) { +}: PlanUpdateFullScreenShellProps) { return ( vi.fn()) const mockPush = vi.hoisted(() => vi.fn()) const mockPartnerManagedResource = vi.hoisted(() => vi.fn()) -const mockUsePHFlag = vi.hoisted(() => vi.fn()) -const mockFeatureFlags = vi.hoisted(() => vi.fn()) -const mockIsPlatform = vi.hoisted(() => ({ value: true })) vi.mock('common', async (importOriginal) => { const original = (await importOriginal()) as typeof import('common') return { ...original, useParams: () => ({ slug: 'stripe-org' }), - useFeatureFlags: mockFeatureFlags, - get IS_PLATFORM() { - return mockIsPlatform.value - }, } }) @@ -142,19 +135,8 @@ vi.mock('./SubscriptionPlanUpdateDialog', () => ({ SubscriptionPlanUpdateDialog: () => null, })) -vi.mock('@/hooks/ui/useFlag', () => ({ - usePHFlag: mockUsePHFlag, -})) - -vi.mock('@/hooks/misc/useTrackExperimentExposure', () => ({ - useTrackExperimentExposure: vi.fn(), -})) - describe('PlanUpdateSidePanel', () => { beforeEach(() => { - mockIsPlatform.value = true - mockFeatureFlags.mockReturnValue({ posthog: {}, configcat: {}, hasLoaded: true }) - mockUsePHFlag.mockReturnValue('control') mockSelectedOrganization.mockReturnValue( createMockOrganization({ slug: 'stripe-org', @@ -219,72 +201,21 @@ describe('PlanUpdateSidePanel', () => { expect(screen.getByTestId('partner-managed-resource')).toBeInTheDocument() }) - describe('parity variant', () => { + describe('plan presentation', () => { beforeEach(() => { - mockUsePHFlag.mockReturnValue('parity') mockSelectedOrganization.mockReturnValue( createMockOrganization({ slug: 'test-org', billing_partner: null }) ) }) - it('renders plan description', () => { - render() - expect( - screen.getByText('Perfect for passion projects & simple websites.') - ).toBeInTheDocument() - }) - - it('renders preface line', () => { - render() - expect(screen.getByText('Everything in the Free Plan, plus:')).toBeInTheDocument() - }) - }) - - describe('gaps variant', () => { - beforeEach(() => { - mockUsePHFlag.mockReturnValue('gaps') - mockSelectedOrganization.mockReturnValue( - createMockOrganization({ slug: 'test-org', billing_partner: null }) - ) - }) - - it('renders gap rows on the Free plan', () => { - render() - expect(screen.getByText('Daily backups')).toBeInTheDocument() - // "Email support" appears twice: once as a Pro plan feature (✓), once as a Free plan gap (✗) - expect(screen.getAllByText('Email support')).toHaveLength(2) - }) - - it('shows log retention as a lesser value, not a missing feature', () => { - render() - expect(screen.getByText('1-day log retention')).toBeInTheDocument() - }) - - it('renders the gap section heading on Free and Pro cards', () => { - render() - // Free has a `lesser` item, so its heading is "Plan limits"; Pro has only `missing` items → "Not included" - expect(screen.getByText('Plan limits')).toBeInTheDocument() - expect(screen.getByText('Not included')).toBeInTheDocument() - }) - }) - - describe.each(['fullscreen', 'fullscreen-gaps'])('%s variant', (flag) => { - beforeEach(() => { - mockUsePHFlag.mockReturnValue(flag) - mockSelectedOrganization.mockReturnValue( - createMockOrganization({ slug: 'test-org', billing_partner: null }) - ) - }) - - it('renders the full-screen shell instead of the sheet', () => { + it('renders the full-screen shell', () => { render() expect(screen.getByRole('button', { name: /Go back to Studio/ })).toBeInTheDocument() expect(screen.getByRole('link', { name: /Compare plans/ })).toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'Pricing' })).not.toBeInTheDocument() }) - it('renders parity cards', () => { + it('renders pricing-page style cards with description and preface', () => { render() expect( @@ -292,15 +223,6 @@ describe('PlanUpdateSidePanel', () => { ).toBeInTheDocument() expect(screen.getByText('Everything in the Free Plan, plus:')).toBeInTheDocument() }) - }) - - describe('fullscreen variant', () => { - beforeEach(() => { - mockUsePHFlag.mockReturnValue('fullscreen') - mockSelectedOrganization.mockReturnValue( - createMockOrganization({ slug: 'test-org', billing_partner: null }) - ) - }) it('does not render gap rows', () => { render() @@ -309,88 +231,4 @@ describe('PlanUpdateSidePanel', () => { expect(screen.queryByText('Plan limits')).not.toBeInTheDocument() }) }) - - describe('fullscreen-gaps variant', () => { - beforeEach(() => { - mockUsePHFlag.mockReturnValue('fullscreen-gaps') - mockSelectedOrganization.mockReturnValue( - createMockOrganization({ slug: 'test-org', billing_partner: null }) - ) - }) - - it('renders gap rows inside the full-screen shell', () => { - render() - - expect(screen.getByRole('button', { name: /Go back to Studio/ })).toBeInTheDocument() - expect(screen.getByText('Daily backups')).toBeInTheDocument() - expect(screen.getByText('1-day log retention')).toBeInTheDocument() - // Free has a `lesser` item → "Plan limits"; Pro has only `missing` items → "Not included" - expect(screen.getByText('Plan limits')).toBeInTheDocument() - expect(screen.getByText('Not included')).toBeInTheDocument() - }) - }) - - describe('while feature flags are still loading', () => { - beforeEach(() => { - mockFeatureFlags.mockReturnValue({ posthog: {}, configcat: {}, hasLoaded: false }) - mockUsePHFlag.mockReturnValue(undefined) - mockSelectedOrganization.mockReturnValue( - createMockOrganization({ slug: 'test-org', billing_partner: null }) - ) - }) - - it('opens neither shell, so the fullscreen variant cannot flash the sheet', () => { - render() - - expect(screen.queryByRole('button', { name: /Go back to Studio/ })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Upgrade to Pro' })).not.toBeInTheDocument() - }) - - it('does not gate the panel when self-hosted, where flags never load', () => { - mockIsPlatform.value = false - - render() - - expect(screen.getByRole('button', { name: 'Upgrade to Pro' })).toBeInTheDocument() - }) - }) - - describe.each(['control', 'parity', 'gaps'])('%s variant keeps the sheet shell', (flag) => { - beforeEach(() => { - mockUsePHFlag.mockReturnValue(flag) - mockSelectedOrganization.mockReturnValue( - createMockOrganization({ slug: 'test-org', billing_partner: null }) - ) - }) - - it('renders the sheet header and no full-screen chrome', () => { - render() - - expect(screen.getByRole('link', { name: 'Pricing' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Go back to Studio/ })).not.toBeInTheDocument() - }) - }) - - describe('control variant', () => { - beforeEach(() => { - mockUsePHFlag.mockReturnValue('control') - mockSelectedOrganization.mockReturnValue( - createMockOrganization({ slug: 'test-org', billing_partner: null }) - ) - }) - - it('does not render preface or description', () => { - render() - expect(screen.queryByText('Everything in the Free Plan, plus:')).not.toBeInTheDocument() - expect( - screen.queryByText('Perfect for passion projects & simple websites.') - ).not.toBeInTheDocument() - }) - - it('does not render gap rows', () => { - render() - expect(screen.queryByText('Not included')).not.toBeInTheDocument() - expect(screen.queryByText('Plan limits')).not.toBeInTheDocument() - }) - }) }) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index 6bd719154d1..5b6db4b8590 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -7,13 +7,8 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } fro import { plans as subscriptionsPlans } from 'shared-data/plans' import { CancellationFlow } from './CancellationFlow' -import { - isFullScreenPresentation, - isPlanChangeEligible, - usePlanPresentationExperiment, -} from './plan-presentation' import { PlanCards } from './PlanCards' -import { PlanUpdateFullScreenShell, PlanUpdateSheetShell } from './PlanUpdatePanelShell' +import { PlanUpdateFullScreenShell } from './PlanUpdatePanelShell' import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog' import UpgradeSurveyModal from './UpgradeModal' import { STRIPE_PROJECTS_DOCS_URL } from '@/components/interfaces/Billing/Payment/PaymentMethods/StripePaymentConnection' @@ -182,23 +177,6 @@ export const PlanUpdateSidePanel = () => { const availablePlans: OrgPlan[] = plans?.plans ?? [] - // Only orgs that can actually act on the panel enter the experiment, and only once it is open - const isPlanPresentationEligible = - visible && - isPlanChangeEligible({ - managedBy: selectedOrganization?.managed_by, - billingPartner: selectedOrganization?.billing_partner, - currentPlanId: subscription?.plan?.id, - canUpdateSubscription, - }) - - const { variant: presentation, isResolved: isPresentationResolved } = - usePlanPresentationExperiment({ eligible: isPlanPresentationEligible }) - const isFullScreen = isFullScreenPresentation(presentation) - - // The fullscreen variant swaps the shell, so hold the panel closed until the variant is known - const isPanelOpen = visible && isPresentationResolved - const onPanelOpened = useEffectEvent( (properties: StudioPricingSidePanelOpenedEvent['properties']) => { track('studio_pricing_side_panel_opened', properties) @@ -265,15 +243,14 @@ export const PlanUpdateSidePanel = () => { isPartnerBilledOrganization={isPartnerBilledOrganization} hasOrioleProjects={hasOrioleProjects} selectedOrganization={selectedOrganization} - variant={presentation} - entryDelay={isFullScreen ? contentDelay : undefined} + entryDelay={contentDelay} onSelectTier={setSelectedTier} /> ) return ( <> - {isFullScreen && isPanelOpen && ( + {visible && ( { )} - {!isFullScreen && ( - - {planCards} - - )} - setSelectedTier(undefined)} @@ -305,7 +271,7 @@ export const PlanUpdateSidePanel = () => { setSelectedTier(undefined)} - onSuccess={isFullScreen ? onClose : undefined} + onSuccess={onClose} planMeta={planMeta} subscriptionPreviewQueryResult={subscriptionPreviewData} projects={orgProjects} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/plan-presentation.test.ts b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/plan-presentation.test.ts deleted file mode 100644 index 9a5fbed383e..00000000000 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/plan-presentation.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - FREE_PLAN_GAPS, - hasPlanGaps, - isFullScreenPresentation, - isParityPresentation, - isPlanChangeEligible, - parsePlanPresentationVariant, - PLAN_PRESENTATION_VARIANTS, - PRO_PLAN_GAPS, -} from './plan-presentation' -import { MANAGED_BY } from '@/lib/constants/infrastructure' - -describe('isPlanChangeEligible', () => { - const baseArgs = { - managedBy: 'supabase' as const, - billingPartner: null as string | null | undefined, - currentPlanId: 'free', - canUpdateSubscription: true, - } - - it('returns true for a standard free-plan org with billing permissions', () => { - expect(isPlanChangeEligible(baseArgs)).toBe(true) - }) - - it('returns false for Stripe-managed orgs', () => { - expect(isPlanChangeEligible({ ...baseArgs, managedBy: MANAGED_BY.STRIPE_PROJECTS })).toBe(false) - }) - - it('returns false for AWS Marketplace orgs', () => { - expect(isPlanChangeEligible({ ...baseArgs, managedBy: MANAGED_BY.AWS_MARKETPLACE })).toBe(false) - }) - - it('returns false for partner-billed orgs', () => { - expect(isPlanChangeEligible({ ...baseArgs, billingPartner: 'aws_marketplace' })).toBe(false) - }) - - it('returns false for enterprise plan', () => { - expect(isPlanChangeEligible({ ...baseArgs, currentPlanId: 'enterprise' })).toBe(false) - }) - - it('returns false for platform plan', () => { - expect(isPlanChangeEligible({ ...baseArgs, currentPlanId: 'platform' })).toBe(false) - }) - - it('returns false when user cannot update subscription', () => { - expect(isPlanChangeEligible({ ...baseArgs, canUpdateSubscription: false })).toBe(false) - }) - - it('returns true for pro-plan org with billing permissions', () => { - expect(isPlanChangeEligible({ ...baseArgs, currentPlanId: 'pro' })).toBe(true) - }) -}) - -describe('parsePlanPresentationVariant', () => { - it.each(PLAN_PRESENTATION_VARIANTS)('accepts %s', (variant) => { - expect(parsePlanPresentationVariant(variant)).toBe(variant) - }) - - it.each([undefined, false, '', 'Fullscreen', 'full-screen', 'fullscreen_gaps'])( - 'rejects %o as not in the experiment', - (flag) => { - expect(parsePlanPresentationVariant(flag)).toBeUndefined() - } - ) -}) - -describe('presentation matrix', () => { - // cards, shell, gaps — the three things a variant decides - it.each([ - { variant: 'control', isParity: false, isFullScreen: false, showsGaps: false }, - { variant: 'parity', isParity: true, isFullScreen: false, showsGaps: false }, - { variant: 'gaps', isParity: true, isFullScreen: false, showsGaps: true }, - { variant: 'fullscreen', isParity: true, isFullScreen: true, showsGaps: false }, - { variant: 'fullscreen-gaps', isParity: true, isFullScreen: true, showsGaps: true }, - ] as const)('$variant', ({ variant, isParity, isFullScreen, showsGaps }) => { - expect(isParityPresentation(variant)).toBe(isParity) - expect(isFullScreenPresentation(variant)).toBe(isFullScreen) - expect(hasPlanGaps(variant)).toBe(showsGaps) - }) - - it('covers every variant in the experiment', () => { - expect(PLAN_PRESENTATION_VARIANTS).toHaveLength(5) - }) -}) - -describe('gap lists', () => { - it('FREE_PLAN_GAPS includes backups and email support as missing', () => { - const missing = FREE_PLAN_GAPS.filter((g) => g.type === 'missing') - expect(missing.map((g) => g.label)).toContain('Daily backups') - expect(missing.map((g) => g.label)).toContain('Email support') - }) - - it('FREE_PLAN_GAPS shows log retention as lesser, not missing', () => { - const logRetention = FREE_PLAN_GAPS.find((g) => g.label.includes('log retention')) - expect(logRetention).toBeDefined() - expect(logRetention!.type).toBe('lesser') - }) - - it('PRO_PLAN_GAPS includes SOC2 and SSO as missing', () => { - const missing = PRO_PLAN_GAPS.filter((g) => g.type === 'missing') - expect(missing.map((g) => g.label)).toContain('SOC2 & ISO 27001') - expect(missing.map((g) => g.label)).toContain('SSO for Supabase Dashboard') - }) -}) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/plan-presentation.ts b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/plan-presentation.ts deleted file mode 100644 index e7512346010..00000000000 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/plan-presentation.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { IS_PLATFORM, useFeatureFlags } from 'common' -import { useMemo } from 'react' - -import { isPartnerBillingOrganization } from '@/data/organizations/managed-by-utils' -import { useTrackExperimentExposure } from '@/hooks/misc/useTrackExperimentExposure' -import { usePHFlag } from '@/hooks/ui/useFlag' -import { MANAGED_BY } from '@/lib/constants/infrastructure' -import type { ManagedBy } from '@/lib/constants/infrastructure' - -export const PLAN_PRESENTATION_VARIANTS = [ - 'control', - 'parity', - 'gaps', - 'fullscreen', - 'fullscreen-gaps', -] as const - -export type PlanPresentationVariant = (typeof PLAN_PRESENTATION_VARIANTS)[number] - -const FLAG_KEY = 'pricingPanelPlanPresentation' -const EXPERIMENT_ID = 'pricing_panel_plan_presentation' - -interface PlanPresentation { - /** `control` is the original compact card; `parity` is the richer card. */ - cards: 'control' | 'parity' - /** `sheet` is the SidePanel; `fullscreen` is a full-screen overlay. */ - shell: 'sheet' | 'fullscreen' - /** Whether cards list what the plan is missing ("Not included" / "Plan limits"). */ - showsGaps: boolean -} - -const PLAN_PRESENTATIONS: Record = { - control: { cards: 'control', shell: 'sheet', showsGaps: false }, - parity: { cards: 'parity', shell: 'sheet', showsGaps: false }, - gaps: { cards: 'parity', shell: 'sheet', showsGaps: true }, - fullscreen: { cards: 'parity', shell: 'fullscreen', showsGaps: false }, - 'fullscreen-gaps': { cards: 'parity', shell: 'fullscreen', showsGaps: true }, -} - -export function parsePlanPresentationVariant( - flag: string | boolean | undefined -): PlanPresentationVariant | undefined { - return PLAN_PRESENTATION_VARIANTS.find((variant) => variant === flag) -} - -export function isParityPresentation(variant: PlanPresentationVariant): boolean { - return PLAN_PRESENTATIONS[variant].cards === 'parity' -} - -export function isFullScreenPresentation(variant: PlanPresentationVariant): boolean { - return PLAN_PRESENTATIONS[variant].shell === 'fullscreen' -} - -export function hasPlanGaps(variant: PlanPresentationVariant): boolean { - return PLAN_PRESENTATIONS[variant].showsGaps -} - -export function isPlanChangeEligible({ - managedBy, - billingPartner, - currentPlanId, - canUpdateSubscription, -}: { - managedBy: ManagedBy | undefined - billingPartner: string | null | undefined - currentPlanId: string | undefined - canUpdateSubscription: boolean -}): boolean { - if (managedBy === MANAGED_BY.STRIPE_PROJECTS) return false - if (managedBy === MANAGED_BY.AWS_MARKETPLACE) return false - if (isPartnerBillingOrganization(billingPartner)) return false - if (currentPlanId === 'enterprise' || currentPlanId === 'platform') return false - if (!canUpdateSubscription) return false - return true -} - -export interface PlanPresentationExperiment { - variant: PlanPresentationVariant - /** - * False while PostHog flags are still in flight. The `fullscreen` variant swaps the whole panel - * shell, so callers must wait rather than open the control sheet and swap it out mid-animation. - */ - isResolved: boolean -} - -export function usePlanPresentationExperiment({ - eligible, -}: { - eligible: boolean -}): PlanPresentationExperiment { - const { hasLoaded } = useFeatureFlags() - const flag = usePHFlag(FLAG_KEY) - - const assignedVariant = useMemo(() => parsePlanPresentationVariant(flag), [flag]) - - const liveVariant = eligible ? assignedVariant : undefined - useTrackExperimentExposure(EXPERIMENT_ID, liveVariant) - - return { - variant: liveVariant ?? 'control', - isResolved: !IS_PLATFORM || !!hasLoaded, - } -} - -export interface GapFeature { - label: string - type: 'missing' | 'lesser' -} - -export const FREE_PLAN_GAPS: GapFeature[] = [ - { label: 'Daily backups', type: 'missing' }, - { label: 'Email support', type: 'missing' }, - { label: '1-day log retention', type: 'lesser' }, -] - -export const PRO_PLAN_GAPS: GapFeature[] = [ - { label: 'SOC2 & ISO 27001', type: 'missing' }, - { label: 'SSO for Supabase Dashboard', type: 'missing' }, - { label: 'Priority email support & SLAs', type: 'missing' }, -] diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index c43618bad4f..0983ac0b168 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -3875,25 +3875,6 @@ export interface UpgradeCtaClickedEvent { groups: Omit } -/** - * User was exposed to the plan-change panel presentation experiment. - * Fires once per session per enrolled user in any variant (including control), so the - * conversion analysis has a baseline cohort. Conversion itself is tracked server-side. - * GROWTH experiment: `pricingPanelPlanPresentation`. - * - * @group Events - * @page /org/[slug]/billing (plan-change side panel) - * @source studio - */ -export interface PricingPanelPlanPresentationExperimentExposedEvent { - action: 'pricing_panel_plan_presentation_experiment_exposed' - properties: { - /** The experiment variant the user is enrolled in */ - variant: 'control' | 'parity' | 'gaps' | 'fullscreen' | 'fullscreen-gaps' - } - groups: Omit -} - /** * User clicked the primary CTA on a resource exhaustion warning banner. * @@ -4386,7 +4367,6 @@ export type TelemetryEvent = | FreeMicroUpgradeBannerDismissedEvent | FreeMicroUpgradeBannerCtaClickedEvent | UpgradeCtaClickedEvent - | PricingPanelPlanPresentationExperimentExposedEvent | AccessTokenCreatedEvent | AccessTokenCreationSheetDismissedEvent | AccessTokenRemovedEvent