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.
);
}
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.
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.
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.
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.