mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 06:09:08 +03:00
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:
1 parent
6f45cf8f63
commit
e5bc7d8fa1
10 files changed
+20
-708
No files matched your search
+1
-204
@@ -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>
|
||||
|
||||
+2
-6
@@ -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={() =>
|
||||
|
||||
+2
-9
@@ -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')
|
||||
|
||||
+3
-7
@@ -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: {
|
||||
|
||||
+5
-33
@@ -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"
|
||||
|
||||
+3
-165
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
+4
-38
@@ -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}
|
||||
|
||||
-106
@@ -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')
|
||||
})
|
||||
})
|
||||
-120
@@ -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' },
|
||||
]
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user