import { ChevronRight } from 'lucide-react' import Link from 'next/link' import { cn } from 'ui' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' import { PricingMetric } from '@/data/analytics/org-daily-stats-query' import { OrgMetricsUsage, useOrgUsageQuery } from '@/data/usage/org-usage-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useTrack } from '@/lib/telemetry/track' type MetricUnit = 'gigabytes' | 'count' type MetricConfig = { key: PricingMetric label: string unit: MetricUnit /** Anchor id of the matching section on the org usage page. */ anchor: string } const METRICS: MetricConfig[] = [ { key: PricingMetric.EGRESS, label: 'Egress', unit: 'gigabytes', anchor: 'egress' }, { key: PricingMetric.DATABASE_SIZE, label: 'Database size', unit: 'gigabytes', anchor: 'databaseSize', }, { key: PricingMetric.MONTHLY_ACTIVE_USERS, label: 'Monthly active users', unit: 'count', anchor: 'mau', }, { key: PricingMetric.STORAGE_SIZE, label: 'File storage', unit: 'gigabytes', anchor: 'storageSize', }, ] const formatGigabytes = (value: number) => { if (value === 0) return '0 GB' if (value < 1) return `${(value * 1000).toFixed(0)} MB` return `${value.toFixed(value < 10 ? 2 : 1)} GB` } const formatGigabyteLimit = (limit: number) => { if (limit < 1) return `${(limit * 1000).toFixed(0)} MB` return `${limit} GB` } // Show counts in full with thousands separators (e.g. `50,000`) rather than abbreviated // (`50k`), to match the pricing page and avoid ambiguity around plan limits. const formatCount = (value: number) => value.toLocaleString() const formatValue = (value: number, unit: MetricUnit) => unit === 'gigabytes' ? formatGigabytes(value) : formatCount(value) const formatLimit = (limit: number, unit: MetricUnit) => unit === 'gigabytes' ? formatGigabyteLimit(limit) : formatCount(limit) const RING_RADIUS = 7 const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS const ProgressRing = ({ ratio, isOver, isApproaching, }: { ratio: number isOver: boolean isApproaching: boolean }) => { const clamped = Math.max(0, Math.min(1, ratio)) const offset = RING_CIRCUMFERENCE * (1 - clamped) return ( ) } // The upgrade CTA placement experiment variant this card represents. Used as the telemetry // `source` + `placement` value. Kept as a constant so the tracking stays explicit. const PLACEMENT = 'org_projects_list' const CompactMetricRow = ({ usageItem, config, orgSlug, }: { usageItem: OrgMetricsUsage config: MetricConfig orgSlug: string }) => { const current = usageItem.usage ?? 0 const limit = usageItem.pricing_free_units ?? 0 const ratio = limit > 0 ? current / limit : 0 const isOver = limit > 0 && current >= limit const isApproaching = limit > 0 && ratio >= 0.8 && !isOver return (
{config.label}
{formatValue(current, config.unit)} / {formatLimit(limit, config.unit)}
) } const SkeletonMetricRow = ({ label }: { label: string }) => (
{label}
) /** * Renders the upgrade CTA's plan-usage card as the first tile in the org project list * (the `org_projects_list` experiment variant). The parent is responsible for gating on * the experiment variant + free plan — this component only renders the visual sections * once usage data is available. Shaped like a `ProjectCard` so it reads as another tile. */ export const PlanUsageCard = () => { const track = useTrack() const { data: organization } = useSelectedOrganizationQuery() const { data: usage, isSuccess, isError } = useOrgUsageQuery({ orgSlug: organization?.slug }) const visibleRows = isSuccess ? METRICS.map((config) => { const usageItem = usage.usages.find((u) => u.metric === config.key) if (!usageItem) return null if (!usageItem.available_in_plan) return null if (!usageItem.pricing_free_units || usageItem.pricing_free_units <= 0) return null return { config, usageItem } }).filter((row): row is { config: MetricConfig; usageItem: OrgMetricsUsage } => row !== null) : [] // Hide entirely on hard error or when the org has zero applicable metrics — both // are extreme edge cases. Otherwise always render the card shell so the layout is // reserved from first paint and the usage rows fade in once the query resolves. if (isError) return null if (isSuccess && visibleRows.length === 0) return null return (
  • Free plan usage

    Current billing cycle

    track('upgrade_cta_clicked', { placement: PLACEMENT })} />
    {isSuccess ? visibleRows.map(({ config, usageItem }) => ( )) : METRICS.map((config) => )}
  • ) }