diff --git a/apps/www/components/PricingCalculator/PricingCalculator.tsx b/apps/www/components/PricingCalculator/PricingCalculator.tsx new file mode 100644 index 00000000000..0ddc42bbf84 --- /dev/null +++ b/apps/www/components/PricingCalculator/PricingCalculator.tsx @@ -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('stage1') + const [inputs, setInputs] = useState(() => 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 ( +
+
+
+ +
+
+

Core calculator

+

+ Costs update as you adjust inputs. Use the share link to send your estimate to your team. +

+
+
+ + +
+
+
+ + goToStage('stage1')} + > + + +
+ +
+
+ + goToStage('stage2')} + > + +
+ +
+
+ + goToStage('stage3')} + > + +
+ +
+
+ +
+
+ + goToStage('stage4')} + > + + + + + +
+
+

Final report

+

+ A shareable, detailed breakdown combining pricing and ROI. Estimates are transparent and traceable. +

+
+
+ + + Competitor pricing is sourced from official pricing pages (see report for sources and as-of dates). + +
+
+ + sendTelemetryEvent({ + action: 'www_pricing_calculator_report_viewed', + properties: { recommendedPlan: pricingReport.recommended.plan }, + }) + } + /> +
+
+ +
+ + sendTelemetryEvent({ + action: 'www_pricing_calculator_talk_to_sales_clicked', + properties: { recommendedPlan: pricingReport.recommended.plan }, + }) + } + /> +
+
+
+ ) +} + +// 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 ( + + + {active &&
{children}
} +
+ ) +} + diff --git a/apps/www/components/PricingCalculator/components/CalloutCard.tsx b/apps/www/components/PricingCalculator/components/CalloutCard.tsx new file mode 100644 index 00000000000..272b2d1cd9e --- /dev/null +++ b/apps/www/components/PricingCalculator/components/CalloutCard.tsx @@ -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 ( + +

{title}

+ {body &&

{body}

} + {children} +
+ ) +} + diff --git a/apps/www/components/PricingCalculator/components/GrowthChart.tsx b/apps/www/components/PricingCalculator/components/GrowthChart.tsx new file mode 100644 index 00000000000..5bd48016ead --- /dev/null +++ b/apps/www/components/PricingCalculator/components/GrowthChart.tsx @@ -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 ( + +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ +
+ + + + + `$${v}`} + /> + `Month ${label}`} + formatter={(value: any, name: any) => [formatUsd(Number(value)), formatSeriesName(String(name))]} + /> + ( + {formatSeriesName(String(value))} + )} + /> + + + +
+
+ ) +} + +function formatSeriesName(key: string) { + switch (key) { + case 'supabase': + return 'Supabase' + default: + return key + } +} + diff --git a/apps/www/components/PricingCalculator/components/HowCalculated.tsx b/apps/www/components/PricingCalculator/components/HowCalculated.tsx new file mode 100644 index 00000000000..9a0d71dbdc3 --- /dev/null +++ b/apps/www/components/PricingCalculator/components/HowCalculated.tsx @@ -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 ( + + + + + +
    + {items.map((i) => ( +
  • {i}
  • + ))} +
+
+
+ ) +} + diff --git a/apps/www/components/PricingCalculator/components/ReportView.tsx b/apps/www/components/PricingCalculator/components/ReportView.tsx new file mode 100644 index 00000000000..68c7d3e4f99 --- /dev/null +++ b/apps/www/components/PricingCalculator/components/ReportView.tsx @@ -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 + +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 ( +
+ {/* Executive Summary */} + +

Executive summary

+
+
+

Recommended tier

+

{recommended.toUpperCase()}

+

+ Estimated monthly cost: {formatUsd(estimate.totalMonthlyUsd)} +

+

+ This estimate is based on Supabase pricing and the inputs you provided. +

+
+
+

Estimated annual cost

+

{formatUsd(annualSupabase)}

+

+ Subscription, compute, and any overages (based on your inputs). +

+
+
+

+ Notes: {pricingReport.recommended.reasons[0] ?? ''} +

+
+ + {/* Projected Costs over Time */} + +

Projected costs over time

+ + +
+ + {/* Pricing Details */} + +

Pricing details

+
+ {estimate.lineItems.map((li) => ( +
+
+ {li.key.replaceAll('_', ' ')} + {li.details?.length ? ( +
    + {li.details.map((d) => ( +
  • {d}
  • + ))} +
+ ) : null} +
+ + {formatUsd(li.monthlyUsd)} + +
+ ))} +
+ Total + + {formatUsd(estimate.totalMonthlyUsd)}/mo + +
+
+ +
+ + {/* Value Analysis */} + +

Value analysis

+
+
+

Time reallocation estimate

+

{formatNumber(roiSummary.hoursRecoveredPerMonth)} hrs/mo

+

+ That's {formatNumber(roiSummary.hoursRecoveredPerYear)} hours/year, valued at{' '} + {formatUsd(roiSummary.valueRecoveredPerYearUsd)} using your hourly rate. +

+
+
+

How we calculate this

+
+

+ Step 1: 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: +

+
    +
  • Authentication: {formatNumber(roiSummary.breakdown.auth.hoursBefore)} hours/month
  • +
  • Database management: {formatNumber(roiSummary.breakdown.database.hoursBefore)} hours/month
  • +
  • API development: {formatNumber(roiSummary.breakdown.api.hoursBefore)} hours/month
  • +
  • DevOps and infrastructure: {formatNumber(roiSummary.breakdown.devops.hoursBefore)} hours/month
  • +
+

+ Step 2: We estimate how much time Supabase saves you in each area. Based on typical customer experiences, we apply these reduction percentages: +

+
    +
  • Authentication: {Math.round(roiSummary.breakdown.auth.reductionPct * 100)}% reduction = {formatNumber(roiSummary.breakdown.auth.hoursRecovered)} hours/month recovered
  • +
  • Database management: {Math.round(roiSummary.breakdown.database.reductionPct * 100)}% reduction = {formatNumber(roiSummary.breakdown.database.hoursRecovered)} hours/month recovered
  • +
  • API development: {Math.round(roiSummary.breakdown.api.reductionPct * 100)}% reduction = {formatNumber(roiSummary.breakdown.api.hoursRecovered)} hours/month recovered
  • +
  • DevOps and infrastructure: {Math.round(roiSummary.breakdown.devops.reductionPct * 100)}% reduction = {formatNumber(roiSummary.breakdown.devops.hoursRecovered)} hours/month recovered
  • +
+

+ Step 3: 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. +

+
+
+
+
+ + {/* Competitive Comparison */} + +
+

Competitive comparison

+

+ Pricing as of {new Date(authComparison.snapshot.as_of).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} +

+
+
+
+
Provider
+
Monthly
+
Annual
+
Notes
+
+ + {authComparison.firebaseMonthlyUsd != null ? ( + + ) : ( + + )} + {authComparison.auth0MonthlyUsd != null ? ( + + ) : ( + + )} + {authComparison.clerkMonthlyUsd != null ? ( + + ) : ( + + )} + + +
+
+

+ Note: 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. +

+
+ View detailed assumptions +
    + {authComparison.snapshot.providers.firebase.notes && ( +
  • + Firebase: {authComparison.snapshot.providers.firebase.notes} +
  • + )} + {authComparison.snapshot.providers.clerk.notes && ( +
  • + Clerk: {authComparison.snapshot.providers.clerk.notes} +
  • + )} + {authComparison.snapshot.providers.auth0.notes && ( +
  • + Auth0: {authComparison.snapshot.providers.auth0.notes} +
  • + )} + {competitorSnapshot.providers.convex.notes && ( +
  • + Convex: {competitorSnapshot.providers.convex.notes} +
  • + )} + {competitorSnapshot.providers.aws.notes && ( +
  • + AWS: {competitorSnapshot.providers.aws.notes} +
  • + )} +
+
+
+
+
+ ) +} + +function Row({ + provider, + monthly, + annual, + notes, +}: { + provider: string + monthly: number + annual: number + notes: string +}) { + return ( +
+
{provider.replaceAll('_', ' ')}
+
+ {formatUsd(monthly)} +
+
+ {formatUsd(annual)} +
+
{notes}
+
+ ) +} + +function RowWithSources({ + provider, + monthly, + annual, + notes, + sourceUrls, +}: { + provider: string + monthly: number + annual: number + notes: string + sourceUrls: string[] +}) { + return ( +
+
{provider}
+
+ {formatUsd(monthly)} +
+
+ {formatUsd(annual)} +
+
+
+ {notes} +
+ {sourceUrls.map((url, idx) => ( + + Source{sourceUrls.length > 1 ? ` ${idx + 1}` : ''} + + ))} +
+
+
+
+ ) +} + +function RowLink({ provider, href }: { provider: string; href: string }) { + return ( +
+
{provider}
+
—
+
—
+
+ + Pricing + +
+
+ ) +} + diff --git a/apps/www/components/PricingCalculator/components/format.ts b/apps/www/components/PricingCalculator/components/format.ts new file mode 100644 index 00000000000..3efaf298196 --- /dev/null +++ b/apps/www/components/PricingCalculator/components/format.ts @@ -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) +} + diff --git a/apps/www/components/PricingCalculator/stages/Stage1ProjectBasics.tsx b/apps/www/components/PricingCalculator/stages/Stage1ProjectBasics.tsx new file mode 100644 index 00000000000..e76835b7fbb --- /dev/null +++ b/apps/www/components/PricingCalculator/stages/Stage1ProjectBasics.tsx @@ -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) => onChange({ ...inputs, ...patch }) + + return ( +
+
+ + + set({ projects: Math.max(1, Number(e.target.value || 1)) })} + /> +

