← 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>
  );
}