mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 11:55:05 +03: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>
333 lines
12 KiB
TypeScript
333 lines
12 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 { ReactNode, 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
|
|
/**
|
|
* Optional content rendered as the first `<li>` inside the grid view, before the
|
|
* project cards. Used by the upgrade CTA placement experiment to slot a card-shaped
|
|
* usage tile in the project list. Ignored in table view.
|
|
*/
|
|
prependCard?: ReactNode
|
|
}
|
|
|
|
export const ProjectList = ({
|
|
organization: organization_,
|
|
rewriteHref,
|
|
prependCard,
|
|
}: 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 prependCard={prependCard} />
|
|
)
|
|
}
|
|
|
|
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'
|
|
)}
|
|
>
|
|
{prependCard}
|
|
{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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|