Most users need 1–3 (dev/staging/prod).

+
+ + + + +

Are you starting with a fresh project or migrating?

+
+
+ +
+ + + set({ teamSize: Math.max(1, Number(e.target.value || 1)) })} + /> +

Developers working on backend/infrastructure.

+
+ + + + set({ hourlyCostUsd: Math.max(0, Number(e.target.value || 0)) })} + /> +

Default $150/hour (US). Used for time savings.

+
+
+ + + +
+ set({ needCompliance: e.target.checked })} + className={cn('h-4 w-4')} + /> + SOC2 / HIPAA / compliance requirements indicated +
+
+ + {/* Integrated ROI messaging */} + {inputs.currentInfrastructure === 'firebase' && ( + + )} + {inputs.currentInfrastructure === 'aws_self_hosted' && ( + + )} + {inputs.projects >= 3 && ( + + )} +
+ ) +} + diff --git a/apps/www/components/PricingCalculator/stages/Stage2UsageEstimation.tsx b/apps/www/components/PricingCalculator/stages/Stage2UsageEstimation.tsx new file mode 100644 index 00000000000..4b1cb47a423 --- /dev/null +++ b/apps/www/components/PricingCalculator/stages/Stage2UsageEstimation.tsx @@ -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 + freePlanEgressLimitGb: number + proEgressIncludedGb: number +}) { + const set = (patch: Partial) => onChange({ ...inputs, ...patch }) + + return ( +
+
+ +
+
+

Database size (GB)

+

Default 2 GB

+
+ set({ databaseSizeGb: clamp(Number(e.target.value || 0.5), 0.5, 10_000) })} + className="max-w-[120px]" + /> +
+ set({ databaseSizeGb: v[0] })} + /> +
+ + +
+
+

