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
69 changes: 69 additions & 0 deletions app/api/scout/discover/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server"
import { generateObject } from "ai"
import { google } from "@ai-sdk/google"
import { z } from "zod"

const DiscoverSchema = z.object({
companies: z.array(
z.object({
name: z.string().describe("Company name"),
url: z
.string()
.describe("Company website URL including https://"),
reason: z
.string()
.describe(
"One sentence: why this company is relevant to the query"
),
})
),
})

export async function POST(req: NextRequest) {
let body: any
try {
body = await req.json()
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 })
}

const { query } = body
if (!query || typeof query !== "string" || query.trim().length === 0) {
return NextResponse.json(
{ error: "query is required" },
{ status: 400 }
)
}

try {
const { object } = await generateObject({
model: google("gemini-2.5-flash"),
schema: DiscoverSchema,
prompt: `You are a Vercel enterprise sales researcher. Given a territory query, return a list of real companies with their actual website URLs.

QUERY: "${query.trim()}"

RULES:
- Return 20-30 companies that match the query.
- Every URL must be a real, publicly accessible website. Use https://.
- Use the company's primary marketing/product website, not social media or app store links.
- Focus on companies that are likely to have a web presence worth analysing (e-commerce sites, SaaS products, media sites, etc.).
- Prioritise companies that might benefit from a modern frontend platform (large sites with traffic, not tiny brochure sites).
- Include a mix of well-known and mid-market companies, not just the top 5 everyone knows.
- The reason should explain why this company matches the query in one sentence.
- Do NOT make up companies or URLs. Only include companies you are confident exist with the URL you provide.
- Do NOT include companies that are primarily API-only or have no public website.`,
})

return NextResponse.json({ companies: object.companies })
} catch (error) {
console.error("Scout discover failed:", error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Discovery failed",
},
{ status: 500 }
)
}
}
84 changes: 84 additions & 0 deletions app/api/scout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server"
import { isValidPublicUrl } from "@/lib/utils"
import { runScout } from "@/lib/scout/pipeline"

export const maxDuration = 300

export async function POST(req: NextRequest) {
let body: any
try {
body = await req.json()
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 })
}

const { urls, tier3_limit, skip_vercel, skip_tier3 } = body

if (!Array.isArray(urls) || urls.length === 0) {
return NextResponse.json(
{ error: "urls must be a non-empty array of strings" },
{ status: 400 }
)
}

if (urls.length > 50) {
return NextResponse.json(
{ error: "Maximum 50 URLs per scan" },
{ status: 400 }
)
}

// Validate each URL is a string
for (const url of urls) {
if (typeof url !== "string" || url.trim().length === 0) {
return NextResponse.json(
{ error: `Invalid URL in list: ${url}` },
{ status: 400 }
)
}
}

const encoder = new TextEncoder()

