import { useEffect, useMemo, useRef, useState } from "react"; import type { CredentialResponse } from "@react-oauth/google"; import { Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { useAuth } from "../../hooks/useAuth"; import { googleLogin } from "../../api/client"; import GoogleAuthButton from "../public/GoogleAuthButton"; import { BASE_REVENUE_MULTIPLE, COUNTRIES, DEFAULT_INPUTS, MARKET_PRICE_TO_SALES, MAX_REVENUE_MULTIPLE, MIN_REVENUE_MULTIPLE, NICHES, US_GDP_PER_CAPITA, buildBridge, buildDistribution, computeValuation, nicheFor, normaliseAudience, type AudienceSlice, type FactorWeights, type ValuationInputs, } from "../../content/substackValuationModel"; // Validated against the white page surface with scripts/validate_palette.js — // all checks pass on the all-pairs list. Do not substitute by eye. const INK = "#0b0b0b"; const MUTED = "#898781"; const GRID = "#e1e0d9"; const AXIS = "#c3c2b7"; const SERIES_BASE = "#7c3aed"; const SERIES_UP = "#0d9488"; const SERIES_DOWN = "#d97706"; // ─── Formatting ────────────────────────────────────────────────────────────── function usd(value: number) { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0, }).format(value); } function usdCompact(value: number) { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: 1, }).format(value); } function num(value: number) { return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value); } function multiple(value: number) { return `${value.toFixed(2)}x`; } function signedPercent(value: number) { const pct = (value - 1) * 100; const sign = pct >= 0 ? "+" : ""; return `${sign}${pct.toFixed(0)}%`; } // ─── Small UI primitives ───────────────────────────────────────────────────── function fieldClass() { return "w-full rounded-xl border border-gray-200 bg-white px-3.5 py-2.5 text-sm text-gray-900 shadow-sm transition focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200"; } function NumberField({ label, hint, value, onChange, min = 0, max, step = 1, prefix, suffix, }: { label: string; hint?: string; value: number; onChange: (next: number) => void; min?: number; max?: number; step?: number; prefix?: string; suffix?: string; }) { return ( ); } function WeightSlider({ label, description, value, onChange, }: { label: string; description: string; value: number; onChange: (next: number) => void; }) { const tone = value === 0 ? "Ignored" : value < 0.9 ? "Muted" : value > 1.1 ? "Amplified" : "Normal"; return (
{label} {value.toFixed(1)}x · {tone}

{description}

onChange(Number(event.target.value))} className="mt-3 w-full accent-purple-600" aria-label={`${label} weight`} />
Off Normal Double
); } function StatCard({ label, value, helper, emphasis = false, }: { label: string; value: string; helper?: string; emphasis?: boolean; }) { return (

{label}

{value}

{helper ?

{helper}

: null}
); } // ─── Charts ────────────────────────────────────────────────────────────────── type CurveDatum = { valuation: number; density: number; bandDensity: number | null; }; function DistributionTooltip({ active, payload, }: { active?: boolean; payload?: { payload: CurveDatum }[]; }) { if (!active || !payload?.length) return null; const point = payload[0].payload; return (

{usd(point.valuation)}