File storage (GB)

+

Default 10 GB

+
+ set({ storageSizeGb: clamp(Number(e.target.value || 0), 0, 100_000) })} + className="max-w-[120px]" + /> +
+ set({ storageSizeGb: v[0] })} + /> +
+
+ + + + +
+
+

Expected monthly egress (GB)

+

Default 50 GB

+
+ set({ egressGb: clamp(Number(e.target.value || 0), 0, 100_000) })} + className="max-w-[120px]" + /> +
+ set({ egressGb: v[0] })} + /> +
+ + {inputs.egressGb <= freePlanEgressLimitGb ? ( + + ) : inputs.egressGb > proEgressIncludedGb ? ( + + ) : null} + +
+ +
+
+

Monthly active users

+

Default 10,000

+
+ set({ mau: clamp(Math.round(Number(e.target.value || 0)), 0, 50_000_000) })} + className="max-w-[140px]" + /> +
+ set({ mau: v[0] })} + /> +
+ + +

Authentication needs

+
+ + +
+

+ SSO here refers to end-user SAML 2.0 (Auth). Phone MFA is priced per project add-on. +

+
+
+ + +
+
+ Building your own authentication is error prone and complex. It’s costly to build and costly to maintain.{' '} + + Read our blog post + {' '} + for more information. +
+
+
+ +
+ +

