[object Object]

← 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

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 &amp; 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 →