Relative likelihood of this outcome

); } function ValuationDistributionChart({ median, sigma, }: { median: number; sigma: number; }) { const { curve, p10, p90 } = useMemo( () => buildDistribution(median, sigma), [median, sigma] ); if (!curve.length) return null; return (

Where your valuation is likely to land

A valuation is a range, not a number. The shaded band is the middle 80% of plausible outcomes — the tails are what a hard negotiation looks like in either direction.

usdCompact(value)} tick={{ fill: MUTED, fontSize: 11 }} tickLine={false} axisLine={{ stroke: AXIS }} tickMargin={8} /> } cursor={{ stroke: AXIS, strokeWidth: 1 }} />
); } type BridgeDatum = { label: string; base: number; delta: number; running: number; direction: "start" | "up" | "down" | "total"; }; function BridgeTooltip({ active, payload, }: { active?: boolean; payload?: { payload: BridgeDatum }[]; }) { if (!active || !payload?.length) return null; const point = payload[0].payload; const verb = point.direction === "up" ? "adds" : point.direction === "down" ? "removes" : "sets"; return (

{point.label}

{point.direction === "start" || point.direction === "total" ? `${verb} ${usd(point.running)}` : `${verb} ${usd(point.delta)}`}

Running total {usd(point.running)}

); } function barColor(direction: BridgeDatum["direction"]) { if (direction === "up") return SERIES_UP; if (direction === "down") return SERIES_DOWN; return SERIES_BASE; } function ValuationBridgeChart({ steps }: { steps: BridgeDatum[] }) { return (

How each factor moves the number

Starting from your ARR at the base {BASE_REVENUE_MULTIPLE.toFixed(1)}x multiple, each adjustment either adds to or takes from the valuation.

Anchor Adds value Removes value
usdCompact(value)} tick={{ fill: MUTED, fontSize: 11 }} tickLine={false} axisLine={false} width={62} /> } cursor={{ fill: "rgba(124,58,237,0.06)" }} /> {/* Transparent riser floats each bar to its running height. */} {steps.map((step) => ( ))}
); } // ─── Soft auth gate ────────────────────────────────────────────────────────── // The whole input panel stays open to anonymous visitors — they can model the // business freely. Sign-in is only demanded at the moment they ask for the // answer, which is also the moment the tool has proved it is worth an account. function SignInPrompt({ onCancel }: { onCancel: () => void }) { const { login } = useAuth(); const [signingIn, setSigningIn] = useState(false); const [error, setError] = useState(null); const handleSuccess = async (response: CredentialResponse) => { if (!response.credential) return; setSigningIn(true); setError(null); try { const res = await googleLogin( response.credential, false, localStorage.getItem("b2v_ref_code") ); localStorage.removeItem("b2v_ref_code"); login(res.data.access_token, res.data.user); // login() flips `user`; the parent watches for that and runs the analysis. } catch { setError("Sign-in failed. Please try again."); setSigningIn(false); } }; return (

Your valuation is ready

Sign in to see the full breakdown — headline valuation, the P10–P90 range, the factor bridge, and every assumption behind the number. Free account, no credit card.

{signingIn ? (
Signing you in…
) : ( setError("Sign-in failed. Please try again.")} text="continue_with" width="320" /> )}
{error ?

{error}

: null}
); } // ─── Audience mix editor ───────────────────────────────────────────────────── function AudienceEditor({ audience, onChange, }: { audience: AudienceSlice[]; onChange: (next: AudienceSlice[]) => void; }) { const total = audience.reduce((sum, slice) => sum + Math.max(slice.share, 0), 0); const unused = COUNTRIES.filter( (country) => !audience.some((slice) => slice.code === country.code) ); const update = (index: number, patch: Partial) => { onChange(audience.map((slice, i) => (i === index ? { ...slice, ...patch } : slice))); }; return (
{audience.map((slice, index) => { const country = COUNTRIES.find((entry) => entry.code === slice.code); return (
update(index, { share: Math.max(Number(event.target.value) || 0, 0) }) } aria-label={`${country?.name ?? "Country"} share of paid list`} /> %
); })}
{unused.length ? ( ) : ( )} {total.toFixed(0)}% entered {Math.abs(total - 100) < 0.5 ? "" : " — shares are normalised to 100%"}
); } // ─── Main widget ───────────────────────────────────────────────────────────── export default function SubstackValuationTool() { const { user } = useAuth(); const [inputs, setInputs] = useState(DEFAULT_INPUTS); const [analysed, setAnalysed] = useState(false); const [awaitingAuth, setAwaitingAuth] = useState(false); const resultsRef = useRef(null); const result = useMemo(() => computeValuation(inputs), [inputs]); const bridge = useMemo(() => buildBridge(result), [result]); const niche = nicheFor(inputs.nicheId); const normalisedAudience = useMemo( () => normaliseAudience(inputs.audience), [inputs.audience] ); const set = (key: K, value: ValuationInputs[K]) => setInputs((prev) => ({ ...prev, [key]: value })); const setWeight = (key: keyof FactorWeights, value: number) => setInputs((prev) => ({ ...prev, weights: { ...prev.weights, [key]: value } })); const handleAnalyse = () => { if (!user) { setAwaitingAuth(true); return; } setAnalysed(true); }; // Once the visitor signs in from the prompt, run the analysis they asked for // rather than making them click Analyze a second time. useEffect(() => { if (user && awaitingAuth) { setAwaitingAuth(false); setAnalysed(true); } }, [user, awaitingAuth]); useEffect(() => { if (analysed) resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); }, [analysed]); return (
{/* ── Inputs (always open, no sign-in required) ── */}