Realtime

+
+
+
+

Concurrent realtime connections

+
+ + set({ realtimePeakConnections: clamp(Math.round(Number(e.target.value || 0)), 0, 10_000_000) }) + } + className="max-w-[140px]" + /> +
+ set({ realtimePeakConnections: v[0] })} + /> + +
+
+

Realtime messages per month

+
+ + set({ realtimeMessages: clamp(Math.round(Number(e.target.value || 0)), 0, 10_000_000_000) }) + } + className="max-w-[160px]" + /> +
+ set({ realtimeMessages: v[0] })} + /> +
+
+ + +

Edge Functions

+
+
+

Edge function invocations per month

+
+ + set({ edgeInvocations: clamp(Math.round(Number(e.target.value || 0)), 0, 10_000_000_000) }) + } + className="max-w-[160px]" + /> +
+ set({ edgeInvocations: v[0] })} + /> +
+
+ + +
+
+

Compute tier

+

+ Default Micro. Each project requires dedicated compute. +

+
+ + + + + + {COMPUTE_TIERS.map((t) => ( + set({ computeTier: t.tier })} + className={cn(inputs.computeTier === t.tier && 'text-brand-600')} + > +
+ + + {t.tier} + + + {formatUsd(t.monthlyUsd)} + +
+
+ ))} +
+
+
+ +
+ + Compute is billed hourly. Temporary upgrades for migrations or traffic spikes cost only for the hours used. + + Compute is billed hourly. +
+ + +
+ + {/* Upsell triggers during input stages */} + {(inputs.mau >= 100_000 || inputs.needSso) && ( + + + + )} + {inputs.databaseSizeGb >= 50 && ( + + + + )} +
+ ) +} + diff --git a/apps/www/components/PricingCalculator/stages/Stage3Growth.tsx b/apps/www/components/PricingCalculator/stages/Stage3Growth.tsx new file mode 100644 index 00000000000..4ee6e316d2f --- /dev/null +++ b/apps/www/components/PricingCalculator/stages/Stage3Growth.tsx @@ -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) => onChange({ ...inputs, ...patch }) + + return ( +
+ +
+
+

Expected monthly user growth rate

+

Default 10%

+
+ + set({ userGrowthRateMonthlyPct: clamp(Number(e.target.value || 0), 0, 50) }) + } + className="max-w-[120px]" + /> +
+ set({ userGrowthRateMonthlyPct: v[0] })} + /> +
+ + +
+
+

Expected monthly data growth (GB)

+

Default 1 GB/month

+
+ set({ dataGrowthGbPerMonth: clamp(Number(e.target.value || 0), 0, 100_000) })} + className="max-w-[140px]" + /> +
+ set({ dataGrowthGbPerMonth: v[0] })} + /> +
+ + +

Projection period

