Pricing page redesign: usage estimator + flat layout (#368)

This commit is contained in:
Ben Senescu 2026-07-19 00:51:51 -04:00 committed by GitHub
parent 072fbecae3
commit cd550729a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 684 additions and 149 deletions

View File

@ -46,6 +46,7 @@
"test:e2e:keywords": "playwright test e2e/keyword-research-navigation.spec.ts", "test:e2e:keywords": "playwright test e2e/keyword-research-navigation.spec.ts",
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts", "billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
"billing:brand-lookup": "tsx scripts/brand-lookup-cost-profile.ts", "billing:brand-lookup": "tsx scripts/brand-lookup-cost-profile.ts",
"billing:usage": "tsx scripts/dataforseo-account-usage.ts",
"cleanup:default-projects:d1": "tsx scripts/d1-default-project-cleanup.ts", "cleanup:default-projects:d1": "tsx scripts/d1-default-project-cleanup.ts",
"seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts", "seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts",
"ci:check": "prettier --check . && knip && tsc --noEmit && tsc --noEmit -p badseo/tsconfig.json && oxlint . --type-aware" "ci:check": "prettier --check . && knip && tsc --noEmit && tsc --noEmit -p badseo/tsconfig.json && oxlint . --type-aware"

View File

@ -0,0 +1,116 @@
import process from "node:process";
import type { AppendixStatisticsRatesDataInfo } from "dataforseo-client";
import { fetchUserData } from "@/server/lib/dataforseo/appendix";
import { loadLocalEnv, parseArgs } from "./cli-utils";
loadLocalEnv();
const args = parseArgs(process.argv.slice(2));
await main();
/**
* Inspect real DataForSEO account spend via the FREE GET
* /v3/appendix/user_data endpoint. Unlike the other billing:* scripts, this
* makes NO billable calls it just reads the numbers DataForSEO already
* tracks: lifetime deposit, remaining balance, and per-function spend for the
* rolling day / minute window.
*
* Usage: pnpm billing:usage [--json=true]
*/
async function main() {
if (!process.env.DATAFORSEO_API_KEY) {
printUsageAndExit("Missing DATAFORSEO_API_KEY.");
}
const account = await fetchUserData();
if (!account) {
console.error(
"DataForSEO returned no account data (empty result). Check the API key.",
);
process.exit(1);
}
if (args.json === "true") {
console.log(JSON.stringify(account, null, 2));
return;
}
const money = account.money;
console.log("DataForSEO account usage");
console.log("========================");
console.log(`Login: ${account.login ?? "(unknown)"}`);
if (account.timezone) console.log(`Timezone: ${account.timezone}`);
console.log(`Deposited total: ${formatUsd(money?.total)}`);
console.log(`Balance left: ${formatUsd(money?.balance)}`);
printFunctionTable("Spend by function — rolling DAY", money?.statistics?.day);
printFunctionTable(
"Spend by function — rolling MINUTE",
money?.statistics?.minute,
);
}
// DataForSEO groups spend under `total_<function>` keys on each statistics
// window. Field names mirror the SDK's AppendixStatisticsRatesDataInfo.
const FUNCTION_TOTALS: ReadonlyArray<{ label: string; key: string }> = [
{ label: "serp", key: "total_serp" },
{ label: "keywords_data", key: "total_keywords_data" },
{ label: "dataforseo_labs", key: "total_dataforseo_labs" },
{ label: "backlinks", key: "total_backlinks" },
{ label: "on_page", key: "total_on_page" },
{ label: "business_data", key: "total_business_data" },
{ label: "domain_analytics", key: "total_domain_analytics" },
{ label: "merchant", key: "total_merchant" },
{ label: "app_data", key: "total_app_data" },
{ label: "content_analysis", key: "total_content_analysis" },
{ label: "content_generation", key: "total_content_generation" },
{ label: "appendix", key: "total_appendix" },
];
function printFunctionTable(
heading: string,
stats: AppendixStatisticsRatesDataInfo | undefined,
) {
console.log("");
console.log(heading);
console.log("-".repeat(heading.length));
if (!stats) {
console.log("(no data)");
return;
}
if (stats.value) console.log(`Window: ${stats.value}`);
const rows = FUNCTION_TOTALS.map(({ label, key }) => ({
function: label,
spend: readNumber(stats[key]),
})).filter((row) => row.spend > 0);
if (rows.length === 0) {
console.log("(no spend recorded in this window)");
} else {
for (const row of rows) {
console.log(` ${row.function.padEnd(20)} ${formatUsd(row.spend)}`);
}
}
// Prefer the API's own grand total; fall back to the summed rows.
const total =
readNumber(stats.total) || rows.reduce((sum, row) => sum + row.spend, 0);
console.log(` ${"TOTAL".padEnd(20)} ${formatUsd(total)}`);
}
function readNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function formatUsd(value: number | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "$0.00";
return `$${value.toFixed(2)}`;
}
function printUsageAndExit(message: string): never {
console.error(message);
console.error("Usage: pnpm billing:usage [--json=true]");
process.exit(1);
}

View File

@ -8,6 +8,7 @@ import {
ONBOARDING_LAST_STEP, ONBOARDING_LAST_STEP,
type OnboardingAnswers, type OnboardingAnswers,
SOURCE_OPTIONS, SOURCE_OPTIONS,
SOURCE_OPTIONS_HIDDEN_ON_MOBILE,
WORK_FOR_OPTIONS, WORK_FOR_OPTIONS,
} from "@/client/features/onboarding/onboardingModel"; } from "@/client/features/onboarding/onboardingModel";
import { SearchConsoleOnboardingStep } from "@/client/features/onboarding/SearchConsoleOnboardingStep"; import { SearchConsoleOnboardingStep } from "@/client/features/onboarding/SearchConsoleOnboardingStep";
@ -121,6 +122,7 @@ export function PostSignupOnboarding({
onToggle={(source) => updateAnswers({ source })} onToggle={(source) => updateAnswers({ source })}
otherValue={answers.sourceOther} otherValue={answers.sourceOther}
onOtherChange={(sourceOther) => updateAnswers({ sourceOther })} onOtherChange={(sourceOther) => updateAnswers({ sourceOther })}
hiddenOnMobile={[...SOURCE_OPTIONS_HIDDEN_ON_MOBILE]}
/> />
) : ( ) : (
<SearchConsoleOnboardingStep /> <SearchConsoleOnboardingStep />
@ -183,6 +185,7 @@ function OnboardingChoiceGroup({
multiple = false, multiple = false,
maxSelections, maxSelections,
followUp, followUp,
hiddenOnMobile,
}: { }: {
title: string; title: string;
description?: string; description?: string;
@ -193,6 +196,7 @@ function OnboardingChoiceGroup({
onOtherChange: (value: string) => void; onOtherChange: (value: string) => void;
multiple?: boolean; multiple?: boolean;
maxSelections?: number; maxSelections?: number;
hiddenOnMobile?: string[];
followUp?: { followUp?: {
showForValue: string; showForValue: string;
label: string; label: string;
@ -222,12 +226,14 @@ function OnboardingChoiceGroup({
const disabled = atLimit && !selected; const disabled = atLimit && !selected;
const showFollowUpHere = const showFollowUpHere =
showFollowUp && followUp?.showForValue === option; showFollowUp && followUp?.showForValue === option;
// Selected options stay visible so a restored answer never vanishes.
const mobileHidden = hiddenOnMobile?.includes(option) && !selected;
return ( return (
<Fragment key={option}> <Fragment key={option}>
<button <button
type="button" type="button"
className={`flex min-h-11 items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-colors ${ className={`${mobileHidden ? "hidden sm:flex" : "flex"} min-h-11 items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
selected selected
? "border-base-content bg-base-200 text-base-content" ? "border-base-content bg-base-200 text-base-content"
: disabled : disabled

View File

@ -44,7 +44,7 @@ export function SearchConsoleOnboardingStep() {
{project ? <GscConnect projectId={project.id} /> : <Checking />} {project ? <GscConnect projectId={project.id} /> : <Checking />}
<p className="text-xs leading-relaxed text-base-content/55"> <p className="hidden sm:block text-xs leading-relaxed text-base-content/55">
For now, Search Console data flows through the OpenSEO MCP. We're For now, Search Console data flows through the OpenSEO MCP. We're
building it into the OpenSEO app soon too. building it into the OpenSEO app soon too.
</p> </p>
@ -89,8 +89,12 @@ function DefaultMarketPicker({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<ProjectMarketFields value={market} onChange={handleChange} /> <ProjectMarketFields
<p className="text-xs leading-relaxed text-base-content/55"> value={market}
onChange={handleChange}
hideLanguageOnMobile
/>
<p className="hidden sm:block text-xs leading-relaxed text-base-content/55">
We'll use this country and language for keyword, SERP, and domain data We'll use this country and language for keyword, SERP, and domain data
unless you pick a different one. You can change it in project settings. unless you pick a different one. You can change it in project settings.
</p> </p>

View File

@ -37,12 +37,19 @@ export const SOURCE_OPTIONS = [
"Reddit", "Reddit",
"X / Twitter", "X / Twitter",
"GitHub", "GitHub",
"ChatGPT", "AI (Claude, ChatGPT, etc)",
"Claude",
"Friend or colleague", "Friend or colleague",
"Other", "Other",
] as const; ] as const;
// Keep the mobile list short: these still count as known options, they just
// aren't shown on small screens.
export const SOURCE_OPTIONS_HIDDEN_ON_MOBILE = [
"Reddit",
"X / Twitter",
"AI (Claude, ChatGPT, etc)",
] as const;
/** In-progress form state. Step is tracked separately in the URL. */ /** In-progress form state. Step is tracked separately in the URL. */
export type OnboardingAnswers = { export type OnboardingAnswers = {
selectedInterests: string[]; selectedInterests: string[];

View File

@ -14,9 +14,11 @@ import type { ProjectMarket } from "@/client/features/projects/types";
export function ProjectMarketFields({ export function ProjectMarketFields({
value, value,
onChange, onChange,
hideLanguageOnMobile = false,
}: { }: {
value: ProjectMarket; value: ProjectMarket;
onChange: (market: ProjectMarket) => void; onChange: (market: ProjectMarket) => void;
hideLanguageOnMobile?: boolean;
}) { }) {
const languageOptions = getLanguageOptions(value.locationCode); const languageOptions = getLanguageOptions(value.locationCode);
@ -34,7 +36,9 @@ export function ProjectMarketFields({
} }
/> />
</label> </label>
<label className="flex flex-col gap-1.5 text-sm"> <label
className={`${hideLanguageOnMobile ? "hidden sm:flex" : "flex"} flex-col gap-1.5 text-sm`}
>
<span className="font-medium">Language</span> <span className="font-medium">Language</span>
<select <select
value={value.languageCode} value={value.languageCode}

View File

@ -22,7 +22,7 @@ const SUPPORT_EMAIL = "ben@openseo.so";
const PLAN_FEATURES = [ const PLAN_FEATURES = [
"Keyword research, backlinks, rank tracking, and site audits", "Keyword research, backlinks, rank tracking, and site audits",
"MCP server and agent skills for Claude, Cursor, and ChatGPT", "MCP server and agent skills for Claude, Cursor, and ChatGPT",
"Search Console integration that never uses credits", "Google Search Console Integration",
"Includes $10.00 of Usage Credits each month", "Includes $10.00 of Usage Credits each month",
]; ];
@ -251,6 +251,21 @@ function SubscribePage() {
{item} {item}
</li> </li>
))} ))}
{/* Sub-bullet of the Usage Credits line above. */}
<li className="-mt-1 pl-6 text-xs">
<a
className="text-base-content/60 underline decoration-base-content/40 decoration-dotted underline-offset-4 transition-colors hover:text-base-content"
href="https://openseo.so/pricing"
target="_blank"
rel="noreferrer"
onClick={() =>
captureClientEvent("billing:pricing_estimator_click")
}
>
How far do usage credits go?{" "}
<span aria-hidden="true">&#8599;</span>
</a>
</li>
</ul> </ul>
{error ? <p className="text-sm text-error">{error}</p> : null} {error ? <p className="text-sm text-error">{error}</p> : null}
@ -278,11 +293,7 @@ function SubscribePage() {
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<p className="text-sm text-base-content/60"> <p className="text-sm text-base-content/60">
Questions?{" "} Questions? Email {SUPPORT_EMAIL}.
<a className="link" href={`mailto:${SUPPORT_EMAIL}`}>
Email {SUPPORT_EMAIL}
</a>
.
</p> </p>
{isUpgradeFlow ? ( {isUpgradeFlow ? (
<button <button

View File

@ -0,0 +1,30 @@
import type { AppendixUserDataResultInfo } from "dataforseo-client";
import { appendixApi } from "@/server/lib/dataforseo/core";
import { assertOk } from "@/server/lib/dataforseo/envelope";
/**
* Reads account spend + balance from DataForSEO's free GET
* /v3/appendix/user_data. This is the standard, non-billable way to inspect
* cost unlike the other billing scripts, it does NOT make a live billable
* call to observe spend, so there is nothing to meter and it is deliberately
* NOT wired through meterDataforseoCall / client.ts.
*
* The result carries `money.total` (lifetime deposited), `money.balance`
* (remaining), and `money.statistics.day` / `.minute` spend grouped by
* function (serp, keywords_data, backlinks, dataforseo_labs, on_page,
* business_data, ) for the rolling day / minute window.
*
* SDK types are loose (every field optional + index signatures), so callers
* must optional-chain into `.money.statistics.day`; it can be undefined.
*/
export async function fetchUserData(): Promise<
AppendixUserDataResultInfo | undefined
> {
const response = await appendixApi().userData();
// Validates top-level + task status; the call is free so there is no billing
// envelope to build.
const task = assertOk(response);
return task.result?.[0];
}

View File

@ -1,5 +1,6 @@
import { import {
AiOptimizationApi, AiOptimizationApi,
AppendixApi,
BacklinksApi, BacklinksApi,
BusinessDataApi, BusinessDataApi,
DataforseoLabsApi, DataforseoLabsApi,
@ -126,6 +127,9 @@ export const keywordsDataApi = () => new KeywordsDataApi(API_BASE, http());
export const serpApi = () => new SerpApi(API_BASE, http()); export const serpApi = () => new SerpApi(API_BASE, http());
export const businessDataApi = () => new BusinessDataApi(API_BASE, http()); export const businessDataApi = () => new BusinessDataApi(API_BASE, http());
export const onPageApi = () => new OnPageApi(API_BASE, http()); export const onPageApi = () => new OnPageApi(API_BASE, http());
// Account/appendix data (spend, balance, rates). userData() is FREE ($0) and
// read-only — do NOT wire it through metering.
export const appendixApi = () => new AppendixApi(API_BASE, http());
export const backlinksApi = (classify?: DataforseoErrorClassifier) => export const backlinksApi = (classify?: DataforseoErrorClassifier) =>
new BacklinksApi(API_BASE, http(classify)); new BacklinksApi(API_BASE, http(classify));
export const aiOptimizationApi = (classify?: DataforseoErrorClassifier) => export const aiOptimizationApi = (classify?: DataforseoErrorClassifier) =>

View File

@ -360,11 +360,29 @@
gap: 12px; gap: 12px;
margin-top: 32px; margin-top: 32px;
} }
.itc-hero-cta-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.itc-hero-cta-note {
margin: 0;
color: var(--ink-subtle);
font-size: 13px;
line-height: 1.4;
}
@media (max-width: 479px) { @media (max-width: 479px) {
.itc-hero-ctas { .itc-hero-ctas {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.itc-hero-cta-group {
align-items: stretch;
}
.itc-hero-cta-note {
text-align: center;
}
} }
/* ── Testimonial band (colors driven by --q-* palette vars) ── */ /* ── Testimonial band (colors driven by --q-* palette vars) ── */

View File

@ -216,7 +216,10 @@ function Hero() {
</span> </span>
</p> </p>
<div className="itc-hero-ctas"> <div className="itc-hero-ctas">
<div className="itc-hero-cta-group">
<ArrowCta size="lg" /> <ArrowCta size="lg" />
<p className="itc-hero-cta-note">No credit card required</p>
</div>
</div> </div>
</Container> </Container>
</section> </section>

View File

@ -1,58 +1,226 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { MoneyBackGuarantee } from "@/components/money-back-guarantee"; import { useMemo, useState } from "react";
import { buildPageSeo } from "@/lib/seo"; import { buildPageSeo } from "@/lib/seo";
export const Route = createFileRoute("/_marketing/pricing")({ export const Route = createFileRoute("/_marketing/pricing")({
head: () => head: () =>
buildPageSeo({ buildPageSeo({
title: "Pricing", title: "Pricing estimator",
description: description:
"OpenSEO is free to self-host. The managed service is $10/month with a 30-day money-back guarantee.", "Start at $10/mo. The $10 includes $10 of usage, enough for most people. Move the sliders to estimate what your month would cost.",
path: "/pricing", path: "/pricing",
titleSuffix: "OpenSEO", titleSuffix: "OpenSEO",
}), }),
component: Pricing, component: Pricing,
}); });
/* ------------------------------------------------------------------ *
* COST MODEL edit everything pricing-related here.
* Verified against the app call paths and live DataForSEO prices (Jul 2026).
* Keep the math consistent with src/shared/billing.ts:
* billedUsd = roundTo5Decimals(rawDataForSeoCostUsd * MARKUP)
* creditsCharged = ceil(billedUsd * 1000)
* 1 credit = $0.001 (1,000 credits = $1.00)
* ------------------------------------------------------------------ */
const MARKUP = 1.28; // OpenSEO's flat 28% premium over raw DataForSEO cost
const CREDIT_USD = 0.001; // $ value of a single credit
const BASE_PRICE_USD = 10; // Base Plan / month
const BASE_INCLUDED_CREDIT_USD = 10; // $10 of usage credits included, reset monthly
const WEEKS_PER_MONTH = 4.345; // 52 / 12
const DEFAULT_RANK_DEPTH = 40;
const DEFAULT_LOCAL_SERP_DEPTH = 20;
const BACKLINK_HISTORY_DAYS = 365;
const RANK_CHECK_OPTIONS = [0, 1, 7] as const;
const RANK_CHECK_LABELS: Record<number, string> = {
0: "Manual",
1: "Weekly",
7: "Daily",
};
// Raw DataForSEO per-call cost in USD (NOT including OpenSEO's markup).
const RAW_COST_USD = {
// Scheduled checks use the queued API. The app defaults to one device and
// the top 40 results: $0.0006 for page one + $0.00045 per extra page.
rankCheck: 0.0006 + (DEFAULT_RANK_DEPTH / 10 - 1) * 0.00045,
// A 150300 result Labs search is currently $0.030$0.048 raw. Use the
// midpoint so the customer estimate is a memorable $0.05 per search.
keywordLabs: 0.039,
// The MCP-only local SERP tool defaults to a live Google Maps/Local Finder
// request with 20 results: $0.002 for page one + $0.0015 for page two.
localSerp: 0.002 + (DEFAULT_LOCAL_SERP_DEPTH / 10 - 1) * 0.0015,
// A domain overview loads a summary plus one year of daily history. Current
// Backlinks API pricing is $0.024/request + $0.000036/result for each call.
backlinkProfile:
0.024 + 0.000036 + (0.024 + BACKLINK_HISTORY_DAYS * 0.000036),
aiCitationPerPlatform: 0.85, // AI-citation / brand scan, per platform (biggest driver)
} as const;
// Ahrefs Lite list price, verified 2026-07-01 (ahrefs.com/pricing).
const COMPETITORS = {
ahrefsLite: 129,
} as const;
/** creditsCharged for a single action, per the billing formula. */
function creditsForRaw(rawUsd: number): number {
const billedUsd = Math.round(rawUsd * MARKUP * 100_000) / 100_000;
return Math.ceil(billedUsd * 1000);
}
// Credits charged per unit of each action (computed once from the model above).
const CREDITS_PER_UNIT = {
keywordLabs: creditsForRaw(RAW_COST_USD.keywordLabs), // 50
localSerp: creditsForRaw(RAW_COST_USD.localSerp), // 5
backlinkProfile: creditsForRaw(RAW_COST_USD.backlinkProfile), // 79
aiCitation: creditsForRaw(RAW_COST_USD.aiCitationPerPlatform), // 1088
} as const;
/* ------------------------------------------------------------------ *
* Personas preset the estimator to the two modeled customers.
* ------------------------------------------------------------------ */
type Inputs = {
sites: number;
keywordsPerSite: number;
checksPerWeek: number;
keywordRuns: number; // keyword-research runs / month
localSerps: number; // MCP-only Google Maps / Local Finder SERPs per month
backlinks: number; // backlink profile lookups / month
aiScans: number; // AI-citation scans / month (per platform)
};
const PRESETS: Record<"business" | "freelancer", Inputs> = {
// About $8/mo of usage: one site with weekly rank tracking and regular research.
business: {
sites: 1,
keywordsPerSite: 50,
checksPerWeek: 1,
keywordRuns: 100,
localSerps: 0,
backlinks: 20,
aiScans: 0,
},
// About $25/mo of usage: an agency checking 15 client sites weekly.
freelancer: {
sites: 15,
keywordsPerSite: 20,
checksPerWeek: 1,
keywordRuns: 370,
localSerps: 200,
backlinks: 30,
aiScans: 0,
},
};
const usd = (n: number) =>
n >= 100 ? `$${Math.round(n).toLocaleString()}` : `$${n.toFixed(2)}`;
function Pricing() { function Pricing() {
const [persona, setPersona] = useState<"business" | "freelancer">("business");
const [inputs, setInputs] = useState<Inputs>(PRESETS.business);
function applyPersona(next: "business" | "freelancer") {
setPersona(next);
setInputs(PRESETS[next]);
}
function set<K extends keyof Inputs>(key: K, value: number) {
setInputs((prev) => ({ ...prev, [key]: value }));
}
const estimate = useMemo(() => {
const scheduledRunsPerMonth =
inputs.sites * inputs.checksPerWeek * WEEKS_PER_MONTH;
const rankChecksPerMonth = Math.round(
scheduledRunsPerMonth * inputs.keywordsPerSite,
);
const lines = [
{
key: "keywords",
label: "Keyword research",
detail: `${inputs.keywordRuns.toLocaleString()} searches this month`,
credits: inputs.keywordRuns * CREDITS_PER_UNIT.keywordLabs,
},
{
key: "backlinks",
label: "Backlink checks",
detail: `${inputs.backlinks.toLocaleString()} checks this month`,
credits: inputs.backlinks * CREDITS_PER_UNIT.backlinkProfile,
},
{
key: "ai",
label: "ChatGPT brand checks",
detail: `${inputs.aiScans.toLocaleString()} checks this month`,
credits: inputs.aiScans * CREDITS_PER_UNIT.aiCitation,
},
...(persona === "freelancer"
? [
{
key: "local-serp",
label: "Local SERP checks",
detail: `${inputs.localSerps.toLocaleString()} checks this month`,
credits: inputs.localSerps * CREDITS_PER_UNIT.localSerp,
},
]
: []),
{
key: "rank",
label: "Rank tracking",
detail: `${rankChecksPerMonth.toLocaleString()} checks this month`,
// The app bills each site's scheduled run as one keyword batch.
credits: Math.round(
scheduledRunsPerMonth *
creditsForRaw(inputs.keywordsPerSite * RAW_COST_USD.rankCheck),
),
},
];
const totalCredits = lines.reduce((sum, l) => sum + l.credits, 0);
const usageUsd = totalCredits * CREDIT_USD;
const includedInBase = usageUsd <= BASE_INCLUDED_CREDIT_USD;
const topUpUsd = Math.max(0, usageUsd - BASE_INCLUDED_CREDIT_USD);
const billUsd = Math.max(BASE_PRICE_USD, usageUsd);
return { lines, usageUsd, includedInBase, topUpUsd, billUsd };
}, [inputs, persona]);
return ( return (
<article className="mx-auto max-w-5xl"> <article className="mx-auto max-w-4xl">
{/* 1. Hero */}
<p className="text-sm font-medium text-[var(--color-brand-accent)]"> <p className="text-sm font-medium text-[var(--color-brand-accent)]">
Pricing Pricing
</p> </p>
<h1 className="mt-3 max-w-3xl text-4xl font-semibold leading-tight tracking-tight text-neutral-950 md:text-6xl"> <h1 className="mt-3 max-w-3xl text-4xl font-semibold leading-tight tracking-tight text-neutral-950 md:text-5xl">
Start small, scale by usage Starts at $10/month
</h1> </h1>
<p className="mt-5 max-w-2xl text-lg leading-8 text-[var(--color-brand-muted)]"> <p className="mt-4 max-w-2xl text-base leading-7 text-[var(--color-brand-muted)]">
Use the managed app for $10/month, or self-host OpenSEO with your own Other SEO tools are too expensive. OpenSEO grows with you.
API keys.
</p> </p>
{/* Managed */} {/* 2. Base Plan */}
<section className="mt-12"> <section className="mt-10 border-y border-[var(--color-border-subtle)] py-8">
<h2 className="text-2xl font-semibold tracking-tight text-neutral-950">
Managed
</h2>
<div className="mt-6 overflow-hidden rounded-xl border border-[var(--color-border-subtle)] bg-white">
{/* Base Plan */}
<div className="p-6">
<div className="flex items-baseline justify-between gap-4"> <div className="flex items-baseline justify-between gap-4">
<div>
<p className="font-semibold text-neutral-950">Base Plan</p> <p className="font-semibold text-neutral-950">Base Plan</p>
<p className="text-xl font-semibold tabular-nums text-neutral-950"> <p className="mt-1 text-sm text-[var(--color-brand-muted)]">
$10/month One plan. Everything included.
</p> </p>
</div> </div>
<ul className="mt-3 space-y-2"> <p className="text-2xl font-semibold tabular-nums text-neutral-950">
$10
<span className="text-base font-normal text-[var(--color-brand-muted)]">
/mo
</span>
</p>
</div>
<ul className="mt-4 space-y-2">
{[ {[
"Keyword research, backlinks, rank tracking, and site audits", "Keyword research, backlinks, rank tracking, and site audits",
"MCP server and agent skills for Claude, Cursor, and ChatGPT", "Works inside Claude, Cursor, and ChatGPT",
"Search Console integration that never uses credits", "Google Search Console data is free and doesn't touch your $10",
"Includes $10.00 of Usage Credits each billing cycle", "Includes $10 of usage every month",
"Buy extra usage anytime; it never expires",
].map((item) => ( ].map((item) => (
<li <li key={item} className="flex gap-2.5 text-sm text-neutral-700">
key={item}
className="flex gap-2.5 text-sm text-neutral-700"
>
<span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-brand-accent)]"> <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-brand-accent)]">
<span className="sr-only">Included:</span> <span className="sr-only">Included:</span>
</span> </span>
@ -60,116 +228,223 @@ function Pricing() {
</li> </li>
))} ))}
</ul> </ul>
</div> <div className="mt-6 flex items-center gap-4">
{/* Usage Credits — add-on, visually connected to Base Plan */}
<div className="border-t border-dashed border-[var(--color-border-subtle)] bg-[#fbfaf8] p-6">
<div className="flex items-baseline gap-3">
<p className="text-sm font-semibold text-neutral-950">
Usage Credits
</p>
<span className="text-xs font-medium text-[var(--color-brand-muted)]">
add-on
</span>
</div>
<p className="mt-2 text-sm leading-6 text-[var(--color-brand-muted)]">
Credits are consumed as you use the app. We use powerful providers
for AI and SEO data. We bill based on usage of those APIs and
charge a small premium.
</p>
<ul className="mt-3 space-y-2">
{[
"Purchase top-up credits if you use up your monthly credits",
"Top-up credits roll over and don't expire",
].map((item) => (
<li
key={item}
className="flex gap-2.5 text-sm text-neutral-700"
>
<span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-brand-accent)]">
<span className="sr-only">Included:</span>
</span>
{item}
</li>
))}
</ul>
</div>
</div>
<a <a
href="https://app.openseo.so/sign-up" href="https://app.openseo.so/sign-up"
className="mt-4 inline-flex items-center justify-center rounded-lg bg-neutral-950 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800" className="inline-flex items-center justify-center rounded-lg bg-neutral-950 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
> >
Get Started{" "} Get Started
<span aria-hidden="true" className="ml-1.5"> <span aria-hidden="true" className="ml-1.5">
&rarr; &rarr;
</span> </span>
</a> </a>
<p className="mt-3 text-xs text-neutral-500"> <p className="text-xs text-neutral-500">Try for free</p>
<MoneyBackGuarantee />.
</p>
</section>
{/* Self-hosted */}
<section className="mt-12 rounded-xl border border-[var(--color-border-subtle)] bg-white p-6">
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-2xl font-semibold tracking-tight text-neutral-950">
Self-hosted
</h2>
</div> </div>
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--color-brand-muted)]">
Deploy OpenSEO yourself via Docker or Cloudflare Workers. Bring your
own API keys and pay DataForSEO and other API providers directly.
</p>
<a
href="https://github.com/every-app/open-seo"
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-neutral-950 transition-colors hover:text-[var(--color-brand-accent)]"
>
View on GitHub
<span aria-hidden="true">&rarr;</span>
</a>
</section> </section>
{/* FAQ */} {/* 3. The estimator */}
<section className="mt-12"> <section className="mt-10">
<h2 className="text-2xl font-semibold tracking-tight text-neutral-950"> <div className="grid divide-y divide-[var(--color-border-subtle)] rounded-xl border border-[var(--color-border-subtle)] bg-white lg:grid-cols-[1.1fr_1fr] lg:divide-x lg:divide-y-0">
{/* Inputs */}
<div className="p-5 sm:p-6">
<h2 className="text-lg font-semibold tracking-tight text-neutral-950">
Estimate your month
</h2>
{/* Persona toggle */}
<div
className="mt-4 inline-flex rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-1"
role="group"
aria-label="Choose a starting point"
>
{(
[
["business", "My own business"],
["freelancer", "Freelancer / agency"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => applyPersona(value)}
aria-pressed={persona === value}
className={`rounded-md px-3.5 py-1.5 text-sm font-medium transition-colors ${
persona === value
? "border border-[var(--color-border-subtle)] bg-white text-neutral-950 shadow-sm"
: "border border-transparent text-neutral-600 hover:text-neutral-950"
}`}
>
{label}
</button>
))}
</div>
<div className="mt-5 space-y-4">
<Slider
label="Keyword searches / month"
hint="About $0.05 per search at typical result limits."
value={inputs.keywordRuns}
min={0}
max={1000}
step={10}
onChange={(v) => set("keywordRuns", v)}
/>
<Slider
label="Backlink checks / month"
hint="About $0.08 for a domain overview with one year of history."
value={inputs.backlinks}
min={0}
max={100}
onChange={(v) => set("backlinks", v)}
/>
<Slider
label="ChatGPT brand checks / month"
hint="This is the expensive one, about $1.09 each."
value={inputs.aiScans}
min={0}
max={50}
onChange={(v) => set("aiScans", v)}
/>
{persona === "freelancer" ? (
<>
<Slider
label="Local SERP checks / month"
hint="Google Maps or Local Finder via MCP, about $0.005 per check."
value={inputs.localSerps}
min={0}
max={1000}
step={10}
onChange={(v) => set("localSerps", v)}
/>
<Slider
label="Websites"
value={inputs.sites}
min={1}
max={50}
onChange={(v) => set("sites", v)}
/>
</>
) : null}
<Slider
label="Keywords tracked per site"
value={inputs.keywordsPerSite}
min={0}
max={200}
step={5}
onChange={(v) => set("keywordsPerSite", v)}
/>
<Slider
label="Rank tracking frequency"
value={inputs.checksPerWeek}
valueLabel={RANK_CHECK_LABELS[inputs.checksPerWeek]}
options={RANK_CHECK_OPTIONS}
onChange={(v) => set("checksPerWeek", v)}
/>
</div>
</div>
{/* Results */}
<div className="p-5 sm:p-6">
<p className="text-sm text-[var(--color-brand-muted)]">
Your estimated bill
</p>
<p className="mt-1 text-4xl font-semibold tabular-nums tracking-tight text-neutral-950">
{estimate.includedInBase
? `$${BASE_PRICE_USD}`
: usd(estimate.billUsd)}
<span className="text-lg font-normal text-[var(--color-brand-muted)]">
/mo
</span>
</p>
{estimate.includedInBase ? (
<p className="mt-2 text-sm leading-6 text-[var(--color-brand-muted)]">
<span
aria-hidden="true"
className="mr-1.5 inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 align-middle"
/>
{usd(estimate.usageUsd)} of estimated usage fits inside the $
{BASE_INCLUDED_CREDIT_USD} already included.
</p>
) : (
<p className="mt-2 text-sm leading-6 tabular-nums text-[var(--color-brand-muted)]">
${BASE_PRICE_USD} plan + ~{usd(estimate.topUpUsd)} of extra
usage. Extra usage never expires.
</p>
)}
{/* Per-feature breakdown */}
<div className="mt-5 border-t border-[var(--color-border-subtle)] pt-5">
<p className="text-sm font-semibold text-neutral-950">
Where it goes
</p>
<dl className="mt-3 space-y-2.5">
{estimate.lines.map((line) => (
<div
key={line.key}
className="flex items-baseline justify-between gap-4"
>
<dt className="text-sm text-neutral-700">
{line.label}
<span className="block text-xs text-neutral-500">
{line.detail}
</span>
</dt>
<dd className="shrink-0 text-sm font-medium tabular-nums text-neutral-950">
{usd(line.credits * CREDIT_USD)}
</dd>
</div>
))}
</dl>
</div>
<p className="mt-5 border-t border-[var(--color-border-subtle)] pt-5 text-sm text-[var(--color-brand-muted)]">
For comparison: Ahrefs&apos; cheapest plan is{" "}
<span className="font-medium text-neutral-950">
{usd(COMPETITORS.ahrefsLite)}/mo
</span>
.
</p>
</div>
</div>
</section>
{/* 4. FAQ */}
<section className="mt-14">
<h2 className="text-xl font-semibold tracking-tight text-neutral-950">
FAQ FAQ
</h2> </h2>
<dl className="mt-5 divide-y divide-[var(--color-border-subtle)] rounded-xl border border-[var(--color-border-subtle)] bg-white"> <dl className="mt-5 divide-y divide-[var(--color-border-subtle)]">
<div className="p-5"> <div className="py-4 first:pt-0 last:pb-0">
<dt className="text-sm font-medium text-neutral-950"> <dt className="text-sm font-medium text-neutral-950">
Is there a free trial? Is there a free trial?
</dt> </dt>
<dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]"> <dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]">
No instead, every subscription comes with a 30-day money-back Yes. The free trial includes $0.50 of credits so you can test
guarantee. If OpenSEO isn&apos;t for you, email ben@openseo.so OpenSEO before subscribing.
within 30 days of your first charge and we&apos;ll refund it. You
can also self-host the open-source version for free.
</dd> </dd>
</div> </div>
<div className="p-5"> <div className="py-4 first:pt-0 last:pb-0">
<dt className="text-sm font-medium text-neutral-950"> <dt className="text-sm font-medium text-neutral-950">
What if I use all my included credits? What if I use all my credits for the month?
</dt> </dt>
<dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]"> <dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]">
You'll never have unexpected costs or bills. If you use all your You&apos;ll never have unexpected costs or bills. If you use all
credits, you'll see errors when you try to do tasks. You can your credits, you&apos;ll see errors when you try to do tasks. You
purchase more top up credits at any time. can purchase more top up credits at any time.
</dd> </dd>
</div> </div>
<div className="p-5"> <div className="py-4 first:pt-0 last:pb-0">
<dt className="text-sm font-medium text-neutral-950"> <dt className="text-sm font-medium text-neutral-950">
What features use credits? What features use credits?
</dt> </dt>
<dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]"> <dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]">
Credits are consumed by features that query DataForSEO's API Credits are consumed by features that query DataForSEO&apos;s API
backlinks, keyword volume, competitor data, and site audits. Your backlinks, keyword volume, competitor data, and site audits.
projects, settings, and any data already fetched don't cost Your projects, settings, and any data already fetched don&apos;t
credits. cost credits.
</dd> </dd>
</div> </div>
<div className="p-5"> <div className="py-4 first:pt-0 last:pb-0">
<dt className="text-sm font-medium text-neutral-950"> <dt className="text-sm font-medium text-neutral-950">
Do unused credits roll over? Do unused credits roll over?
</dt> </dt>
@ -178,24 +453,22 @@ function Pricing() {
with your Base Plan reset each billing cycle. with your Base Plan reset each billing cycle.
</dd> </dd>
</div> </div>
<div className="p-5"> <div className="py-4 first:pt-0 last:pb-0">
<dt className="text-sm font-medium text-neutral-950"> <dt className="text-sm font-medium text-neutral-950">
Can I cancel anytime? Can I cancel anytime?
</dt> </dt>
<dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]"> <dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]">
Yes. Cancel from your billing portal at any time. Your access Yes. Cancel from your billing portal at any time. Your access
continues through the end of the current billing period. Within continues through the end of the current billing period.
your first 30 days, you can email ben@openseo.so for a full
refund.{" "}
</dd> </dd>
</div> </div>
<div className="p-5"> <div className="py-4 first:pt-0 last:pb-0">
<dt className="text-sm font-medium text-neutral-950"> <dt className="text-sm font-medium text-neutral-950">
Do I need a subscription or just usage credits? Do I need a subscription or just usage credits?
</dt> </dt>
<dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]"> <dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]">
While top-up Usage Credits roll over and don't expire, you need an While top-up Usage Credits roll over and don&apos;t expire, you
active subscription in order to use OpenSEO. need an active subscription in order to use OpenSEO.
</dd> </dd>
</div> </div>
</dl> </dl>
@ -203,3 +476,61 @@ function Pricing() {
</article> </article>
); );
} }
/* ------------------------------------------------------------------ *
* Slider native range input + live numeric readout.
* ------------------------------------------------------------------ */
function Slider({
label,
hint,
value,
min,
max,
options,
step = 1,
valueLabel,
onChange,
}: {
label: string;
hint?: string;
value: number;
min?: number;
max?: number;
options?: readonly number[];
step?: number;
valueLabel?: string;
onChange: (value: number) => void;
}) {
const optionIndex = options?.indexOf(value) ?? -1;
const sliderValue = options ? Math.max(0, optionIndex) : value;
const sliderMin = options ? 0 : (min ?? 0);
const sliderMax = options ? options.length - 1 : (max ?? 0);
return (
<div>
<div className="flex items-baseline justify-between gap-4">
<label className="text-sm font-medium text-neutral-800">{label}</label>
<span className="text-sm font-semibold tabular-nums text-neutral-950">
{valueLabel ?? value.toLocaleString()}
</span>
</div>
{hint ? (
<p className="mt-0.5 text-xs text-[var(--color-brand-muted)]">{hint}</p>
) : null}
<input
type="range"
min={sliderMin}
max={sliderMax}
step={options ? 1 : step}
value={sliderValue}
onChange={(e) => {
const next = Number(e.target.value);
onChange(options?.[next] ?? next);
}}
aria-label={label}
aria-valuetext={valueLabel}
className="mt-2 h-1 w-full cursor-pointer appearance-none rounded-full bg-[var(--color-border-subtle)] accent-[var(--color-brand-accent)]"
/>
</div>
);
}