diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index dab3c1a8..39a41e60 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { HeroAuditForm } from "@/components/hero-audit-form"; +import { ANON_DAILY_SCANS, SCAN_CREDITS, SIGNUP_CREDITS } from "@/lib/credits"; import { OrganizationJsonLd, SoftwareApplicationJsonLd, FaqJsonLd } from "@/components/json-ld"; import { PerformancePreview } from "@/components/report/performance-preview"; import { UptimePreview } from "@/components/report/uptime-preview"; @@ -33,7 +34,7 @@ const faqs = [ }, { q: "Is the free tier really free?", - a: "Yes — anonymous visitors get 10 audits per day per IP with no signup, and signing up unlocks 20 free credits (1 AI-model scan). Paid scans cost 20 credits (~$1) with volume discounts down to $0.50/scan at the 100-scan pack. No subscription, credits never expire.", + a: `Yes — anonymous visitors get ${ANON_DAILY_SCANS} audits per day per IP with no signup, and signing up unlocks ${SIGNUP_CREDITS} free credits (${SIGNUP_CREDITS / SCAN_CREDITS} AI-model scan). Paid scans cost ${SCAN_CREDITS} credits (~$1) with volume discounts down to $0.50/scan at the 100-scan pack. No subscription, credits never expire.`, }, ]; diff --git a/app/(marketing)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx index a6d738af..c69147b9 100644 --- a/app/(marketing)/pricing/page.tsx +++ b/app/(marketing)/pricing/page.tsx @@ -1,14 +1,16 @@ import Link from "next/link"; import { + ANON_DAILY_SCANS, CREDIT_PACKS, CREDIT_RACK_CENTS, ENGINES, LEAD_RUN_CREDITS, OUTREACH_CREDITS, SCAN_CREDITS, + SIGNUP_CREDITS, discountPct, dollars, - perCreditCents, + perCreditLabel, } from "@/lib/credits"; export const metadata = { @@ -30,7 +32,7 @@ export default function PricingPage() {

Pay per scan

Rule-based scans are free up to the daily limit. AI-model scans cost{" "} - 20 credits (~$1) each. Buy credits with crypto via CoinPay; + {SCAN_CREDITS} credits (~$1) each. Buy credits with crypto via CoinPay; no subscription, no expiry.

@@ -131,7 +133,7 @@ export default function PricingPage() {
{p.credits} credit{p.credits === 1 ? "" : "s"} ·{" "} - {dollars(perCreditCents(p))}/credit + {perCreditLabel(p)}/credit

- Sign-ups include 20 free credits (1 AI-model scan). Anonymous visitors get 10 - free rule-based scans per day per IP. + Sign-ups include {SIGNUP_CREDITS} free credits ({SIGNUP_CREDITS / SCAN_CREDITS}{" "} + AI-model scan). Anonymous visitors get {ANON_DAILY_SCANS} free rule-based scans + per day per IP.

); diff --git a/app/(marketing)/tools/page.tsx b/app/(marketing)/tools/page.tsx new file mode 100644 index 00000000..d1cfc51e --- /dev/null +++ b/app/(marketing)/tools/page.tsx @@ -0,0 +1,107 @@ +import Link from "next/link"; +import { ANON_DAILY_SCANS, ENGINES, SIGNUP_CREDITS, type Engine } from "@/lib/credits"; + +// Every free scanner, in one place. +// +// Six of the engines cost nothing, but only two of them — the rule-based AEO +// audit and the Slop Score — can run without an account, and those two are the +// only ones the homepage selector offers. So the other four were real, free, +// and undiscoverable: /pricing listed them among the paid engines, and nothing +// linked anywhere. +// +// The split is stated rather than hidden. Putting all six in the anonymous +// selector would be the obvious "fix" and the wrong one — four of them would +// fail on submit, because `ANON_ENGINES` in app/actions/runAudit.ts genuinely +// only permits two. Telling somebody a tool needs an account is a better +// experience than letting them find out by having it not work. + +export const metadata = { + title: "Free website scanners", + description: + "Every free CrawlProof scanner: AEO audit, Slop Score, DNS analyzer, link checker, specification.website checklist and the Vu1nz security scan.", + alternates: { canonical: "/tools" }, +}; + +/** Runs without an account — kept in sync with ANON_ENGINES in app/actions/runAudit.ts. */ +const ANONYMOUS: Engine[] = ["rule", "slop"]; + +/** Where each free engine can be started, when it has its own entry point. */ +const START_AT: Partial> = { + rule: "/", + slop: "/slop", +}; + +export default function ToolsPage() { + const free = (Object.keys(ENGINES) as Engine[]).filter( + (k) => ENGINES[k].cost === 0 && ENGINES[k].available, + ); + const anonymous = free.filter((k) => ANONYMOUS.includes(k)); + const needsAccount = free.filter((k) => !ANONYMOUS.includes(k)); + + return ( +
+

Free website scanners

+

+ {free.length} scanners that cost nothing to run. {anonymous.length} need no + account at all — anonymous visitors get {ANON_DAILY_SCANS} scans per day per + IP. The rest are free once you sign up, which also includes{" "} + {SIGNUP_CREDITS} credits for the AI-model audits. +

+ +
+

No account needed

+
+ {anonymous.map((k) => ( + + ))} +
+
+ +
+

Free with an account

+

+ Still zero credits to run — signing in is what makes them available. +

+
+ {needsAccount.map((k) => ( + + ))} +
+
+ +

+ Looking for the AI-model audits?{" "} + + See pricing + + . +

+
+ ); +} + +function ToolCard({ + engine, + href, + cta, +}: { + engine: Engine; + href?: string; + cta: string; +}) { + const meta = ENGINES[engine]; + return ( +
+
+ Free · 0 credits +
+

{meta.label}

+

{meta.blurb}

+ {href && ( + + {cta} + + )} +
+ ); +} diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index 479e2484..f703ff0b 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -1,3 +1,4 @@ +import { SCAN_CREDITS, SIGNUP_CREDITS } from "@/lib/credits"; import { env } from "@/lib/env"; const body = `# CrawlProof @@ -6,7 +7,7 @@ const body = `# CrawlProof ## Product - Free single-URL AEO audit, 10 per day per IP, no signup required. -- Signed-in users get 20 free credits (1 AI-model scan); each AI-model scan costs 20 credits (~$1, volume discounts down to $0.50/scan at the 100-scan pack). No subscription — credits never expire. +- Signed-in users get ${SIGNUP_CREDITS} free credits (${SIGNUP_CREDITS / SCAN_CREDITS} AI-model scan); each AI-model scan costs ${SCAN_CREDITS} credits (~$1, volume discounts down to $0.50/scan at the 100-scan pack). No subscription — credits never expire. - Saved projects, scheduled re-audits, multi-engine LLM scans (Claude Sonnet 4.6, OpenAI GPT-5 Mini, Gemini 2.5 Pro, Perplexity Sonar Pro, Qwen Plus, Kimi v2.6, DeepSeek V4, Z.AI GLM-5.2), consolidated PDF reports, and diff view. - Identifies as CrawlProofBot/1.0 (+https://crawlproof.com/bot). diff --git a/app/sitemap.ts b/app/sitemap.ts index 83f6e69a..ccdd1c06 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -15,6 +15,9 @@ export default async function sitemap(): Promise { { url: `${base}/pricing`, changeFrequency: "monthly", priority: 0.9 }, { url: `${base}/hire`, changeFrequency: "monthly", priority: 0.9 }, { url: `${base}/slop`, changeFrequency: "monthly", priority: 0.9 }, + // Hub for the six free scanners — the four that need an account had no + // inbound link anywhere before this page existed. + { url: `${base}/tools`, changeFrequency: "monthly", priority: 0.9 }, { url: `${base}/get-guide`, changeFrequency: "monthly", priority: 0.9 }, { url: `${base}/about`, changeFrequency: "monthly", priority: 0.7 }, { url: `${base}/press`, changeFrequency: "monthly", priority: 0.6 }, diff --git a/components/hero-audit-form.tsx b/components/hero-audit-form.tsx index 5ac86d0d..dad4e9a3 100644 --- a/components/hero-audit-form.tsx +++ b/components/hero-audit-form.tsx @@ -2,6 +2,7 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; import { startAuditFromForm } from "@/app/actions/runAudit"; // Datafa.st injects `window.datafast` once its script.js loads. Calls @@ -134,7 +135,16 @@ export function HeroAuditForm({ defaultScan = "rule" }: { defaultScan?: HeroScan