+ +
+ + +
+ ) +} + diff --git a/apps/www/components/PricingCalculator/stages/Stage4TimeAllocation.tsx b/apps/www/components/PricingCalculator/stages/Stage4TimeAllocation.tsx new file mode 100644 index 00000000000..c33880b6d9b --- /dev/null +++ b/apps/www/components/PricingCalculator/stages/Stage4TimeAllocation.tsx @@ -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 = { + 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 ( +
+ {(Object.keys(FIELD_LABELS) as TimeAllocationKey[]).map((key) => ( + + + { + const raw = e.target.value + if (raw === '') return clearOverride(key) + return setOverride(key, Math.max(0, Number(raw))) + }} + /> +

Optional override. Leave blank to use smart defaults.

+
+ ))} +
+ ) +} + diff --git a/apps/www/components/PricingCalculator/stages/index.ts b/apps/www/components/PricingCalculator/stages/index.ts new file mode 100644 index 00000000000..5e7c5d1e7ab --- /dev/null +++ b/apps/www/components/PricingCalculator/stages/index.ts @@ -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' + diff --git a/apps/www/components/PricingCalculator/summary/SummaryPanel.tsx b/apps/www/components/PricingCalculator/summary/SummaryPanel.tsx new file mode 100644 index 00000000000..a4a2c65f7d6 --- /dev/null +++ b/apps/www/components/PricingCalculator/summary/SummaryPanel.tsx @@ -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 + if (status === 'overage') return + return +} + +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 ( + +
+

Live cost summary

+

+ Recommended tier: {recommended.plan.toUpperCase()} +

+ {recommended.reasons.length > 0 && ( +
    + {recommended.reasons.slice(0, 3).map((r) => ( +
  • {r}
  • + ))} +
+ )} +
+ +
+ {(['free', 'pro', 'team'] as const).map((plan) => ( +
+

{plan}

+

+ {formatUsd(estimates[plan].totalMonthlyUsd)} + /mo +

+
+ ))} +
+ +
+
+
Dimension
+
Free
+
Pro
+
Team
+
+ {DIMENSIONS.map((d) => ( +
+
{d.label}
+
+ +
+
+ +
+
+ +
+
+ ))} +
+ + {showSalesCta && ( + +

Need custom pricing?

+

+ For teams spending $500+/month, annual commitments unlock 15-25% savings. +

+ +
+ )} + +
+ + +
+
+ ) +} + diff --git a/apps/www/data/pricing-calculator-competitors.json b/apps/www/data/pricing-calculator-competitors.json new file mode 100644 index 00000000000..f0584ea7d34 --- /dev/null +++ b/apps/www/data/pricing-calculator-competitors.json @@ -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." + } + } +} diff --git a/apps/www/lib/pricing-calculator/competitors.ts b/apps/www/lib/pricing-calculator/competitors.ts new file mode 100644 index 00000000000..85bf8256463 --- /dev/null +++ b/apps/www/lib/pricing-calculator/competitors.ts @@ -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): 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): 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 +): 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 +): 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 +): 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) { + 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, + }, + }, + }, + } +} + diff --git a/apps/www/lib/pricing-calculator/growth.ts b/apps/www/lib/pricing-calculator/growth.ts new file mode 100644 index 00000000000..c4cb98eb068 --- /dev/null +++ b/apps/www/lib/pricing-calculator/growth.ts @@ -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 + ) + + 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 + ) + const savingsUsd = platforms.reduce( + (acc, key) => { + acc[key] = round2(cumulativeCompetitorsUsd[key] - cumulativeSupabase) + return acc + }, + {} as Record + ) + + return { + months: inputs.projectionMonths, + points, + cumulative: { + supabaseUsd: cumulativeSupabase, + competitorsUsd: cumulativeCompetitorsUsd, + savingsUsd, + }, + } +} + diff --git a/apps/www/lib/pricing-calculator/index.ts b/apps/www/lib/pricing-calculator/index.ts new file mode 100644 index 00000000000..78dd71c3d3f --- /dev/null +++ b/apps/www/lib/pricing-calculator/index.ts @@ -0,0 +1,7 @@ +export * from './types' +export * from './supabase' +export * from './competitors' +export * from './roi' +export * from './growth' +export * from './serialize' + diff --git a/apps/www/lib/pricing-calculator/roi.ts b/apps/www/lib/pricing-calculator/roi.ts new file mode 100644 index 00000000000..e8c49ccbd6f --- /dev/null +++ b/apps/www/lib/pricing-calculator/roi.ts @@ -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): 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, + }, + } +} + diff --git a/apps/www/lib/pricing-calculator/serialize.ts b/apps/www/lib/pricing-calculator/serialize.ts new file mode 100644 index 00000000000..d86ac928f7c --- /dev/null +++ b/apps/www/lib/pricing-calculator/serialize.ts @@ -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 } +} + diff --git a/apps/www/lib/pricing-calculator/supabase.ts b/apps/www/lib/pricing-calculator/supabase.ts new file mode 100644 index 00000000000..e6278672291 --- /dev/null +++ b/apps/www/lib/pricing-calculator/supabase.ts @@ -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 => { + // `PricingAddOnTable.json` stores compute as rows with columns; we normalize into a map + const rows = pricingAddOn.database?.rows ?? [] + const map: Record = {} + + 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) { + 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 = { + 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 + + 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, + }, + } +} + diff --git a/apps/www/lib/pricing-calculator/types.ts b/apps/www/lib/pricing-calculator/types.ts new file mode 100644 index 00000000000..6187d1106c1 --- /dev/null +++ b/apps/www/lib/pricing-calculator/types.ts @@ -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 + +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 +} + +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 + lineItems: CostLineItem[] +} + +export type PricingReport = { + inputs: CalculatorInputs + estimates: Record + recommended: { + plan: PricingCalculatorPlan + reasons: string[] + } +} + +export type RoiSummary = { + hoursRecoveredPerMonth: number + hoursRecoveredPerYear: number + valueRecoveredPerMonthUsd: number + valueRecoveredPerYearUsd: number + breakdown: Record +} + +export type CompetitorKey = 'supabase' | 'firebase' | 'auth0' | 'clerk' | 'self_hosted' | 'convex' | 'aws' + +export type ComparisonPlatform = Exclude + +export type CostComparison = { + monthlyUsd: Record + annualUsd: Record +} + +export type ProjectionPoint = { + monthIndex: number + mau: number + databaseSizeGb: number + storageSizeGb: number + egressGb: number + supabaseMonthlyUsd: number + competitorsMonthlyUsd: Record +} + +export type ProjectionSeries = { + months: ProjectionPeriodMonths + points: ProjectionPoint[] + cumulative: { + supabaseUsd: number + competitorsUsd: Record + savingsUsd: Record + } +} + diff --git a/apps/www/pages/pricing-calculator.tsx b/apps/www/pages/pricing-calculator.tsx new file mode 100644 index 00000000000..5daf93418ff --- /dev/null +++ b/apps/www/pages/pricing-calculator.tsx @@ -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 ( + + + + +
+

