diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index bbc8e2c..dd88164 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -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; diff --git a/apps/web/src/__tests__/hire-page.test.ts b/apps/web/src/__tests__/hire-page.test.ts new file mode 100644 index 0000000..ef3295a --- /dev/null +++ b/apps/web/src/__tests__/hire-page.test.ts @@ -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"/); + }); +}); diff --git a/apps/web/src/app/about/page.tsx b/apps/web/src/app/about/page.tsx index a5d06c7..b965a26 100644 --- a/apps/web/src/app/about/page.tsx +++ b/apps/web/src/app/about/page.tsx @@ -161,7 +161,7 @@ export default function AboutPage() {
Talk to sales diff --git a/apps/web/src/app/account/account-content.tsx b/apps/web/src/app/account/account-content.tsx index 921d9f3..2d9f327 100644 --- a/apps/web/src/app/account/account-content.tsx +++ b/apps/web/src/app/account/account-content.tsx @@ -367,7 +367,7 @@ export default function AccountContent() { {profile?.license_status !== "active" && (

{profile?.email_verified && profile?.phone_verified ? ( - Contact us for pricing → + Contact us for a quote → ) : ( Complete verification to purchase → )} diff --git a/apps/web/src/app/affiliates/page.tsx b/apps/web/src/app/affiliates/page.tsx index 1472896..a43bb52 100644 --- a/apps/web/src/app/affiliates/page.tsx +++ b/apps/web/src/app/affiliates/page.tsx @@ -19,7 +19,7 @@ export default function AffiliatesPage() {

Features Module Store - Pricing + Hire Us Affiliates
@@ -173,7 +173,7 @@ export default function AffiliatesPage() {
Features Module Store - Pricing + Hire Us Affiliates GitHub
diff --git a/apps/web/src/app/api/contact/route.ts b/apps/web/src/app/api/contact/route.ts index 546f4c0..0b993f6 100644 --- a/apps/web/src/app/api/contact/route.ts +++ b/apps/web/src/app/api/contact/route.ts @@ -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 = { + 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 { + 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 ", 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", @@ -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") diff --git a/apps/web/src/app/hire/page.tsx b/apps/web/src/app/hire/page.tsx new file mode 100644 index 0000000..101bf9f --- /dev/null +++ b/apps/web/src/app/hire/page.tsx @@ -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 ( +
+