mirror of
https://github.com/supabase/supabase.git
synced 2026-06-28 19:39:19 -04:00
74d634b9d9
## Summary Follow-up to the upgrade CTA placement experiment (#45858). The `PlanUsageCard` count metrics (Monthly active users) were abbreviated to `50k`, which reads ambiguously. This shows them in full with thousands separators (`50,000`) to match the pricing page. - `Monthly active users` row now renders e.g. `8,200 / 50,000` instead of `8.2k / 50k`. - Collapses the two count formatters (`formatCount` / `formatCountLimit`) into one `toLocaleString()` call so the current value and limit always use the same format. - GB metrics (Egress, Database size, File storage) are unchanged. ## Test plan - [ ] On the org projects page with the `org_projects_list` variant, the Monthly active users row shows full numbers (`0 / 50,000`). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Updated plan usage display formatting to show full numbers with thousands separators instead of abbreviated notation, improving clarity when viewing usage limits. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
245 lines
8.2 KiB
TypeScript
245 lines
8.2 KiB
TypeScript
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 (
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 18 18"
|
|
className="shrink-0"
|
|
aria-hidden="true"
|
|
role="presentation"
|
|
>
|
|
<circle
|
|
cx="9"
|
|
cy="9"
|
|
r={RING_RADIUS}
|
|
strokeWidth="2"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
className="text-foreground-muted/40"
|
|
/>
|
|
<circle
|
|
cx="9"
|
|
cy="9"
|
|
r={RING_RADIUS}
|
|
strokeWidth="2"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeDasharray={RING_CIRCUMFERENCE}
|
|
strokeDashoffset={offset}
|
|
strokeLinecap="round"
|
|
transform="rotate(-90 9 9)"
|
|
className={cn(
|
|
'transition-[stroke-dashoffset] duration-500',
|
|
isOver ? 'text-warning-600' : isApproaching ? 'text-warning' : 'text-brand'
|
|
)}
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<Link
|
|
href={`/org/${orgSlug}/usage#${config.anchor}`}
|
|
className="group/row block px-4 hover:bg-surface-200 transition-colors"
|
|
>
|
|
<div className="flex items-center justify-between gap-2 py-2 border-t border-dashed">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<ProgressRing ratio={ratio} isOver={isOver} isApproaching={isApproaching} />
|
|
<span className="text-[11px] text-foreground-light uppercase font-mono truncate">
|
|
{config.label}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center shrink-0">
|
|
<span className="text-[11px] font-mono tabular-nums whitespace-nowrap">
|
|
<span className={cn(isOver ? 'text-warning' : 'text-foreground')}>
|
|
{formatValue(current, config.unit)}
|
|
</span>
|
|
<span className="text-muted"> / </span>
|
|
<span className="text-foreground-lighter"> {formatLimit(limit, config.unit)}</span>
|
|
</span>
|
|
<ChevronRight
|
|
size={12}
|
|
strokeWidth={1.5}
|
|
className="text-foreground w-0 ml-0 opacity-0 overflow-hidden group-hover/row:w-3 group-hover/row:ml-1 group-hover/row:opacity-100 transition-all"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
const SkeletonMetricRow = ({ label }: { label: string }) => (
|
|
<div className="px-4">
|
|
<div className="flex items-center justify-between gap-2 py-2 border-t border-dashed">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<div
|
|
className="w-4 h-4 rounded-full border-2 border-foreground-muted/40 shrink-0"
|
|
aria-hidden
|
|
/>
|
|
<span className="text-xs text-foreground-light truncate">{label}</span>
|
|
</div>
|
|
<div className="h-3 w-16 rounded bg-surface-200 animate-pulse" aria-hidden />
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
/**
|
|
* 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 (
|
|
<li className="list-none h-min">
|
|
<div
|
|
className={cn(
|
|
'group relative bg-surface-100 border border-surface rounded-md',
|
|
'flex flex-col gap-y-2'
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between gap-4 p-4 pb-2">
|
|
<div className="flex flex-col min-w-0">
|
|
<h5 className="text-sm text-foreground truncate">Free plan usage</h5>
|
|
<p className="text-xs text-foreground-lighter truncate">Current billing cycle</p>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
|
<UpgradePlanButton
|
|
source={PLACEMENT}
|
|
plan="Pro"
|
|
onClick={() => track('upgrade_cta_clicked', { placement: PLACEMENT })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col justify-end pb-2 [&>:first-child>*]:border-t-0">
|
|
{isSuccess
|
|
? visibleRows.map(({ config, usageItem }) => (
|
|
<CompactMetricRow
|
|
key={config.key}
|
|
usageItem={usageItem}
|
|
config={config}
|
|
orgSlug={organization?.slug ?? '_'}
|
|
/>
|
|
))
|
|
: METRICS.map((config) => <SkeletonMetricRow key={config.key} label={config.label} />)}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
)
|
|
}
|