Size & revenue

set("paidSubscribers", value)} /> set("freeSubscribers", value)} /> set("monthlyPrice", value)} /> set("sponsorshipRevenuePerMonth", value)} /> set("annualShare", value)} /> set("annualDiscount", value)} />

Retention & category

set("monthlyChurn", value)} /> set("monthlyGrowthRate", value)} />

Priced off {niche.proxySector} at{" "} {niche.priceToSales.toFixed(1)}x sales, against the market at {MARKET_PRICE_TO_SALES.toFixed(1)}x.

Audience geography

Weighted GDP per capita{" "} {usd(result.weightedGdpPerCapita)} {" "} vs US {usd(US_GDP_PER_CAPITA)}

Where your paid readers live changes what each one is worth to a buyer. Enter the split of your paid list — shares are normalised, so rough numbers are fine.

set("audience", next)} />

Factor weights

Disagree with one of the adjustments? Turn it down or off. Every factor is a multiplier on the base {BASE_REVENUE_MULTIPLE.toFixed(1)}x, and these sliders control how hard each one is allowed to push.

setWeight("geography", value)} /> setWeight("niche", value)} /> setWeight("retention", value)} /> setWeight("scale", value)} /> setWeight("growth", value)} />
{/* ── Analyze ── */} {!analysed ? (

Free. Takes your inputs only — nothing is published anywhere.

) : null} {awaitingAuth ? setAwaitingAuth(false)} /> : null} {/* ── Results ── */} {analysed ? (

Estimated valuation

{usd(result.valuation)}

Plausible range {usd(result.low)} – {usd(result.high)} · {multiple(result.finalMultiple)}{" "} on {usd(result.arr)} ARR

{result.multipleClamped ? (

Your inputs compounded to {multiple(result.unboundedMultiple)}, which is outside the{" "} {MIN_REVENUE_MULTIPLE.toFixed(1)}x–{MAX_REVENUE_MULTIPLE.toFixed(1)}x band that real subscription-media deals settle in. The multiple has been capped at{" "} {multiple(result.finalMultiple)}.

) : null}

Annual revenue

{usd(result.arr)}

Final multiple

{multiple(result.finalMultiple)}

Revenue per paid subscriber

{usd(result.revenuePerSubscriber)}

0 ? `${((result.sponsorshipArr / result.arr) * 100).toFixed(0)}% of revenue — buyers discount this more than subscriptions.` : "No sponsorship revenue entered." } />
{/* Factor table — the numbers behind both charts. */}

Factor detail

What the raw data said, and what actually reached the multiple after dampening and your weights.

{result.factors.map((factor) => ( ))}
Factor Raw signal Weight Applied
= 1 ? SERIES_UP : SERIES_DOWN, }} /> {factor.label} {factor.detail} {factor.rawRatio.toFixed(2)}x {inputs.weights[factor.key].toFixed(1)}x {factor.applied.toFixed(2)}x {signedPercent(factor.applied)}
{/* Audience breakdown so the geography factor is auditable. */}

Audience mix used

{normalisedAudience.map((slice) => { const country = COUNTRIES.find((entry) => entry.code === slice.code); return ( {country?.name ?? slice.code} {slice.share.toFixed(0)}% {usdCompact(country?.gdpPerCapita ?? 0)} GDP/capita ); })}

This is a planning estimate, not an appraisal.{" "} Real newsletter sales turn on things no model sees: whether the audience follows the writer or the publication, how transferable the voice is, sponsor relationships, and how many buyers are actually at the table. Use this to set a starting range and to see which inputs matter most — then get a real valuation before you sign anything.

) : null}
); }