Files
supabase/apps/studio/hooks/misc/useUpgradeCtaExperiment.ts
Mert YEREKAPAN 2191669d08 feat(studio): add upgrade CTA placement experiment (#45858)
## Summary

Replaces the header upgrade CTA (PR #44494, which design team wanted to
iterate on) with a placement experiment that tests three non-chrome
surfaces for the free-plan "Upgrade to Pro" CTA.

PostHog flag `upgradeCtaPlacement` (free-plan users only) with four
arms:

| Variant | Surface |
| --- | --- |
| `control` | No CTA (baseline cohort, still tracked) |
| `user_dropdown` | Full-width button pinned in the account dropdown |
| `org_projects_list` | Project-card-shaped usage tile, first card in
the org project grid |

## Details

### `user_dropdown`
- Full-width `Upgrade to Pro` button in `UserDropdown`, gated to
org-scoped routes only (`/project/*`, `/org/*`) so the org-billing CTA
never shows on `/account`, `/organizations`, etc. — addresses the scope
concern raised in review.
- Dropdown uses controlled `open` state so it closes before navigation
(it lives in the global layout, so a route change alone wouldn't dismiss
the overlay).


### `org_projects_list`
- `PlanUsageCard` renders as the first `<li>` in the project grid (via
`ProjectList`'s `prependCard`), matching `ProjectCard` shape so it reads
like another project tile. Also renders during the project-list loading
state to avoid pop-in.
<img width="3804" height="1494" alt="Arc 2026-06-08 20 02 54"
src="https://github.com/user-attachments/assets/09c2218c-43d1-49ce-bae7-5075c9750d72"
/>

### Shared card styling
- Metric rows (Egress, Database size, Monthly active users, File
storage) show `current / limit` with a progress ring; ring/value turn
warning at ≥80% and over at ≥100%.
- Rows are clickable deep-links to `/org/[slug]/usage#<anchor>` with a
hover chevron and dashed separators; the same row component is used by
both the embedded and project-card variants.
- Skeleton placeholder renders from first paint so the card reserves
layout while `useOrgUsageQuery` resolves.
- Exposure is fired optimistically while the org query loads (skeleton
shows immediately), but the experiment exposure event only fires once
free-plan is confirmed.

### Telemetry
- `upgrade_cta_clicked` with `placement: user_dropdown | home_usage_card
| org_projects_list`.
- `upgrade_cta_placement_experiment_exposed` with `variant` — the custom
exposure event (snake_case experiment id `upgrade_cta_placement`; the
flag key stays camelCase `upgradeCtaPlacement`).

### Header CTA sunset
- `HeaderUpgradeButton` and its wiring in `LayoutHeader` /
`MobileNavigationBar` removed (master's #46144 already removed the
button; this branch drops the remaining `header_upgrade_cta_clicked`
event).

## Before merging
- [x] Create the `upgradeCtaPlacement` flag + experiment in PostHog (4
variants, free-plan targeting, custom exposure event
`upgrade_cta_placement_experiment_exposed`).
- [x] Archive the now-orphaned `headerUpgradeCta` flag.

## Test plan
- [x] Override the flag per variant via the dev toolbar on staging;
confirm each surface renders and `upgrade_cta_clicked` fires with the
right `placement`.
- [x] `control` shows no CTA but still emits the exposure event.
- [x] Paid-plan org and self-hosted (`IS_PLATFORM=false`) show nothing.
- [x] Clicking a metric row deep-links to the matching usage section;
clicking Upgrade routes to billing.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Experimentally surface an “Upgrade to Pro” CTA in the user dropdown
and on the projects page for eligible free-plan orgs.
* Added a Plan Usage card showing free-plan metrics and upgrade prompts
on the organization projects page; hides when irrelevant or errored.
* Project list and its loading view support inserting a custom/prepend
card in card mode.

* **Telemetry**
* Added tracking for CTA clicks and experiment exposure; placement is
persisted per org.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: kemal <hello@kemal.earth>
2026-06-10 11:20:27 +00:00

98 lines
4.5 KiB
TypeScript

import { IS_PLATFORM, safeLocalStorage, useFeatureFlags, useParams } from 'common'
import { useEffect, useMemo } from 'react'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useTrackExperimentExposure } from '@/hooks/misc/useTrackExperimentExposure'
import { usePHFlag } from '@/hooks/ui/useFlag'
// PostHog flag key (camelCase, matches other flag naming in the codebase).
export const UPGRADE_CTA_FLAG_NAME = 'upgradeCtaPlacement'
// snake_case experiment ID so the auto-fired exposure event name matches the
// `[experiment_id]_experiment_exposed` typed event registered in telemetry-constants.ts.
const UPGRADE_CTA_EXPERIMENT_ID = 'upgrade_cta_placement'
// localStorage key prefix for the seeded variant (see hook docs). Keyed per org slug
// because eligibility folds in that org's plan.
const UPGRADE_CTA_SEED_PREFIX = 'supabase-upgrade-cta-variant-'
export type UpgradeCtaPlacement = 'control' | 'user_dropdown' | 'org_projects_list'
const VALID_VARIANTS: UpgradeCtaPlacement[] = ['control', 'user_dropdown', 'org_projects_list']
/**
* Shared experiment state for the upgrade CTA placement test.
*
* `variant` is the placement to render, and is gated on a confirmed free plan + the
* experiment flag — paid users never receive a variant, so the CTA never renders for them.
*
* First-paint correctness: PostHog flags are fetched async on every load, so the flag
* (and therefore the variant) is unknown at first paint. To avoid the CTA popping in
* (layout shift) or flashing, we persist the last resolved variant per org and seed from
* it synchronously. The seed is used only until the live flag + plan resolve, then the
* live value takes over and is re-persisted — so it self-heals if anything changed. A
* confirmed paid plan always wins over a stale seed, so the CTA can't flash for paid users.
*
* Net effect: correct at first paint with no shift/flash on every visit after the first
* (the first-ever visit to a given org still resolves async).
*
* Exposure tracking fires only once confirmed (free-plan + in experiment), so the
* experiment cohort stays accurate.
*/
export const useUpgradeCtaExperiment = () => {
const { slug } = useParams()
const { data: organization, isPending: isOrgPending } = useSelectedOrganizationQuery()
const flagStore = useFeatureFlags()
const flagValue = usePHFlag<UpgradeCtaPlacement | false>(UPGRADE_CTA_FLAG_NAME)
const flagsLoaded = flagStore.hasLoaded === true
const planKnown = !isOrgPending
const isFreePlan = organization?.plan?.id === 'free'
const isInExperiment =
typeof flagValue === 'string' && VALID_VARIANTS.includes(flagValue as UpgradeCtaPlacement)
// The definitive variant for a confirmed free-plan user in the experiment.
const liveVariant = isFreePlan && isInExperiment ? (flagValue as UpgradeCtaPlacement) : 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 = `${UPGRADE_CTA_SEED_PREFIX}${slug ?? 'none'}`
const seededVariant = useMemo<UpgradeCtaPlacement | null>(() => {
const item = safeLocalStorage.getItem(seedKey)
if (!item) return null
try {
const parsed = JSON.parse(item)
return VALID_VARIANTS.includes(parsed) ? (parsed as UpgradeCtaPlacement) : null
} catch {
return null
}
}, [seedKey])
let variant: UpgradeCtaPlacement | undefined
if (!IS_PLATFORM) {
// No billing/plans on self-hosted, so there is nothing to upgrade to — never show.
variant = undefined
} else if (flagsLoaded && planKnown) {
// Fully resolved — source of truth.
variant = liveVariant
} else if (planKnown && !isFreePlan) {
// Confirmed paid — never show, even if a stale seed says otherwise.
variant = undefined
} else {
// Free, 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 || !flagsLoaded || !planKnown) return
safeLocalStorage.setItem(seedKey, JSON.stringify(liveVariant ?? null))
}, [flagsLoaded, planKnown, liveVariant, seedKey])
useTrackExperimentExposure(UPGRADE_CTA_EXPERIMENT_ID, liveVariant)
return { isFreePlan, variant }
}