No email, no signup, no card. The report opens on-page in seconds — you - can email yourself a PDF copy from the report once it's ready. + can email yourself a PDF copy from the report once it's ready.{" "} + {/* Only two of the six free scanners can run anonymously, so the other + four were invisible from here — real, free, and reachable by nobody. + A link rather than four more radio buttons: the rest genuinely + require an account, and offering them in this selector would mean + submitting a form that cannot succeed. */} + + All free tools + + .

-
- - { - setEmail(e.target.value); - if (error) setError(null); - }} - className="input" - autoComplete="email" - /> - - - setPhone(e.target.value)} - className="input" - autoComplete="tel" - /> - -
- + {/* Email alone, and nothing else, until the visitor asks for more. + + The form previously showed three inputs for what is a one-field + request: a phone number and "monthly website sales" sat next to the + email, both marked optional. Optional or not, being asked your + revenue in order to receive a PDF reads as a sales qualification, + and it is the wrong trade at the moment somebody is doing us the + favour of handing over an address. The commercial questions are + worth asking — but behind something the visitor chose, for a thing + they get in return. */} + setMonthlySales(e.target.value)} + type="email" + placeholder="you@example.com" + value={email} + onChange={(e) => { + setEmail(e.target.value); + if (error) setError(null); + }} className="input" + autoComplete="email" /> + + {!wantsEstimate ? ( + + ) : ( +
+

+ We'll add a revenue-impact estimate to the report. Both fields are + optional — the PDF sends either way. +

+ + setMonthlySales(e.target.value)} + className="input" + autoFocus + /> + + + setPhone(e.target.value)} + className="input" + autoComplete="tel" + /> + +
+ )} + diff --git a/components/report/performance-charts.tsx b/components/report/performance-charts.tsx index 940c5859..df55d132 100644 --- a/components/report/performance-charts.tsx +++ b/components/report/performance-charts.tsx @@ -1,5 +1,7 @@ "use client"; +import { SCAN_CREDITS, SIGNUP_CREDITS } from "@/lib/credits"; + import dynamic from "next/dynamic"; import Link from "next/link"; @@ -84,7 +86,8 @@ export function PerformanceCharts({ overlayTitle, overlayBody }: PerformanceChar Sign up free

- 3 free AI credits on signup · free rule-based scans up to the daily limit. + {SIGNUP_CREDITS} free credits on signup ({SIGNUP_CREDITS / SCAN_CREDITS} AI-model + scan) · free rule-based scans up to the daily limit.

diff --git a/lib/credits.ts b/lib/credits.ts index adcd2bf5..52105087 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -12,6 +12,31 @@ export const CREDIT_RACK_CENTS = 5; // auto-fix). 20 × $0.05 = $1.00 rack, unchanged from the old 1-credit-=-$1 era. export const SCAN_CREDITS = 20; +/** + * Credits a new account starts with. + * + * **Mirrors the database**: `profiles.credits_balance` has a column default of + * 20, and that default is what actually grants the credits — this constant + * only describes it for the marketing copy. Changing this number alone changes + * what the site promises, not what a signup receives; the column default has to + * move with it. + * + * It exists because the promise had already drifted. The report paywall card + * said "3 free AI credits on signup" while /pricing, the homepage FAQ, llms.txt + * and the JSON-LD all said 20 — so a visitor comparing two pages saw a number + * that decided whether signing up unlocks a paid scan at all. Every one of + * those strings now reads from here. + */ +export const SIGNUP_CREDITS = 20; + +/** + * Free rule-based scans an anonymous visitor gets per day, per IP. + * + * Same reasoning as SIGNUP_CREDITS: quoted on four separate pages, and a + * number quoted in four places is a number that will disagree with itself. + */ +export const ANON_DAILY_SCANS = 10; + // Credits charged for one outreach send (email / SMS recipient / social post). export const OUTREACH_CREDITS = 1; @@ -119,10 +144,34 @@ export function perScanCents(pack: CreditPack): number { } // Effective price of a single credit under this pack, in cents. +// +// Rounded, and therefore NOT safe to display on its own — see +// `perCreditLabel`. Kept for callers doing comparison or sorting, where a whole +// cent is precise enough. export function perCreditCents(pack: CreditPack): number { return Math.round(pack.amountCents / pack.credits); } +/** + * Exact per-credit price, as displayed. + * + * Every pack's true per-credit price lands on a half-cent — $9/200 = $0.045, + * $35/1000 = $0.035, $50/2000 = $0.025 — and rounding half-up sent all three + * *upward*, so the page advertised $0.05, $0.04 and $0.03. Overstating your own + * price on the pricing page is a strange way to lose an argument with a + * customer who can do the division. + * + * Three decimals, with a trailing zero trimmed only down to two, so a pack that + * really is a round number ($1.00/20 = $0.05) reads "$0.05" rather than + * "$0.050" — and a hypothetical $0.10 never degrades to "$0.1", which reads as + * a typo rather than a price. + */ +export function perCreditLabel(pack: CreditPack): string { + const exact = pack.amountCents / pack.credits / 100; + const three = exact.toFixed(3); + return `$${three.endsWith("0") ? three.slice(0, -1) : three}`; +} + // ----- Engines (free utilities + partner scanners + LLM providers) ---------- export type Engine = | "rule" diff --git a/tests/credits-per-credit-label.test.ts b/tests/credits-per-credit-label.test.ts new file mode 100644 index 00000000..4b4a0468 --- /dev/null +++ b/tests/credits-per-credit-label.test.ts @@ -0,0 +1,69 @@ +// The pricing page was overstating its own prices. +// +// Every bundle's true per-credit price lands on a half-cent, and +// `Math.round` on integer cents took all three of them UP: $0.045 shown as +// $0.05, $0.035 as $0.04, $0.025 as $0.03. Reported by a visitor who did the +// division, which is the worst way to find out. + +import { describe, expect, it } from "vitest"; +import { + CREDIT_PACKS, + SIGNUP_CREDITS, + SCAN_CREDITS, + findPack, + perCreditLabel, +} from "@/lib/credits"; + +describe("perCreditLabel", () => { + it.each([ + ["pack-1", "$0.05"], // $1.00 / 20 — genuinely round + ["pack-10", "$0.045"], // $9 / 200, was shown as $0.05 + ["pack-50", "$0.035"], // $35 / 1000, was shown as $0.04 + ["pack-100", "$0.025"], // $50 / 2000, was shown as $0.03 + ])("prices %s at %s", (id, expected) => { + const pack = findPack(id); + expect(pack).toBeDefined(); + expect(perCreditLabel(pack!)).toBe(expected); + }); + + it("never overstates the price", () => { + // The property that actually matters: what we print must be <= what we + // charge, for every pack, including any added later. + for (const pack of CREDIT_PACKS) { + const printed = Number(perCreditLabel(pack).slice(1)); + const actual = pack.amountCents / pack.credits / 100; + expect(printed).toBeLessThanOrEqual(actual + 1e-9); + } + }); + + it("keeps at least two decimals, so a price never reads as a typo", () => { + for (const pack of CREDIT_PACKS) { + const decimals = perCreditLabel(pack).split(".")[1] ?? ""; + expect(decimals.length).toBeGreaterThanOrEqual(2); + expect(decimals.length).toBeLessThanOrEqual(3); + } + }); + + it("is exact to three decimals for every pack", () => { + for (const pack of CREDIT_PACKS) { + const printed = Number(perCreditLabel(pack).slice(1)); + const actual = pack.amountCents / pack.credits / 100; + expect(printed).toBeCloseTo(actual, 3); + } + }); +}); + +describe("signup credits", () => { + it("is one AI-model scan, which is what the copy claims", () => { + // Several pages say "N free credits (1 AI-model scan)". If these ever + // diverge, that sentence becomes false everywhere at once. + expect(SIGNUP_CREDITS).toBe(SCAN_CREDITS); + expect(SIGNUP_CREDITS / SCAN_CREDITS).toBe(1); + }); + + it("matches the profiles.credits_balance column default of 20", () => { + // The database default is what actually grants the credits; this constant + // only describes it. Pinned so a change here without a migration is loud. + expect(SIGNUP_CREDITS).toBe(20); + }); +});