const stream = new ReadableStream({
start(controller) {
;(async () => {
try {
for await (const event of runScout(urls, {
tier3_limit: tier3_limit ?? 5,
skip_vercel: skip_vercel ?? true,
skip_tier3: skip_tier3 ?? false,
})) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
)
}
} catch (error) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
stage: "error",
data: {
message:
error instanceof Error
? error.message
: "An unknown error occurred",
},
})}\n\n`
)
)
} finally {
controller.close()
}
})()
},
})

return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
})
}
5 changes: 5 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export default function RootLayout({
<Link href="/" className="text-lg font-semibold tracking-tight">
◆ Lighthouse
</Link>
<nav className="ml-8 flex items-center gap-6">
<Link href="/scout" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
Scout
</Link>
</nav>
</div>
</nav>
<main className="mx-auto max-w-7xl px-6 py-8">
Expand Down
3 changes: 2 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { UrlInput } from '@/components/url-input'
import { ProspectCard } from '@/components/prospect-card'

interface ProspectNode {
id?: string
title: string
body: string
metadata?: Record<string, any>
Expand Down Expand Up @@ -86,7 +87,7 @@ export default function HomePage() {
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{sorted.map((prospect, idx) => (
<ProspectCard key={prospect.title ?? idx} prospect={prospect} />
<ProspectCard key={prospect.id ?? `${prospect.title}-${idx}`} prospect={prospect} />
))}
</div>
)}
Expand Down
165 changes: 165 additions & 0 deletions app/scout/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"use client"

import { useState, useCallback, useRef } from "react"
import { ScoutInput } from "@/components/scout-input"
import { ScoutProgress } from "@/components/scout-progress"
import { ScoutResultsTable } from "@/components/scout-results-table"
import type { Tier1Result, Tier2Result } from "@/lib/scout/types"

export default function ScoutPage() {
const [running, setRunning] = useState(false)
const [tier1Results, setTier1Results] = useState<Tier1Result[]>([])
const [tier2Results, setTier2Results] = useState<Tier2Result[]>([])
const [tier3Domains, setTier3Domains] = useState<string[]>([])
const [inputCount, setInputCount] = useState(0)
const [tier2Expected, setTier2Expected] = useState(0)
const [tier3Expected, setTier3Expected] = useState(0)
const [error, setError] = useState<string | null>(null)

const abortRef = useRef<AbortController | null>(null)

const handleStart = useCallback(
async (
urls: string[],
options: { tier3Limit: number; skipVercel: boolean }
) => {
setRunning(true)
setTier1Results([])
setTier2Results([])
setTier3Domains([])
setInputCount(urls.length)
setTier2Expected(0)
setTier3Expected(options.tier3Limit)
setError(null)

const controller = new AbortController()
abortRef.current = controller

try {
const res = await fetch("/api/scout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
urls,
tier3_limit: options.tier3Limit,
skip_vercel: options.skipVercel,
skip_tier3: options.tier3Limit === 0,
}),
signal: controller.signal,
})

if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(data.error ?? `Request failed: ${res.status}`)
setRunning(false)
return
}

const reader = res.body?.getReader()
if (!reader) {
setError("No response body")
setRunning(false)
return
}

const decoder = new TextDecoder()
let buffer = ""

while (true) {
const { done, value } = await reader.read()
if (done) break

buffer += decoder.decode(value, { stream: true })

const lines = buffer.split("\n")
buffer = lines.pop() ?? ""

for (const line of lines) {
if (!line.startsWith("data: ")) continue
const json = line.slice(6).trim()
if (!json) continue

try {
const event = JSON.parse(json)

if (event.stage === "tier1") {
const t1 = event.data as Tier1Result
setTier1Results((prev) => [...prev, t1])
if (t1.verdict !== "skip") {
setTier2Expected((prev) => prev + 1)
}
} else if (event.stage === "tier2") {
setTier2Results((prev) => [...prev, event.data as Tier2Result])
} else if (event.stage === "tier3") {
const msg = (event.data as { message: string }).message
if (msg.startsWith("Full analysis complete:")) {
const domain = msg.replace("Full analysis complete: ", "")
setTier3Domains((prev) => [...prev, domain])
}
} else if (event.stage === "complete") {
// Done
} else if (event.stage === "error") {
console.warn("Scout error event:", event.data)
}
} catch {
// Ignore malformed events
}
}
}
} catch (err) {
if ((err as Error).name !== "AbortError") {
setError((err as Error).message ?? "Unknown error")
}
} finally {
setRunning(false)
abortRef.current = null
}
},
[]
)

return (
<div className="mx-auto max-w-6xl px-4 py-12 space-y-8">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Scout</h1>
<p className="text-muted-foreground text-sm">
Batch territory qualification. Paste URLs, get a ranked prospect list.
</p>
</div>

<ScoutInput onStart={handleStart} disabled={running} />

{running && (
<ScoutProgress
tier1={{ done: tier1Results.length, total: inputCount }}
tier2={{ done: tier2Results.length, total: tier2Expected }}
tier3={{ done: tier3Domains.length, total: tier3Expected }}
/>
)}

{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}

<ScoutResultsTable
tier2Results={tier2Results}
tier3Domains={tier3Domains}
tier1Results={tier1Results}
/>

{!running && tier2Results.length === 0 && tier1Results.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground text-sm space-y-2">
<p>
Paste a list of company URLs to qualify them for Vercel.
</p>
<p>
Scout scans headers, qualifies via AI, and runs full analysis on the
top prospects.
</p>
</div>
)}
</div>
)
}
Loading
Loading