← back to Govarbitrage
src/components/agent-finder.tsx
338 lines
"use client";
import { useEffect, useMemo, 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;
// The engine fires this many curated query lenses per kind (see KIND_LENSES in
// src/lib/places.ts). Each lens is one billed Text Search call, so the estimate
// must be lenses-per-kind × kinds × COST_PER_QUERY to match what actually bills.
const LENSES_PER_KIND = 3;
// Results grid — sort + density controls (Steve standing rule: every grid gets
// sort + density, persisted to localStorage). Sorting is client-side over the
// already-fetched results (no extra Places cost).
type SortKey = "best" | "rating" | "reviews" | "name";
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
{ key: "best", label: "Best match" },
{ key: "rating", label: "Rating ↓" },
{ key: "reviews", label: "Most reviews" },
{ key: "name", label: "Name A→Z" },
];
const LS_SORT = "agents.sort";
const LS_CARDMIN = "agents.cardMin";
const CARDMIN_MIN = 220;
const CARDMIN_MAX = 440;
const CARDMIN_DEFAULT = 300;
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);
// Sort + density (persisted). Read from localStorage after mount to avoid an
// SSR/hydration mismatch — both server and first client render use defaults,
// then we hydrate the stored prefs on the client. This is a deliberate
// synchronize-external-state-into-React effect (localStorage is the external
// store), so the set-state-in-effect rule is intentionally disabled here.
const [sort, setSort] = useState<SortKey>("best");
const [cardMin, setCardMin] = useState<number>(CARDMIN_DEFAULT);
useEffect(() => {
try {
const s = localStorage.getItem(LS_SORT) as SortKey | null;
/* eslint-disable react-hooks/set-state-in-effect -- deliberate: hydrate persisted prefs from localStorage (external store) after mount */
if (s && SORT_OPTIONS.some((o) => o.key === s)) setSort(s);
const d = Number(localStorage.getItem(LS_CARDMIN));
if (d >= CARDMIN_MIN && d <= CARDMIN_MAX) setCardMin(d);
/* eslint-enable react-hooks/set-state-in-effect */
} catch {}
}, []);
useEffect(() => {
try {
localStorage.setItem(LS_SORT, sort);
} catch {}
}, [sort]);
useEffect(() => {
try {
localStorage.setItem(LS_CARDMIN, String(cardMin));
} catch {}
}, [cardMin]);
const sortedResults = useMemo(() => {
const list = data ? [...data.results] : [];
switch (sort) {
case "rating":
return list.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0) || b.reviews - a.reviews);
case "reviews":
return list.sort((a, b) => b.reviews - a.reviews);
case "name":
return list.sort((a, b) => a.name.localeCompare(b.name));
default:
return list; // "best" = server's ranked order
}
}, [data, sort]);
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 kindCount = type === "both" ? 2 : 1;
const estCost = kindCount * LENSES_PER_KIND * COST_PER_QUERY;
return (
<div className="space-y-5">
<Card>
<CardHeader>
<CardTitle className="text-foreground">Find commercial real-estate agents by location</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={run} className="grid gap-3 sm:grid-cols-[1fr_110px_110px_auto]">
<Input
aria-label="City"
placeholder="City (e.g. Los Angeles)"
value={city}
onChange={(e) => setCity(e.target.value)}
/>
<Input
aria-label="State"
placeholder="State (CA)"
value={state}
onChange={(e) => setState(e.target.value)}
/>
<Input
aria-label="ZIP code"
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" role="group" aria-label="Agent type filter">
<span className="text-muted-foreground" id="agent-type-label">
Show:
</span>
{(["both", "buyer", "leasing"] as TypeOpt[]).map((t) => (
<button
key={t}
type="button"
aria-pressed={type === t}
onClick={() => setType(t)}
className={`rounded-full border px-3 py-1 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 ${
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 — {kindCount * LENSES_PER_KIND} queries
{type === "both" ? ", 2 categories × 3 lenses" : " (3 lenses)"}).
</p>
</CardContent>
</Card>
{error && (
<p role="alert" className="text-sm text-warning">
{error}
</p>
)}
{/* Screen-reader status: announces searching / result count changes. */}
<p className="sr-only" role="status" aria-live="polite">
{loading
? "Searching for commercial agents…"
: data
? `${data.results.length} commercial agents found near ${data.location}`
: ""}
</p>
{/* Loading skeleton — a shaped placeholder grid while the first search runs,
so the panel doesn't collapse to a bare "Searching…" button. */}
{loading && !data && (
<div
aria-hidden
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}
>
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardContent className="space-y-3 p-4">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
<div className="flex gap-1">
<div className="h-5 w-24 animate-pulse rounded-full bg-muted" />
<div className="h-5 w-16 animate-pulse rounded-full bg-muted" />
</div>
<div className="h-3 w-full animate-pulse rounded bg-muted" />
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
))}
</div>
)}
{data && (
<div className="space-y-3" aria-busy={loading}>
<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>
)}
{data.results.length > 1 && (
<div className="flex flex-wrap items-center gap-4 text-sm">
<label className="flex items-center gap-2">
<span className="text-muted-foreground">Sort</span>
<select
aria-label="Sort results"
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
className="rounded-md border border-border bg-transparent px-2 py-1 text-xs text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{SORT_OPTIONS.map((o) => (
<option key={o.key} value={o.key}>
{o.label}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="text-muted-foreground">Density</span>
{/* Right = denser (more, narrower cards): invert the card-min width. */}
<input
type="range"
min={CARDMIN_MIN}
max={CARDMIN_MAX}
step={20}
value={CARDMIN_MIN + CARDMIN_MAX - cardMin}
onChange={(e) => setCardMin(CARDMIN_MIN + CARDMIN_MAX - Number(e.target.value))}
className="accent-primary"
aria-label="Grid density"
/>
</label>
</div>
)}
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}
>
{sortedResults.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>
);
}