From cd550729a2e292fa32a39a1324c412d8afec2345 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:51:51 -0400 Subject: [PATCH] Pricing page redesign: usage estimator + flat layout (#368) --- package.json | 1 + scripts/dataforseo-account-usage.ts | 116 ++++ .../onboarding/PostSignupOnboarding.tsx | 8 +- .../SearchConsoleOnboardingStep.tsx | 10 +- .../features/onboarding/onboardingModel.ts | 11 +- .../features/projects/ProjectMarketFields.tsx | 6 +- src/routes/_authenticated.subscribe.tsx | 23 +- src/server/lib/dataforseo/appendix.ts | 30 + src/server/lib/dataforseo/core.ts | 4 + web/src/components/landing-page.css | 18 + web/src/components/landing-page.tsx | 5 +- web/src/routes/_marketing/pricing.tsx | 601 ++++++++++++++---- 12 files changed, 684 insertions(+), 149 deletions(-) create mode 100644 scripts/dataforseo-account-usage.ts create mode 100644 src/server/lib/dataforseo/appendix.ts diff --git a/package.json b/package.json index ca283ae..aac2b4f 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "test:e2e:keywords": "playwright test e2e/keyword-research-navigation.spec.ts", "billing:backlinks": "tsx scripts/backlinks-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", "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" diff --git a/scripts/dataforseo-account-usage.ts b/scripts/dataforseo-account-usage.ts new file mode 100644 index 0000000..b6fc1da --- /dev/null +++ b/scripts/dataforseo-account-usage.ts @@ -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_` 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); +} diff --git a/src/client/features/onboarding/PostSignupOnboarding.tsx b/src/client/features/onboarding/PostSignupOnboarding.tsx index e750997..a16e269 100644 --- a/src/client/features/onboarding/PostSignupOnboarding.tsx +++ b/src/client/features/onboarding/PostSignupOnboarding.tsx @@ -8,6 +8,7 @@ import { ONBOARDING_LAST_STEP, type OnboardingAnswers, SOURCE_OPTIONS, + SOURCE_OPTIONS_HIDDEN_ON_MOBILE, WORK_FOR_OPTIONS, } from "@/client/features/onboarding/onboardingModel"; import { SearchConsoleOnboardingStep } from "@/client/features/onboarding/SearchConsoleOnboardingStep"; @@ -121,6 +122,7 @@ export function PostSignupOnboarding({ onToggle={(source) => updateAnswers({ source })} otherValue={answers.sourceOther} onOtherChange={(sourceOther) => updateAnswers({ sourceOther })} + hiddenOnMobile={[...SOURCE_OPTIONS_HIDDEN_ON_MOBILE]} /> ) : ( @@ -183,6 +185,7 @@ function OnboardingChoiceGroup({ multiple = false, maxSelections, followUp, + hiddenOnMobile, }: { title: string; description?: string; @@ -193,6 +196,7 @@ function OnboardingChoiceGroup({ onOtherChange: (value: string) => void; multiple?: boolean; maxSelections?: number; + hiddenOnMobile?: string[]; followUp?: { showForValue: string; label: string; @@ -222,12 +226,14 @@ function OnboardingChoiceGroup({ const disabled = atLimit && !selected; const showFollowUpHere = showFollowUp && followUp?.showForValue === option; + // Selected options stay visible so a restored answer never vanishes. + const mobileHidden = hiddenOnMobile?.includes(option) && !selected; return (