mirror of
https://github.com/supabase/supabase.git
synced 2026-06-23 01:08:27 +08:00
## 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>
98 lines
4.5 KiB
TypeScript
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 }
|
|
}
|