← back to Govarbitrage
feat(agents): commercial CRE buyer/leasing agent finder via Google Places (commercial-only, login-gated, rate-limited, cost-surfaced)
74ea2fe1c423bd1a4aa583b5819f0f08c14e3ee1 · 2026-07-25 13:26:51 -0700 · Steve Abrams
Files touched
A src/app/agents/page.tsxA src/app/api/agents/search/route.tsM src/app/page.tsxA src/components/agent-finder.tsxA src/lib/places.ts
Diff
commit 74ea2fe1c423bd1a4aa583b5819f0f08c14e3ee1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Jul 25 13:26:51 2026 -0700
feat(agents): commercial CRE buyer/leasing agent finder via Google Places (commercial-only, login-gated, rate-limited, cost-surfaced)
---
src/app/agents/page.tsx | 59 ++++++++++++
src/app/api/agents/search/route.ts | 44 +++++++++
src/app/page.tsx | 3 +
src/components/agent-finder.tsx | 182 +++++++++++++++++++++++++++++++++++++
src/lib/places.ts | 157 ++++++++++++++++++++++++++++++++
5 files changed, 445 insertions(+)
diff --git a/src/app/agents/page.tsx b/src/app/agents/page.tsx
new file mode 100644
index 0000000..1325cb4
--- /dev/null
+++ b/src/app/agents/page.tsx
@@ -0,0 +1,59 @@
+import Link from "next/link";
+import { getCurrentUser } from "@/lib/current-user";
+import { LogoutButton } from "@/components/logout-button";
+import { AgentFinder } from "@/components/agent-finder";
+
+export const dynamic = "force-dynamic";
+
+export const metadata = {
+ title: "Commercial Real Estate Agents",
+ description:
+ "Find commercial real-estate buyer / acquisition brokers and leasing agents near a government-surplus property, sourced live from Google Places.",
+};
+
+// Deep-linkable from a listing: /agents?city=…&state=…&zip=…&type=both
+export default async function AgentsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ city?: string; state?: string; zip?: string; type?: string }>;
+}) {
+ const [user, sp] = await Promise.all([getCurrentUser(), searchParams]);
+ const type = sp.type === "buyer" || sp.type === "leasing" ? sp.type : "both";
+
+ return (
+ <main className="mx-auto max-w-[1100px] space-y-5 p-5">
+ <header className="flex items-center justify-between">
+ <div>
+ <h1 className="text-xl font-semibold tracking-tight">
+ Commercial <span className="text-primary">Agents</span>
+ </h1>
+ <p className="text-sm text-muted-foreground">
+ Commercial-only buyer / acquisition brokers & leasing agents near a surplus property — live from Google
+ Places.
+ </p>
+ </div>
+ <nav className="flex items-center gap-4 text-sm">
+ <Link href="/" className="text-primary underline-offset-4 hover:underline">
+ ← Dashboard
+ </Link>
+ <Link href="/selling-avenues" className="text-primary underline-offset-4 hover:underline">
+ Selling avenues →
+ </Link>
+ {user && (
+ <span className="flex items-center gap-2 text-muted-foreground">
+ {user.email} · {user.role}
+ <LogoutButton />
+ </span>
+ )}
+ </nav>
+ </header>
+
+ <AgentFinder
+ initialCity={sp.city ?? ""}
+ initialState={sp.state ?? ""}
+ initialZip={sp.zip ?? ""}
+ initialType={type}
+ />
+ </main>
+ );
+}
diff --git a/src/app/api/agents/search/route.ts b/src/app/api/agents/search/route.ts
new file mode 100644
index 0000000..b92acab
--- /dev/null
+++ b/src/app/api/agents/search/route.ts
@@ -0,0 +1,44 @@
+import { NextRequest, NextResponse } from "next/server";
+import { searchCommercialAgents, type AgentKind } from "@/lib/places";
+import { rateLimit } from "@/lib/rate-limit";
+
+export const dynamic = "force-dynamic";
+
+// This route is NOT in middleware's PUBLIC allowlist, so it inherits the app's
+// login gate. We STILL rate-limit: the upstream (Google Places) is a paid API, and
+// one authed session shouldn't be able to run up the bill.
+function clientKey(req: NextRequest): string {
+ const fwd = req.headers.get("x-forwarded-for");
+ return "agents:" + (fwd?.split(",")[0]?.trim() || "local");
+}
+
+export async function GET(req: NextRequest) {
+ const rl = rateLimit(clientKey(req), 20, 60_000);
+ if (!rl.allowed) {
+ return NextResponse.json(
+ { error: "Too many searches — wait a moment." },
+ { status: 429, headers: { "Retry-After": String(rl.retryAfterSec) } },
+ );
+ }
+
+ const sp = req.nextUrl.searchParams;
+ const city = sp.get("city") ?? undefined;
+ const state = sp.get("state") ?? undefined;
+ const zip = sp.get("zip") ?? undefined;
+ const typeParam = (sp.get("type") ?? "both").toLowerCase();
+ const kinds: AgentKind[] =
+ typeParam === "buyer" ? ["buyer"] : typeParam === "leasing" ? ["leasing"] : ["buyer", "leasing"];
+
+ if (!city && !state && !zip) {
+ return NextResponse.json({ error: "Provide a city, state, or ZIP." }, { status: 400 });
+ }
+
+ try {
+ const data = await searchCommercialAgents({ city, state, zip, kinds });
+ return NextResponse.json(data);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : "Search failed";
+ const status = /not configured/.test(msg) ? 503 : 502;
+ return NextResponse.json({ error: msg }, { status });
+ }
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 105a894..d5fdc86 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -27,6 +27,9 @@ export default async function DashboardPage() {
<a href="/selling-avenues" className="text-primary underline-offset-4 hover:underline">
Selling avenues →
</a>
+ <a href="/agents" className="text-primary underline-offset-4 hover:underline">
+ Commercial agents →
+ </a>
<a href="/newsletter" className="text-primary underline-offset-4 hover:underline">
Newsletter →
</a>
diff --git a/src/components/agent-finder.tsx b/src/components/agent-finder.tsx
new file mode 100644
index 0000000..cdd9d45
--- /dev/null
+++ b/src/components/agent-finder.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+import { useState } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+type Kind = "buyer" | "leasing";
+type TypeOpt = "buyer" | "leasing" | "both";
+
+interface AgentResult {
+ id: string;
+ name: string;
+ address: string | null;
+ rating: number | null;
+ reviews: number;
+ phone: string | null;
+ website: string | null;
+ mapsUrl: string | null;
+ primaryType: string | null;
+ kinds: Kind[];
+}
+interface ApiResponse {
+ location: string;
+ kinds: Kind[];
+ results: AgentResult[];
+ queries: number;
+}
+
+const KIND_LABEL: Record<Kind, string> = { buyer: "Buyer / acquisition", leasing: "Leasing" };
+// Google Places Text Search (New) with contact + atmosphere fields ≈ $0.04 / query.
+const COST_PER_QUERY = 0.04;
+
+export function AgentFinder({
+ initialCity,
+ initialState,
+ initialZip,
+ initialType,
+}: {
+ initialCity: string;
+ initialState: string;
+ initialZip: string;
+ initialType: TypeOpt;
+}) {
+ const [city, setCity] = useState(initialCity);
+ const [state, setState] = useState(initialState);
+ const [zip, setZip] = useState(initialZip);
+ const [type, setType] = useState<TypeOpt>(initialType);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [data, setData] = useState<ApiResponse | null>(null);
+
+ async function run(e?: React.FormEvent) {
+ e?.preventDefault();
+ if (!city.trim() && !state.trim() && !zip.trim()) {
+ setError("Enter a city, state, or ZIP.");
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ const qs = new URLSearchParams();
+ if (city.trim()) qs.set("city", city.trim());
+ if (state.trim()) qs.set("state", state.trim());
+ if (zip.trim()) qs.set("zip", zip.trim());
+ qs.set("type", type);
+ const res = await fetch(`/api/agents/search?${qs.toString()}`);
+ const json = await res.json();
+ if (!res.ok) throw new Error(json.error || "Search failed");
+ setData(json as ApiResponse);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Search failed");
+ setData(null);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const estCost = (type === "both" ? 2 : 1) * COST_PER_QUERY;
+
+ return (
+ <div className="space-y-5">
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-foreground">Find commercial agents near a property</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <form onSubmit={run} className="grid gap-3 sm:grid-cols-[1fr_110px_110px_auto]">
+ <Input placeholder="City (e.g. Los Angeles)" value={city} onChange={(e) => setCity(e.target.value)} />
+ <Input placeholder="State (CA)" value={state} onChange={(e) => setState(e.target.value)} />
+ <Input placeholder="ZIP" value={zip} onChange={(e) => setZip(e.target.value)} />
+ <Button type="submit" disabled={loading}>
+ {loading ? "Searching…" : "Search"}
+ </Button>
+ </form>
+ <div className="mt-3 flex flex-wrap items-center gap-2 text-sm">
+ <span className="text-muted-foreground">Show:</span>
+ {(["both", "buyer", "leasing"] as TypeOpt[]).map((t) => (
+ <button
+ key={t}
+ type="button"
+ onClick={() => setType(t)}
+ className={`rounded-full border px-3 py-1 text-xs transition-colors ${
+ type === t
+ ? "border-primary text-primary"
+ : "border-border text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ {t === "both" ? "Buyer + Leasing" : t === "buyer" ? "Buyer / acquisition" : "Leasing"}
+ </button>
+ ))}
+ </div>
+ <p className="mt-2 text-xs text-muted-foreground">
+ Commercial only — residential home agents are filtered out. Live Google Places lookup
+ (~${estCost.toFixed(2)} per search{type === "both" ? ", 2 categories" : ""}).
+ </p>
+ </CardContent>
+ </Card>
+
+ {error && <p className="text-sm text-warning">{error}</p>}
+
+ {data && (
+ <div className="space-y-3">
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
+ <span>
+ {data.results.length} commercial agent{data.results.length === 1 ? "" : "s"} near{" "}
+ <span className="font-medium text-foreground">{data.location}</span>
+ </span>
+ <span>Cost this search: ${(data.queries * COST_PER_QUERY).toFixed(2)}</span>
+ </div>
+ {data.results.length === 0 && (
+ <p className="text-sm text-muted-foreground">No commercial agents found — try a broader location.</p>
+ )}
+ <div className="grid gap-3 sm:grid-cols-2">
+ {data.results.map((a) => (
+ <Card key={a.id}>
+ <CardContent className="space-y-2 p-4 text-sm">
+ <div className="flex items-start justify-between gap-2">
+ <span className="font-medium text-foreground">{a.name}</span>
+ {a.rating != null && (
+ <span className="whitespace-nowrap text-xs text-muted-foreground">
+ ★ {a.rating.toFixed(1)} ({a.reviews})
+ </span>
+ )}
+ </div>
+ <div className="flex flex-wrap gap-1">
+ {a.kinds.map((k) => (
+ <Badge key={k} variant="primary">
+ {KIND_LABEL[k]}
+ </Badge>
+ ))}
+ {a.primaryType && <Badge variant="outline">{a.primaryType}</Badge>}
+ </div>
+ {a.address && <p className="text-muted-foreground">{a.address}</p>}
+ <div className="flex flex-wrap gap-3 pt-1 text-xs">
+ {a.phone && (
+ <a href={`tel:${a.phone}`} className="text-primary hover:underline">
+ {a.phone}
+ </a>
+ )}
+ {a.website && (
+ <a href={a.website} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
+ Website ↗
+ </a>
+ )}
+ {a.mapsUrl && (
+ <a href={a.mapsUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
+ Google Maps ↗
+ </a>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ <p className="pt-2 text-xs text-muted-foreground">Business data powered by Google.</p>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/src/lib/places.ts b/src/lib/places.ts
new file mode 100644
index 0000000..be314e3
--- /dev/null
+++ b/src/lib/places.ts
@@ -0,0 +1,157 @@
+// Google Places API (New) — Text Search for COMMERCIAL real-estate professionals
+// near a government-surplus property: buyer / acquisition brokers + leasing agents.
+//
+// Commercial-only by construction: the text queries target commercial CRE, and a
+// light residential filter drops obvious home-sale / apartment-locator results
+// ("just commercial for this build"). Server-side ONLY — the API key never reaches
+// the client. Live-query with `no-store`; we don't persist Google place data beyond
+// the response (Places ToS: no long-term caching of place fields except the id).
+
+export type AgentKind = "buyer" | "leasing";
+
+export interface AgentResult {
+ id: string;
+ name: string;
+ address: string | null;
+ rating: number | null;
+ reviews: number;
+ phone: string | null;
+ website: string | null;
+ mapsUrl: string | null;
+ primaryType: string | null;
+ kinds: AgentKind[]; // which query lens(es) surfaced it
+}
+
+export interface CommercialAgentSearch {
+ city?: string;
+ state?: string;
+ zip?: string;
+ kinds: AgentKind[];
+}
+
+export interface CommercialAgentResponse {
+ location: string;
+ kinds: AgentKind[];
+ results: AgentResult[];
+ queries: number; // billed Places Text Search calls (for cost surfacing)
+}
+
+const ENDPOINT = "https://places.googleapis.com/v1/places:searchText";
+const FIELD_MASK = [
+ "places.id",
+ "places.displayName",
+ "places.formattedAddress",
+ "places.rating",
+ "places.userRatingCount",
+ "places.nationalPhoneNumber",
+ "places.websiteUri",
+ "places.googleMapsUri",
+ "places.businessStatus",
+ "places.primaryTypeDisplayName",
+].join(",");
+
+const KIND_QUERY: Record<AgentKind, string> = {
+ buyer: "commercial real estate broker",
+ leasing: "commercial real estate leasing agent",
+};
+
+// Obvious residential-only signals to exclude; commercial signals to keep.
+const RESIDENTIAL_RE =
+ /\b(residential|homes?\s+for\s+sale|home\s+loans?|apartment\s+locator|realty\s+homes|home\s+realty|houses?\s+for\s+sale)\b/i;
+const COMMERCIAL_RE =
+ /\b(commercial|CRE|industrial|retail|office|investment|tenant\s+rep|net\s+lease)\b/i;
+
+interface RawPlace {
+ id?: string;
+ displayName?: { text?: string };
+ formattedAddress?: string;
+ rating?: number;
+ userRatingCount?: number;
+ nationalPhoneNumber?: string;
+ websiteUri?: string;
+ googleMapsUri?: string;
+ businessStatus?: string;
+ primaryTypeDisplayName?: { text?: string };
+}
+
+function locationString(city?: string, state?: string, zip?: string): string {
+ return [city?.trim(), state?.trim(), zip?.trim()].filter(Boolean).join(", ");
+}
+
+function isCommercial(p: RawPlace): boolean {
+ const name = p.displayName?.text ?? "";
+ if (COMMERCIAL_RE.test(name)) return true;
+ if (RESIDENTIAL_RE.test(name)) return false;
+ // No explicit signal either way — the query was commercial, so keep it.
+ return true;
+}
+
+async function textSearch(query: string, apiKey: string): Promise<RawPlace[]> {
+ const res = await fetch(ENDPOINT, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Goog-Api-Key": apiKey,
+ "X-Goog-FieldMask": FIELD_MASK,
+ },
+ body: JSON.stringify({ textQuery: query, maxResultCount: 20, languageCode: "en", regionCode: "US" }),
+ cache: "no-store",
+ });
+ if (!res.ok) {
+ const detail = await res.text().catch(() => "");
+ throw new Error(`Places ${res.status}: ${detail.slice(0, 200)}`);
+ }
+ const json = (await res.json()) as { places?: RawPlace[] };
+ return json.places ?? [];
+}
+
+export async function searchCommercialAgents(
+ input: CommercialAgentSearch,
+): Promise<CommercialAgentResponse> {
+ const apiKey = process.env.GOOGLE_PLACES_API_KEY;
+ if (!apiKey) throw new Error("GOOGLE_PLACES_API_KEY is not configured");
+ const loc = locationString(input.city, input.state, input.zip);
+ if (!loc) throw new Error("A city, state, or ZIP is required");
+
+ const kinds = input.kinds.length ? input.kinds : (["buyer", "leasing"] as AgentKind[]);
+ const byId = new Map<string, AgentResult>();
+ let queries = 0;
+
+ for (const kind of kinds) {
+ queries++;
+ const places = await textSearch(`${KIND_QUERY[kind]} in ${loc}`, apiKey);
+ for (const p of places) {
+ if (!p.id || !p.displayName?.text) continue;
+ if (p.businessStatus && p.businessStatus !== "OPERATIONAL") continue;
+ if (!isCommercial(p)) continue;
+ const existing = byId.get(p.id);
+ if (existing) {
+ if (!existing.kinds.includes(kind)) existing.kinds.push(kind);
+ continue;
+ }
+ byId.set(p.id, {
+ id: p.id,
+ name: p.displayName.text,
+ address: p.formattedAddress ?? null,
+ rating: typeof p.rating === "number" ? p.rating : null,
+ reviews: p.userRatingCount ?? 0,
+ phone: p.nationalPhoneNumber ?? null,
+ website: p.websiteUri ?? null,
+ mapsUrl: p.googleMapsUri ?? null,
+ primaryType: p.primaryTypeDisplayName?.text ?? null,
+ kinds: [kind],
+ });
+ }
+ }
+
+ // Rank by rating weighted by review volume, then rating, then review count.
+ const results = [...byId.values()].sort((a, b) => {
+ const sa = (a.rating ?? 0) * Math.log((a.reviews ?? 0) + 1);
+ const sb = (b.rating ?? 0) * Math.log((b.reviews ?? 0) + 1);
+ if (sb !== sa) return sb - sa;
+ if ((b.rating ?? 0) !== (a.rating ?? 0)) return (b.rating ?? 0) - (a.rating ?? 0);
+ return (b.reviews ?? 0) - (a.reviews ?? 0);
+ });
+
+ return { location: loc, kinds, results, queries };
+}
← 4501587 fleet-sso: bump minted role VIEWER->ANALYST + PREMIUM tier f
·
back to Govarbitrage
·
chore: lint, refactor (ssrf fe80::/10 mask, middleware dedup dd2a641 →