Files
Mert YEREKAPAN 320604d0e7 feat(studio): ship both upgrade CTA placements, remove A/B experiment (#47881)
## What

Concludes the `upgradeCtaPlacement` experiment
([#45858](https://github.com/supabase/supabase/pull/45858)) by shipping
**both** placements permanently and removing the A/B scaffolding.

The experiment tested two upgrade-CTA placements against a control:
- **`user_dropdown`** — an "Upgrade to Pro" button in the account/user
dropdown
- **`org_projects_list`** — a plan-usage card in the org project list

Both moved paid conversion in the same direction over control with no
activation downside, and they live on separate surfaces, so we're
keeping both rather than picking one.

## Changes

- **Remove the experiment machinery.** Deleted `useUpgradeCtaExperiment`
(PostHog `upgradeCtaPlacement` flag read, `control` arm, variant
selection, per-org localStorage seeding, exposure tracking) and replaced
it with a small `useShowUpgradeCta` hook that gates purely on **free
plan + hosted platform**. Both placements now render for every eligible
free-plan org.
- **Telemetry cleanup.** Removed the
`upgrade_cta_placement_experiment_exposed` event (pure experiment
scaffolding). Kept `upgrade_cta_clicked` (with its `placement` property)
so we can still measure the CTAs going forward; de-experimented its
wording.
- **Dead code.** Removed the unused `prependCard` prop plumbing added to
`ProjectList` / `EmptyStates` (no caller ever passed it — the org card
renders via the `<aside>`).
- De-experimented the remaining doc comments in `PlanUsageCard`.

Gating is unchanged in spirit: paid orgs and self-hosted never see the
CTA. `useShowUpgradeCta` waits until the org plan is known before
returning true, so the CTA fades in for free users and never flashes for
paid users.

## Testing

- `pnpm --filter common typecheck` and `pnpm --filter studio typecheck`
pass.
- eslint + prettier clean on the changed files.
- Manually checked


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

- **New Features**
- Upgrade/usage CTAs now appear only when the organization is confirmed
to be on a free plan, avoiding premature rendering before plan data is
available.
- Upgrade CTA visibility is now consistently driven across supported
project and organization surfaces (including the user dropdown) for
org-scoped routes.

- **Bug Fixes**
- Removed obsolete “prepend” placeholders from the project list loading
and grid views to keep card layouts consistent.

- **Documentation**
- Clarified upgrade CTA telemetry wording and adjusted the frontend
telemetry contract.

- **Tests**
- Added coverage for upgrade-CTA visibility behavior across key
scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 12:17:07 +00:00

318 lines
11 KiB
TypeScript

import { keepPreviousData } from '@tanstack/react-query'
import { useDebounce } from '@uidotdev/usehooks'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { parseAsArrayOf, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
import { useMemo } from 'react'
import {
Card,
cn,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableHeadSort,
TableRow,
} from 'ui'
import { LoadingCardView, LoadingTableView, NoProjectsState } from './EmptyStates'
import { LoadMoreRows } from './LoadMoreRow'
import { ProjectCard } from './ProjectCard'
import {
getNextProjectListSortForColumn,
getProjectListAriaSort,
PROJECT_LIST_SORT_VALUES,
toTableHeadSortValue,
} from './ProjectListSort.utils'
import { ProjectTableRow } from './ProjectTableRow'
import { AlertError } from '@/components/ui/AlertError'
import { NoSearchResults } from '@/components/ui/NoSearchResults'
import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query'
import { useOrgIntegrationsQuery } from '@/data/integrations/integrations-query-org-only'
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from '@/lib/constants'
import type { Organization } from '@/types'
export interface ProjectListProps {
organization?: Organization
rewriteHref?: (projectRef: string) => string
}
export const ProjectList = ({ organization: organization_, rewriteHref }: ProjectListProps) => {
const { slug: urlSlug } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const [search] = useQueryState('search', parseAsString.withDefault(''))
const debouncedSearch = useDebounce(search, 500)
const [filterStatus, setFilterStatus] = useQueryState(
'status',
parseAsArrayOf(parseAsString, ',').withDefault([])
)
const [sort, setSort] = useQueryState(
'sort',
parseAsStringLiteral(PROJECT_LIST_SORT_VALUES).withDefault('name_asc')
)
const [viewMode] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.PROJECTS_VIEW, 'grid')
const organization = organization_ ?? selectedOrganization
const slug = organization?.slug ?? urlSlug
const {
data,
error: projectsError,
isLoading: isLoadingProjects,
isSuccess: isSuccessProjects,
isError: isErrorProjects,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useOrgProjectsInfiniteQuery(
{
slug,
sort,
search: search.length === 0 ? search : debouncedSearch,
statuses: filterStatus,
},
{
placeholderData: keepPreviousData,
}
)
const orgProjects =
useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || []
const {
isPending: _isLoadingPermissions,
isError: isErrorPermissions,
error: permissionsError,
} = usePermissionsQuery()
const { data: resourceWarnings } = useResourceWarningsQuery({ slug })
// Move all hooks to the top to comply with Rules of Hooks
const { data: integrations } = useOrgIntegrationsQuery({ orgSlug: organization?.slug })
const { data: connections } = useGitHubConnectionsQuery({ organizationId: organization?.id })
const isLoadingPermissions = IS_PLATFORM ? _isLoadingPermissions : false
const isEmpty =
debouncedSearch.length === 0 &&
filterStatus.length === 0 &&
(!orgProjects || orgProjects.length === 0)
const noResultsFromSearch =
debouncedSearch.length > 0 && isSuccessProjects && orgProjects.length === 0
const noResultsFromStatusFilter =
filterStatus.length > 0 && isSuccessProjects && orgProjects.length === 0
const noResults = noResultsFromStatusFilter || noResultsFromSearch
const tableHeadSortValue = toTableHeadSortValue(sort)
const githubConnections = connections?.map((connection) => ({
id: String(connection.id),
added_by: {
id: String(connection.user?.id),
primary_email: connection.user?.primary_email ?? '',
username: connection.user?.username ?? '',
},
foreign_project_id: String(connection.repository.id),
supabase_project_ref: connection.project.ref,
organization_integration_id: 'unused',
inserted_at: connection.inserted_at,
updated_at: connection.updated_at,
metadata: {
name: connection.repository.name,
} as any,
}))
const vercelConnections = integrations
?.filter((integration) => integration.integration.name === 'Vercel')
.flatMap((integration) => integration.connections)
if (isErrorPermissions) {
return (
<AlertError
subject="Failed to retrieve permissions for your account"
error={permissionsError}
/>
)
}
if (isErrorProjects) {
return (
<AlertError
subject={`Failed to retrieve projects under ${organization?.name}`}
error={projectsError}
/>
)
}
if (isLoadingPermissions || isLoadingProjects || !organization) {
return viewMode === 'table' ? <LoadingTableView /> : <LoadingCardView />
}
if (isEmpty) {
return <NoProjectsState slug={organization?.slug ?? ''} />
}
if (viewMode === 'table') {
return (
<Card className="flex-1 min-h-0 overflow-y-auto">
<Table>
{/* [Joshen] Ideally we can figure out sticky table headers here */}
<TableHeader>
<TableRow>
<TableHead
className={cn(noResults && 'text-foreground-muted')}
aria-sort={getProjectListAriaSort(sort)}
>
<TableHeadSort
column="name"
currentSort={tableHeadSortValue}
onSortChange={() => {
const sortValue = sort.includes('created')
? 'name_asc'
: getNextProjectListSortForColumn(sort)
setSort(sortValue)
}}
className={cn(noResults && 'text-foreground-muted')}
>
Project
</TableHeadSort>
</TableHead>
<TableHead className={cn(noResults && 'text-foreground-muted')}>Status</TableHead>
<TableHead className={cn(noResults && 'text-foreground-muted')}>Compute</TableHead>
<TableHead className={cn(noResults && 'text-foreground-muted')}>Region</TableHead>
<TableHead
className={cn(noResults && 'text-foreground-muted')}
aria-sort={getProjectListAriaSort(sort)}
>
<TableHeadSort
column="created"
currentSort={tableHeadSortValue}
onSortChange={() => {
const sortValue = sort.includes('name')
? 'created_asc'
: getNextProjectListSortForColumn(sort)
setSort(sortValue)
}}
className={cn(noResults && 'text-foreground-muted')}
>
Created
</TableHeadSort>
</TableHead>
<TableHead className={cn(noResults && 'text-foreground-muted')} />
</TableRow>
</TableHeader>
<TableBody>
{noResultsFromStatusFilter ? (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={6}>
<NoSearchResults
withinTableCell
label={
filterStatus.length === 0
? `No projects found`
: `No ${filterStatus[0] === 'INACTIVE' ? 'paused' : 'active'} projects found`
}
description="Your search for projects with the specified status did not return any results"
onResetFilter={() => setFilterStatus([])}
/>
</TableCell>
</TableRow>
) : noResultsFromSearch ? (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={6}>
<NoSearchResults searchString={search} withinTableCell />
</TableCell>
</TableRow>
) : (
<>
{orgProjects?.map((project) => (
<ProjectTableRow
key={project.ref}
project={project}
organization={organization}
rewriteHref={rewriteHref ? rewriteHref(project.ref) : undefined}
resourceWarnings={resourceWarnings?.find(
(resourceWarning) => resourceWarning.project === project.ref
)}
githubIntegration={githubConnections?.find(
(connection) => connection.supabase_project_ref === project.ref
)}
vercelIntegration={vercelConnections?.find(
(connection) => connection.supabase_project_ref === project.ref
)}
/>
))}
{hasNextPage && (
<LoadMoreRows
type="table"
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
)}
</>
)}
</TableBody>
</Table>
</Card>
)
}
return (
<>
{noResultsFromStatusFilter ? (
<NoSearchResults
label={
filterStatus.length === 0
? `No projects found`
: `No ${filterStatus[0] === 'INACTIVE' ? 'paused' : 'active'} projects found`
}
description="Your search for projects with the specified status did not return any results"
onResetFilter={() => setFilterStatus([])}
/>
) : noResultsFromSearch ? (
<NoSearchResults searchString={search} />
) : (
<div className="flex flex-col gap-y-2 md:gap-y-4">
<ul
className={cn(
'min-h-0 w-full mx-auto',
'grid grid-cols-1 gap-2 md:gap-4',
'sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3'
)}
>
{orgProjects?.map((project) => (
<ProjectCard
key={project.ref}
slug={slug}
project={project}
rewriteHref={rewriteHref ? rewriteHref(project.ref) : undefined}
resourceWarnings={resourceWarnings?.find(
(resourceWarning) => resourceWarning.project === project.ref
)}
githubIntegration={githubConnections?.find(
(connection) => connection.supabase_project_ref === project.ref
)}
vercelIntegration={vercelConnections?.find(
(connection) => connection.supabase_project_ref === project.ref
)}
/>
))}
</ul>
{hasNextPage && (
<LoadMoreRows
type="card"
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
)}
</div>
)}
</>
)
}