From 0b341a541ea87466bc141e28018f33415439a396 Mon Sep 17 00:00:00 2001 From: Mert YEREKAPAN Date: Wed, 12 Aug 2026 13:58:22 +0300 Subject: [PATCH] refactor(studio): inline plan badge experiment and drop client-side conversion tracking Replaces the bespoke experiment hook with the inline pattern used by HeaderUpgradeButton, drops the localStorage seeding and viewport gating, and removes the client conversion event since upgrades are tracked server-side. Keeps the partner-managed exclusion and the trailingContent slot. --- .../SubscriptionPlanUpdateDialog.tsx | 30 +-- .../AppLayout/OrganizationDropdown.test.tsx | 81 ++------ .../AppLayout/OrganizationDropdown.tsx | 42 ++-- .../usePlanBadgeUpgradeExperiment.test.ts | 55 ------ .../misc/usePlanBadgeUpgradeExperiment.ts | 182 ------------------ packages/common/telemetry-constants.ts | 40 +--- 6 files changed, 44 insertions(+), 386 deletions(-) delete mode 100644 apps/studio/hooks/misc/__tests__/usePlanBadgeUpgradeExperiment.test.ts delete mode 100644 apps/studio/hooks/misc/usePlanBadgeUpgradeExperiment.ts 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