// pricing.jsx — "What would this cost?" mini-calculator section.
// 3 inputs (calls/week slider, avg-length segmented, setup-help toggle) →
// 2 outputs (monthly range + optional setup fee). Pure client-side compute,
// brutalist controls that match the rest of the page kit-of-parts.
(function () {
const { PALETTE, SectionLabel, OLButton } = window;
const serif = `"Iowan Old Style", "Hoefler Text", "Georgia", "Times New Roman", serif`;
const sans = `"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif`;
const mono = `ui-monospace, SFMono-Regular, Menlo, monospace`;
// --- Pricing constants — fetched live from /api/public/landing-config ---
// SINGLE SOURCE OF TRUTH: pricing.yaml (defaults + marketing_calculator blocks).
// Refactor 2026-05-18: was previously hardcoded here; would drift from pricing.yaml.
// The fallback values below are used ONLY if the API fetch fails on first load —
// they match the YAML defaults as of 2026-05-18, but stale fallbacks are fine
// since the live values arrive within a few hundred ms of page load.
const FALLBACK_CONFIG = {
target_margin_percent: 60,
overage_rate_usd_per_min: 0.20,
setup_fee_usd: 149,
trial_days: 7,
marketing_calculator: {
per_min_rate: 0.41,
fixed_overhead: 8,
weeks_per_month: 4.33,
},
};
// React state holds the live config — populated by useEffect on mount.
// Math below reads from state, so it re-runs whenever the config arrives.
// --- Inject pricing-specific styles once ---
if (typeof document !== "undefined" && !document.getElementById("v1-pricing-styles")) {
const s = document.createElement("style");
s.id = "v1-pricing-styles";
s.textContent = `
/* Custom range slider — brutalist square thumb with oxblood offset shadow */
.v1-pricing-slider {
-webkit-appearance: none;
appearance: none;
position: relative;
width: 100%;
height: 2px;
background: ${PALETTE.line};
outline: none;
margin: 0;
padding: 0;
cursor: pointer;
}
.v1-pricing-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 22px;
height: 22px;
background: ${PALETTE.ink};
border: none;
cursor: grab;
box-shadow: 3px 3px 0 0 ${PALETTE.accent};
transition: box-shadow .2s ease, transform .2s ease;
}
.v1-pricing-slider::-webkit-slider-thumb:hover {
box-shadow: 2px 2px 0 0 ${PALETTE.accent};
transform: translate(1px, 1px);
}
.v1-pricing-slider::-webkit-slider-thumb:active { cursor: grabbing; }
.v1-pricing-slider::-moz-range-thumb {
width: 22px;
height: 22px;
background: ${PALETTE.ink};
border: none;
cursor: grab;
box-shadow: 3px 3px 0 0 ${PALETTE.accent};
}
.v1-pricing-slider-fill {
position: absolute;
left: 0;
top: 0;
height: 2px;
background: ${PALETTE.ink};
pointer-events: none;
}
/* Segmented brutalist buttons */
.v1-pricing-seg {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
.v1-pricing-seg-2 { grid-template-columns: 1fr 1fr; }
.v1-pricing-seg-btn {
font-family: ${sans};
font-size: 13.5px;
font-weight: 500;
padding: 14px 14px;
background: transparent;
color: ${PALETTE.ink};
border: none;
box-shadow: inset 0 0 0 1.5px ${PALETTE.hairline};
cursor: pointer;
transition: all .2s ease;
white-space: nowrap;
text-align: left;
}
.v1-pricing-seg-btn:hover:not(.v1-pricing-seg-btn-active) {
box-shadow: inset 0 0 0 1.5px ${PALETTE.ink};
}
.v1-pricing-seg-btn-active {
background: ${PALETTE.ink};
color: ${PALETTE.bg};
box-shadow: inset 0 0 0 1.5px ${PALETTE.ink}, 4px 4px 0 0 ${PALETTE.accent};
transform: translate(0, 0);
}
.v1-pricing-seg-btn-active:hover {
box-shadow: inset 0 0 0 1.5px ${PALETTE.ink}, 2px 2px 0 0 ${PALETTE.accent};
transform: translate(2px, 2px);
}
/* Layout — mobile stacks inputs above output */
/* Tablet breakpoint aligns to --bp-tablet-landscape (1024px) from foundation tokens */
@media (max-width: 1023px) {
.v1-pricing { padding: 100px 32px 80px !important; }
.v1-pricing h2 { font-size: 46px !important; letter-spacing: -1.2px !important; }
.v1-pricing-grid { gap: 56px !important; }
.v1-pricing-output { padding-left: 40px !important; }
}
/* Mobile breakpoint aligns to --bp-mobile (700px) from foundation tokens */
@media (max-width: 700px) {
.v1-pricing { padding: 64px 20px 64px !important; }
.v1-pricing h2 { font-size: 36px !important; letter-spacing: -0.8px !important; }
.v1-pricing > p { font-size: 16px !important; margin-bottom: 44px !important; }
.v1-pricing-grid { grid-template-columns: 1fr !important; gap: 48px !important; }
.v1-pricing-output { padding: 32px 0 0 !important; border-left: none !important; border-top: 1px solid ${PALETTE.line} !important; min-height: 0 !important; }
.v1-pricing-output-num { font-size: 56px !important; letter-spacing: -1.4px !important; }
.v1-pricing-seg-btn { font-size: 12.5px !important; padding: 12px 10px !important; }
.v1-pricing-seg { gap: 6px !important; }
}
`;
document.head.appendChild(s);
}
// --- Math (cfg = live config from /api/public/landing-config) ---
function compute({ callsPerWeek, avgMin, setupHelp, cfg }) {
const mc = cfg.marketing_calculator;
const targetMargin = cfg.target_margin_percent / 100;
const monthlyCalls = callsPerWeek * mc.weeks_per_month;
const monthlyMinutes = monthlyCalls * avgMin;
const monthlyCost = monthlyMinutes * mc.per_min_rate + mc.fixed_overhead;
const suggestedPrice = monthlyCost / (1 - targetMargin);
let low = Math.floor((suggestedPrice * 0.85) / 5) * 5;
let high = Math.ceil((suggestedPrice * 1.15) / 5) * 5;
low = Math.max(0, low);
if (high <= low) high = low + 5;
return { low, high, setup: setupHelp ? cfg.setup_fee_usd : 0 };
}
function PricingSection({ buttonVariant = "brutal" }) {
const [callsPerWeek, setCallsPerWeek] = React.useState(10);
const [avgMin, setAvgMin] = React.useState(2);
const [setupHelp, setSetupHelp] = React.useState(true);
// Live config from API; falls back to hardcoded if fetch fails on first load.
const [cfg, setCfg] = React.useState(FALLBACK_CONFIG);
React.useEffect(() => {
fetch("/api/public/landing-config")
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setCfg(data); })
.catch(() => { /* keep fallback */ });
}, []);
const { low, high, setup } = compute({ callsPerWeek, avgMin, setupHelp, cfg });
const fillPct = (callsPerWeek / 100) * 100;
// Build mailto with the user's current estimate baked into subject + body
const lengthLabel =
avgMin === 0.75 ? "Short (under 1 min)" : avgMin === 2 ? "Normal (1–3 min)" : "Long (3+ min)";
const mailtoUrl =
"mailto:lucas@getopenlines.com?subject=" +
encodeURIComponent(`OpenLines pricing question — $${low}–$${high}/mo estimate`) +
"&body=" +
encodeURIComponent(
`Hi Lucas,\n\n` +
`I'm interested in OpenLines. Based on the calculator on your site:\n\n` +
`· About ${callsPerWeek} missed calls per week\n` +
`· Avg call length: ${lengthLabel}\n` +
`· Setup help: ${setupHelp ? "Yes — please handle the setup" : "No — I'll handle the call forwarding myself"}\n` +
`· Estimated range: $${low}–$${high}/month${setup ? ` + $${setup} one-time setup` : ""}\n\n` +
`I'd like to schedule a 15-min call to get an exact quote.\n\n` +
`Thanks!`
);
return (
Plug in a rough estimate of your missed-call volume to see a ballpark monthly price. The real number comes from a 15-minute call.
Ballpark only. Your actual price depends on your call patterns and how custom we make the agent. We'll confirm the real number in a free 15-minute call — no surprises.
What would this cost?
{hint}
) : null}