Know your costs before you build

+

+ Estimate your Supabase bill in 60 seconds. See how much you save compared to Firebase, + Auth0, and self-hosted alternatives. +

+
+ +

No signup required. Get a shareable report.

+
+
+
+ +
+ +
+ + +
+
+

Four stages to your estimate

+
+
+ +

Stage 1: Project basics

+

+ Number of environments, team size, and where you're coming from. +

+
+ +

Stage 2: Usage estimation

+

+ Database size, monthly users, bandwidth, and realtime needs. +

+
+ +

Stage 3: Growth projections

+

+ See how costs change over 12 or 36 months as you grow. +

+
+ +

Stage 4: Time allocation (optional)

+

+ Add detail to value analysis by estimating time spent on infrastructure today. +

+
+
+
+
+ + + + +
+ ) +} + diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 313a635b5bf..13f04c14647 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -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 diff --git a/packages/shared-data/index.ts b/packages/shared-data/index.ts index 9f334c453e8..ce0264dfd3d 100644 --- a/packages/shared-data/index.ts +++ b/packages/shared-data/index.ts @@ -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' diff --git a/packages/shared-data/pricing-calculator.ts b/packages/shared-data/pricing-calculator.ts new file mode 100644 index 00000000000..878d5b87be6 --- /dev/null +++ b/packages/shared-data/pricing-calculator.ts @@ -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 + +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'], + }, + }, +} +