chore(studio): ship fullscreen pricing panel, remove plan presentation experiment (#51554)

## Summary

The `pricingPanelPlanPresentation` experiment is concluded with
`fullscreen` as the winner. This makes it the only presentation and
removes the experiment code.

- Plan-change panel always opens as the full-screen overlay with the
pricing-page style cards.
- Removed the `control`, `parity`, `gaps` and `fullscreen-gaps`
presentations: the side-sheet shell, the original compact card, and the
"Not included" / "Plan limits" gap rows.
- Removed `plan-presentation.ts` (flag read, eligibility gate, exposure
tracking, gap lists) and its tests.
- Removed the `pricing_panel_plan_presentation_experiment_exposed` event
from `telemetry-constants.ts`.
- The panel no longer waits for PostHog flags to resolve before opening.
- `useTrackExperimentExposure` stays; it's kept across experiment gaps
in `knip.jsonc`.

**Behavior change for previously ineligible orgs:** partner-billed,
Stripe-managed, AWS Marketplace, Enterprise/Platform orgs and members
without billing write were held on `control` during the test. They now
also get the full-screen panel. Their partner-managed notices and
disabled CTAs are unchanged.

## After merge

- [ ] Archive the `pricingPanelPlanPresentation` flag and end the
experiment in PostHog.

## Test plan

- [x] `vitest` for `BillingSettings/Subscription`: 11 passing
(partner-managed messaging, full-screen shell, pricing-page cards, no
gap rows)
- [x] `tsc`: no errors in touched files
- [x] `knip --workspace apps/studio`: clean
- [ ] Preview: open `/org/[slug]/billing?panel=subscriptionPlan` for a
Free org and a partner-billed org

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Mert YEREKAPANandJoshen Lim authored and GitHub committed 2026-10-13 06:58:30 -07:00
1 parent 6f45cf8f63
commit e5bc7d8fa1
10 files changed
+20 -708

No files matched your search

@@ -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<string, GapFeature[]> = {
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 ? (
<Button block disabled>
Current plan
@@ -109,154 +92,6 @@ export function PlanCard({
</ButtonTooltip>
)
if (!isParity) {
return (
<ControlCard
plan={plan}
price={price}
isCurrentPlan={isCurrentPlan}
isLoadingPlans={isLoadingPlans}
shouldHighlight={shouldHighlight}
features={features}
footer={footer}
ctaButton={ctaButton}
variants={variants}
/>
)
}
return (
<ParityCard
plan={plan}
price={price}
isCurrentPlan={isCurrentPlan}
isLoadingPlans={isLoadingPlans}
shouldHighlight={shouldHighlight}
features={features}
footer={footer}
gaps={gaps}
ctaButton={ctaButton}
variants={variants}
/>
)
}
function ControlCard({
plan,
price,
isCurrentPlan,
isLoadingPlans,
shouldHighlight,
features,
footer,
ctaButton,
variants,
}: {
plan: PricingInformation
price: number
isCurrentPlan: boolean
isLoadingPlans: boolean
shouldHighlight: boolean
features: (string | string[])[]
footer: string | undefined
ctaButton: React.ReactNode
variants?: Variants
}) {
return (
<motion.div
variants={variants}
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-primary-bright animate-[pulse_1.5s_ease-in-out_1] motion-reduce:animate-none shadow-md shadow-primary-bright/40'
)}
>
<div className="w-full">
<div className="flex items-center space-x-2">
<p className="text-primary 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>
{ctaButton}
<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-primary 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>
)}
</motion.div>
)
}
function ParityCard({
plan,
price,
isCurrentPlan,
isLoadingPlans,
shouldHighlight,
features,
footer,
gaps,
ctaButton,
variants,
}: {
plan: PricingInformation
price: number
isCurrentPlan: boolean
isLoadingPlans: boolean
shouldHighlight: boolean
features: (string | string[])[]
footer: string | undefined
gaps: GapFeature[]
ctaButton: React.ReactNode
variants?: Variants
}) {
return (
<motion.div
variants={variants}
@@ -344,44 +179,6 @@ function ParityCard({
))}
</ul>
{gaps.length > 0 && (
<>
<div className="border-t my-3" />
<p className="text-foreground-muted text-[13px] mb-3">
{gaps.some((g) => g.type === 'lesser') ? 'Plan limits' : 'Not included'}
</p>
<ul className="text-[13px]">
{gaps.map((gap) => (
<li key={gap.label} className="flex items-center py-1.5">
{gap.type === 'missing' ? (
<>
<div className="flex w-5">
<X
className="h-4 w-4 text-foreground-muted"
aria-hidden="true"
strokeWidth={2}
/>
</div>
<span className="text-foreground-muted">{gap.label}</span>
</>
) : (
<>
<div className="flex w-5">
<Check
className="h-4 w-4 text-foreground-muted"
aria-hidden="true"
strokeWidth={2}
/>
</div>
<span className="text-foreground-muted">{gap.label}</span>
</>
)}
</li>
))}
</ul>
</>
)}
{footer && (
<div className="border-t pt-4 mt-auto">
<p className="text-foreground-lighter text-[13px] whitespace-pre-wrap">{footer}</p>
@@ -3,7 +3,6 @@ import { useRouter } from 'next/router'
import { plans as subscriptionsPlans } from 'shared-data/plans'
import { EnterpriseCard } from './EnterpriseCard'
import type { PlanPresentationVariant } from './plan-presentation'
import { PlanCard } from './PlanCard'
import { getPlanCardsEntryAnimation } from './PlanCards.utils'
import { getPlanChangeType } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
@@ -20,9 +19,8 @@ export interface PlanCardsProps {
isPartnerBilledOrganization: boolean
hasOrioleProjects: boolean
selectedOrganization: Organization | undefined
variant: PlanPresentationVariant
/** Seconds to wait before the cards stagger in. Omit to render them without an entry animation. */
entryDelay?: number
/** Seconds to wait before the cards stagger in. */
entryDelay: number
onSelectTier: (tier: 'tier_free' | 'tier_pro' | 'tier_team') => void
}
@@ -35,7 +33,6 @@ export function PlanCards({
isPartnerBilledOrganization,
hasOrioleProjects,
selectedOrganization,
variant,
entryDelay,
onSelectTier,
}: PlanCardsProps) {
@@ -83,7 +80,6 @@ export function PlanCards({
currentSubscriptionPlanId={currentSubscriptionPlanId}
managedBy={selectedOrganization?.managed_by}
shouldHighlight={shouldHighlight}
variant={variant}
variants={entryAnimation.card}
onSelectTier={() => onSelectTier(plan.id as 'tier_free' | 'tier_pro' | 'tier_team')}
onTrackCtaClick={() =>
@@ -3,13 +3,6 @@ import { describe, expect, it } from 'vitest'
import { getPlanCardsEntryAnimation } from './PlanCards.utils'
describe('getPlanCardsEntryAnimation', () => {
it('returns no animation props when no entry delay is given', () => {
const animation = getPlanCardsEntryAnimation(undefined)
expect(animation.container).toEqual({})
expect(animation.card).toBeUndefined()
})
it('staggers the cards after the container delay', () => {
const animation = getPlanCardsEntryAnimation(0.4)
@@ -17,12 +10,12 @@ describe('getPlanCardsEntryAnimation', () => {
expect(animation.container.animate).toBe('visible')
expect(animation.card).toBeDefined()
const visible = animation.container.variants?.visible as { transition: Record<string, number> }
const visible = animation.container.variants.visible as { transition: Record<string, number> }
expect(visible.transition.delayChildren).toBeCloseTo(0.6)
expect(visible.transition.staggerChildren).toBe(0.08)
})
it('treats a zero delay as an animation, not as absent', () => {
it('animates with a zero delay', () => {
const animation = getPlanCardsEntryAnimation(0)
expect(animation.container.initial).toBe('hidden')
@@ -9,15 +9,11 @@ const STAGGER_AFTER_CONTAINER = 0.2
const STAGGER_BETWEEN_CARDS = 0.08
export interface PlanCardsEntryAnimation {
container: { variants?: Variants; initial?: string; animate?: string }
card: Variants | undefined
container: { variants: Variants; initial: string; animate: string }
card: Variants
}
export function getPlanCardsEntryAnimation(
entryDelay: number | undefined
): PlanCardsEntryAnimation {
if (entryDelay === undefined) return { container: {}, card: undefined }
export function getPlanCardsEntryAnimation(entryDelay: number): PlanCardsEntryAnimation {
return {
container: {
variants: {
@@ -1,46 +1,18 @@
import { motion } from 'framer-motion'
import { ArrowLeft, ExternalLink } from 'lucide-react'
import type { ReactNode } from 'react'
import { Button, SidePanel } from 'ui'
import { Button } from 'ui'
export interface PlanUpdatePanelShellProps {
interface PlanUpdateFullScreenShellProps {
organizationName: string | undefined
/** Rendered above the plan cards, outside the scrollable card grid. */
notice: ReactNode
skipOverlayFade: boolean
contentDelay: number
onClose: () => void
children: ReactNode
}
export function PlanUpdateSheetShell({
visible,
organizationName,
notice,
onClose,
children,
}: PlanUpdatePanelShellProps & { visible: boolean }) {
return (
<SidePanel
hideFooter
size="xxlarge"
visible={visible}
onCancel={onClose}
header={
<div className="flex items-center justify-between w-full">
<h4>Change subscription plan for {organizationName}</h4>
<Button asChild icon={<ExternalLink />}>
<a href="https://supabase.com/pricing" target="_blank" rel="noreferrer">
Pricing
</a>
</Button>
</div>
}
>
{notice}
<SidePanel.Content>{children}</SidePanel.Content>
</SidePanel>
)
}
export function PlanUpdateFullScreenShell({
organizationName,
notice,
@@ -48,7 +20,7 @@ export function PlanUpdateFullScreenShell({
contentDelay,
onClose,
children,
}: PlanUpdatePanelShellProps & { skipOverlayFade: boolean; contentDelay: number }) {
}: PlanUpdateFullScreenShellProps) {
return (
<motion.div
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-studio"
@@ -8,19 +8,12 @@ import { createMockOrganization, render } from '@/tests/helpers'
const mockSelectedOrganization = vi.hoisted(() => 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(<PlanUpdateSidePanel />)
expect(
screen.getByText('Perfect for passion projects & simple websites.')
).toBeInTheDocument()
})
it('renders preface line', () => {
render(<PlanUpdateSidePanel />)
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(<PlanUpdateSidePanel />)
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(<PlanUpdateSidePanel />)
expect(screen.getByText('1-day log retention')).toBeInTheDocument()
})
it('renders the gap section heading on Free and Pro cards', () => {
render(<PlanUpdateSidePanel />)
// 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(<PlanUpdateSidePanel />)
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(<PlanUpdateSidePanel />)
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(<PlanUpdateSidePanel />)
@@ -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(<PlanUpdateSidePanel />)
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(<PlanUpdateSidePanel />)
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(<PlanUpdateSidePanel />)
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(<PlanUpdateSidePanel />)
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(<PlanUpdateSidePanel />)
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(<PlanUpdateSidePanel />)
expect(screen.queryByText('Not included')).not.toBeInTheDocument()
expect(screen.queryByText('Plan limits')).not.toBeInTheDocument()
})
})
})
@@ -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 && (
<PlanUpdateFullScreenShell
organizationName={selectedOrganization?.name}
notice={notice}
@@ -285,17 +262,6 @@ export const PlanUpdateSidePanel = () => {
</PlanUpdateFullScreenShell>
)}
{!isFullScreen && (
<PlanUpdateSheetShell
visible={isPanelOpen}
organizationName={selectedOrganization?.name}
notice={notice}
onClose={onClose}
>
{planCards}
</PlanUpdateSheetShell>
)}
<CancellationFlow
visible={selectedTier === 'tier_free'}
onCancel={() => setSelectedTier(undefined)}
@@ -305,7 +271,7 @@ export const PlanUpdateSidePanel = () => {
<SubscriptionPlanUpdateDialog
selectedTier={selectedTier}
onClose={() => setSelectedTier(undefined)}
onSuccess={isFullScreen ? onClose : undefined}
onSuccess={onClose}
planMeta={planMeta}
subscriptionPreviewQueryResult={subscriptionPreviewData}
projects={orgProjects}
@@ -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')
})
})
@@ -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<PlanPresentationVariant, PlanPresentation> = {
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<PlanPresentationVariant>(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' },
]
-20
View File
@@ -3875,25 +3875,6 @@ export interface UpgradeCtaClickedEvent {
groups: Omit<TelemetryGroups, 'project'>
}
/**
* 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<TelemetryGroups, 'project'>
}
/**
* 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