diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index d9eb5d249c6..f67b5a04540 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -4,8 +4,7 @@ import { useParams } from 'common' import { Check, InfoIcon } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' -import { useRouter } from 'next/router' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { plans as subscriptionsPlans } from 'shared-data/plans' import { toast } from 'sonner' import { Button, cn, Dialog, DialogContent } from 'ui' @@ -29,10 +28,6 @@ import { useConfirmPendingSubscriptionChangeMutation } from '@/data/subscription import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation' import { OrgPlan, SubscriptionTier } from '@/data/subscriptions/types' -import { - usePlanBadgeUpgradeExperiment, - type PlanBadgeUpgradeVariant, -} from '@/hooks/misc/usePlanBadgeUpgradeExperiment' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { DOCS_URL, @@ -41,7 +36,6 @@ import { STRIPE_PUBLIC_KEY, } from '@/lib/constants' import { formatCurrency } from '@/lib/helpers' -import { useTrack } from '@/lib/telemetry/track' const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -93,9 +87,7 @@ export const SubscriptionPlanUpdateDialog = ({ onUseAsDefaultBillingAddressChange, }: Props) => { const { slug } = useParams() - const router = useRouter() const { resolvedTheme } = useTheme() - const track = useTrack() const { data: selectedOrganization } = useSelectedOrganizationQuery() const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() const [paymentIntentSecret, setPaymentIntentSecret] = useState(null) @@ -134,28 +126,8 @@ export const SubscriptionPlanUpdateDialog = ({ [selectedTier] ) - // GROWTH-775 conversion tracking. Read without exposure — the badge doesn't render here, - // and this surface is reached from every upgrade entry point, not just the experiment's. - const { variant: planBadgeVariant } = usePlanBadgeUpgradeExperiment({ trackExposure: false }) - // The org drops out of the experiment the moment the plan changes (eligibility requires - // the free plan), so latch the arm while the user is still enrolled. - const enrolledVariantRef = useRef(undefined) - useEffect(() => { - if (planBadgeVariant !== undefined) enrolledVariantRef.current = planBadgeVariant - }, [planBadgeVariant]) - const onSuccessfulPlanChange = () => { setPaymentConfirmationLoading(false) - - const enrolledVariant = enrolledVariantRef.current - if (enrolledVariant !== undefined && changeType !== 'downgrade') { - track('plan_badge_upgrade_experiment_converted', { - variant: enrolledVariant, - upgradedToPlan: subscriptionPlanMeta?.name ?? selectedTier ?? 'unknown', - viaPlanBadge: router.query.source === 'org_plan_badge', - }) - } - toast.success( `Successfully ${changeType === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!` ) diff --git a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.test.tsx b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.test.tsx index c85fe26dff2..c5c596caeff 100644 --- a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.test.tsx +++ b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.test.tsx @@ -1,5 +1,4 @@ import { screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { OrganizationDropdown } from './OrganizationDropdown' @@ -10,26 +9,20 @@ const { mockUseIsFeatureEnabled, mockUseOrganizationsQuery, mockUseSelectedOrganizationQuery, - mockUsePlanBadgeUpgradeExperiment, - mockTrack, + mockUsePHFlag, } = vi.hoisted(() => ({ mockUseIsFeatureEnabled: vi.fn(), mockUseOrganizationsQuery: vi.fn(), mockUseSelectedOrganizationQuery: vi.fn(), - mockUsePlanBadgeUpgradeExperiment: vi.fn(), - mockTrack: vi.fn(), -})) - -vi.mock('@/lib/telemetry/track', () => ({ - useTrack: () => mockTrack, + mockUsePHFlag: vi.fn(), })) vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ useIsFeatureEnabled: mockUseIsFeatureEnabled, })) -vi.mock('@/hooks/misc/usePlanBadgeUpgradeExperiment', () => ({ - usePlanBadgeUpgradeExperiment: mockUsePlanBadgeUpgradeExperiment, +vi.mock('@/hooks/ui/useFlag', () => ({ + usePHFlag: mockUsePHFlag, })) vi.mock('@/data/organizations/organizations-query', () => ({ @@ -45,19 +38,12 @@ vi.mock('@/components/ui/PartnerIcon', () => ({ organization.managed_by === MANAGED_BY.SUPABASE ? null :
, })) -// `useWindowSize` reads `window.innerWidth` when it initializes, so set this before render. -const DESKTOP_VIEWPORT_WIDTH = 1024 -const setViewportWidth = (width: number) => { - Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width }) -} - describe('OrganizationDropdown', () => { beforeEach(() => { vi.clearAllMocks() - setViewportWidth(DESKTOP_VIEWPORT_WIDTH) mockUseIsFeatureEnabled.mockReturnValue(false) // Default to the control arm so the plan badge stays inline (non-clickable). - mockUsePlanBadgeUpgradeExperiment.mockReturnValue({ isFreePlan: true, variant: 'control' }) + mockUsePHFlag.mockReturnValue('control') mockUseOrganizationsQuery.mockReturnValue({ data: [ createMockOrganization({ slug: 'org-one', name: 'Org One' }), @@ -99,79 +85,50 @@ describe('OrganizationDropdown', () => { }) it('renders the plan badge as an upgrade link in the experiment test arm', () => { - mockUsePlanBadgeUpgradeExperiment.mockReturnValue({ isFreePlan: true, variant: 'test' }) + mockUsePHFlag.mockReturnValue('test') mockUseSelectedOrganizationQuery.mockReturnValue({ data: createMockOrganization({ slug: 'org-one', name: 'Org One' }), }) render() - const badgeLink = screen.getByRole('link', { name: /free/i }) - expect(badgeLink).toHaveAttribute( + expect(screen.getByRole('link', { name: /upgrade from the free plan/i })).toHaveAttribute( 'href', '/org/org-one/billing?panel=subscriptionPlan&source=org_plan_badge' ) - // The org name link should not point to the upgrade funnel. expect(screen.getByRole('link', { name: /org one/i })).toHaveAttribute('href', '/org/org-one') }) - it('tracks the badge click with the arm it fired from', async () => { - mockUsePlanBadgeUpgradeExperiment.mockReturnValue({ isFreePlan: true, variant: 'test' }) + it('keeps the plan badge inline for a partner-managed org in the test arm', () => { + mockUsePHFlag.mockReturnValue('test') mockUseSelectedOrganizationQuery.mockReturnValue({ - data: createMockOrganization({ slug: 'org-one', name: 'Org One' }), + data: createMockOrganization({ + slug: 'org-one', + name: 'Org One', + managed_by: MANAGED_BY.VERCEL_MARKETPLACE, + }), }) render() - await userEvent.click(screen.getByRole('link', { name: /free/i })) - expect(mockTrack).toHaveBeenCalledWith('plan_badge_upgrade_clicked', { variant: 'test' }) + // Partner-managed orgs can't change plans here, so they never get the upgrade link. + const upgradeLink = screen + .getAllByRole('link') + .find((link) => link.getAttribute('href')?.includes('panel=subscriptionPlan')) + expect(upgradeLink).toBeUndefined() }) it('keeps the plan badge non-clickable in the control arm', () => { - mockUsePlanBadgeUpgradeExperiment.mockReturnValue({ isFreePlan: true, variant: 'control' }) mockUseSelectedOrganizationQuery.mockReturnValue({ data: createMockOrganization({ slug: 'org-one', name: 'Org One' }), }) render() - // No standalone upgrade link — the badge stays inline within the org name link, which - // still points at the org overview rather than the upgrade funnel. const upgradeLink = screen .getAllByRole('link') .find((link) => link.getAttribute('href')?.includes('panel=subscriptionPlan')) expect(upgradeLink).toBeUndefined() expect(screen.getByRole('link', { name: /org one/i })).toHaveAttribute('href', '/org/org-one') }) - - it('tracks exposure from the desktop header, the only place the badge renders', () => { - mockUseSelectedOrganizationQuery.mockReturnValue({ - data: createMockOrganization({ slug: 'org-one', name: 'Org One' }), - }) - - render() - - expect(mockUsePlanBadgeUpgradeExperiment).toHaveBeenCalledWith({ trackExposure: true }) - }) - - it('does not track exposure when embedded, since the badge is not rendered there', () => { - mockUseSelectedOrganizationQuery.mockReturnValue({ - data: createMockOrganization({ slug: 'org-one', name: 'Org One' }), - }) - - render() - - expect(mockUsePlanBadgeUpgradeExperiment).toHaveBeenCalledWith({ trackExposure: false }) - }) - - it('does not track exposure below md, where the header is hidden', () => { - setViewportWidth(390) - mockUseSelectedOrganizationQuery.mockReturnValue({ - data: createMockOrganization({ slug: 'org-one', name: 'Org One' }), - }) - - render() - - expect(mockUsePlanBadgeUpgradeExperiment).toHaveBeenCalledWith({ trackExposure: false }) - }) }) diff --git a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx index 20636cef47f..575788b8687 100644 --- a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx @@ -3,7 +3,6 @@ import { Boxes } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' -import { useWindowSize } from 'react-use' import { Badge, cn } from 'ui' import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' @@ -17,12 +16,16 @@ import { useEmbeddedCloseHandler } from './useEmbeddedCloseHandler' import PartnerIcon from '@/components/ui/PartnerIcon' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' -import { usePlanBadgeUpgradeExperiment } from '@/hooks/misc/usePlanBadgeUpgradeExperiment' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { useTrackExperimentExposure } from '@/hooks/misc/useTrackExperimentExposure' +import { usePHFlag } from '@/hooks/ui/useFlag' +import { MANAGED_BY } from '@/lib/constants/infrastructure' import { useTrack } from '@/lib/telemetry/track' -// Tailwind's `md`, matching the `hidden md:flex` on the header this renders in. -const MD_BREAKPOINT = 768 +// GROWTH-775. Delete this block and the `trailingContent` branch below once the readout +// lands — either inline the linked badge for everyone, or revert to the plain one. +const PLAN_BADGE_UPGRADE_EXPERIMENT_ID = 'plan_badge_upgrade' +type PlanBadgeUpgradeVariant = 'control' | 'test' interface OrganizationDropdownProps { embedded?: boolean @@ -49,22 +52,19 @@ export const OrganizationDropdown = ({ const slug = selectedOrganization?.slug const orgName = selectedOrganization?.name - // GROWTH-775 experiment: in the `test` arm the Free plan badge becomes a clickable - // entry point into the upgrade funnel. The hook only returns a variant for orgs that can - // actually convert through the plan panel, so everyone else keeps the plain badge. - // - // The badge only exists in the desktop header — `embedded` renders just the command list, - // and the header itself is `hidden md:flex`. Firing exposure from those enrols users who - // can never see the treatment, which dilutes both arms. - // - // Reading the width directly rather than via `useBreakpoint`, whose state starts at - // `false` (i.e. "not below the breakpoint") until its layout effect runs — on a remount - // with flags already cached that reads as "desktop" and fires exposure on mobile. - const { width: viewportWidth } = useWindowSize() - const { variant: planBadgeVariant } = usePlanBadgeUpgradeExperiment({ - trackExposure: !embedded && viewportWidth >= MD_BREAKPOINT, - }) - const showPlanBadgeUpgrade = planBadgeVariant === 'test' && !!selectedOrganization && !!slug + // GROWTH-775 experiment: in the `test` arm the Free plan badge links into the upgrade + // funnel. Partner-managed orgs are excluded — they change plans at the partner, and + // PlanUpdateSidePanel disables every paid tier for them, so they can't convert here. + const planBadgeFlag = usePHFlag('planBadgeUpgrade') + const isUpgradableOrg = + selectedOrganization?.plan.id === 'free' && + selectedOrganization.managed_by === MANAGED_BY.SUPABASE && + !selectedOrganization.billing_partner + const isInPlanBadgeExperiment = planBadgeFlag === 'control' || planBadgeFlag === 'test' + const planBadgeVariant = isUpgradableOrg && isInPlanBadgeExperiment ? planBadgeFlag : undefined + useTrackExperimentExposure(PLAN_BADGE_UPGRADE_EXPERIMENT_ID, planBadgeVariant) + + const showPlanBadgeUpgrade = planBadgeVariant === 'test' && !!slug const [open, setOpen] = useState(false) const close = useEmbeddedCloseHandler(embedded, onClose, setOpen) @@ -124,7 +124,7 @@ export const OrganizationDropdown = ({ href={`/org/${slug}/billing?panel=subscriptionPlan&source=org_plan_badge`} className="ml-2 shrink-0" aria-label={`Upgrade from the ${selectedOrganization?.plan.name} plan`} - onClick={() => track('plan_badge_upgrade_clicked', { variant: 'test' })} + onClick={() => track('plan_badge_upgrade_clicked')} > { - it('accepts a Supabase-managed free-plan organization', () => { - expect(isOrganizationUpgradableInDashboard(createMockOrganization({}))).toBe(true) - }) - - it('rejects an organization that has not resolved yet', () => { - expect(isOrganizationUpgradableInDashboard(undefined)).toBe(false) - }) - - it('rejects paid plans', () => { - const org = createMockOrganization({ plan: { id: 'pro', name: 'Pro' } }) - expect(isOrganizationUpgradableInDashboard(org)).toBe(false) - }) - - // Partner-managed orgs change plans through the partner — PlanUpdateSidePanel disables - // every paid tier for them, so the badge would link to a panel with no enabled action. - it.each([['vercel-marketplace'], ['aws-marketplace'], ['stripe-projects']])( - 'rejects organizations managed by %s', - (managedBy) => { - const org = createMockOrganization({ managed_by: managedBy }) - expect(isOrganizationUpgradableInDashboard(org)).toBe(false) - } - ) - - // `getManagedByFromOrganizationPartner` has no case for `fly`, so it falls through to - // `supabase` — but PlanUpdateSidePanel still disables every paid tier for any org with a - // billing partner. The `billing_partner` check is what catches this. - it('rejects organizations with a billing partner that managed_by does not map', () => { - const org = createMockOrganization({ managed_by: 'supabase', billing_partner: 'fly' }) - expect(isOrganizationUpgradableInDashboard(org)).toBe(false) - }) -}) - -describe('isPlanBadgeUpgradeEligible', () => { - it('accepts an upgradable organization when the user can reach billing', () => { - expect(isPlanBadgeUpgradeEligible(createMockOrganization({}), true)).toBe(true) - }) - - it('rejects when billing is disabled for the user, since the billing page 404s', () => { - expect(isPlanBadgeUpgradeEligible(createMockOrganization({}), false)).toBe(false) - }) - - it('rejects a non-upgradable organization even when billing is enabled', () => { - const org = createMockOrganization({ managed_by: 'vercel-marketplace' }) - expect(isPlanBadgeUpgradeEligible(org, true)).toBe(false) - }) -}) diff --git a/apps/studio/hooks/misc/usePlanBadgeUpgradeExperiment.ts b/apps/studio/hooks/misc/usePlanBadgeUpgradeExperiment.ts deleted file mode 100644 index e9000b1f4ac..00000000000 --- a/apps/studio/hooks/misc/usePlanBadgeUpgradeExperiment.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { IS_PLATFORM, safeLocalStorage, useFeatureFlags, useParams } from 'common' -import { useEffect, useMemo } from 'react' - -import { isPartnerBillingOrganization } from '@/data/organizations/managed-by-utils' -import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' -import { useTrackExperimentExposure } from '@/hooks/misc/useTrackExperimentExposure' -import { usePHFlag } from '@/hooks/ui/useFlag' -import { MANAGED_BY } from '@/lib/constants/infrastructure' -import { useProfile } from '@/lib/profile' -import type { Organization } from '@/types' - -// PostHog flag key (camelCase, matches other flag naming in the codebase). -// -// Sunset: this flag and everything in this file are experiment scaffolding. Once the -// readout lands, either delete the file and inline the winning behavior in -// `OrganizationDropdown`, or delete both if `control` wins. Nothing else should start -// depending on this hook in the meantime. -export const PLAN_BADGE_UPGRADE_FLAG_NAME = 'planBadgeUpgrade' - -// snake_case experiment ID so the auto-fired exposure event name matches the -// `[experiment_id]_experiment_exposed` typed event registered in telemetry-constants.ts. -export const PLAN_BADGE_UPGRADE_EXPERIMENT_ID = 'plan_badge_upgrade' - -// localStorage key prefix for the seeded variant (see hook docs). Keyed per org slug -// because eligibility folds in that org's plan. -const PLAN_BADGE_UPGRADE_SEED_PREFIX = 'supabase-plan-badge-upgrade-variant-' - -export type PlanBadgeUpgradeVariant = 'control' | 'test' - -const VALID_VARIANTS: PlanBadgeUpgradeVariant[] = ['control', 'test'] - -/** - * Whether this org can complete an upgrade through the dashboard's plan panel, which is - * what the badge links to. Anyone who can't is excluded from the experiment entirely — not - * just from the treatment — so they don't sit in the exposure cohort as guaranteed - * non-converters. - * - * Org-level only, so it resolves as soon as the organization query does. The user-level - * half of eligibility (`billing:all`) resolves separately — see `isPlanBadgeUpgradeEligible`. - * - * Mirrors the eligibility `UpgradePlanButton` applies before linking to the same panel; - * the one case deliberately left in is a free-plan member without `BILLING_WRITE`, since - * `PlanUpdateSidePanel` renders `RequestUpgradeToBillingOwners` per plan card for them — - * that's a real next action, not a dead end. - */ -export const isOrganizationUpgradableInDashboard = (organization: Organization | undefined) => { - if (organization?.plan?.id !== 'free') return false - - // Partner-managed orgs change plans through Vercel / AWS Marketplace / Stripe Projects. - // `PlanUpdateSidePanel` shows a `PartnerManagedResource` notice and disables every paid - // tier for them, so the panel has no enabled action to convert on. - if (organization.managed_by !== MANAGED_BY.SUPABASE) return false - if (isPartnerBillingOrganization(organization.billing_partner)) return false - - return true -} - -/** - * Full eligibility: the org can be upgraded in-dashboard *and* this user can reach the - * billing page at all. With `billing:all` disabled, `/org/[slug]/billing` renders - * `UnknownInterface`, so the badge would link to a dead end — `UpgradePlanButton` falls - * back to the support form in that case. - * - * Note `billingAll` reads from the profile, which loads independently of the organization - * query and defaults to `true` while pending. Callers must not treat this as definitive - * until the profile has resolved, or a user with billing disabled can be enrolled during - * the gap. - */ -export const isPlanBadgeUpgradeEligible = ( - organization: Organization | undefined, - billingAll: boolean -) => isOrganizationUpgradableInDashboard(organization) && billingAll - -interface UsePlanBadgeUpgradeExperimentOptions { - /** - * Whether this call site is the one that actually renders the badge. Only that call site - * should fire the exposure event — see the note on dilution in the hook docs. - */ - trackExposure?: boolean -} - -/** - * Shared experiment state for the "make the Free plan badge a clickable upgrade entry - * point" test (GROWTH-775). - * - * `variant` is the resolved arm, gated on the experiment flag plus - * `isPlanBadgeUpgradeEligible` — orgs that can't convert through the plan panel never - * receive a variant, so the clickable badge never renders for them. - * - * Read it with `trackExposure: false` from surfaces that need the arm for attribution but - * don't show the badge — the billing panel does this to emit the conversion event. - * - * First-paint correctness: PostHog flags are fetched - * async on every load, so the variant is unknown at first paint. To avoid the badge's - * affordance popping in or flashing, we persist the last resolved variant per org and seed - * from it synchronously. The seed is used only until the live flag + org resolve, then the - * live value takes over and is re-persisted — so it self-heals if anything changed. A - * confirmed ineligible org always wins over a stale seed. - * - * Exposure tracking fires only once confirmed (eligible + in experiment) and only from the - * call site passing `trackExposure`, so users who can never see the treatment — the mobile - * sheets, and the `hidden md:flex` header below `md` — stay out of the cohort instead of - * diluting both arms. - */ -export const usePlanBadgeUpgradeExperiment = ({ - trackExposure = true, -}: UsePlanBadgeUpgradeExperimentOptions = {}) => { - const { slug } = useParams() - const { data: organization, isPending: isOrgPending } = useSelectedOrganizationQuery() - const flagStore = useFeatureFlags() - const flagValue = usePHFlag(PLAN_BADGE_UPGRADE_FLAG_NAME) - const { billingAll } = useIsFeatureEnabled(['billing:all']) - const { isLoading: isProfileLoading } = useProfile() - - const flagsLoaded = flagStore.hasLoaded === true - const orgKnown = !isOrgPending - // `billingAll` defaults to `true` while the profile is in flight, and the profile loads - // independently of the org query. Without this the flag store can resolve first and enrol - // a user whose `billing:all` is actually off. - const featuresKnown = !isProfileLoading - const isResolved = flagsLoaded && orgKnown && featuresKnown - - const isFreePlan = organization?.plan?.id === 'free' - const isOrgUpgradable = isOrganizationUpgradableInDashboard(organization) - const isEligible = isPlanBadgeUpgradeEligible(organization, billingAll) - const isInExperiment = - typeof flagValue === 'string' && VALID_VARIANTS.includes(flagValue as PlanBadgeUpgradeVariant) - - // The definitive variant for a confirmed eligible user in the experiment. - const liveVariant = - isResolved && isEligible && isInExperiment ? (flagValue as PlanBadgeUpgradeVariant) : undefined - - // Synchronous seed from the last resolved variant for this org. Read via useMemo so it - // re-reads when the org slug changes (e.g. navigating between orgs without a remount). - const seedKey = `${PLAN_BADGE_UPGRADE_SEED_PREFIX}${slug ?? 'none'}` - const seededVariant = useMemo(() => { - const item = safeLocalStorage.getItem(seedKey) - if (!item) return null - try { - const parsed = JSON.parse(item) - return VALID_VARIANTS.includes(parsed) ? (parsed as PlanBadgeUpgradeVariant) : null - } catch { - return null - } - }, [seedKey]) - - // Each "confirmed ineligible" branch is checked against the query that establishes it, so - // a stale seed is beaten as soon as *that* answer lands rather than waiting for all of them. - let variant: PlanBadgeUpgradeVariant | undefined - if (!IS_PLATFORM) { - // No billing/plans on self-hosted, so there is nothing to upgrade to — never show. - variant = undefined - } else if (isResolved) { - // Fully resolved — source of truth. - variant = liveVariant - } else if (orgKnown && !isOrgUpgradable) { - // Confirmed paid or partner-managed — never show, even if a stale seed says otherwise. - variant = undefined - } else if (featuresKnown && !billingAll) { - // Confirmed no access to billing — same. - variant = undefined - } else { - // Eligible, or still loading — trust the last known value to avoid a first-paint shift. - variant = seededVariant ?? undefined - } - - // Persist the last resolved variant once we have a definitive answer, so the next visit - // to this org can seed from it. Only matters before the live value resolves, so we don't - // need it in component state. - useEffect(() => { - if (!IS_PLATFORM || !isResolved) return - safeLocalStorage.setItem(seedKey, JSON.stringify(liveVariant ?? null)) - }, [isResolved, liveVariant, seedKey]) - - useTrackExperimentExposure( - PLAN_BADGE_UPGRADE_EXPERIMENT_ID, - trackExposure ? liveVariant : undefined - ) - - return { isFreePlan, isEligible, variant } -} diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index e5ad5e5b292..cf0a073c920 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -3459,7 +3459,7 @@ export interface UpgradeCtaClickedEvent { /** * User was exposed to the plan badge upgrade experiment. * Fires once per session per enrolled user in any variant (including control), so the - * conversion analysis has a baseline cohort. + * conversion analysis has a baseline cohort. Conversion itself is tracked server-side. * GROWTH experiment: `planBadgeUpgrade` (GROWTH-775). * * @group Events @@ -3469,11 +3469,7 @@ export interface UpgradeCtaClickedEvent { export interface PlanBadgeUpgradeExperimentExposedEvent { action: 'plan_badge_upgrade_experiment_exposed' properties: { - /** - * The experiment variant the user is enrolled in. `captureExperimentExposure` also - * attaches an `experiment_id` property at send time — it isn't declared here because - * this event is sent through `posthogClient`, not `useTrack`. - */ + /** The experiment variant the user is enrolled in */ variant: 'control' | 'test' } groups: Omit @@ -3482,8 +3478,7 @@ export interface PlanBadgeUpgradeExperimentExposedEvent { /** * User clicked the Free plan badge next to the organization name (in the org dropdown) to * start the upgrade flow. Only reachable in the `test` arm, so this measures click-through - * on the treatment rather than comparing arms — `plan_badge_upgrade_experiment_converted` - * is the arm-comparable metric. + * on the treatment rather than comparing arms. * GROWTH experiment: `planBadgeUpgrade` (GROWTH-775). * * @group Events @@ -3492,34 +3487,6 @@ export interface PlanBadgeUpgradeExperimentExposedEvent { */ export interface PlanBadgeUpgradeClickedEvent { action: 'plan_badge_upgrade_clicked' - properties: { - /** The experiment variant the user is enrolled in */ - variant: 'control' | 'test' - } - groups: Omit -} - -/** - * An enrolled user completed a paid subscription upgrade. This is the experiment's - * conversion event: it fires for both arms on any successful upgrade, whichever entry point - * the user took, so lift is measurable against the exposure cohort. Kept separate from the - * generic upgrade tracking so the experiment signal can be isolated. - * GROWTH experiment: `planBadgeUpgrade` (GROWTH-775). - * - * @group Events - * @page /org/[slug]/billing - * @source studio - */ -export interface PlanBadgeUpgradeExperimentConvertedEvent { - action: 'plan_badge_upgrade_experiment_converted' - properties: { - /** The experiment variant the user was enrolled in when they upgraded */ - variant: 'control' | 'test' - /** The plan the user upgraded to, e.g. `Pro` */ - upgradedToPlan: string - /** Whether the user reached the plan panel through the experiment's badge link */ - viaPlanBadge: boolean - } groups: Omit } @@ -3943,7 +3910,6 @@ export type TelemetryEvent = | UpgradeCtaClickedEvent | PlanBadgeUpgradeExperimentExposedEvent | PlanBadgeUpgradeClickedEvent - | PlanBadgeUpgradeExperimentConvertedEvent | AccessTokenCreatedEvent | AccessTokenRemovedEvent | ResourceExhaustionBannerUpgradeClickedEvent