Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ const nextConfig: NextConfig = {
},
];
},
// /pricing was the quote-request page; /hire replaced it. Permanent so the
// indexed URL and any links already in the wild follow through.
async redirects() {
return [{ source: "/pricing", destination: "/hire", permanent: true }];
},
};

export default nextConfig;
73 changes: 73 additions & 0 deletions apps/web/src/__tests__/hire-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";

const appRoot = join(__dirname, "..", "..");
const read = (p: string) => readFileSync(join(appRoot, p), "utf8");

describe("hire page", () => {
it("states the $400 starting point without quoting a full price", () => {
const page = read("src/app/hire/page.tsx");

expect(page).toContain("$400");
expect(page).toContain("scan with human input");
expect(page).toMatch(/don't quote prices/);
});

it("is discoverable — canonical, sitemap, header and footer links", () => {
expect(read("src/app/hire/page.tsx")).toContain('canonical: "/hire"');

expect(read("src/app/sitemap.ts")).toContain('"/hire"');
expect(read("src/components/SiteHeader.tsx")).toContain('href="/hire"');
expect(read("src/components/SiteFooter.tsx")).toContain('href="/hire"');
});

it("submits inquiries to the shared contact route under the hire topic", () => {
const form = read("src/components/HireForm.tsx");

expect(form).toContain('"use client"');
expect(form).toContain('fetch("/api/contact"');
expect(form).toContain('topic: "hire"');
});
});

describe("pricing page replacement", () => {
it("no longer exists as a route", () => {
expect(existsSync(join(appRoot, "src/app/pricing"))).toBe(false);
});

it("redirects the old URL to /hire permanently", () => {
const config = read("next.config.ts");

expect(config).toContain('source: "/pricing"');
expect(config).toContain('destination: "/hire"');
expect(config).toContain("permanent: true");
});

it("leaves no link pointing at the removed route", () => {
for (const file of [
"src/app/page.tsx",
"src/app/about/page.tsx",
"src/app/affiliates/page.tsx",
"src/app/account/account-content.tsx",
"src/app/layout.tsx",
"src/components/SiteHeader.tsx",
"src/components/SiteFooter.tsx",
"src/app/sitemap.ts",
]) {
expect(read(file)).not.toContain("/pricing");
}
});
});

describe("contact route", () => {
it("keeps extra form fields by appending them to the persisted message", () => {
const route = read("src/app/api/contact/route.ts");

expect(route).toContain("messageWithExtras");
expect(route).toContain("message: messageWithExtras(s)");
// company and topic have their own columns, so they must not be duplicated
// into the message body.
expect(route).toMatch(/key !== "company" && key !== "topic"/);
});
});
2 changes: 1 addition & 1 deletion apps/web/src/app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export default function AboutPage() {

<div className="mt-10 flex flex-wrap gap-4">
<Link
href="/pricing"
href="/hire"
className="rounded-xl bg-tc-green px-6 py-3 font-bold text-black hover:bg-tc-green-dim"
>
Talk to sales
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/account/account-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ export default function AccountContent() {
{profile?.license_status !== "active" && (
<p className="text-tc-text-dim text-sm mt-2">
{profile?.email_verified && profile?.phone_verified ? (
<Link href="/pricing" className="text-tc-green hover:underline">Contact us for pricing →</Link>
<Link href="/hire" className="text-tc-green hover:underline">Contact us for a quote →</Link>
) : (
<Link href="/auth/verify" className="text-yellow-500 hover:underline">Complete verification to purchase →</Link>
)}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/affiliates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function AffiliatesPage() {
<div className="hidden sm:flex items-center gap-6 text-sm text-[#999]">
<a href="/#features" className="hover:text-[#00ff41] transition-colors">Features</a>
<a href="/store" className="hover:text-[#00ff41] transition-colors">Module Store</a>
<a href="/pricing" className="hover:text-[#00ff41] transition-colors">Pricing</a>
<a href="/hire" className="hover:text-[#00ff41] transition-colors">Hire Us</a>
<a href="/affiliates" className="text-[#00ff41]">Affiliates</a>
</div>
</div>
Expand Down Expand Up @@ -173,7 +173,7 @@ export default function AffiliatesPage() {
<div className="flex items-center gap-6 text-sm text-[#999]">
<a href="/#features" className="hover:text-[#00ff41] transition-colors">Features</a>
<a href="/store" className="hover:text-[#00ff41] transition-colors">Module Store</a>
<a href="/pricing" className="hover:text-[#00ff41] transition-colors">Pricing</a>
<a href="/hire" className="hover:text-[#00ff41] transition-colors">Hire Us</a>
<a href="/affiliates" className="hover:text-[#00ff41] transition-colors">Affiliates</a>
<a href="https://github.com/profullstack/threatcrush" target="_blank" rel="noopener noreferrer" className="hover:text-[#00ff41] transition-colors">GitHub</a>
</div>
Expand Down
28 changes: 26 additions & 2 deletions apps/web/src/app/api/contact/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,35 @@ function getSupabase() {
return supabase;
}

// Labels for the extra fields a form may submit (the hire form sends these).
// Used both in the notification email and in the persisted message.
const FIELD_LABELS: Record<string, string> = {
email: "Valid email",
target: "App or repo",
stack: "Stack",
timeline: "Timeline",
};

// contact_requests only has columns for company and topic, so any other extra
// field would be lost on the way to the database. Append them to the stored
// message instead of dropping them.
function messageWithExtras(s: {
message: string;
fields: Record<string, string>;
}): string {
const extras = Object.entries(s.fields).filter(
([key, value]) => key !== "company" && key !== "topic" && value,
);
if (extras.length === 0) return s.message;
const lines = extras.map(([key, value]) => `${FIELD_LABELS[key] ?? key}: ${value}`);
return `${s.message}\n\n---\n${lines.join("\n")}`;
}

export const POST = createContactRoute({
from: "ThreatCrush <hello@threatcrush.com>",
to: "hello@threatcrush.com",
honeypot: false,
fieldLabels: { email: "Valid email" },
fieldLabels: FIELD_LABELS,
subject: (s) =>
`[ThreatCrush] New ${s.fields.topic ?? "general"} inquiry from ${s.name}`,
onSendError: "ignore",
Expand All @@ -28,7 +52,7 @@ export const POST = createContactRoute({
name: s.name,
email: s.email.toLowerCase(),
company: s.fields.company ?? null,
message: s.message,
message: messageWithExtras(s),
topic: s.fields.topic ?? "general",
})
.select("id")
Expand Down
191 changes: 191 additions & 0 deletions apps/web/src/app/hire/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/lib/blog";
import { HireForm } from "@/components/HireForm";

export const metadata: Metadata = {
title: "Hire Us — human-led security assessments",
description:
"Have our team run the scan for you and read the results by hand. Engagements start at $400 for a scan with human input and scale with the complexity of your application.",
alternates: { canonical: "/hire" },
openGraph: {
title: "Hire Us · ThreatCrush",
description:
"Human-led security assessments built on the ThreatCrush engine. Starts at $400 for a scan with human input.",
url: `${SITE_URL}/hire`,
type: "website",
},
};

const serviceJsonLd = {
"@context": "https://schema.org",
"@type": "Service",
name: "ThreatCrush human-led security assessment",
serviceType: "Application security assessment",
url: `${SITE_URL}/hire`,
provider: { "@type": "Organization", name: "ThreatCrush", url: SITE_URL },
areaServed: "Worldwide",
offers: {
"@type": "Offer",
priceCurrency: "USD",
availability: "https://schema.org/InStock",
url: `${SITE_URL}/hire`,
priceSpecification: {
"@type": "PriceSpecification",
priceCurrency: "USD",
minPrice: 400,
},
},
};

const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: `${SITE_URL}/` },
{ "@type": "ListItem", position: 2, name: "Hire Us", item: `${SITE_URL}/hire` },
],
};

const deliverables = [
{
title: "A scan you didn't have to run",
body: "We point the ThreatCrush engine at your repositories and infrastructure, tune the rules to your stack, and re-run until the picture is complete.",
},
{
title: "Findings read by a human",
body: "Every finding is triaged by an engineer before you see it. False positives get dropped; real issues arrive with severity, reproduction steps, and a fix.",
},
{
title: "A report you can hand to anyone",
body: "One document for your engineers and one summary for whoever asked — customer, auditor, or board — mapped to MITRE ATT&CK and NIST CSF.",
},
{
title: "A working session at the end",
body: "We walk the findings with your team, answer questions, and agree what gets fixed first. A re-scan after your fixes land is part of the engagement.",
},
];

const steps = [
{
n: "01",
title: "Tell us about the app",
body: "Stack, size, where it runs, and what you are worried about. A couple of minutes on the form below.",
},
{
n: "02",
title: "We scope it and send a number",
body: "You get a fixed price and a timeline in writing before anything starts. No hourly surprises.",
},
{
n: "03",
title: "We scan, triage, and report",
body: "Automated coverage first, then human review of everything it surfaced — plus the things a scanner cannot see.",
},
{
n: "04",
title: "You fix, we verify",
body: "We re-run the assessment against your fixes so the close-out report shows the delta, not just the starting point.",
},
];

export default function HirePage() {
return (
<div className="min-h-screen bg-tc-darker pt-24 pb-20">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(serviceJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>

<main className="mx-auto max-w-4xl px-6">
<p className="font-mono text-sm text-tc-green tracking-wider mb-3">// HIRE US</p>
<h1 className="text-4xl sm:text-5xl font-bold text-white leading-tight">
Let us run it,{" "}
<span className="text-tc-green glow-green">and read the results</span>.
</h1>
<p className="mt-6 max-w-2xl text-lg text-tc-text-dim leading-relaxed">
ThreatCrush is open source and you can run it yourself. When you would rather
hand the whole thing to someone, our team scans your application, triages every
finding by hand, and gives you a report that says what is actually broken and
what to do about it.
</p>

{/* No public rate card, but the floor is stated plainly so nobody has to book a
call just to find out whether they can afford us. */}
<section className="mt-10 relative bg-tc-card border border-tc-border rounded-2xl overflow-hidden">
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-tc-green to-transparent opacity-40" />
<div className="p-8">
<h2 className="text-2xl font-bold text-white">What it costs</h2>
<p className="mt-3 text-tc-text-dim leading-relaxed">
We don&apos;t quote prices up front, because no two applications are the
same amount of work. What we can tell you is where it starts:{" "}
<strong className="text-white">
engagements begin at $400 for a scan with human input
</strong>{" "}
— the engine runs, and an engineer reads and triages what it found.
</p>
<p className="mt-3 text-tc-text-dim leading-relaxed">
From there the price moves with the complexity of the app: how many
services and repositories are in scope, what it is written in, whether
there is infrastructure and cloud configuration to review, and how much
manual testing the thing warrants. Tell us about it below and we come back
with a fixed number and a timeline before any work begins.
</p>
<p className="mt-3 text-sm text-tc-text-dim">
Prefer to self-serve? The{" "}
<a href="/docs" className="text-tc-green hover:underline">
docs
</a>{" "}
will get you scanning in a few minutes, and the{" "}
<a href="/store" className="text-tc-green hover:underline">
module store
</a>{" "}
covers what the agent can do.
</p>
</div>
</section>

<section className="mt-12">
<h2 className="text-2xl font-bold text-white mb-5">What you get</h2>
<ul className="grid gap-4 sm:grid-cols-2">
{deliverables.map((d) => (
<li key={d.title} className="bg-tc-card border border-tc-border rounded-xl p-5">
<h3 className="text-white font-bold">{d.title}</h3>
<p className="mt-2 text-sm text-tc-text-dim leading-relaxed">{d.body}</p>
</li>
))}
</ul>
</section>

<section className="mt-12">
<h2 className="text-2xl font-bold text-white mb-5">How it runs</h2>
<ol className="space-y-4">
{steps.map((s) => (
<li key={s.n} className="flex gap-4">
<span className="font-mono text-tc-green text-sm pt-1 shrink-0">{s.n}</span>
<div>
<h3 className="text-white font-bold">{s.title}</h3>
<p className="mt-1 text-sm text-tc-text-dim leading-relaxed">{s.body}</p>
</div>
</li>
))}
</ol>
</section>

<section className="mt-14" id="inquiry">
<h2 className="text-2xl font-bold text-white">Tell us about your app</h2>
<p className="mt-2 text-tc-text-dim">
A few details is enough to scope it. We reply with a price and a timeline,
usually the same business day.
</p>
<div className="mt-6">
<HireForm />
</div>
</section>
</main>
</div>
);
}
4 changes: 2 additions & 2 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const organizationJsonLd = {
"@type": "ContactPoint",
contactType: "customer support",
email: "hello@threatcrush.com",
url: `${SITE_URL}/pricing`,
url: `${SITE_URL}/hire`,
availableLanguage: ["English"],
},
{
Expand Down Expand Up @@ -189,7 +189,7 @@ const softwareApplicationJsonLd = {
price: "0",
description:
"Private beta — contact sales for lifetime licensing. AI-enhanced modules billed by usage.",
url: `${SITE_URL}/pricing`,
url: `${SITE_URL}/hire`,
},
};

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -907,10 +907,10 @@ export default function Home() {
</ul>

<a
href="/pricing"
href="/hire"
className="block w-full text-center rounded-xl bg-tc-green py-4 text-lg font-bold text-black transition-all hover:bg-tc-green-dim pulse-glow"
>
Contact Us for Pricing
Get a Quote
</a>

<p className="text-center text-xs text-tc-text-dim mt-4">
Expand Down
Loading
Loading