mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 20:35:07 +03:00
Prototype pricing calculator
This commit is contained in:
1 parent
5d85e33bc4
commit
33a809d870
24 files changed
+3101
-1
No files matched your search
@@ -0,0 +1,284 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowUpRight, ChevronRight, Copy } from 'lucide-react'
|
||||
|
||||
import { Button, cn } from 'ui'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
|
||||
import Panel from '~/components/Panel'
|
||||
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
||||
import {
|
||||
buildProjectionSeries,
|
||||
calculatePricingReport,
|
||||
calculateRoiSummary,
|
||||
decodeInputsFromSearchParams,
|
||||
estimateAuthComparison,
|
||||
encodeInputsToSearchParams,
|
||||
getDefaultInputs,
|
||||
} from '~/lib/pricing-calculator'
|
||||
import type { CalculatorInputs, CalculatorStageId } from '~/lib/pricing-calculator'
|
||||
|
||||
import CalloutCard from './components/CalloutCard'
|
||||
import GrowthChart from './components/GrowthChart'
|
||||
import ReportView from './components/ReportView'
|
||||
import Stage1ProjectBasics from './stages/Stage1ProjectBasics'
|
||||
import Stage2UsageEstimation from './stages/Stage2UsageEstimation'
|
||||
import Stage3Growth from './stages/Stage3Growth'
|
||||
import Stage4TimeAllocation from './stages/Stage4TimeAllocation'
|
||||
import SummaryPanel from './summary/SummaryPanel'
|
||||
|
||||
export default function PricingCalculator() {
|
||||
const sendTelemetryEvent = useSendTelemetryEvent()
|
||||
|
||||
const [activeStage, setActiveStage] = useState<CalculatorStageId>('stage1')
|
||||
const [inputs, setInputs] = useState<CalculatorInputs>(() => getDefaultInputs())
|
||||
|
||||
// Hydrate state from URL (shareable links)
|
||||
useEffect(() => {
|
||||
if (!('URLSearchParams' in window)) return
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const decoded = decodeInputsFromSearchParams(params, getDefaultInputs())
|
||||
setInputs(decoded.inputs)
|
||||
}, [])
|
||||
|
||||
// Keep URL in sync as inputs change (shallow replace)
|
||||
useEffect(() => {
|
||||
if (!('URLSearchParams' in window)) return
|
||||
const { pathname } = window.location
|
||||
const params = encodeInputsToSearchParams(inputs)
|
||||
const search = params.toString()
|
||||
const newAsPath = pathname + (search ? `?${search}` : '')
|
||||
history.replaceState({ url: newAsPath }, '', newAsPath)
|
||||
}, [inputs])
|
||||
|
||||
const pricingReport = useMemo(() => calculatePricingReport(inputs), [inputs])
|
||||
const roiSummary = useMemo(
|
||||
() =>
|
||||
calculateRoiSummary({
|
||||
teamSize: inputs.teamSize,
|
||||
hourlyCostUsd: inputs.hourlyCostUsd,
|
||||
timeAllocationOverrides: inputs.timeAllocationOverrides,
|
||||
}),
|
||||
[inputs.teamSize, inputs.hourlyCostUsd, inputs.timeAllocationOverrides]
|
||||
)
|
||||
|
||||
const authComparison = useMemo(() => estimateAuthComparison({ mau: inputs.mau }), [inputs.mau])
|
||||
const projectionSeries = useMemo(() => buildProjectionSeries(inputs), [inputs])
|
||||
|
||||
const copyShareableLink = async () => {
|
||||
const url = window.location.href
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
sendTelemetryEvent({
|
||||
action: 'www_pricing_calculator_share_link_copied',
|
||||
properties: { stage: activeStage },
|
||||
})
|
||||
} catch {
|
||||
// Fallback: do nothing (prototype)
|
||||
}
|
||||
}
|
||||
|
||||
const goToStage = (stage: CalculatorStageId) => {
|
||||
setActiveStage(stage)
|
||||
sendTelemetryEvent({
|
||||
action: 'www_pricing_calculator_stage_changed',
|
||||
properties: { stage },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||
<div className="lg:col-span-8 flex flex-col gap-4">
|
||||
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-3">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-foreground text-2xl">Core calculator</h2>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Costs update as you adjust inputs. Use the share link to send your estimate to your team.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Copy className="w-3 h-3" />}
|
||||
onClick={copyShareableLink}
|
||||
>
|
||||
Copy link
|
||||
</Button>
|
||||
<Button asChild type="outline" size="tiny" iconRight={<ArrowUpRight className="w-4 h-4" />}>
|
||||
<Link href="/contact/sales" target="_blank">
|
||||
Talk to sales
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<StagePanel
|
||||
id="stage1"
|
||||
title="Stage 1: Project basics"
|
||||
description="Capture foundational information that shapes pricing and value calculations."
|
||||
active={activeStage === 'stage1'}
|
||||
onActivate={() => goToStage('stage1')}
|
||||
>
|
||||
<Stage1ProjectBasics inputs={inputs} onChange={setInputs} />
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button type="primary" iconRight={<ChevronRight className="w-4 h-4" />} onClick={() => goToStage('stage2')}>
|
||||
Continue to usage estimation
|
||||
</Button>
|
||||
</div>
|
||||
</StagePanel>
|
||||
|
||||
<StagePanel
|
||||
id="stage2"
|
||||
title="Stage 2: Usage estimation"
|
||||
description="Estimate the six dimensions that drive Supabase costs."
|
||||
active={activeStage === 'stage2'}
|
||||
onActivate={() => goToStage('stage2')}
|
||||
>
|
||||
<Stage2UsageEstimation
|
||||
inputs={inputs}
|
||||
onChange={setInputs}
|
||||
authComparison={authComparison}
|
||||
freePlanEgressLimitGb={5}
|
||||
proEgressIncludedGb={250}
|
||||
/>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button type="primary" iconRight={<ChevronRight className="w-4 h-4" />} onClick={() => goToStage('stage3')}>
|
||||
Continue to growth projections
|
||||
</Button>
|
||||
</div>
|
||||
</StagePanel>
|
||||
|
||||
<StagePanel
|
||||
id="stage3"
|
||||
title="Stage 3: Growth projections"
|
||||
description="Model future costs and demonstrate predictability advantage."
|
||||
active={activeStage === 'stage3'}
|
||||
onActivate={() => goToStage('stage3')}
|
||||
>
|
||||
<Stage3Growth inputs={inputs} onChange={setInputs} />
|
||||
<div className="mt-6">
|
||||
<GrowthChart
|
||||
title="Projected costs over time"
|
||||
subtitle="Supabase vs common alternatives (estimated)"
|
||||
series={projectionSeries}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button type="primary" iconRight={<ChevronRight className="w-4 h-4" />} onClick={() => goToStage('stage4')}>
|
||||
Continue to time allocation
|
||||
</Button>
|
||||
</div>
|
||||
</StagePanel>
|
||||
|
||||
<StagePanel
|
||||
id="stage4"
|
||||
title="Stage 4: Optional time allocation"
|
||||
description="Adjust time estimates to power the ROI calculation."
|
||||
active={activeStage === 'stage4'}
|
||||
onActivate={() => goToStage('stage4')}
|
||||
>
|
||||
<Stage4TimeAllocation inputs={inputs} onChange={setInputs} />
|
||||
<CalloutCard
|
||||
title="Context"
|
||||
body="These estimates power your ROI calculation. Skip if you want a quick estimate, or adjust for accuracy."
|
||||
className="mt-4"
|
||||
/>
|
||||
</StagePanel>
|
||||
|
||||
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-2" style={{ scrollMarginTop: 120 }}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-foreground text-2xl">Final report</h2>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
A shareable, detailed breakdown combining pricing and ROI. Estimates are transparent and traceable.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Copy className="w-3 h-3" />}
|
||||
onClick={copyShareableLink}
|
||||
>
|
||||
Copy link
|
||||
</Button>
|
||||
<InfoTooltip side="top" className="max-w-[260px]">
|
||||
Competitor pricing is sourced from official pricing pages (see report for sources and as-of dates).
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<ReportView
|
||||
pricingReport={pricingReport}
|
||||
roiSummary={roiSummary}
|
||||
authComparison={authComparison}
|
||||
projectionSeries={projectionSeries}
|
||||
onViewed={() =>
|
||||
sendTelemetryEvent({
|
||||
action: 'www_pricing_calculator_report_viewed',
|
||||
properties: { recommendedPlan: pricingReport.recommended.plan },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4">
|
||||
<SummaryPanel
|
||||
className="lg:sticky lg:top-20"
|
||||
pricingReport={pricingReport}
|
||||
roiSummary={roiSummary}
|
||||
onTalkToSales={() =>
|
||||
sendTelemetryEvent({
|
||||
action: 'www_pricing_calculator_talk_to_sales_clicked',
|
||||
properties: { recommendedPlan: pricingReport.recommended.plan },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// comparison label helper removed in multi-compare mode
|
||||
|
||||
function StagePanel({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
active,
|
||||
onActivate,
|
||||
children,
|
||||
}: {
|
||||
id: CalculatorStageId
|
||||
title: string
|
||||
description: string
|
||||
active: boolean
|
||||
onActivate: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Panel outerClassName="w-full" innerClassName={cn('p-5 md:p-6', !active && 'cursor-pointer')}>
|
||||
<button type="button" className="w-full text-left" onClick={onActivate}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-foreground text-lg">{title}</h3>
|
||||
<p className="text-foreground-lighter text-sm">{description}</p>
|
||||
</div>
|
||||
<div className={cn('text-foreground-lighter text-xs mt-1', active ? 'opacity-0' : 'opacity-100')}>
|
||||
Click to edit
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{active && <div className="mt-6">{children}</div>}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from 'ui'
|
||||
import Panel from '~/components/Panel'
|
||||
|
||||
export default function CalloutCard({
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
body?: string
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Panel
|
||||
outerClassName={cn('w-full', className)}
|
||||
innerClassName="p-4 md:p-5 flex flex-col gap-2 bg-alternative"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">{title}</p>
|
||||
{body && <p className="text-foreground-lighter text-sm whitespace-pre-line">{body}</p>}
|
||||
{children}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from 'recharts'
|
||||
import { cn } from 'ui'
|
||||
import Panel from '~/components/Panel'
|
||||
import type { ProjectionSeries } from '~/lib/pricing-calculator'
|
||||
import { formatUsd } from './format'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
subtitle?: string
|
||||
series: ProjectionSeries
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function GrowthChart({ title, subtitle, series, className }: Props) {
|
||||
const data = series.points.map((p) => ({
|
||||
month: p.monthIndex,
|
||||
supabase: p.supabaseMonthlyUsd,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Panel outerClassName={cn('w-full', className)} innerClassName="p-5 md:p-6">
|
||||
<div className="flex flex-col gap-1 mb-4">
|
||||
<h3 className="text-foreground text-lg">{title}</h3>
|
||||
{subtitle && <p className="text-foreground-lighter text-sm">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-[260px] md:h-[320px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 10, right: 10, bottom: 10, left: 0 }}>
|
||||
<CartesianGrid stroke="hsl(var(--border-default))" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{ fill: 'hsl(var(--foreground-lighter))', fontSize: 12 }}
|
||||
axisLine={{ stroke: 'hsl(var(--border-default))' }}
|
||||
tickLine={{ stroke: 'hsl(var(--border-default))' }}
|
||||
label={{
|
||||
value: 'Month',
|
||||
position: 'insideBottom',
|
||||
offset: -5,
|
||||
fill: 'hsl(var(--foreground-lighter))',
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: 'hsl(var(--foreground-lighter))', fontSize: 12 }}
|
||||
axisLine={{ stroke: 'hsl(var(--border-default))' }}
|
||||
tickLine={{ stroke: 'hsl(var(--border-default))' }}
|
||||
tickFormatter={(v) => `$${v}`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'hsl(var(--background-surface-100))',
|
||||
border: '1px solid hsl(var(--border-default))',
|
||||
}}
|
||||
labelFormatter={(label) => `Month ${label}`}
|
||||
formatter={(value: any, name: any) => [formatUsd(Number(value)), formatSeriesName(String(name))]}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
align="left"
|
||||
iconSize={8}
|
||||
wrapperStyle={{ paddingTop: 8, fontSize: 12, lineHeight: '16px' }}
|
||||
formatter={(value: any) => (
|
||||
<span className="text-foreground-lighter text-xs">{formatSeriesName(String(value))}</span>
|
||||
)}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="supabase"
|
||||
stroke="hsl(var(--brand-default))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function formatSeriesName(key: string) {
|
||||
switch (key) {
|
||||
case 'supabase':
|
||||
return 'Supabase'
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { Collapsible } from 'ui'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
|
||||
export default function HowCalculated({
|
||||
title = 'How we calculated this',
|
||||
items,
|
||||
}: {
|
||||
title?: string
|
||||
items: string[]
|
||||
}) {
|
||||
return (
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger asChild>
|
||||
<button type="button" className="text-sm text-foreground-lighter hover:text-foreground flex items-center gap-2">
|
||||
<ChevronUp className="data-open-parent:rotate-0 data-closed-parent:rotate-180 h-4 w-4 transition" />
|
||||
{title}
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="mt-2">
|
||||
<ul className="text-sm text-foreground-lighter list-disc pl-5 space-y-1">
|
||||
{items.map((i) => (
|
||||
<li key={i}>{i}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect } from 'react'
|
||||
import Panel from '~/components/Panel'
|
||||
import type { PricingReport, ProjectionSeries, RoiSummary } from '~/lib/pricing-calculator'
|
||||
import { estimateCompetitorMonthlyUsdForKey } from '~/lib/pricing-calculator/competitors'
|
||||
import { getCompetitorSnapshot } from '~/lib/pricing-calculator/competitors'
|
||||
import GrowthChart from './GrowthChart'
|
||||
import HowCalculated from './HowCalculated'
|
||||
import { formatNumber, formatUsd } from './format'
|
||||
|
||||
type AuthComparison = ReturnType<typeof import('~/lib/pricing-calculator/competitors').estimateAuthComparison>
|
||||
|
||||
export default function ReportView({
|
||||
pricingReport,
|
||||
roiSummary,
|
||||
projectionSeries,
|
||||
authComparison,
|
||||
onViewed,
|
||||
}: {
|
||||
pricingReport: PricingReport
|
||||
roiSummary: RoiSummary
|
||||
projectionSeries: ProjectionSeries
|
||||
authComparison: AuthComparison
|
||||
onViewed?: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
onViewed?.()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const recommended = pricingReport.recommended.plan
|
||||
const estimate = pricingReport.estimates[recommended]
|
||||
|
||||
const annualSupabase = estimate.totalMonthlyUsd * 12
|
||||
|
||||
// Calculate Convex and AWS costs based on full inputs
|
||||
const convexEstimate = estimateCompetitorMonthlyUsdForKey('convex', pricingReport.inputs)
|
||||
const awsEstimate = estimateCompetitorMonthlyUsdForKey('aws', pricingReport.inputs)
|
||||
const competitorSnapshot = getCompetitorSnapshot()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
{/* Executive Summary */}
|
||||
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-3">
|
||||
<h3 className="text-foreground text-lg">Executive summary</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="border rounded-lg p-4">
|
||||
<p className="text-foreground-lighter text-sm">Recommended tier</p>
|
||||
<p className="text-foreground text-xl font-mono">{recommended.toUpperCase()}</p>
|
||||
<p className="text-foreground-lighter text-sm mt-1">
|
||||
Estimated monthly cost: <span className="text-foreground">{formatUsd(estimate.totalMonthlyUsd)}</span>
|
||||
</p>
|
||||
<p className="text-foreground-lighter text-xs mt-2">
|
||||
This estimate is based on Supabase pricing and the inputs you provided.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<p className="text-foreground-lighter text-sm">Estimated annual cost</p>
|
||||
<p className="text-foreground text-xl font-mono">{formatUsd(annualSupabase)}</p>
|
||||
<p className="text-foreground-lighter text-xs mt-2">
|
||||
Subscription, compute, and any overages (based on your inputs).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Notes: <span className="text-foreground">{pricingReport.recommended.reasons[0] ?? ''}</span>
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
{/* Projected Costs over Time */}
|
||||
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-3">
|
||||
<h3 className="text-foreground text-lg">Projected costs over time</h3>
|
||||
<GrowthChart title="Projected costs over time" subtitle="Supabase estimate based on your inputs" series={projectionSeries} />
|
||||
<HowCalculated
|
||||
items={[
|
||||
'Each month, we grow MAU and data according to your Stage 3 inputs',
|
||||
'We recompute Supabase monthly cost based on the updated usage',
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{/* Pricing Details */}
|
||||
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-3">
|
||||
<h3 className="text-foreground text-lg">Pricing details</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{estimate.lineItems.map((li) => (
|
||||
<div key={li.key} className="flex items-start justify-between gap-4 text-sm">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground-lighter">{li.key.replaceAll('_', ' ')}</span>
|
||||
{li.details?.length ? (
|
||||
<ul className="text-foreground-muted text-xs list-disc pl-5">
|
||||
{li.details.map((d) => (
|
||||
<li key={d}>{d}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-foreground font-mono" translate="no">
|
||||
{formatUsd(li.monthlyUsd)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t pt-3 flex items-center justify-between">
|
||||
<span className="text-foreground">Total</span>
|
||||
<span className="text-foreground font-mono" translate="no">
|
||||
{formatUsd(estimate.totalMonthlyUsd)}/mo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<HowCalculated
|
||||
items={[
|
||||
'Compute cost = projects × selected compute tier monthly price',
|
||||
'Compute credits apply once per paid organization and offset compute cost',
|
||||
'Database included quota is per project; most other quotas are per organization',
|
||||
'Overages are charged only for usage beyond included quotas (where applicable)',
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{/* Value Analysis */}
|
||||
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-3">
|
||||
<h3 className="text-foreground text-lg">Value analysis</h3>
|
||||
<div className="border rounded-lg p-4 flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-foreground-lighter text-sm">Time reallocation estimate</p>
|
||||
<p className="text-foreground text-xl font-mono">{formatNumber(roiSummary.hoursRecoveredPerMonth)} hrs/mo</p>
|
||||
<p className="text-foreground-lighter text-sm mt-1">
|
||||
That's {formatNumber(roiSummary.hoursRecoveredPerYear)} hours/year, valued at{' '}
|
||||
<span className="text-foreground">{formatUsd(roiSummary.valueRecoveredPerYearUsd)}</span> using your hourly rate.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t pt-4 flex flex-col gap-3">
|
||||
<p className="text-foreground text-sm font-medium">How we calculate this</p>
|
||||
<div className="text-foreground-lighter text-sm space-y-2">
|
||||
<p>
|
||||
<strong className="text-foreground">Step 1:</strong> We start with how many hours your team spends each month on infrastructure tasks. If you provided this in Stage 4, we use your numbers. Otherwise, we estimate based on your team size:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Authentication: {formatNumber(roiSummary.breakdown.auth.hoursBefore)} hours/month</li>
|
||||
<li>Database management: {formatNumber(roiSummary.breakdown.database.hoursBefore)} hours/month</li>
|
||||
<li>API development: {formatNumber(roiSummary.breakdown.api.hoursBefore)} hours/month</li>
|
||||
<li>DevOps and infrastructure: {formatNumber(roiSummary.breakdown.devops.hoursBefore)} hours/month</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong className="text-foreground">Step 2:</strong> We estimate how much time Supabase saves you in each area. Based on typical customer experiences, we apply these reduction percentages:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Authentication: {Math.round(roiSummary.breakdown.auth.reductionPct * 100)}% reduction = {formatNumber(roiSummary.breakdown.auth.hoursRecovered)} hours/month recovered</li>
|
||||
<li>Database management: {Math.round(roiSummary.breakdown.database.reductionPct * 100)}% reduction = {formatNumber(roiSummary.breakdown.database.hoursRecovered)} hours/month recovered</li>
|
||||
<li>API development: {Math.round(roiSummary.breakdown.api.reductionPct * 100)}% reduction = {formatNumber(roiSummary.breakdown.api.hoursRecovered)} hours/month recovered</li>
|
||||
<li>DevOps and infrastructure: {Math.round(roiSummary.breakdown.devops.reductionPct * 100)}% reduction = {formatNumber(roiSummary.breakdown.devops.hoursRecovered)} hours/month recovered</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong className="text-foreground">Step 3:</strong> We add up all the recovered hours ({formatNumber(roiSummary.hoursRecoveredPerMonth)} hours/month) and multiply by your hourly cost rate to get the dollar value: {formatUsd(roiSummary.valueRecoveredPerMonthUsd)}/month, or {formatUsd(roiSummary.valueRecoveredPerYearUsd)} per year.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Competitive Comparison */}
|
||||
<Panel outerClassName="w-full" innerClassName="p-5 md:p-6 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-foreground text-lg">Competitive comparison</h3>
|
||||
<p className="text-foreground-lighter text-xs">
|
||||
Pricing as of {new Date(authComparison.snapshot.as_of).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-4 bg-surface-200 text-xs">
|
||||
<div className="p-2 text-foreground-lighter">Provider</div>
|
||||
<div className="p-2 text-foreground-lighter text-right">Monthly</div>
|
||||
<div className="p-2 text-foreground-lighter text-right">Annual</div>
|
||||
<div className="p-2 text-foreground-lighter text-right">Notes</div>
|
||||
</div>
|
||||
<Row provider="Supabase" monthly={estimate.totalMonthlyUsd} annual={annualSupabase} notes={`Recommended: ${recommended.toUpperCase()}`} />
|
||||
{authComparison.firebaseMonthlyUsd != null ? (
|
||||
<RowWithSources
|
||||
provider="Firebase"
|
||||
monthly={authComparison.firebaseMonthlyUsd}
|
||||
annual={authComparison.firebaseMonthlyUsd * 12}
|
||||
notes={authComparison.snapshot.providers.firebase.plan_name}
|
||||
sourceUrls={authComparison.snapshot.providers.firebase.source_urls}
|
||||
/>
|
||||
) : (
|
||||
<RowLink provider="Firebase" href="https://firebase.google.com/pricing" />
|
||||
)}
|
||||
{authComparison.auth0MonthlyUsd != null ? (
|
||||
<RowWithSources
|
||||
provider="Auth0"
|
||||
monthly={authComparison.auth0MonthlyUsd}
|
||||
annual={authComparison.auth0MonthlyUsd * 12}
|
||||
notes={authComparison.snapshot.providers.auth0.plan_name}
|
||||
sourceUrls={authComparison.snapshot.providers.auth0.source_urls}
|
||||
/>
|
||||
) : (
|
||||
<RowLink provider="Auth0" href="https://auth0.com/pricing" />
|
||||
)}
|
||||
{authComparison.clerkMonthlyUsd != null ? (
|
||||
<RowWithSources
|
||||
provider="Clerk"
|
||||
monthly={authComparison.clerkMonthlyUsd}
|
||||
annual={authComparison.clerkMonthlyUsd * 12}
|
||||
notes={authComparison.snapshot.providers.clerk.plan_name}
|
||||
sourceUrls={authComparison.snapshot.providers.clerk.source_urls}
|
||||
/>
|
||||
) : (
|
||||
<RowLink provider="Clerk" href="https://clerk.com/pricing" />
|
||||
)}
|
||||
<RowWithSources
|
||||
provider="Convex"
|
||||
monthly={convexEstimate.monthlyUsd}
|
||||
annual={convexEstimate.monthlyUsd * 12}
|
||||
notes={competitorSnapshot.providers.convex.plan_name}
|
||||
sourceUrls={competitorSnapshot.providers.convex.source_urls}
|
||||
/>
|
||||
<RowWithSources
|
||||
provider="AWS"
|
||||
monthly={awsEstimate.monthlyUsd}
|
||||
annual={awsEstimate.monthlyUsd * 12}
|
||||
notes={competitorSnapshot.providers.aws.plan_name}
|
||||
sourceUrls={competitorSnapshot.providers.aws.source_urls}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 text-foreground-lighter text-xs">
|
||||
<p>
|
||||
<strong>Note:</strong> Pricing estimates vary by provider model. Auth providers (Firebase, Auth0, Clerk) are based on authentication costs. Full-stack providers (Convex, AWS) are modeled based on typical usage patterns for database, storage, bandwidth, and compute. See source links and detailed assumptions below for full pricing details.
|
||||
</p>
|
||||
<details className="cursor-pointer">
|
||||
<summary className="text-brand hover:text-brand-600">View detailed assumptions</summary>
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
{authComparison.snapshot.providers.firebase.notes && (
|
||||
<li>
|
||||
<strong>Firebase:</strong> {authComparison.snapshot.providers.firebase.notes}
|
||||
</li>
|
||||
)}
|
||||
{authComparison.snapshot.providers.clerk.notes && (
|
||||
<li>
|
||||
<strong>Clerk:</strong> {authComparison.snapshot.providers.clerk.notes}
|
||||
</li>
|
||||
)}
|
||||
{authComparison.snapshot.providers.auth0.notes && (
|
||||
<li>
|
||||
<strong>Auth0:</strong> {authComparison.snapshot.providers.auth0.notes}
|
||||
</li>
|
||||
)}
|
||||
{competitorSnapshot.providers.convex.notes && (
|
||||
<li>
|
||||
<strong>Convex:</strong> {competitorSnapshot.providers.convex.notes}
|
||||
</li>
|
||||
)}
|
||||
{competitorSnapshot.providers.aws.notes && (
|
||||
<li>
|
||||
<strong>AWS:</strong> {competitorSnapshot.providers.aws.notes}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({
|
||||
provider,
|
||||
monthly,
|
||||
annual,
|
||||
notes,
|
||||
}: {
|
||||
provider: string
|
||||
monthly: number
|
||||
annual: number
|
||||
notes: string
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 border-t text-sm">
|
||||
<div className="p-2 text-foreground-lighter capitalize">{provider.replaceAll('_', ' ')}</div>
|
||||
<div className="p-2 text-right text-foreground font-mono" translate="no">
|
||||
{formatUsd(monthly)}
|
||||
</div>
|
||||
<div className="p-2 text-right text-foreground font-mono" translate="no">
|
||||
{formatUsd(annual)}
|
||||
</div>
|
||||
<div className="p-2 text-right text-foreground-lighter">{notes}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RowWithSources({
|
||||
provider,
|
||||
monthly,
|
||||
annual,
|
||||
notes,
|
||||
sourceUrls,
|
||||
}: {
|
||||
provider: string
|
||||
monthly: number
|
||||
annual: number
|
||||
notes: string
|
||||
sourceUrls: string[]
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 border-t text-sm">
|
||||
<div className="p-2 text-foreground-lighter">{provider}</div>
|
||||
<div className="p-2 text-right text-foreground font-mono" translate="no">
|
||||
{formatUsd(monthly)}
|
||||
</div>
|
||||
<div className="p-2 text-right text-foreground font-mono" translate="no">
|
||||
{formatUsd(annual)}
|
||||
</div>
|
||||
<div className="p-2 text-right text-foreground-lighter text-xs">
|
||||
<div className="flex flex-col gap-1 items-end">
|
||||
<span className="line-clamp-1">{notes}</span>
|
||||
<div className="flex gap-1">
|
||||
{sourceUrls.map((url, idx) => (
|
||||
<Link key={idx} href={url} target="_blank" className="underline text-brand hover:text-brand-600 text-xs">
|
||||
Source{sourceUrls.length > 1 ? ` ${idx + 1}` : ''}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RowLink({ provider, href }: { provider: string; href: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 border-t text-sm">
|
||||
<div className="p-2 text-foreground-lighter">{provider}</div>
|
||||
<div className="p-2 text-right text-foreground-lighter">—</div>
|
||||
<div className="p-2 text-right text-foreground-lighter">—</div>
|
||||
<div className="p-2 text-right">
|
||||
<Link href={href} target="_blank" className="underline text-brand hover:text-brand-600">
|
||||
Pricing
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
export function formatUsd(value: number): string {
|
||||
const rounded = Math.round((value + Number.EPSILON) * 100) / 100
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: rounded % 1 === 0 ? 0 : 2,
|
||||
}).format(rounded)
|
||||
}
|
||||
|
||||
export function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { Select, Input, cn } from 'ui'
|
||||
import Panel from '~/components/Panel'
|
||||
import CalloutCard from '../components/CalloutCard'
|
||||
import type { CalculatorInputs, CurrentInfrastructure } from '~/lib/pricing-calculator'
|
||||
|
||||
const INFRA_OPTIONS: { value: CurrentInfrastructure; label: string }[] = [
|
||||
{ value: 'starting_fresh', label: 'Starting fresh' },
|
||||
{ value: 'firebase', label: 'Firebase' },
|
||||
{ value: 'aws_self_hosted', label: 'AWS/self-hosted' },
|
||||
{ value: 'auth0_plus_db', label: 'Auth0 + separate database' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
export default function Stage1ProjectBasics({
|
||||
inputs,
|
||||
onChange,
|
||||
}: {
|
||||
inputs: CalculatorInputs
|
||||
onChange: (next: CalculatorInputs) => void
|
||||
}) {
|
||||
const set = (patch: Partial<CalculatorInputs>) => onChange({ ...inputs, ...patch })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
|
||||
<label className="text-foreground text-sm">Number of projects</label>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={String(inputs.projects)}
|
||||
onChange={(e: any) => set({ projects: Math.max(1, Number(e.target.value || 1)) })}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-xs">Most users need 1–3 (dev/staging/prod).</p>
|
||||
</Panel>
|
||||
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
|
||||
<label className="text-foreground text-sm">Current infrastructure</label>
|
||||
<Select
|
||||
id="current-infra"
|
||||
name="Current infrastructure"
|
||||
layout="vertical"
|
||||
value={inputs.currentInfrastructure}
|
||||
onChange={(e: any) => set({ currentInfrastructure: e.target.value })}
|
||||
>
|
||||
{INFRA_OPTIONS.map((opt) => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-foreground-lighter text-xs">Are you starting with a fresh project or migrating?</p>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
|
||||
<label className="text-foreground text-sm">Team size</label>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={String(inputs.teamSize)}
|
||||
onChange={(e: any) => set({ teamSize: Math.max(1, Number(e.target.value || 1)) })}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-xs">Developers working on backend/infrastructure.</p>
|
||||
</Panel>
|
||||
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
|
||||
<label className="text-foreground text-sm">Average hourly developer cost</label>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={String(inputs.hourlyCostUsd)}
|
||||
onChange={(e: any) => set({ hourlyCostUsd: Math.max(0, Number(e.target.value || 0)) })}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-xs">Default $150/hour (US). Used for time savings.</p>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
|
||||
<label className="text-foreground text-sm">Compliance needs</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inputs.needCompliance}
|
||||
onChange={(e) => set({ needCompliance: e.target.checked })}
|
||||
className={cn('h-4 w-4')}
|
||||
/>
|
||||
<span className="text-foreground-lighter text-sm">SOC2 / HIPAA / compliance requirements indicated</span>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Integrated ROI messaging */}
|
||||
{inputs.currentInfrastructure === 'firebase' && (
|
||||
<CalloutCard
|
||||
title="Key consideration"
|
||||
body="Firebase users typically see 60-80% cost reduction at scale due to Supabase's predictable pricing vs per-operation charges."
|
||||
/>
|
||||
)}
|
||||
{inputs.currentInfrastructure === 'aws_self_hosted' && (
|
||||
<CalloutCard
|
||||
title="Key consideration"
|
||||
body="Teams managing their own Postgres typically spend 40-100 hours/month on DevOps. Supabase eliminates most of this."
|
||||
/>
|
||||
)}
|
||||
{inputs.projects >= 3 && (
|
||||
<CalloutCard
|
||||
title="Key consideration"
|
||||
body="Multi-project setups benefit from Supabase's unified dashboard and consolidated billing. No more juggling separate services."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Slider_Shadcn_, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, cn, Input } from 'ui'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { ComputeBadge } from 'ui-patterns/ComputeBadge'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { components } from 'api-types'
|
||||
|
||||
import pricingAddOn from '~/data/PricingAddOnTable.json'
|
||||
import Panel from '~/components/Panel'
|
||||
import CalloutCard from '../components/CalloutCard'
|
||||
import type { CalculatorInputs, ComputeTier } from '~/lib/pricing-calculator'
|
||||
import { formatUsd, formatNumber } from '../components/format'
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
||||
|
||||
type InfraComputeSize = components['schemas']['ProjectDetailResponse']['infra_compute_size']
|
||||
type ComputeBadgeSize = InfraComputeSize | '>16XL' | undefined
|
||||
|
||||
function toComputeBadgeSize(tier: ComputeTier): ComputeBadgeSize {
|
||||
switch (tier) {
|
||||
case 'Micro':
|
||||
return 'micro'
|
||||
case 'Small':
|
||||
return 'small'
|
||||
case 'Medium':
|
||||
return 'medium'
|
||||
case 'Large':
|
||||
return 'large'
|
||||
case 'XL':
|
||||
return 'xlarge'
|
||||
case '2XL':
|
||||
return '2xlarge'
|
||||
case '4XL':
|
||||
return '4xlarge'
|
||||
case '8XL':
|
||||
return '8xlarge'
|
||||
case '12XL':
|
||||
return '12xlarge'
|
||||
case '16XL':
|
||||
return '16xlarge'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getComputeTiers(): { tier: ComputeTier; monthlyUsd: number }[] {
|
||||
const tiers: { tier: string; monthlyUsd: number }[] = []
|
||||
for (const row of pricingAddOn.database?.rows ?? []) {
|
||||
const planCol = row.columns?.find((c: any) => c.key === 'plan')
|
||||
const priceCol = row.columns?.find((c: any) => c.key === 'pricing')
|
||||
if (!planCol || !priceCol) continue
|
||||
if (priceCol.value === 'Contact Us') continue
|
||||
const price = Number(String(priceCol.value).replace('$', '').replaceAll(',', ''))
|
||||
tiers.push({ tier: String(planCol.value), monthlyUsd: Number.isFinite(price) ? price : 0 })
|
||||
}
|
||||
|
||||
const allowed = new Set([
|
||||
'Micro',
|
||||
'Small',
|
||||
'Medium',
|
||||
'Large',
|
||||
'XL',
|
||||
'2XL',
|
||||
'4XL',
|
||||
'8XL',
|
||||
'12XL',
|
||||
'16XL',
|
||||
])
|
||||
|
||||
return tiers
|
||||
.filter((t) => allowed.has(t.tier))
|
||||
.map((t) => ({ tier: t.tier as ComputeTier, monthlyUsd: t.monthlyUsd }))
|
||||
}
|
||||
|
||||
const COMPUTE_TIERS = getComputeTiers()
|
||||
|
||||
export default function Stage2UsageEstimation({
|
||||
inputs,
|
||||
onChange,
|
||||
authComparison,
|
||||
freePlanEgressLimitGb,
|
||||
proEgressIncludedGb,
|
||||
}: {
|
||||
inputs: CalculatorInputs
|
||||
onChange: (next: CalculatorInputs) => void
|
||||
authComparison: ReturnType<typeof import('~/lib/pricing-calculator/competitors').estimateAuthComparison>
|
||||
freePlanEgressLimitGb: number
|
||||
proEgressIncludedGb: number
|
||||
}) {
|
||||
const set = (patch: Partial<CalculatorInputs>) => onChange({ ...inputs, ...patch })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground text-sm font-medium">Database size (GB)</p>
|
||||
<p className="text-foreground-lighter text-xs">Default 2 GB</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={String(inputs.databaseSizeGb)}
|
||||
onChange={(e: any) => set({ databaseSizeGb: clamp(Number(e.target.value || 0.5), 0.5, 10_000) })}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<Slider_Shadcn_
|
||||
value={[inputs.databaseSizeGb]}
|
||||
min={0.5}
|
||||
max={100}
|
||||
step={0.5}
|
||||
onValueChange={(v) => set({ databaseSizeGb: v[0] })}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground text-sm font-medium">File storage (GB)</p>
|
||||
<p className="text-foreground-lighter text-xs">Default 10 GB</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={String(inputs.storageSizeGb)}
|
||||
onChange={(e: any) => set({ storageSizeGb: clamp(Number(e.target.value || 0), 0, 100_000) })}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<Slider_Shadcn_
|
||||
value={[inputs.storageSizeGb]}
|
||||
min={1}
|
||||
max={500}
|
||||
step={1}
|
||||
onValueChange={(v) => set({ storageSizeGb: v[0] })}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<CalloutCard
|
||||
title="Key consideration"
|
||||
body="Supabase includes unlimited API requests. Firebase charges $0.18 per 100,000 reads/writes, which compounds quickly."
|
||||
/>
|
||||
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground text-sm font-medium">Expected monthly egress (GB)</p>
|
||||
<p className="text-foreground-lighter text-xs">Default 50 GB</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={String(inputs.egressGb)}
|
||||
onChange={(e: any) => set({ egressGb: clamp(Number(e.target.value || 0), 0, 100_000) })}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<Slider_Shadcn_
|
||||
value={[inputs.egressGb]}
|
||||
min={0}
|
||||
max={1000}
|
||||
step={1}
|
||||
onValueChange={(v) => set({ egressGb: v[0] })}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{inputs.egressGb <= freePlanEgressLimitGb ? (
|
||||
<CalloutCard
|
||||
title="Key consideration"
|
||||
body="The free tier's 5 GB egress limit is the most common upgrade trigger. At your estimated usage, you're well within Pro limits."
|
||||
/>
|
||||
) : inputs.egressGb > proEgressIncludedGb ? (
|
||||
<CalloutCard
|
||||
title="Key consideration"
|
||||
body="High egress workloads like yours benefit from Supabase's flat $0.09/GB vs Firebase's variable regional pricing."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground text-sm font-medium">Monthly active users</p>
|
||||
<p className="text-foreground-lighter text-xs">Default 10,000</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={String(inputs.mau)}
|
||||
onChange={(e: any) => set({ mau: clamp(Math.round(Number(e.target.value || 0)), 0, 50_000_000) })}
|
||||
className="max-w-[140px]"
|
||||
/>
|
||||
</div>
|
||||
<Slider_Shadcn_
|
||||
value={[inputs.mau]}
|
||||
min={1000}
|
||||
max={500000}
|
||||
step={1000}
|
||||
onValueChange={(v) => set({ mau: v[0] })}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
|
||||
<p className="text-foreground text-sm font-medium">Authentication needs</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground-lighter">
|
||||
<input type="checkbox" checked={inputs.needSso} onChange={(e) => set({ needSso: e.target.checked })} />
|
||||
Need SSO/SAML?
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground-lighter">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inputs.needPhoneMfa}
|
||||
onChange={(e) => set({ needPhoneMfa: e.target.checked })}
|
||||
/>
|
||||
Need phone-based MFA?
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-foreground-lighter text-xs">
|
||||
SSO here refers to end-user SAML 2.0 (Auth). Phone MFA is priced per project add-on.
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<CalloutCard title="Key consideration">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm text-foreground-lighter">
|
||||
Building your own authentication is error prone and complex. It’s costly to build and costly to maintain.{' '}
|
||||
<Link
|
||||
href="https://supabase.com/blog/supabase-auth-build-vs-buy"
|
||||
target="_blank"
|
||||
className="transition underline text-brand-link hover:text-brand-600"
|
||||
>
|
||||
Read our blog post
|
||||
</Link>{' '}
|
||||
for more information.
|
||||
</div>
|
||||
</div>
|
||||
</CalloutCard>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
|
||||
<p className="text-foreground text-sm font-medium">Realtime</p>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground-lighter text-xs">Concurrent realtime connections</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={String(inputs.realtimePeakConnections)}
|
||||
onChange={(e: any) =>
|
||||
set({ realtimePeakConnections: clamp(Math.round(Number(e.target.value || 0)), 0, 10_000_000) })
|
||||
}
|
||||
className="max-w-[140px]"
|
||||
/>
|
||||
</div>
|
||||
<Slider_Shadcn_
|
||||
value={[inputs.realtimePeakConnections]}
|
||||
min={0}
|
||||
max={5000}
|
||||
step={10}
|
||||
onValueChange={(v) => set({ realtimePeakConnections: v[0] })}
|
||||
/>
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground-lighter text-xs">Realtime messages per month</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={String(inputs.realtimeMessages)}
|
||||
onChange={(e: any) =>
|
||||
set({ realtimeMessages: clamp(Math.round(Number(e.target.value || 0)), 0, 10_000_000_000) })
|
||||
}
|
||||
className="max-w-[160px]"
|
||||
/>
|
||||
</div>
|
||||
<Slider_Shadcn_
|
||||
value={[inputs.realtimeMessages]}
|
||||
min={0}
|
||||
max={10_000_000}
|
||||
step={50_000}
|
||||
onValueChange={(v) => set({ realtimeMessages: v[0] })}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
|
||||
<p className="text-foreground text-sm font-medium">Edge Functions</p>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground-lighter text-xs">Edge function invocations per month</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={String(inputs.edgeInvocations)}
|
||||
onChange={(e: any) =>
|
||||
set({ edgeInvocations: clamp(Math.round(Number(e.target.value || 0)), 0, 10_000_000_000) })
|
||||
}
|
||||
className="max-w-[160px]"
|
||||
/>
|
||||
</div>
|
||||
<Slider_Shadcn_
|
||||
value={[inputs.edgeInvocations]}
|
||||
min={0}
|
||||
max={5_000_000}
|
||||
step={50_000}
|
||||
onValueChange={(v) => set({ edgeInvocations: v[0] })}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-4 flex-col md:flex-row">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground text-sm font-medium">Compute tier</p>
|
||||
<p className="text-foreground-lighter text-xs">
|
||||
Default Micro. Each project requires dedicated compute.
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="outline" size="small" iconRight={<ChevronDown className="w-4 h-4" />}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ComputeBadge infraComputeSize={toComputeBadgeSize(inputs.computeTier)} />
|
||||
<span className="text-foreground">{inputs.computeTier}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
{COMPUTE_TIERS.map((t) => (
|
||||
<DropdownMenuItem
|
||||
key={t.tier}
|
||||
onClick={() => set({ computeTier: t.tier })}
|
||||
className={cn(inputs.computeTier === t.tier && 'text-brand-600')}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-8">
|
||||
<span className="flex items-center gap-2">
|
||||
<ComputeBadge infraComputeSize={toComputeBadgeSize(t.tier)} />
|
||||
<span>{t.tier}</span>
|
||||
</span>
|
||||
<span className="font-mono text-foreground-lighter" translate="no">
|
||||
{formatUsd(t.monthlyUsd)}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-foreground-lighter text-sm">
|
||||
<InfoTooltip side="top" className="max-w-[280px]">
|
||||
Compute is billed hourly. Temporary upgrades for migrations or traffic spikes cost only for the hours used.
|
||||
</InfoTooltip>
|
||||
<span>Compute is billed hourly.</span>
|
||||
</div>
|
||||
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href="https://supabase.com/docs/guides/platform/compute-add-ons" target="_blank">
|
||||
Learn about Compute add-ons
|
||||
</Link>
|
||||
</Button>
|
||||
</Panel>
|
||||
|
||||
{/* Upsell triggers during input stages */}
|
||||
{(inputs.mau >= 100_000 || inputs.needSso) && (
|
||||
<CalloutCard
|
||||
title="Find more cost savings"
|
||||
body="At your scale, custom enterprise pricing typically saves 20-40%. Want to discuss volume options?"
|
||||
>
|
||||
<Button asChild type="default" size="tiny" className="w-fit mt-2">
|
||||
<Link href="/contact/sales" target="_blank">
|
||||
Talk to sales
|
||||
</Link>
|
||||
</Button>
|
||||
</CalloutCard>
|
||||
)}
|
||||
{inputs.databaseSizeGb >= 50 && (
|
||||
<CalloutCard
|
||||
title="Upsell"
|
||||
body="Databases over 50 GB benefit from custom compute configurations. Let's optimize your architecture."
|
||||
>
|
||||
<Button asChild type="default" size="tiny" className="w-fit mt-2">
|
||||
<Link href="/contact/sales" target="_blank">
|
||||
Schedule consultation
|
||||
</Link>
|
||||
</Button>
|
||||
</CalloutCard>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { Slider_Shadcn_, Select, Input } from 'ui'
|
||||
import Panel from '~/components/Panel'
|
||||
import CalloutCard from '../components/CalloutCard'
|
||||
import type { CalculatorInputs, ProjectionPeriodMonths } from '~/lib/pricing-calculator'
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
||||
|
||||
export default function Stage3Growth({
|
||||
inputs,
|
||||
onChange,
|
||||
}: {
|
||||
inputs: CalculatorInputs
|
||||
onChange: (next: CalculatorInputs) => void
|
||||
}) {
|
||||
const set = (patch: Partial<CalculatorInputs>) => onChange({ ...inputs, ...patch })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground text-sm font-medium">Expected monthly user growth rate</p>
|
||||
<p className="text-foreground-lighter text-xs">Default 10%</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={String(inputs.userGrowthRateMonthlyPct)}
|
||||
onChange={(e: any) =>
|
||||
set({ userGrowthRateMonthlyPct: clamp(Number(e.target.value || 0), 0, 50) })
|
||||
}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<Slider_Shadcn_
|
||||
value={[inputs.userGrowthRateMonthlyPct]}
|
||||
min={0}
|
||||
max={50}
|
||||
step={1}
|
||||
onValueChange={(v) => set({ userGrowthRateMonthlyPct: v[0] })}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground text-sm font-medium">Expected monthly data growth (GB)</p>
|
||||
<p className="text-foreground-lighter text-xs">Default 1 GB/month</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={String(inputs.dataGrowthGbPerMonth)}
|
||||
onChange={(e: any) => set({ dataGrowthGbPerMonth: clamp(Number(e.target.value || 0), 0, 100_000) })}
|
||||
className="max-w-[140px]"
|
||||
/>
|
||||
</div>
|
||||
<Slider_Shadcn_
|
||||
value={[inputs.dataGrowthGbPerMonth]}
|
||||
min={0}
|
||||
max={50}
|
||||
step={1}
|
||||
onValueChange={(v) => set({ dataGrowthGbPerMonth: v[0] })}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
|
||||
<p className="text-foreground text-sm font-medium">Projection period</p>
|
||||
<Select
|
||||
id="projection-months"
|
||||
name="Projection months"
|
||||
layout="vertical"
|
||||
value={String(inputs.projectionMonths)}
|
||||
onChange={(e: any) => set({ projectionMonths: Number(e.target.value) as ProjectionPeriodMonths })}
|
||||
>
|
||||
<Select.Option value="12">12 months</Select.Option>
|
||||
<Select.Option value="36">36 months</Select.Option>
|
||||
</Select>
|
||||
</Panel>
|
||||
|
||||
<CalloutCard
|
||||
title="Key consideration"
|
||||
body="With consumption-based pricing, costs compound with growth. Supabase's tiered model keeps costs predictable."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from 'ui'
|
||||
import Panel from '~/components/Panel'
|
||||
import type { CalculatorInputs, TimeAllocationKey } from '~/lib/pricing-calculator'
|
||||
|
||||
const FIELD_LABELS: Record<TimeAllocationKey, string> = {
|
||||
auth: 'Hours per month on authentication',
|
||||
database: 'Hours per month on database management',
|
||||
api: 'Hours per month on API development',
|
||||
devops: 'Hours per month on DevOps',
|
||||
}
|
||||
|
||||
export default function Stage4TimeAllocation({
|
||||
inputs,
|
||||
onChange,
|
||||
}: {
|
||||
inputs: CalculatorInputs
|
||||
onChange: (next: CalculatorInputs) => void
|
||||
}) {
|
||||
const merged = inputs.timeAllocationOverrides ?? {}
|
||||
|
||||
const setOverride = (key: TimeAllocationKey, value: number) => {
|
||||
onChange({
|
||||
...inputs,
|
||||
timeAllocationOverrides: {
|
||||
...merged,
|
||||
[key]: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const clearOverride = (key: TimeAllocationKey) => {
|
||||
const next = { ...merged }
|
||||
delete (next as any)[key]
|
||||
onChange({
|
||||
...inputs,
|
||||
timeAllocationOverrides: next,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{(Object.keys(FIELD_LABELS) as TimeAllocationKey[]).map((key) => (
|
||||
<Panel key={key} outerClassName="w-full" innerClassName="p-4 md:p-5 flex flex-col gap-2">
|
||||
<label className="text-foreground text-sm">{FIELD_LABELS[key]}</label>
|
||||
<Input
|
||||
type="number"
|
||||
size="small"
|
||||
layout="vertical"
|
||||
value={merged[key] == null ? '' : String(merged[key])}
|
||||
placeholder="Use default"
|
||||
onChange={(e: any) => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') return clearOverride(key)
|
||||
return setOverride(key, Math.max(0, Number(raw)))
|
||||
}}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-xs">Optional override. Leave blank to use smart defaults.</p>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as Stage1ProjectBasics } from './Stage1ProjectBasics'
|
||||
export { default as Stage2UsageEstimation } from './Stage2UsageEstimation'
|
||||
export { default as Stage3Growth } from './Stage3Growth'
|
||||
export { default as Stage4TimeAllocation } from './Stage4TimeAllocation'
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { Button, cn } from 'ui'
|
||||
import Panel from '~/components/Panel'
|
||||
import type { PricingReport, RoiSummary } from '~/lib/pricing-calculator'
|
||||
import { formatNumber, formatUsd } from '../components/format'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
pricingReport: PricingReport
|
||||
roiSummary: RoiSummary
|
||||
onTalkToSales?: () => void
|
||||
}
|
||||
|
||||
const DIMENSIONS: { key: keyof PricingReport['estimates']['free']['fits']; label: string }[] = [
|
||||
{ key: 'projects', label: 'Projects' },
|
||||
{ key: 'database_size', label: 'Database' },
|
||||
{ key: 'storage_size', label: 'Storage' },
|
||||
{ key: 'egress', label: 'Egress' },
|
||||
{ key: 'mau', label: 'MAU' },
|
||||
{ key: 'sso', label: 'SSO/SAML' },
|
||||
{ key: 'phone_mfa', label: 'Phone MFA' },
|
||||
{ key: 'realtime_peak_connections', label: 'Realtime connections' },
|
||||
{ key: 'realtime_messages', label: 'Realtime messages' },
|
||||
{ key: 'edge_invocations', label: 'Edge invocations' },
|
||||
{ key: 'compliance', label: 'Compliance' },
|
||||
]
|
||||
|
||||
function StatusIcon({ status }: { status: string }) {
|
||||
if (status === 'ok') return <CheckCircle2 className="w-4 h-4 text-brand" />
|
||||
if (status === 'overage') return <AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
return <XCircle className="w-4 h-4 text-red-500" />
|
||||
}
|
||||
|
||||
export default function SummaryPanel({ className, pricingReport, roiSummary, onTalkToSales }: Props) {
|
||||
const { estimates, recommended } = pricingReport
|
||||
|
||||
const recommendedEstimate = estimates[recommended.plan]
|
||||
|
||||
const showSalesCta =
|
||||
recommendedEstimate.totalMonthlyUsd >= 500 || pricingReport.inputs.needCompliance
|
||||
|
||||
return (
|
||||
<Panel outerClassName={cn('w-full', className)} innerClassName="p-5 md:p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-foreground text-lg">Live cost summary</h3>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Recommended tier: <span className="text-foreground">{recommended.plan.toUpperCase()}</span>
|
||||
</p>
|
||||
{recommended.reasons.length > 0 && (
|
||||
<ul className="text-foreground-lighter text-sm list-disc pl-5 mt-2">
|
||||
{recommended.reasons.slice(0, 3).map((r) => (
|
||||
<li key={r}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['free', 'pro', 'team'] as const).map((plan) => (
|
||||
<div key={plan} className={cn('border rounded-lg p-3', plan === recommended.plan && 'border-stronger')}>
|
||||
<p className="text-foreground text-xs font-medium uppercase truncate">{plan}</p>
|
||||
<p className="text-foreground mt-1 font-mono text-sm" translate="no">
|
||||
{formatUsd(estimates[plan].totalMonthlyUsd)}
|
||||
<span className="text-foreground-lighter text-xs font-sans">/mo</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-4 bg-surface-200 text-xs">
|
||||
<div className="p-2 text-foreground-lighter">Dimension</div>
|
||||
<div className="p-2 text-foreground-lighter text-center">Free</div>
|
||||
<div className="p-2 text-foreground-lighter text-center">Pro</div>
|
||||
<div className="p-2 text-foreground-lighter text-center">Team</div>
|
||||
</div>
|
||||
{DIMENSIONS.map((d) => (
|
||||
<div key={d.key} className="grid grid-cols-4 border-t text-sm">
|
||||
<div className="p-2 text-foreground-lighter">{d.label}</div>
|
||||
<div className="p-2 flex justify-center">
|
||||
<StatusIcon status={estimates.free.fits[d.key]} />
|
||||
</div>
|
||||
<div className="p-2 flex justify-center">
|
||||
<StatusIcon status={estimates.pro.fits[d.key]} />
|
||||
</div>
|
||||
<div className="p-2 flex justify-center">
|
||||
<StatusIcon status={estimates.team.fits[d.key]} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showSalesCta && (
|
||||
<Panel outerClassName="w-full" innerClassName="p-4 bg-alternative flex flex-col gap-3">
|
||||
<p className="text-foreground text-sm font-medium">Need custom pricing?</p>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
For teams spending $500+/month, annual commitments unlock 15-25% savings.
|
||||
</p>
|
||||
<Button asChild type="default" size="tiny" onClick={onTalkToSales}>
|
||||
<Link href="/contact/sales" target="_blank">
|
||||
Contact sales
|
||||
</Link>
|
||||
</Button>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild type="outline" size="tiny">
|
||||
<Link href="/pricing" target="_blank">
|
||||
View pricing page
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild type="default" size="tiny" onClick={onTalkToSales}>
|
||||
<Link href="/contact/sales" target="_blank">
|
||||
Talk to sales
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"as_of": "2026-01-21",
|
||||
"currency": "USD",
|
||||
"assumptions": [
|
||||
"All costs are monthly list prices and exclude taxes, discounts, annual-commit pricing, and negotiated enterprise contracts.",
|
||||
"MAU means monthly active users as defined by each provider; definitions differ (not normalized).",
|
||||
"All calculations assume core authentication for end users and exclude add-ons unless explicitly stated in notes.",
|
||||
"Costs for SMS/phone verification and other per-message fees are excluded unless explicitly stated; these vary by region and usage.",
|
||||
"For Firebase, MAU pricing below is for Firebase Authentication when billed via Google Cloud Identity Platform Tier 1 providers (Email/Social/Anonymous/Phone) on pay-as-you-go, and does not include Firestore (which is priced by reads/writes/storage, not MAU)."
|
||||
],
|
||||
"providers": {
|
||||
"auth0": {
|
||||
"plan_name": "Essentials / Professional / Enterprise (interactive pricing by MAU; exact price for 10k/50k/100k not derivable from page contents)",
|
||||
"source_urls": [
|
||||
"https://auth0.com/pricing"
|
||||
],
|
||||
"monthly_cost_by_mau": {
|
||||
"10000": null,
|
||||
"50000": null,
|
||||
"100000": null
|
||||
},
|
||||
"notes": "Auth0's official pricing page presents an interactive MAU selector and plan cards, but the page content available without executing client-side pricing logic does not expose a deterministic price table for the requested MAU points (10k/50k/100k). The page also indicates an MAU selection maximum of \"30,000+\" and includes a \"Contact us\" path for Enterprise, implying pricing may depend on tier/contract for higher MAU. Because an exact, reproducible monthly price for those MAU values cannot be derived directly from the official pricing page contents, values are null."
|
||||
},
|
||||
"clerk": {
|
||||
"plan_name": "Pro plan",
|
||||
"source_urls": [
|
||||
"https://clerk.com/pricing"
|
||||
],
|
||||
"monthly_cost_by_mau": {
|
||||
"10000": 25,
|
||||
"50000": 825,
|
||||
"100000": 1825
|
||||
},
|
||||
"notes": "Computed from the Pro plan pricing shown: base price $25/month includes 10,000 MAUs; overage is $0.02 per additional MAU. Calculations: 10,000 MAU = $25. 50,000 MAU = $25 + (40,000 * $0.02) = $825. 100,000 MAU = $25 + (90,000 * $0.02) = $1,825. Excludes paid add-ons (for example, enterprise features, advanced security, support upgrades), monthly active organizations (MAOs) overages ($1 per additional MAO beyond included), and additional dashboard seats ($10/mo per additional seat)."
|
||||
},
|
||||
"firebase": {
|
||||
"plan_name": "Firebase Authentication on Blaze (via Google Cloud Identity Platform) — Tier 1 providers",
|
||||
"source_urls": [
|
||||
"https://firebase.google.com/docs/auth",
|
||||
"https://cloud.google.com/identity-platform/pricing",
|
||||
"https://firebase.google.com/docs/firestore/enterprise/pricing"
|
||||
],
|
||||
"monthly_cost_by_mau": {
|
||||
"10000": 0,
|
||||
"50000": 0,
|
||||
"100000": 275
|
||||
},
|
||||
"notes": "What is priced here: Firebase Authentication MAU billing for Tier 1 providers (Email/Social/Anonymous/Phone) using Identity Platform pricing. Official Identity Platform Tier 1 MAU pricing: 0–49,999 MAUs = $0; 50,000–99,999 = $0.0055 per MAU; 100,000–999,999 = $0.0046 per MAU; higher tiers continue. Assumption for 100,000 MAUs: tiered (marginal) pricing above the no-cost tier, so billed MAUs = 100,000 - 50,000 = 50,000 at $0.0055 = $275. Excludes SMS/phone and MFA per-message charges (region-dependent) and excludes Tier 2 providers (SAML/OIDC) which are billed separately ($0.015 per MAU beyond 50). Firestore note: Firestore is not priced per MAU; it is billed by reads/writes/deletes/storage and varies heavily with workload. Firestore Standard/Enterprise unit rates and free tier are documented at the Firestore enterprise pricing page linked in source_urls."
|
||||
},
|
||||
"convex": {
|
||||
"plan_name": "Professional plan (usage-based pricing)",
|
||||
"source_urls": [
|
||||
"https://www.convex.dev/pricing"
|
||||
],
|
||||
"monthly_cost_by_mau": {
|
||||
"10000": null,
|
||||
"50000": null,
|
||||
"100000": null
|
||||
},
|
||||
"notes": "Convex pricing is usage-based (function calls, storage, bandwidth, compute) rather than MAU-based. Authentication (Convex Auth and AuthKit via WorkOS) is included at no additional cost in all plans. Estimates are modeled based on typical usage patterns: ~50 function calls per MAU per month (including auth-related function calls), storage costs, and bandwidth. Professional plan base: $25/month includes 1M function calls, 0.5 GiB storage, 10 GiB bandwidth. Overage: $0.00001 per function call, $0.20 per GiB storage, $0.12 per GiB bandwidth. Estimates assume typical backend workload patterns and may vary significantly based on actual usage."
|
||||
},
|
||||
"aws": {
|
||||
"plan_name": "AWS full stack (RDS PostgreSQL, S3, CloudFront, EC2, etc.)",
|
||||
"source_urls": [
|
||||
"https://aws.amazon.com/rds/pricing/",
|
||||
"https://aws.amazon.com/s3/pricing/",
|
||||
"https://aws.amazon.com/cloudfront/pricing/"
|
||||
],
|
||||
"monthly_cost_by_mau": {
|
||||
"10000": null,
|
||||
"50000": null,
|
||||
"100000": null
|
||||
},
|
||||
"notes": "AWS pricing is usage-based and varies by service, region, and instance types. Estimates model a typical full-stack setup: RDS PostgreSQL (db.t3.micro to db.t3.medium based on database size), S3 storage, CloudFront egress, EC2 compute for API servers, and other supporting services. Estimates assume US East (N. Virginia) region pricing and typical usage patterns. Actual costs can vary significantly based on instance sizing, data transfer patterns, and service selection. Includes estimated costs for: RDS database ($15-150+/mo depending on size), S3 storage ($0.023/GB), CloudFront egress ($0.085/GB first 10TB), EC2 compute ($15-75+/mo), and other supporting services."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import competitorSnapshot from '~/data/pricing-calculator-competitors.json'
|
||||
import type { CalculatorInputs, CompetitorKey, ComparisonPlatform } from './types'
|
||||
|
||||
const roundUsd = (value: number) => Math.round((value + Number.EPSILON) * 100) / 100
|
||||
|
||||
export type CompetitorSnapshot = typeof competitorSnapshot
|
||||
|
||||
export function getCompetitorSnapshot(): CompetitorSnapshot {
|
||||
return competitorSnapshot
|
||||
}
|
||||
|
||||
function interpolateFromAnchors(mau: number, anchors: Record<string, number | null>): number | null {
|
||||
const sorted = Object.keys(anchors)
|
||||
.map(Number)
|
||||
.filter((k) => anchors[String(k)] != null)
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
if (sorted.length === 0) return null
|
||||
if (mau <= sorted[0]) return anchors[String(sorted[0])] as number
|
||||
if (mau >= sorted[sorted.length - 1]) {
|
||||
const lastKey = String(sorted[sorted.length - 1])
|
||||
const lastVal = anchors[lastKey] as number
|
||||
// Extrapolate: assume linear continuation from last anchor
|
||||
if (sorted.length >= 2) {
|
||||
const prevKey = String(sorted[sorted.length - 2])
|
||||
const prevVal = anchors[prevKey] as number
|
||||
const slope = (lastVal - prevVal) / (sorted[sorted.length - 1] - sorted[sorted.length - 2])
|
||||
return roundUsd(lastVal + (mau - sorted[sorted.length - 1]) * slope)
|
||||
}
|
||||
return lastVal
|
||||
}
|
||||
|
||||
// Interpolate between anchors
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
if (mau >= sorted[i] && mau <= sorted[i + 1]) {
|
||||
const lowerKey = String(sorted[i])
|
||||
const upperKey = String(sorted[i + 1])
|
||||
const lowerVal = anchors[lowerKey] as number
|
||||
const upperVal = anchors[upperKey] as number
|
||||
const t = (mau - sorted[i]) / (sorted[i + 1] - sorted[i])
|
||||
return roundUsd(lowerVal + t * (upperVal - lowerVal))
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function estimateAuth0MonthlyUsd(mau: number): number | null {
|
||||
const snapshot = competitorSnapshot.providers.auth0
|
||||
return interpolateFromAnchors(mau, snapshot.monthly_cost_by_mau)
|
||||
}
|
||||
|
||||
export function estimateClerkMonthlyUsd(mau: number): number | null {
|
||||
const snapshot = competitorSnapshot.providers.clerk
|
||||
return interpolateFromAnchors(mau, snapshot.monthly_cost_by_mau)
|
||||
}
|
||||
|
||||
export function estimateFirebaseAuthMonthlyUsd(mau: number): number | null {
|
||||
const snapshot = competitorSnapshot.providers.firebase
|
||||
return interpolateFromAnchors(mau, snapshot.monthly_cost_by_mau)
|
||||
}
|
||||
|
||||
export function estimateFirebaseMonthlyUsd(inputs: Pick<CalculatorInputs, 'mau' | 'egressGb'>): number {
|
||||
// Use snapshot for Firebase Auth (Tier 1 providers)
|
||||
const authCost = estimateFirebaseAuthMonthlyUsd(inputs.mau) ?? 0
|
||||
|
||||
// Note: Firestore is not priced per MAU; it's billed by reads/writes/storage.
|
||||
// For a full comparison, we'd need Firestore usage estimates, but for this prototype
|
||||
// we only include the auth component from the snapshot.
|
||||
return roundUsd(authCost)
|
||||
}
|
||||
|
||||
export function estimateSelfHostedMonthlyUsd(
|
||||
inputs: Pick<CalculatorInputs, 'databaseSizeGb' | 'storageSizeGb' | 'egressGb' | 'projects'>
|
||||
): number {
|
||||
// Prototype baseline for infra: per-project overhead + storage + bandwidth.
|
||||
const basePerProject = 75
|
||||
const dbPerGb = 0.25
|
||||
const storagePerGb = 0.02
|
||||
const egressPerGb = 0.05
|
||||
|
||||
const dbTotalGb = inputs.databaseSizeGb * inputs.projects
|
||||
return roundUsd(
|
||||
inputs.projects * basePerProject +
|
||||
dbTotalGb * dbPerGb +
|
||||
inputs.storageSizeGb * storagePerGb +
|
||||
inputs.egressGb * egressPerGb
|
||||
)
|
||||
}
|
||||
|
||||
export function estimateConvexMonthlyUsd(
|
||||
inputs: Pick<CalculatorInputs, 'mau' | 'databaseSizeGb' | 'storageSizeGb' | 'egressGb'>
|
||||
): number {
|
||||
// Convex Professional plan: $25/month base includes 1M function calls, 0.5 GiB storage, 10 GiB bandwidth
|
||||
const BASE_MONTHLY = 25
|
||||
const INCLUDED_FUNCTION_CALLS = 1_000_000
|
||||
const INCLUDED_STORAGE_GB = 0.5
|
||||
const INCLUDED_BANDWIDTH_GB = 10
|
||||
|
||||
// Estimate function calls: ~50 calls per MAU per month (typical backend workload)
|
||||
const functionCalls = inputs.mau * 50
|
||||
const functionCallsOverage = Math.max(0, functionCalls - INCLUDED_FUNCTION_CALLS)
|
||||
const functionCallsCost = functionCallsOverage * 0.00001 // $0.00001 per call
|
||||
|
||||
// Storage: database + file storage
|
||||
const totalStorageGb = inputs.databaseSizeGb + inputs.storageSizeGb
|
||||
const storageOverage = Math.max(0, totalStorageGb - INCLUDED_STORAGE_GB)
|
||||
const storageCost = storageOverage * 0.20 // $0.20 per GiB
|
||||
|
||||
// Bandwidth (egress)
|
||||
const bandwidthOverage = Math.max(0, inputs.egressGb - INCLUDED_BANDWIDTH_GB)
|
||||
const bandwidthCost = bandwidthOverage * 0.12 // $0.12 per GiB
|
||||
|
||||
return roundUsd(BASE_MONTHLY + functionCallsCost + storageCost + bandwidthCost)
|
||||
}
|
||||
|
||||
export function estimateAwsMonthlyUsd(
|
||||
inputs: Pick<CalculatorInputs, 'databaseSizeGb' | 'storageSizeGb' | 'egressGb' | 'projects' | 'mau'>
|
||||
): number {
|
||||
// AWS full stack estimate: RDS, S3, CloudFront, EC2, etc.
|
||||
// Assumes US East (N. Virginia) region pricing
|
||||
|
||||
// RDS PostgreSQL: db.t3.micro ($15/mo) for small DBs, db.t3.small ($30/mo) for medium, db.t3.medium ($60/mo) for large
|
||||
// Scale based on database size
|
||||
let rdsCost = 0
|
||||
const dbTotalGb = inputs.databaseSizeGb * inputs.projects
|
||||
if (dbTotalGb <= 20) {
|
||||
rdsCost = 15 // db.t3.micro
|
||||
} else if (dbTotalGb <= 100) {
|
||||
rdsCost = 30 // db.t3.small
|
||||
} else if (dbTotalGb <= 500) {
|
||||
rdsCost = 60 // db.t3.medium
|
||||
} else {
|
||||
rdsCost = 150 // Larger instance estimate
|
||||
}
|
||||
rdsCost = rdsCost * inputs.projects // Per project
|
||||
|
||||
// S3 storage: $0.023 per GB
|
||||
const s3StorageCost = inputs.storageSizeGb * 0.023
|
||||
|
||||
// CloudFront egress: $0.085 per GB for first 10TB
|
||||
const cloudfrontCost = inputs.egressGb * 0.085
|
||||
|
||||
// EC2 compute for API servers: t3.micro ($7.50/mo) to t3.small ($15/mo) per instance
|
||||
// Estimate 1-2 instances based on MAU
|
||||
const ec2Instances = inputs.mau < 10_000 ? 1 : 2
|
||||
const ec2Cost = ec2Instances * (inputs.mau < 50_000 ? 7.5 : 15)
|
||||
|
||||
// Other services (Load Balancer, CloudWatch, etc.): ~$20-50/month baseline
|
||||
const otherServicesCost = 30
|
||||
|
||||
return roundUsd(rdsCost + s3StorageCost + cloudfrontCost + ec2Cost + otherServicesCost)
|
||||
}
|
||||
|
||||
export function estimateCompetitorKeyForInfrastructure(infra: CalculatorInputs['currentInfrastructure']): CompetitorKey {
|
||||
switch (infra) {
|
||||
case 'firebase':
|
||||
return 'firebase'
|
||||
case 'aws_self_hosted':
|
||||
return 'self_hosted'
|
||||
case 'auth0_plus_db':
|
||||
return 'auth0'
|
||||
case 'starting_fresh':
|
||||
case 'other':
|
||||
default:
|
||||
return 'firebase'
|
||||
}
|
||||
}
|
||||
|
||||
export function estimateCompetitorMonthlyUsdForKey(
|
||||
key: ComparisonPlatform,
|
||||
inputs: CalculatorInputs
|
||||
): { key: CompetitorKey; monthlyUsd: number } {
|
||||
if (key === 'firebase') {
|
||||
return { key, monthlyUsd: estimateFirebaseMonthlyUsd({ mau: inputs.mau, egressGb: inputs.egressGb }) }
|
||||
}
|
||||
|
||||
if (key === 'clerk') {
|
||||
const clerkCost = estimateClerkMonthlyUsd(inputs.mau)
|
||||
return { key, monthlyUsd: clerkCost ?? 0 }
|
||||
}
|
||||
|
||||
if (key === 'auth0') {
|
||||
// Auth0 + separate database: model as Auth0 auth cost + a light self-hosted DB baseline
|
||||
const auth = estimateAuth0MonthlyUsd(inputs.mau) ?? 0
|
||||
const db = estimateSelfHostedMonthlyUsd({
|
||||
databaseSizeGb: inputs.databaseSizeGb,
|
||||
storageSizeGb: inputs.storageSizeGb,
|
||||
egressGb: inputs.egressGb,
|
||||
projects: inputs.projects,
|
||||
})
|
||||
return { key, monthlyUsd: roundUsd(auth + db) }
|
||||
}
|
||||
|
||||
if (key === 'self_hosted') {
|
||||
return {
|
||||
key,
|
||||
monthlyUsd: estimateSelfHostedMonthlyUsd({
|
||||
databaseSizeGb: inputs.databaseSizeGb,
|
||||
storageSizeGb: inputs.storageSizeGb,
|
||||
egressGb: inputs.egressGb,
|
||||
projects: inputs.projects,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'convex') {
|
||||
return {
|
||||
key,
|
||||
monthlyUsd: estimateConvexMonthlyUsd({
|
||||
mau: inputs.mau,
|
||||
databaseSizeGb: inputs.databaseSizeGb,
|
||||
storageSizeGb: inputs.storageSizeGb,
|
||||
egressGb: inputs.egressGb,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'aws') {
|
||||
return {
|
||||
key,
|
||||
monthlyUsd: estimateAwsMonthlyUsd({
|
||||
databaseSizeGb: inputs.databaseSizeGb,
|
||||
storageSizeGb: inputs.storageSizeGb,
|
||||
egressGb: inputs.egressGb,
|
||||
projects: inputs.projects,
|
||||
mau: inputs.mau,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
return { key, monthlyUsd: 0 }
|
||||
}
|
||||
|
||||
export function estimateCompetitorMonthlyUsd(inputs: CalculatorInputs): { key: CompetitorKey; monthlyUsd: number } {
|
||||
const key = estimateCompetitorKeyForInfrastructure(inputs.currentInfrastructure) as ComparisonPlatform
|
||||
return estimateCompetitorMonthlyUsdForKey(key, inputs)
|
||||
}
|
||||
|
||||
export function estimateAuthComparison(inputs: Pick<CalculatorInputs, 'mau'>) {
|
||||
const mau = inputs.mau
|
||||
const snapshot = competitorSnapshot
|
||||
|
||||
// Supabase auth-only estimate: free if <= 50K, otherwise Pro base as baseline.
|
||||
const supabase = mau <= 50_000 ? 0 : 25
|
||||
|
||||
return {
|
||||
mau,
|
||||
supabaseMonthlyUsd: supabase,
|
||||
auth0MonthlyUsd: estimateAuth0MonthlyUsd(mau),
|
||||
clerkMonthlyUsd: estimateClerkMonthlyUsd(mau),
|
||||
firebaseMonthlyUsd: estimateFirebaseAuthMonthlyUsd(mau) ?? 0,
|
||||
snapshot: {
|
||||
as_of: snapshot.as_of,
|
||||
providers: {
|
||||
auth0: {
|
||||
plan_name: snapshot.providers.auth0.plan_name,
|
||||
source_urls: snapshot.providers.auth0.source_urls,
|
||||
notes: snapshot.providers.auth0.notes,
|
||||
},
|
||||
clerk: {
|
||||
plan_name: snapshot.providers.clerk.plan_name,
|
||||
source_urls: snapshot.providers.clerk.source_urls,
|
||||
notes: snapshot.providers.clerk.notes,
|
||||
},
|
||||
firebase: {
|
||||
plan_name: snapshot.providers.firebase.plan_name,
|
||||
source_urls: snapshot.providers.firebase.source_urls,
|
||||
notes: snapshot.providers.firebase.notes,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { CalculatorInputs, ComparisonPlatform, ProjectionSeries } from './types'
|
||||
import { calculatePricingReport } from './supabase'
|
||||
import { estimateCompetitorMonthlyUsdForKey } from './competitors'
|
||||
|
||||
const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100
|
||||
|
||||
function compound(value: number, pct: number, periods: number) {
|
||||
return value * Math.pow(1 + pct / 100, periods)
|
||||
}
|
||||
|
||||
export function buildProjectionSeries(inputs: CalculatorInputs): ProjectionSeries {
|
||||
const platforms: ComparisonPlatform[] = ['firebase', 'self_hosted', 'auth0', 'clerk', 'convex', 'aws']
|
||||
|
||||
const points = Array.from({ length: inputs.projectionMonths }, (_, idx) => {
|
||||
const mau = Math.round(compound(inputs.mau, inputs.userGrowthRateMonthlyPct, idx))
|
||||
|
||||
// Prototype assumption: data growth is split between DB and Storage.
|
||||
const dbGrowthShare = 0.6
|
||||
const storageGrowthShare = 0.4
|
||||
const dataGrowthTotal = inputs.dataGrowthGbPerMonth * idx
|
||||
|
||||
const databaseSizeGb = round2(inputs.databaseSizeGb + dataGrowthTotal * dbGrowthShare)
|
||||
const storageSizeGb = round2(inputs.storageSizeGb + dataGrowthTotal * storageGrowthShare)
|
||||
|
||||
// Prototype assumption: egress scales proportionally with MAU
|
||||
const egressGb = round2((inputs.egressGb * mau) / Math.max(1, inputs.mau))
|
||||
|
||||
const monthInputs: CalculatorInputs = {
|
||||
...inputs,
|
||||
mau,
|
||||
databaseSizeGb,
|
||||
storageSizeGb,
|
||||
egressGb,
|
||||
}
|
||||
|
||||
const report = calculatePricingReport(monthInputs)
|
||||
const supabaseMonthlyUsd = report.estimates[report.recommended.plan].totalMonthlyUsd
|
||||
const competitorsMonthlyUsd = platforms.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = estimateCompetitorMonthlyUsdForKey(key, monthInputs).monthlyUsd
|
||||
return acc
|
||||
},
|
||||
{} as Record<ComparisonPlatform, number>
|
||||
)
|
||||
|
||||
return {
|
||||
monthIndex: idx + 1,
|
||||
mau,
|
||||
databaseSizeGb,
|
||||
storageSizeGb,
|
||||
egressGb,
|
||||
supabaseMonthlyUsd,
|
||||
competitorsMonthlyUsd,
|
||||
}
|
||||
})
|
||||
|
||||
const cumulativeSupabase = round2(points.reduce((acc, p) => acc + p.supabaseMonthlyUsd, 0))
|
||||
const cumulativeCompetitorsUsd = platforms.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = round2(points.reduce((sum, p) => sum + p.competitorsMonthlyUsd[key], 0))
|
||||
return acc
|
||||
},
|
||||
{} as Record<ComparisonPlatform, number>
|
||||
)
|
||||
const savingsUsd = platforms.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = round2(cumulativeCompetitorsUsd[key] - cumulativeSupabase)
|
||||
return acc
|
||||
},
|
||||
{} as Record<ComparisonPlatform, number>
|
||||
)
|
||||
|
||||
return {
|
||||
months: inputs.projectionMonths,
|
||||
points,
|
||||
cumulative: {
|
||||
supabaseUsd: cumulativeSupabase,
|
||||
competitorsUsd: cumulativeCompetitorsUsd,
|
||||
savingsUsd,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './types'
|
||||
export * from './supabase'
|
||||
export * from './competitors'
|
||||
export * from './roi'
|
||||
export * from './growth'
|
||||
export * from './serialize'
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { CalculatorInputs, RoiSummary, TimeAllocation } from './types'
|
||||
|
||||
const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100
|
||||
|
||||
const REDUCTION_PCT = {
|
||||
auth: 0.85,
|
||||
database: 0.65,
|
||||
api: 0.75,
|
||||
devops: 0.85,
|
||||
} as const
|
||||
|
||||
export function getDefaultTimeAllocation(teamSize: number): TimeAllocation {
|
||||
return {
|
||||
auth: teamSize * 3,
|
||||
database: teamSize * 5,
|
||||
api: teamSize * 8,
|
||||
devops: teamSize * 4,
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateRoiSummary(inputs: Pick<CalculatorInputs, 'teamSize' | 'hourlyCostUsd' | 'timeAllocationOverrides'>): RoiSummary {
|
||||
const defaults = getDefaultTimeAllocation(inputs.teamSize)
|
||||
const merged: TimeAllocation = {
|
||||
auth: inputs.timeAllocationOverrides?.auth ?? defaults.auth,
|
||||
database: inputs.timeAllocationOverrides?.database ?? defaults.database,
|
||||
api: inputs.timeAllocationOverrides?.api ?? defaults.api,
|
||||
devops: inputs.timeAllocationOverrides?.devops ?? defaults.devops,
|
||||
}
|
||||
|
||||
const breakdown = {
|
||||
auth: {
|
||||
hoursBefore: merged.auth,
|
||||
hoursRecovered: merged.auth * REDUCTION_PCT.auth,
|
||||
reductionPct: REDUCTION_PCT.auth,
|
||||
},
|
||||
database: {
|
||||
hoursBefore: merged.database,
|
||||
hoursRecovered: merged.database * REDUCTION_PCT.database,
|
||||
reductionPct: REDUCTION_PCT.database,
|
||||
},
|
||||
api: {
|
||||
hoursBefore: merged.api,
|
||||
hoursRecovered: merged.api * REDUCTION_PCT.api,
|
||||
reductionPct: REDUCTION_PCT.api,
|
||||
},
|
||||
devops: {
|
||||
hoursBefore: merged.devops,
|
||||
hoursRecovered: merged.devops * REDUCTION_PCT.devops,
|
||||
reductionPct: REDUCTION_PCT.devops,
|
||||
},
|
||||
} as const
|
||||
|
||||
const hoursRecoveredPerMonth =
|
||||
breakdown.auth.hoursRecovered +
|
||||
breakdown.database.hoursRecovered +
|
||||
breakdown.api.hoursRecovered +
|
||||
breakdown.devops.hoursRecovered
|
||||
|
||||
const valueRecoveredPerMonthUsd = hoursRecoveredPerMonth * inputs.hourlyCostUsd
|
||||
|
||||
return {
|
||||
hoursRecoveredPerMonth: round2(hoursRecoveredPerMonth),
|
||||
hoursRecoveredPerYear: round2(hoursRecoveredPerMonth * 12),
|
||||
valueRecoveredPerMonthUsd: round2(valueRecoveredPerMonthUsd),
|
||||
valueRecoveredPerYearUsd: round2(valueRecoveredPerMonthUsd * 12),
|
||||
breakdown: {
|
||||
auth: breakdown.auth,
|
||||
database: breakdown.database,
|
||||
api: breakdown.api,
|
||||
devops: breakdown.devops,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { pricingCalculatorVersion } from 'shared-data'
|
||||
|
||||
import type { CalculatorInputs, ComputeTier, CurrentInfrastructure, ProjectionPeriodMonths } from './types'
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
||||
|
||||
const toBool = (v: string | null) => v === '1' || v === 'true'
|
||||
const toNum = (v: string | null, fallback: number) => {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
export function getDefaultInputs(): CalculatorInputs {
|
||||
return {
|
||||
projects: 1,
|
||||
currentInfrastructure: 'starting_fresh',
|
||||
teamSize: 2,
|
||||
hourlyCostUsd: 150,
|
||||
needCompliance: false,
|
||||
|
||||
databaseSizeGb: 2,
|
||||
storageSizeGb: 10,
|
||||
egressGb: 50,
|
||||
mau: 10_000,
|
||||
needSso: false,
|
||||
needPhoneMfa: false,
|
||||
realtimePeakConnections: 100,
|
||||
realtimeMessages: 1_000_000,
|
||||
edgeInvocations: 500_000,
|
||||
computeTier: 'Micro',
|
||||
|
||||
userGrowthRateMonthlyPct: 10,
|
||||
dataGrowthGbPerMonth: 1,
|
||||
projectionMonths: 12,
|
||||
|
||||
timeAllocationOverrides: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeInputsToSearchParams(inputs: CalculatorInputs): URLSearchParams {
|
||||
const p = new URLSearchParams()
|
||||
p.set('v', pricingCalculatorVersion)
|
||||
|
||||
p.set('projects', String(inputs.projects))
|
||||
p.set('infra', inputs.currentInfrastructure)
|
||||
p.set('team', String(inputs.teamSize))
|
||||
p.set('hourly', String(inputs.hourlyCostUsd))
|
||||
p.set('compliance', inputs.needCompliance ? '1' : '0')
|
||||
|
||||
p.set('db', String(inputs.databaseSizeGb))
|
||||
p.set('storage', String(inputs.storageSizeGb))
|
||||
p.set('egress', String(inputs.egressGb))
|
||||
p.set('mau', String(inputs.mau))
|
||||
p.set('sso', inputs.needSso ? '1' : '0')
|
||||
p.set('phone_mfa', inputs.needPhoneMfa ? '1' : '0')
|
||||
p.set('rtc', String(inputs.realtimePeakConnections))
|
||||
p.set('rtm', String(inputs.realtimeMessages))
|
||||
p.set('edge', String(inputs.edgeInvocations))
|
||||
p.set('compute', inputs.computeTier)
|
||||
|
||||
p.set('growth', String(inputs.userGrowthRateMonthlyPct))
|
||||
p.set('data_growth', String(inputs.dataGrowthGbPerMonth))
|
||||
p.set('months', String(inputs.projectionMonths))
|
||||
|
||||
if (inputs.timeAllocationOverrides?.auth != null) p.set('ta_auth', String(inputs.timeAllocationOverrides.auth))
|
||||
if (inputs.timeAllocationOverrides?.database != null)
|
||||
p.set('ta_db', String(inputs.timeAllocationOverrides.database))
|
||||
if (inputs.timeAllocationOverrides?.api != null) p.set('ta_api', String(inputs.timeAllocationOverrides.api))
|
||||
if (inputs.timeAllocationOverrides?.devops != null) p.set('ta_devops', String(inputs.timeAllocationOverrides.devops))
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
export function decodeInputsFromSearchParams(params: URLSearchParams, fallback: CalculatorInputs = getDefaultInputs()): {
|
||||
inputs: CalculatorInputs
|
||||
versionMatch: boolean
|
||||
} {
|
||||
const v = params.get('v')
|
||||
const versionMatch = !v || v === pricingCalculatorVersion
|
||||
|
||||
const inputs: CalculatorInputs = {
|
||||
...fallback,
|
||||
projects: clamp(Math.round(toNum(params.get('projects'), fallback.projects)), 1, 50),
|
||||
currentInfrastructure: (params.get('infra') as CurrentInfrastructure) ?? fallback.currentInfrastructure,
|
||||
teamSize: clamp(Math.round(toNum(params.get('team'), fallback.teamSize)), 1, 500),
|
||||
hourlyCostUsd: clamp(toNum(params.get('hourly'), fallback.hourlyCostUsd), 0, 1000),
|
||||
needCompliance: toBool(params.get('compliance')),
|
||||
|
||||
databaseSizeGb: clamp(toNum(params.get('db'), fallback.databaseSizeGb), 0.5, 10_000),
|
||||
storageSizeGb: clamp(toNum(params.get('storage'), fallback.storageSizeGb), 0, 100_000),
|
||||
egressGb: clamp(toNum(params.get('egress'), fallback.egressGb), 0, 100_000),
|
||||
mau: clamp(Math.round(toNum(params.get('mau'), fallback.mau)), 0, 50_000_000),
|
||||
needSso: toBool(params.get('sso')),
|
||||
needPhoneMfa: toBool(params.get('phone_mfa')),
|
||||
realtimePeakConnections: clamp(Math.round(toNum(params.get('rtc'), fallback.realtimePeakConnections)), 0, 10_000_000),
|
||||
realtimeMessages: clamp(Math.round(toNum(params.get('rtm'), fallback.realtimeMessages)), 0, 10_000_000_000),
|
||||
edgeInvocations: clamp(Math.round(toNum(params.get('edge'), fallback.edgeInvocations)), 0, 10_000_000_000),
|
||||
computeTier: (params.get('compute') as ComputeTier) ?? fallback.computeTier,
|
||||
|
||||
userGrowthRateMonthlyPct: clamp(toNum(params.get('growth'), fallback.userGrowthRateMonthlyPct), 0, 50),
|
||||
dataGrowthGbPerMonth: clamp(toNum(params.get('data_growth'), fallback.dataGrowthGbPerMonth), 0, 100_000),
|
||||
projectionMonths: (Math.round(toNum(params.get('months'), fallback.projectionMonths)) as ProjectionPeriodMonths) ?? fallback.projectionMonths,
|
||||
// Note: we intentionally ignore any `compare` param in v1 multi-compare mode.
|
||||
|
||||
timeAllocationOverrides: {
|
||||
auth: params.get('ta_auth') ? toNum(params.get('ta_auth'), fallback.timeAllocationOverrides?.auth ?? 0) : undefined,
|
||||
database: params.get('ta_db') ? toNum(params.get('ta_db'), fallback.timeAllocationOverrides?.database ?? 0) : undefined,
|
||||
api: params.get('ta_api') ? toNum(params.get('ta_api'), fallback.timeAllocationOverrides?.api ?? 0) : undefined,
|
||||
devops: params.get('ta_devops') ? toNum(params.get('ta_devops'), fallback.timeAllocationOverrides?.devops ?? 0) : undefined,
|
||||
},
|
||||
}
|
||||
|
||||
return { inputs, versionMatch }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { supabasePricingModel } from 'shared-data'
|
||||
import type { PricingCalculatorPlan } from 'shared-data'
|
||||
|
||||
import pricingAddOn from '~/data/PricingAddOnTable.json'
|
||||
import type { CalculatorInputs, ComputeTier, FitStatus, PlanEstimate, PricingReport, UsageDimension } from './types'
|
||||
|
||||
const roundUsd = (value: number) => Math.round((value + Number.EPSILON) * 100) / 100
|
||||
|
||||
const parseUsd = (value: string) => {
|
||||
const normalized = value.replace('$', '').replaceAll(',', '').trim()
|
||||
const parsed = Number(normalized)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
const getComputeTierPriceMap = (): Record<string, number> => {
|
||||
// `PricingAddOnTable.json` stores compute as rows with columns; we normalize into a map
|
||||
const rows = pricingAddOn.database?.rows ?? []
|
||||
const map: Record<string, number> = {}
|
||||
|
||||
for (const row of rows) {
|
||||
const planCol = row.columns?.find((c: any) => c.key === 'plan')
|
||||
const priceCol = row.columns?.find((c: any) => c.key === 'pricing')
|
||||
if (!planCol || !priceCol) continue
|
||||
if (priceCol.value === 'Contact Us') continue
|
||||
map[String(planCol.value)] = parseUsd(String(priceCol.value))
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
const COMPUTE_TIER_PRICE_MAP = getComputeTierPriceMap()
|
||||
|
||||
export function getComputeTierMonthlyUsd(tier: ComputeTier): number {
|
||||
return COMPUTE_TIER_PRICE_MAP[tier] ?? 0
|
||||
}
|
||||
|
||||
function allFitOkOrOverage(fits: Record<UsageDimension, FitStatus>) {
|
||||
return !Object.values(fits).some((s) => s === 'unavailable' || s === 'limit_exceeded')
|
||||
}
|
||||
|
||||
export function calculatePlanEstimate(
|
||||
inputs: CalculatorInputs,
|
||||
plan: PricingCalculatorPlan
|
||||
): PlanEstimate {
|
||||
const planModel = supabasePricingModel.plans[plan]
|
||||
|
||||
const fits: Record<UsageDimension, FitStatus> = {
|
||||
projects: 'ok',
|
||||
compute: 'ok',
|
||||
database_size: 'ok',
|
||||
storage_size: 'ok',
|
||||
egress: 'ok',
|
||||
mau: 'ok',
|
||||
sso: 'ok',
|
||||
phone_mfa: 'ok',
|
||||
realtime_peak_connections: 'ok',
|
||||
realtime_messages: 'ok',
|
||||
edge_invocations: 'ok',
|
||||
compliance: 'ok',
|
||||
}
|
||||
|
||||
// Projects (Free plan limit)
|
||||
if (plan === 'free' && planModel.included.activeProjectsLimit) {
|
||||
fits.projects = inputs.projects <= planModel.included.activeProjectsLimit ? 'ok' : 'limit_exceeded'
|
||||
}
|
||||
|
||||
// Compliance requirement (prototype: maps to Team/Enterprise signals)
|
||||
if (inputs.needCompliance) {
|
||||
fits.compliance = plan === 'team' ? 'ok' : 'unavailable'
|
||||
}
|
||||
|
||||
// DB size (per project quota)
|
||||
const dbIncludedPerProject = planModel.included.databaseSizeGbPerProject
|
||||
if (plan === 'free') {
|
||||
fits.database_size = inputs.databaseSizeGb <= dbIncludedPerProject ? 'ok' : 'limit_exceeded'
|
||||
} else {
|
||||
fits.database_size = inputs.databaseSizeGb <= dbIncludedPerProject ? 'ok' : 'overage'
|
||||
}
|
||||
|
||||
// Storage (org quota)
|
||||
if (plan === 'free') {
|
||||
fits.storage_size = inputs.storageSizeGb <= planModel.included.storageSizeGbPerOrg ? 'ok' : 'limit_exceeded'
|
||||
} else {
|
||||
fits.storage_size = inputs.storageSizeGb <= planModel.included.storageSizeGbPerOrg ? 'ok' : 'overage'
|
||||
}
|
||||
|
||||
// Egress (org quota)
|
||||
if (plan === 'free') {
|
||||
fits.egress = inputs.egressGb <= planModel.included.egressGbPerOrg ? 'ok' : 'limit_exceeded'
|
||||
} else {
|
||||
fits.egress = inputs.egressGb <= planModel.included.egressGbPerOrg ? 'ok' : 'overage'
|
||||
}
|
||||
|
||||
// MAU (org quota)
|
||||
if (plan === 'free') {
|
||||
fits.mau = inputs.mau <= planModel.included.mauPerOrg ? 'ok' : 'limit_exceeded'
|
||||
} else {
|
||||
fits.mau = inputs.mau <= planModel.included.mauPerOrg ? 'ok' : 'overage'
|
||||
}
|
||||
|
||||
// SSO / SAML (end-user auth SSO)
|
||||
if (inputs.needSso) {
|
||||
if (!planModel.availability.sso) {
|
||||
fits.sso = 'unavailable'
|
||||
} else {
|
||||
const ssoMauAssumption = inputs.mau // prototype assumption: all MAUs are SSO users
|
||||
fits.sso = ssoMauAssumption <= planModel.included.ssoMauPerOrg ? 'ok' : 'overage'
|
||||
}
|
||||
}
|
||||
|
||||
// Phone MFA add-on
|
||||
if (inputs.needPhoneMfa) {
|
||||
fits.phone_mfa = plan === 'free' ? 'unavailable' : 'ok'
|
||||
}
|
||||
|
||||
// Realtime peak connections
|
||||
if (plan === 'free') {
|
||||
fits.realtime_peak_connections =
|
||||
inputs.realtimePeakConnections <= planModel.included.realtimePeakConnectionsPerOrg
|
||||
? 'ok'
|
||||
: 'limit_exceeded'
|
||||
} else {
|
||||
fits.realtime_peak_connections =
|
||||
inputs.realtimePeakConnections <= planModel.included.realtimePeakConnectionsPerOrg ? 'ok' : 'overage'
|
||||
}
|
||||
|
||||
// Realtime messages
|
||||
if (plan === 'free') {
|
||||
fits.realtime_messages =
|
||||
inputs.realtimeMessages <= planModel.included.realtimeMessagesPerOrg ? 'ok' : 'limit_exceeded'
|
||||
} else {
|
||||
fits.realtime_messages = inputs.realtimeMessages <= planModel.included.realtimeMessagesPerOrg ? 'ok' : 'overage'
|
||||
}
|
||||
|
||||
// Edge invocations
|
||||
if (plan === 'free') {
|
||||
fits.edge_invocations =
|
||||
inputs.edgeInvocations <= planModel.included.edgeInvocationsPerOrg ? 'ok' : 'limit_exceeded'
|
||||
} else {
|
||||
fits.edge_invocations = inputs.edgeInvocations <= planModel.included.edgeInvocationsPerOrg ? 'ok' : 'overage'
|
||||
}
|
||||
|
||||
const lineItems: PlanEstimate['lineItems'] = []
|
||||
|
||||
// Base subscription
|
||||
lineItems.push({
|
||||
key: 'subscription',
|
||||
monthlyUsd: planModel.subscriptionMonthlyUsd,
|
||||
})
|
||||
|
||||
// Compute (paid plans only)
|
||||
const computeTierMonthlyUsd = getComputeTierMonthlyUsd(inputs.computeTier)
|
||||
const computeMonthlyUsd = plan === 'free' ? 0 : roundUsd(inputs.projects * computeTierMonthlyUsd)
|
||||
if (plan !== 'free') {
|
||||
lineItems.push({
|
||||
key: 'compute',
|
||||
monthlyUsd: computeMonthlyUsd,
|
||||
details: [
|
||||
`${inputs.projects} project${inputs.projects === 1 ? '' : 's'} × $${roundUsd(computeTierMonthlyUsd)}/month`,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Compute credits (paid plans only)
|
||||
if (plan !== 'free') {
|
||||
const credit = supabasePricingModel.credits.computeCreditsMonthlyUsd
|
||||
const applied = Math.min(credit, computeMonthlyUsd)
|
||||
if (applied > 0) {
|
||||
lineItems.push({
|
||||
key: 'compute_credits',
|
||||
monthlyUsd: roundUsd(-applied),
|
||||
details: [`-$${roundUsd(applied)} compute credits applied`],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DB size overage (paid plans only)
|
||||
if (plan !== 'free') {
|
||||
const included = planModel.included.databaseSizeGbPerProject
|
||||
const overGbPerProject = Math.max(0, inputs.databaseSizeGb - included)
|
||||
const overGbTotal = overGbPerProject * inputs.projects
|
||||
const cost = roundUsd(overGbTotal * planModel.overage.databaseSizeUsdPerGb)
|
||||
if (cost > 0) {
|
||||
lineItems.push({
|
||||
key: 'database_overage',
|
||||
monthlyUsd: cost,
|
||||
details: [
|
||||
`${roundUsd(overGbPerProject)} GB over per project × ${inputs.projects} projects`,
|
||||
`$${planModel.overage.databaseSizeUsdPerGb}/GB`,
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Storage overage
|
||||
if (plan !== 'free') {
|
||||
const overGb = Math.max(0, inputs.storageSizeGb - planModel.included.storageSizeGbPerOrg)
|
||||
const cost = roundUsd(overGb * planModel.overage.storageSizeUsdPerGb)
|
||||
if (cost > 0) {
|
||||
lineItems.push({
|
||||
key: 'storage_overage',
|
||||
monthlyUsd: cost,
|
||||
details: [`${roundUsd(overGb)} GB over`, `$${planModel.overage.storageSizeUsdPerGb}/GB`],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Egress overage
|
||||
if (plan !== 'free') {
|
||||
const overGb = Math.max(0, inputs.egressGb - planModel.included.egressGbPerOrg)
|
||||
const cost = roundUsd(overGb * planModel.overage.egressUsdPerGb)
|
||||
if (cost > 0) {
|
||||
lineItems.push({
|
||||
key: 'egress_overage',
|
||||
monthlyUsd: cost,
|
||||
details: [`${roundUsd(overGb)} GB over`, `$${planModel.overage.egressUsdPerGb}/GB`],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MAU overage
|
||||
if (plan !== 'free') {
|
||||
const over = Math.max(0, inputs.mau - planModel.included.mauPerOrg)
|
||||
const cost = roundUsd(over * planModel.overage.mauUsdPerMau)
|
||||
if (cost > 0) {
|
||||
lineItems.push({
|
||||
key: 'mau_overage',
|
||||
monthlyUsd: cost,
|
||||
details: [`${over.toLocaleString()} MAU over`, `$${planModel.overage.mauUsdPerMau} per MAU`],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SSO MAU overage (if enabled)
|
||||
if (plan !== 'free' && inputs.needSso) {
|
||||
const ssoMauAssumption = inputs.mau
|
||||
const over = Math.max(0, ssoMauAssumption - planModel.included.ssoMauPerOrg)
|
||||
const cost = roundUsd(over * planModel.overage.ssoMauUsdPerMau)
|
||||
if (cost > 0) {
|
||||
lineItems.push({
|
||||
key: 'sso_overage',
|
||||
monthlyUsd: cost,
|
||||
details: [
|
||||
`${over.toLocaleString()} SSO MAU over`,
|
||||
`$${planModel.overage.ssoMauUsdPerMau} per SSO MAU`,
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Realtime peak connections overage
|
||||
if (plan !== 'free') {
|
||||
const over = Math.max(0, inputs.realtimePeakConnections - planModel.included.realtimePeakConnectionsPerOrg)
|
||||
const cost = roundUsd((over / 1000) * planModel.overage.realtimePeakConnectionsUsdPer1000)
|
||||
if (cost > 0) {
|
||||
lineItems.push({
|
||||
key: 'realtime_peak_connections_overage',
|
||||
monthlyUsd: cost,
|
||||
details: [`${over.toLocaleString()} over`, `$${planModel.overage.realtimePeakConnectionsUsdPer1000} per 1000`],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Realtime messages overage
|
||||
if (plan !== 'free') {
|
||||
const over = Math.max(0, inputs.realtimeMessages - planModel.included.realtimeMessagesPerOrg)
|
||||
const cost = roundUsd((over / 1_000_000) * planModel.overage.realtimeMessagesUsdPerMillion)
|
||||
if (cost > 0) {
|
||||
lineItems.push({
|
||||
key: 'realtime_messages_overage',
|
||||
monthlyUsd: cost,
|
||||
details: [
|
||||
`${Math.round(over / 1_000_000 * 100) / 100} million over`,
|
||||
`$${planModel.overage.realtimeMessagesUsdPerMillion} per million`,
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Edge invocations overage
|
||||
if (plan !== 'free') {
|
||||
const over = Math.max(0, inputs.edgeInvocations - planModel.included.edgeInvocationsPerOrg)
|
||||
const cost = roundUsd((over / 1_000_000) * planModel.overage.edgeInvocationsUsdPerMillion)
|
||||
if (cost > 0) {
|
||||
lineItems.push({
|
||||
key: 'edge_invocations_overage',
|
||||
monthlyUsd: cost,
|
||||
details: [
|
||||
`${Math.round(over / 1_000_000 * 100) / 100} million over`,
|
||||
`$${planModel.overage.edgeInvocationsUsdPerMillion} per million`,
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Phone MFA add-on (if enabled)
|
||||
if (plan !== 'free' && inputs.needPhoneMfa) {
|
||||
const addon = supabasePricingModel.addons.phoneMfa
|
||||
const cost = roundUsd(
|
||||
addon.monthlyUsdFirstProject + Math.max(0, inputs.projects - 1) * addon.monthlyUsdPerAdditionalProject
|
||||
)
|
||||
if (cost > 0) {
|
||||
lineItems.push({
|
||||
key: 'phone_mfa_addon',
|
||||
monthlyUsd: cost,
|
||||
details: [
|
||||
`$${addon.monthlyUsdFirstProject} for first project`,
|
||||
`$${addon.monthlyUsdPerAdditionalProject} per additional project`,
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const totalMonthlyUsd = roundUsd(lineItems.reduce((acc, item) => acc + item.monthlyUsd, 0))
|
||||
|
||||
return {
|
||||
plan,
|
||||
totalMonthlyUsd: allFitOkOrOverage(fits) ? totalMonthlyUsd : totalMonthlyUsd,
|
||||
fits,
|
||||
lineItems,
|
||||
}
|
||||
}
|
||||
|
||||
export function calculatePricingReport(inputs: CalculatorInputs): PricingReport {
|
||||
const estimates = {
|
||||
free: calculatePlanEstimate(inputs, 'free'),
|
||||
pro: calculatePlanEstimate(inputs, 'pro'),
|
||||
team: calculatePlanEstimate(inputs, 'team'),
|
||||
} satisfies Record<PricingCalculatorPlan, PlanEstimate>
|
||||
|
||||
const candidates: PricingCalculatorPlan[] = (['free', 'pro', 'team'] as const).filter((p) =>
|
||||
allFitOkOrOverage(estimates[p].fits)
|
||||
)
|
||||
|
||||
const recommendedPlan: PricingCalculatorPlan =
|
||||
candidates.length === 0
|
||||
? 'team'
|
||||
: candidates.reduce((best, next) =>
|
||||
estimates[next].totalMonthlyUsd < estimates[best].totalMonthlyUsd ? next : best
|
||||
)
|
||||
|
||||
const reasons: string[] = []
|
||||
if (recommendedPlan === 'free') {
|
||||
reasons.push('Your usage fits within Free plan limits.')
|
||||
}
|
||||
if (recommendedPlan === 'pro') {
|
||||
if (inputs.needSso) reasons.push('SSO/SAML requires a paid plan.')
|
||||
if (inputs.needPhoneMfa) reasons.push('Phone-based MFA is available on paid plans.')
|
||||
if (
|
||||
estimates.free.fits.egress !== 'ok' ||
|
||||
estimates.free.fits.mau !== 'ok' ||
|
||||
estimates.free.fits.storage_size !== 'ok' ||
|
||||
estimates.free.fits.database_size !== 'ok'
|
||||
) {
|
||||
reasons.push('Your estimated usage exceeds Free plan limits.')
|
||||
}
|
||||
reasons.push('Pro plan is the lowest tier that supports your inputs.')
|
||||
}
|
||||
if (recommendedPlan === 'team') {
|
||||
if (inputs.needCompliance) reasons.push('Your compliance requirements require Team tier features.')
|
||||
reasons.push('Team plan is the lowest tier that supports your inputs.')
|
||||
}
|
||||
|
||||
return {
|
||||
inputs,
|
||||
estimates,
|
||||
recommended: {
|
||||
plan: recommendedPlan,
|
||||
reasons,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { PricingCalculatorPlan } from 'shared-data'
|
||||
|
||||
export type CurrentInfrastructure =
|
||||
| 'starting_fresh'
|
||||
| 'firebase'
|
||||
| 'aws_self_hosted'
|
||||
| 'auth0_plus_db'
|
||||
| 'other'
|
||||
|
||||
export type ProjectionPeriodMonths = 12 | 36
|
||||
|
||||
export type ComputeTier =
|
||||
| 'Micro'
|
||||
| 'Small'
|
||||
| 'Medium'
|
||||
| 'Large'
|
||||
| 'XL'
|
||||
| '2XL'
|
||||
| '4XL'
|
||||
| '8XL'
|
||||
| '12XL'
|
||||
| '16XL'
|
||||
|
||||
export type CalculatorStageId = 'stage1' | 'stage2' | 'stage3' | 'stage4'
|
||||
|
||||
export type FitStatus = 'ok' | 'overage' | 'unavailable' | 'limit_exceeded'
|
||||
|
||||
export type UsageDimension =
|
||||
| 'projects'
|
||||
| 'compute'
|
||||
| 'database_size'
|
||||
| 'storage_size'
|
||||
| 'egress'
|
||||
| 'mau'
|
||||
| 'sso'
|
||||
| 'phone_mfa'
|
||||
| 'realtime_peak_connections'
|
||||
| 'realtime_messages'
|
||||
| 'edge_invocations'
|
||||
| 'compliance'
|
||||
|
||||
export type TimeAllocationKey = 'auth' | 'database' | 'api' | 'devops'
|
||||
|
||||
export type TimeAllocation = Record<TimeAllocationKey, number>
|
||||
|
||||
export type CalculatorInputs = {
|
||||
// Stage 1
|
||||
projects: number
|
||||
currentInfrastructure: CurrentInfrastructure
|
||||
teamSize: number
|
||||
hourlyCostUsd: number
|
||||
needCompliance: boolean
|
||||
|
||||
// Stage 2
|
||||
databaseSizeGb: number
|
||||
storageSizeGb: number
|
||||
egressGb: number
|
||||
mau: number
|
||||
needSso: boolean
|
||||
needPhoneMfa: boolean
|
||||
realtimePeakConnections: number
|
||||
realtimeMessages: number
|
||||
edgeInvocations: number
|
||||
computeTier: ComputeTier
|
||||
|
||||
// Stage 3
|
||||
userGrowthRateMonthlyPct: number
|
||||
dataGrowthGbPerMonth: number
|
||||
projectionMonths: ProjectionPeriodMonths
|
||||
|
||||
// Stage 4 (optional overrides)
|
||||
timeAllocationOverrides?: Partial<TimeAllocation>
|
||||
}
|
||||
|
||||
export type CostLineItemKey =
|
||||
| 'subscription'
|
||||
| 'compute'
|
||||
| 'compute_credits'
|
||||
| 'database_overage'
|
||||
| 'storage_overage'
|
||||
| 'egress_overage'
|
||||
| 'mau_overage'
|
||||
| 'sso_overage'
|
||||
| 'realtime_peak_connections_overage'
|
||||
| 'realtime_messages_overage'
|
||||
| 'edge_invocations_overage'
|
||||
| 'phone_mfa_addon'
|
||||
|
||||
export type CostLineItem = {
|
||||
key: CostLineItemKey
|
||||
monthlyUsd: number
|
||||
details?: string[]
|
||||
}
|
||||
|
||||
export type PlanEstimate = {
|
||||
plan: PricingCalculatorPlan
|
||||
totalMonthlyUsd: number
|
||||
fits: Record<UsageDimension, FitStatus>
|
||||
lineItems: CostLineItem[]
|
||||
}
|
||||
|
||||
export type PricingReport = {
|
||||
inputs: CalculatorInputs
|
||||
estimates: Record<PricingCalculatorPlan, PlanEstimate>
|
||||
recommended: {
|
||||
plan: PricingCalculatorPlan
|
||||
reasons: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type RoiSummary = {
|
||||
hoursRecoveredPerMonth: number
|
||||
hoursRecoveredPerYear: number
|
||||
valueRecoveredPerMonthUsd: number
|
||||
valueRecoveredPerYearUsd: number
|
||||
breakdown: Record<TimeAllocationKey, { hoursBefore: number; hoursRecovered: number; reductionPct: number }>
|
||||
}
|
||||
|
||||
export type CompetitorKey = 'supabase' | 'firebase' | 'auth0' | 'clerk' | 'self_hosted' | 'convex' | 'aws'
|
||||
|
||||
export type ComparisonPlatform = Exclude<CompetitorKey, 'supabase'>
|
||||
|
||||
export type CostComparison = {
|
||||
monthlyUsd: Record<CompetitorKey, number>
|
||||
annualUsd: Record<CompetitorKey, number>
|
||||
}
|
||||
|
||||
export type ProjectionPoint = {
|
||||
monthIndex: number
|
||||
mau: number
|
||||
databaseSizeGb: number
|
||||
storageSizeGb: number
|
||||
egressGb: number
|
||||
supabaseMonthlyUsd: number
|
||||
competitorsMonthlyUsd: Record<ComparisonPlatform, number>
|
||||
}
|
||||
|
||||
export type ProjectionSeries = {
|
||||
months: ProjectionPeriodMonths
|
||||
points: ProjectionPoint[]
|
||||
cumulative: {
|
||||
supabaseUsd: number
|
||||
competitorsUsd: Record<ComparisonPlatform, number>
|
||||
savingsUsd: Record<ComparisonPlatform, number>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextSeo } from 'next-seo'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ArrowDownIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { Button } from 'ui'
|
||||
import DefaultLayout from '~/components/Layouts/Default'
|
||||
import SectionContainer from '~/components/Layouts/SectionContainer'
|
||||
import Panel from '~/components/Panel'
|
||||
import Logos from '~/components/logos'
|
||||
import PricingCalculator from '~/components/PricingCalculator/PricingCalculator'
|
||||
|
||||
export default function PricingCalculatorPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const meta_title = 'Pricing Calculator | Supabase'
|
||||
const meta_description =
|
||||
"Estimate your Supabase bill in 60 seconds. Compare alternatives and generate a shareable report you can send to your team."
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<NextSeo
|
||||
title={meta_title}
|
||||
description={meta_description}
|
||||
openGraph={{
|
||||
title: meta_title,
|
||||
description: meta_description,
|
||||
url: `https://supabase.com/${router.pathname}`,
|
||||
images: [
|
||||
{
|
||||
url: `https://supabase.com/images/og/supabase-og.png`,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
<SectionContainer className="!pb-8 md:!pb-12 !pt-10 md:!pt-14">
|
||||
<div className="mx-auto max-w-3xl text-center flex flex-col items-center gap-4">
|
||||
<h1 className="h1">Know your costs before you build</h1>
|
||||
<p className="text-foreground-light text-lg">
|
||||
Estimate your Supabase bill in 60 seconds. See how much you save compared to Firebase,
|
||||
Auth0, and self-hosted alternatives.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 pt-2">
|
||||
<Button asChild size="large" type="primary" iconRight={<ArrowDownIcon className="w-4" />}>
|
||||
<Link href="#calculator">Calculate my costs</Link>
|
||||
</Button>
|
||||
<p className="text-foreground-lighter text-sm">No signup required. Get a shareable report.</p>
|
||||
</div>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
|
||||
<div className="pb-6 md:pb-10">
|
||||
<Logos showHeading />
|
||||
</div>
|
||||
|
||||
<SectionContainer className="!py-10 md:!py-12">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="text-center mb-8 md:mb-10">
|
||||
<h2 className="text-foreground text-3xl">Four stages to your estimate</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3 md:gap-4">
|
||||
<Panel hasActiveOnHover outerClassName="h-full" innerClassName="p-5 flex flex-col gap-2">
|
||||
<p className="text-foreground text-sm font-medium">Stage 1: Project basics</p>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Number of environments, team size, and where you're coming from.
|
||||
</p>
|
||||
</Panel>
|
||||
<Panel hasActiveOnHover outerClassName="h-full" innerClassName="p-5 flex flex-col gap-2">
|
||||
<p className="text-foreground text-sm font-medium">Stage 2: Usage estimation</p>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Database size, monthly users, bandwidth, and realtime needs.
|
||||
</p>
|
||||
</Panel>
|
||||
<Panel hasActiveOnHover outerClassName="h-full" innerClassName="p-5 flex flex-col gap-2">
|
||||
<p className="text-foreground text-sm font-medium">Stage 3: Growth projections</p>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
See how costs change over 12 or 36 months as you grow.
|
||||
</p>
|
||||
</Panel>
|
||||
<Panel hasActiveOnHover outerClassName="h-full" innerClassName="p-5 flex flex-col gap-2">
|
||||
<p className="text-foreground text-sm font-medium">Stage 4: Time allocation (optional)</p>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Add detail to value analysis by estimating time spent on infrastructure today.
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
|
||||
<SectionContainer id="calculator" className="!pt-10 md:!pt-12">
|
||||
<PricingCalculator />
|
||||
</SectionContainer>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -780,6 +780,62 @@ export interface WwwPricingPlanCtaClickedEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User changed stage in the pricing calculator.
|
||||
*
|
||||
* @group Events
|
||||
* @source www
|
||||
* @page /pricing-calculator
|
||||
*/
|
||||
export interface WwwPricingCalculatorStageChangedEvent {
|
||||
action: 'www_pricing_calculator_stage_changed'
|
||||
properties: {
|
||||
stage: 'stage1' | 'stage2' | 'stage3' | 'stage4'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User copied the shareable link in the pricing calculator.
|
||||
*
|
||||
* @group Events
|
||||
* @source www
|
||||
* @page /pricing-calculator
|
||||
*/
|
||||
export interface WwwPricingCalculatorShareLinkCopiedEvent {
|
||||
action: 'www_pricing_calculator_share_link_copied'
|
||||
properties: {
|
||||
stage: 'stage1' | 'stage2' | 'stage3' | 'stage4'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User viewed the final report section in the pricing calculator.
|
||||
*
|
||||
* @group Events
|
||||
* @source www
|
||||
* @page /pricing-calculator
|
||||
*/
|
||||
export interface WwwPricingCalculatorReportViewedEvent {
|
||||
action: 'www_pricing_calculator_report_viewed'
|
||||
properties: {
|
||||
recommendedPlan: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User clicked talk-to-sales CTA in the pricing calculator.
|
||||
*
|
||||
* @group Events
|
||||
* @source www
|
||||
* @page /pricing-calculator
|
||||
*/
|
||||
export interface WwwPricingCalculatorTalkToSalesClickedEvent {
|
||||
action: 'www_pricing_calculator_talk_to_sales_clicked'
|
||||
properties: {
|
||||
recommendedPlan: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User clicked the main CTA button in an event page.
|
||||
*
|
||||
@@ -2786,6 +2842,10 @@ export type TelemetryEvent =
|
||||
| HomepageFrameworkQuickstartClickedEvent
|
||||
| HomepageProductCardClickedEvent
|
||||
| WwwPricingPlanCtaClickedEvent
|
||||
| WwwPricingCalculatorStageChangedEvent
|
||||
| WwwPricingCalculatorShareLinkCopiedEvent
|
||||
| WwwPricingCalculatorReportViewedEvent
|
||||
| WwwPricingCalculatorTalkToSalesClickedEvent
|
||||
| EventPageCtaClickedEvent
|
||||
| HomepageGitHubButtonClickedEvent
|
||||
| HomepageDiscordButtonClickedEvent
|
||||
|
||||
@@ -3,6 +3,7 @@ import extensions from './extensions.json'
|
||||
import logConstants from './logConstants'
|
||||
import { plans, PricingInformation } from './plans'
|
||||
import { pricing } from './pricing'
|
||||
import { pricingCalculatorVersion, supabasePricingModel } from './pricing-calculator'
|
||||
import { PRODUCT_MODULES, products } from './products'
|
||||
import questions from './questions'
|
||||
import type { AWS_REGIONS_KEYS, CloudProvider, Region } from './regions'
|
||||
@@ -17,10 +18,14 @@ export {
|
||||
logConstants,
|
||||
plans,
|
||||
pricing,
|
||||
pricingCalculatorVersion,
|
||||
supabasePricingModel,
|
||||
PRODUCT_MODULES,
|
||||
products,
|
||||
questions,
|
||||
topTweets,
|
||||
tweets,
|
||||
}
|
||||
export type { AWS_REGIONS_KEYS, CloudProvider, PricingInformation, Region }
|
||||
export type { AWS_REGIONS_KEYS, CloudProvider, Region } from './regions'
|
||||
export type { PricingInformation } from './plans'
|
||||
export type { PricingCalculatorPlan, SupabasePricingModel, UsageDimensionKey } from './pricing-calculator'
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { PlanId } from './plans'
|
||||
|
||||
export type PricingCalculatorCurrency = 'USD'
|
||||
|
||||
export type UsageDimensionKey =
|
||||
| 'projects'
|
||||
| 'compute'
|
||||
| 'database_size_gb'
|
||||
| 'storage_size_gb'
|
||||
| 'egress_gb'
|
||||
| 'mau'
|
||||
| 'sso_mau'
|
||||
| 'realtime_peak_connections'
|
||||
| 'realtime_messages'
|
||||
| 'edge_invocations'
|
||||
| 'phone_mfa'
|
||||
|
||||
export type PricingCalculatorPlan = Exclude<PlanId, 'enterprise'>
|
||||
|
||||
export type SupabasePricingModel = {
|
||||
version: string
|
||||
currency: PricingCalculatorCurrency
|
||||
plans: Record<
|
||||
PricingCalculatorPlan,
|
||||
{
|
||||
subscriptionMonthlyUsd: number
|
||||
included: {
|
||||
// Quotas / allowances
|
||||
databaseSizeGbPerProject: number
|
||||
storageSizeGbPerOrg: number
|
||||
egressGbPerOrg: number
|
||||
mauPerOrg: number
|
||||
// Auth SAML 2.0 (end-user SSO)
|
||||
ssoMauPerOrg: number
|
||||
realtimePeakConnectionsPerOrg: number
|
||||
realtimeMessagesPerOrg: number
|
||||
edgeInvocationsPerOrg: number
|
||||
// Free plan project allowance (projects are a “limit”, not an overage line item)
|
||||
activeProjectsLimit?: number
|
||||
}
|
||||
overage: {
|
||||
databaseSizeUsdPerGb: number
|
||||
storageSizeUsdPerGb: number
|
||||
egressUsdPerGb: number
|
||||
mauUsdPerMau: number
|
||||
ssoMauUsdPerMau: number
|
||||
realtimePeakConnectionsUsdPer1000: number
|
||||
realtimeMessagesUsdPerMillion: number
|
||||
edgeInvocationsUsdPerMillion: number
|
||||
}
|
||||
availability: {
|
||||
sso: boolean
|
||||
}
|
||||
}
|
||||
>
|
||||
credits: {
|
||||
computeCreditsMonthlyUsd: number
|
||||
appliesToPlans: PricingCalculatorPlan[]
|
||||
}
|
||||
addons: {
|
||||
// Advanced Multi-Factor Auth - Phone
|
||||
phoneMfa: {
|
||||
monthlyUsdFirstProject: number
|
||||
monthlyUsdPerAdditionalProject: number
|
||||
appliesToPlans: PricingCalculatorPlan[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Versioned pricing snapshot for the pricing calculator.
|
||||
*
|
||||
* Sources:
|
||||
* - `apps/docs/content/guides/platform/billing-on-supabase.mdx` (quotas + overage rates)
|
||||
* - `apps/docs/content/guides/platform/billing-faq.mdx` (compute credits)
|
||||
* - `packages/shared-data/plans.ts` (subscription plan fees)
|
||||
* - `packages/shared-data/pricing.ts` (phone MFA add-on pricing)
|
||||
*/
|
||||
export const pricingCalculatorVersion = 'v1'
|
||||
|
||||
export const supabasePricingModel: SupabasePricingModel = {
|
||||
version: pricingCalculatorVersion,
|
||||
currency: 'USD',
|
||||
plans: {
|
||||
free: {
|
||||
subscriptionMonthlyUsd: 0,
|
||||
included: {
|
||||
activeProjectsLimit: 2,
|
||||
databaseSizeGbPerProject: 0.5,
|
||||
storageSizeGbPerOrg: 1,
|
||||
egressGbPerOrg: 5,
|
||||
mauPerOrg: 50_000,
|
||||
ssoMauPerOrg: 0,
|
||||
realtimePeakConnectionsPerOrg: 200,
|
||||
realtimeMessagesPerOrg: 2_000_000,
|
||||
edgeInvocationsPerOrg: 500_000,
|
||||
},
|
||||
overage: {
|
||||
// Free plan overages are blocked/restricted; we still keep numeric values as 0.
|
||||
databaseSizeUsdPerGb: 0,
|
||||
storageSizeUsdPerGb: 0,
|
||||
egressUsdPerGb: 0,
|
||||
mauUsdPerMau: 0,
|
||||
ssoMauUsdPerMau: 0,
|
||||
realtimePeakConnectionsUsdPer1000: 0,
|
||||
realtimeMessagesUsdPerMillion: 0,
|
||||
edgeInvocationsUsdPerMillion: 0,
|
||||
},
|
||||
availability: {
|
||||
sso: false,
|
||||
},
|
||||
},
|
||||
pro: {
|
||||
subscriptionMonthlyUsd: 25,
|
||||
included: {
|
||||
databaseSizeGbPerProject: 8,
|
||||
storageSizeGbPerOrg: 100,
|
||||
egressGbPerOrg: 250,
|
||||
mauPerOrg: 100_000,
|
||||
ssoMauPerOrg: 50,
|
||||
realtimePeakConnectionsPerOrg: 500,
|
||||
realtimeMessagesPerOrg: 5_000_000,
|
||||
edgeInvocationsPerOrg: 2_000_000,
|
||||
},
|
||||
overage: {
|
||||
databaseSizeUsdPerGb: 0.125,
|
||||
storageSizeUsdPerGb: 0.021,
|
||||
egressUsdPerGb: 0.09,
|
||||
mauUsdPerMau: 0.00325,
|
||||
ssoMauUsdPerMau: 0.015,
|
||||
realtimePeakConnectionsUsdPer1000: 10,
|
||||
realtimeMessagesUsdPerMillion: 2.5,
|
||||
edgeInvocationsUsdPerMillion: 2,
|
||||
},
|
||||
availability: {
|
||||
sso: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
subscriptionMonthlyUsd: 599,
|
||||
included: {
|
||||
databaseSizeGbPerProject: 8,
|
||||
storageSizeGbPerOrg: 100,
|
||||
egressGbPerOrg: 250,
|
||||
mauPerOrg: 100_000,
|
||||
ssoMauPerOrg: 50,
|
||||
realtimePeakConnectionsPerOrg: 500,
|
||||
realtimeMessagesPerOrg: 5_000_000,
|
||||
edgeInvocationsPerOrg: 2_000_000,
|
||||
},
|
||||
overage: {
|
||||
databaseSizeUsdPerGb: 0.125,
|
||||
storageSizeUsdPerGb: 0.021,
|
||||
egressUsdPerGb: 0.09,
|
||||
mauUsdPerMau: 0.00325,
|
||||
ssoMauUsdPerMau: 0.015,
|
||||
realtimePeakConnectionsUsdPer1000: 10,
|
||||
realtimeMessagesUsdPerMillion: 2.5,
|
||||
edgeInvocationsUsdPerMillion: 2,
|
||||
},
|
||||
availability: {
|
||||
sso: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
credits: {
|
||||
computeCreditsMonthlyUsd: 10,
|
||||
appliesToPlans: ['pro', 'team'],
|
||||
},
|
||||
addons: {
|
||||
phoneMfa: {
|
||||
monthlyUsdFirstProject: 75,
|
||||
monthlyUsdPerAdditionalProject: 10,
|
||||
appliesToPlans: ['pro', 'team'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user