← back to Homesonspec

apps/web/src/app/search/SearchClient.tsx

473 lines

"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { fmtPrice, VerificationBadge } from "@homesonspec/shared-ui";

// Leaflet needs window — client-only load, direct file import (not the barrel).
const MapView = dynamic(() => import("@homesonspec/shared-ui/src/MapView").then((m) => m.MapView), { ssr: false });

interface Home {
  id: string;
  street: string | null;
  city: string;
  state: string;
  zip: string;
  price: string | number | null;
  images: string[];
  beds: number | null;
  bathsTotal: string | number | null;
  sqft: number | null;
  stories: number | null;
  garageSpaces: number | null;
  lat: string | number | null;
  lon: string | number | null;
  constructionStatus: string;
  estCompletionDate: string | null;
  verificationLabel: string;
  lastVerifiedAt: string | null;
  publishedAt: string;
  community: { slug: string; name: string; schoolDistrict: string | null };
  builder: { slug: string; name: string };
}

interface SearchResponse {
  homes: Home[];
  total: number;
  page: number;
  pageSize: number;
  location: { label: string; lat: number; lon: number } | null;
}

interface Facets {
  constructionStatus: { value: string; count: number }[];
  builder: { value: string; label: string; count: number }[];
  beds: { value: number; count: number }[];
  homeType: { value: string; count: number }[];
  state: { value: string; count: number }[];
}

const STATE_NAMES: Record<string, string> = {
  AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas", CA: "California", CO: "Colorado",
  CT: "Connecticut", DE: "Delaware", FL: "Florida", GA: "Georgia", HI: "Hawaii", ID: "Idaho",
  IL: "Illinois", IN: "Indiana", IA: "Iowa", KS: "Kansas", KY: "Kentucky", LA: "Louisiana",
  ME: "Maine", MD: "Maryland", MA: "Massachusetts", MI: "Michigan", MN: "Minnesota", MS: "Mississippi",
  MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada", NH: "New Hampshire", NJ: "New Jersey",
  NM: "New Mexico", NY: "New York", NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma",
  OR: "Oregon", PA: "Pennsylvania", RI: "Rhode Island", SC: "South Carolina", SD: "South Dakota",
  TN: "Tennessee", TX: "Texas", UT: "Utah", VT: "Vermont", VA: "Virginia", WA: "Washington",
  WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming", DC: "Washington DC",
};

const SORTS = [
  { value: "newest", label: "Newest" },
  { value: "price_asc", label: "Price ↑" },
  { value: "price_desc", label: "Price ↓" },
  { value: "sqft_desc", label: "Sq Ft ↓" },
  { value: "closest", label: "Closest" },
] as const;

const STATUS_LABELS: Record<string, string> = {
  MOVE_IN_READY: "Move-in ready",
  UNDER_CONSTRUCTION: "Under construction",
  PLANNED: "Planned",
};

// national-site card upgrades: colored urgency badges + an estimated monthly payment.
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
  MOVE_IN_READY: { label: "Move-in ready", cls: "bg-amber-100 text-amber-800 ring-1 ring-amber-200" },
  UNDER_CONSTRUCTION: { label: "Under construction", cls: "bg-sky-100 text-sky-800 ring-1 ring-sky-200" },
  PLANNED: { label: "Planned", cls: "bg-neutral-100 text-neutral-600 ring-1 ring-neutral-200" },
};
// est. all-in monthly: 20% down, 7% APR, 30yr P&I + ~1.25%/yr taxes+insurance.
function estMonthly(price: string | number | null): number | null {
  const p = Number(price);
  if (!Number.isFinite(p) || p <= 0) return null;
  const loan = p * 0.8, r = 0.07 / 12, n = 360;
  const pi = (loan * (r * Math.pow(1 + r, n))) / (Math.pow(1 + r, n) - 1);
  return Math.round(pi + (p * 0.0125) / 12);
}

export default function SearchClient({ initialQuery }: { initialQuery: string }) {
  const [q, setQ] = useState(initialQuery);
  const [filters, setFilters] = useState<Record<string, string | string[]>>({});
  // Sort + density persist across reloads (standing grid rule).
  const [sort, setSort] = useState<string>("newest");
  const [cols, setCols] = useState<number>(3);
  const [data, setData] = useState<SearchResponse | null>(null);
  const [facets, setFacets] = useState<Facets | null>(null);
  const [loading, setLoading] = useState(true);
  const [page, setPage] = useState(1);
  const bboxRef = useRef<string | null>(null);
  // Favorites (national-site staple) — persisted client-side.
  const [favs, setFavs] = useState<Set<string>>(new Set());
  useEffect(() => { try { setFavs(new Set(JSON.parse(localStorage.getItem("homesonspec.favs") || "[]"))); } catch {} }, []);
  const toggleFav = (id: string) => setFavs((prev) => {
    const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id);
    localStorage.setItem("homesonspec.favs", JSON.stringify([...n])); return n;
  });

  useEffect(() => {
    const savedSort = localStorage.getItem("homesonspec.sort");
    const savedCols = localStorage.getItem("homesonspec.cols");
    if (savedSort) setSort(savedSort);
    if (savedCols) setCols(Number(savedCols));
  }, []);

  const setSortPersist = (value: string) => {
    setSort(value);
    localStorage.setItem("homesonspec.sort", value);
  };
  const setColsPersist = (value: number) => {
    setCols(value);
    localStorage.setItem("homesonspec.cols", String(value));
  };

  const queryString = useMemo(() => {
    const params = new URLSearchParams();
    if (q) params.set("q", q);
    if (bboxRef.current) params.set("bbox", bboxRef.current);
    for (const [key, value] of Object.entries(filters)) {
      if (Array.isArray(value)) value.forEach((v) => params.append(key, v));
      else if (value !== "") params.set(key, value);
    }
    params.set("sort", sort);
    params.set("page", String(page));
    return params.toString();
  }, [q, filters, sort, page]);

  const fetchResults = useCallback(async () => {
    setLoading(true);
    try {
      const [searchRes, facetsRes] = await Promise.all([
        fetch(`/api/search?${queryString}`),
        fetch(`/api/facets?${queryString}`),
      ]);
      setData(await searchRes.json());
      setFacets(await facetsRes.json());
    } finally {
      setLoading(false);
    }
  }, [queryString]);

  useEffect(() => {
    void fetchResults();
  }, [fetchResults]);

  const toggleMulti = (key: string, value: string) => {
    setPage(1);
    setFilters((prev) => {
      const current = Array.isArray(prev[key]) ? (prev[key] as string[]) : [];
      const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
      return { ...prev, [key]: next };
    });
  };
  const setSingle = (key: string, value: string) => {
    setPage(1);
    setFilters((prev) => ({ ...prev, [key]: value }));
  };

  const markers = (data?.homes ?? [])
    .filter((h) => h.lat !== null && h.lon !== null)
    .map((h) => ({
      id: h.id,
      lat: Number(h.lat),
      lon: Number(h.lon),
      label: h.street ?? `${h.community.name} home`,
      sublabel: fmtPrice(h.price),
      href: `/homes/${h.id}`,
    }));

  const onMapMove = useCallback(
    (bbox: { minLat: number; maxLat: number; minLon: number; maxLon: number }) => {
      bboxRef.current = `${bbox.minLat},${bbox.minLon},${bbox.maxLat},${bbox.maxLon}`;
    },
    [],
  );

  const totalPages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;

  return (
    <div className="mx-auto max-w-7xl px-4 py-6">
      {/* Search bar */}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          bboxRef.current = null;
          setPage(1);
          void fetchResults();
        }}
        className="flex gap-2"
      >
        <input
          value={q}
          onChange={(e) => setQ(e.target.value)}
          placeholder="City, ZIP, or community"
          className="field max-w-md flex-1 py-2"
          data-testid="search-input"
        />
        <button type="submit" className="btn-primary">
          Search
        </button>
      </form>

      <div className="mt-6 grid gap-6 lg:grid-cols-[280px_1fr]">
        {/* Amazon-style faceted rail — every dimension a checkable filter with live counts. */}
        <aside className="text-sm lg:sticky lg:top-[68px] lg:max-h-[calc(100vh-84px)] lg:overflow-y-auto lg:pr-1" data-testid="filters">
          <div className="mb-3 flex items-center justify-between">
            <h2 className="font-display text-base font-semibold text-brand-900">Filter homes</h2>
            {Object.keys(filters).length > 0 && (
              <button type="button" className="text-xs font-semibold text-brand-700 hover:text-brand-800"
                onClick={() => { setPage(1); setFilters({}); }}>
                Clear all
              </button>
            )}
          </div>

          {/* State — multi-select, top 12 + overflow. */}
          <FacetSection title="State">
            <ChecklistOverflow
              items={(facets?.state ?? []).map((s) => ({ value: s.value, label: STATE_NAMES[s.value] ?? s.value, count: s.count }))}
              selected={Array.isArray(filters.st) ? (filters.st as string[]) : []}
              onToggle={(v) => toggleMulti("st", v)}
              testid="facet-state"
            />
          </FacetSection>

          {/* Builder / firm — multi-select. */}
          <FacetSection title="Builder">
            <ChecklistOverflow
              items={(facets?.builder ?? []).map((b) => ({ value: b.value, label: b.label, count: b.count }))}
              selected={Array.isArray(filters.builder) ? (filters.builder as string[]) : []}
              onToggle={(v) => toggleMulti("builder", v)}
              testid="facet-builder"
            />
          </FacetSection>

          {/* Min Sq Ft — single-select chips. */}
          <FacetSection title="Min sq ft">
            <div className="flex flex-wrap gap-1.5" data-testid="facet-sqft">
              {[1000, 1500, 2000, 2500, 3000].map((n) => (
                <button key={n} type="button"
                  onClick={() => setSingle("sqftMin", filters.sqftMin === String(n) ? "" : String(n))}
                  className={`rounded-full border px-3 py-1 text-xs transition ${filters.sqftMin === String(n) ? "border-brand-600 bg-brand-50 font-semibold text-brand-800" : "border-neutral-300 hover:border-brand-300"}`}>
                  {n.toLocaleString()}+
                </button>
              ))}
            </div>
          </FacetSection>

          <FacetSection title="Price">
            <div className="flex gap-2">
              <input type="number" placeholder="Min $" className="w-full rounded-lg border-0 px-3 py-1.5 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
                onChange={(e) => setSingle("priceMin", e.target.value)} data-testid="price-min" />
              <input type="number" placeholder="Max $" className="w-full rounded-lg border-0 px-3 py-1.5 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
                onChange={(e) => setSingle("priceMax", e.target.value)} data-testid="price-max" />
            </div>
          </FacetSection>

          <FacetSection title="Beds (min)">
            <div className="flex gap-1.5">
              {[1, 2, 3, 4, 5].map((n) => (
                <button key={n} type="button" data-testid={`beds-${n}`}
                  onClick={() => setSingle("bedsMin", filters.bedsMin === String(n) ? "" : String(n))}
                  className={`rounded-full border px-3 py-1 text-xs transition ${filters.bedsMin === String(n) ? "border-brand-600 bg-brand-50 font-semibold text-brand-800" : "border-neutral-300 hover:border-brand-300"}`}>
                  {n}+
                </button>
              ))}
            </div>
          </FacetSection>

          <FacetSection title="Construction status">
            <div className="space-y-0.5">
              {facets?.constructionStatus.map((facet) => (
                <FacetCheck key={facet.value}
                  label={STATUS_LABELS[facet.value] ?? facet.value} count={facet.count}
                  checked={Array.isArray(filters.status) && (filters.status as string[]).includes(facet.value)}
                  onToggle={() => toggleMulti("status", facet.value)} />
              ))}
            </div>
          </FacetSection>

          <FacetSection title="More">
            <label className="flex cursor-pointer items-center gap-2 py-0.5">
              <input type="checkbox" className="h-4 w-4 rounded accent-brand-700" checked={filters.incentives === "1"}
                onChange={() => setSingle("incentives", filters.incentives === "1" ? "" : "1")} />
              Has incentives
            </label>
            <label className="flex cursor-pointer items-center gap-2 py-0.5">
              <input type="checkbox" className="h-4 w-4 rounded accent-brand-700" checked={filters.ageRestricted === "1"}
                onChange={() => setSingle("ageRestricted", filters.ageRestricted === "1" ? "" : "1")} />
              Age-restricted community
            </label>
            <select className="mt-2 w-full rounded-lg border-0 px-3 py-1.5 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
              value={(filters.moveInByMonths as string) ?? ""}
              onChange={(e) => setSingle("moveInByMonths", e.target.value)}>
              <option value="">Any move-in timeframe</option>
              <option value="0">Ready now</option>
              <option value="3">Within 3 months</option>
              <option value="6">Within 6 months</option>
              <option value="12">Within 12 months</option>
            </select>
          </FacetSection>
        </aside>

        {/* Results */}
        <div>
          <div className="flex flex-wrap items-center justify-between gap-3">
            <div className="text-sm text-neutral-600" data-testid="result-count">
              {loading ? "Searching…" : `${data?.total ?? 0} homes${data?.location ? ` near ${data.location.label}` : ""}`}
            </div>
            {/* Mandatory grid controls: sort select + density slider, localStorage-persisted */}
            <div className="flex items-center gap-4">
              <label className="flex items-center gap-2 text-sm">
                Sort
                <select value={sort} onChange={(e) => setSortPersist(e.target.value)}
                  className="rounded-lg border-0 px-2 py-1 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400" data-testid="sort-select">
                  {SORTS.map((s) => (
                    <option key={s.value} value={s.value}>{s.label}</option>
                  ))}
                </select>
              </label>
              <label className="flex items-center gap-2 text-sm">
                Density
                <input type="range" min={2} max={4} step={1} value={cols} className="accent-brand-700"
                  onChange={(e) => setColsPersist(Number(e.target.value))} data-testid="density-slider" />
              </label>
            </div>
          </div>

          <div className="mt-4 grid gap-4 lg:grid-cols-[1fr_380px]">
            <div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }} data-testid="results-grid">
              {(data?.homes ?? []).map((home) => (
                <Link key={home.id} href={`/homes/${home.id}`}
                  className="card-interactive relative overflow-hidden"
                  data-testid="home-card">
                  {/* Photo header — the primary scan signal for a real-estate grid. Falls back
                      to a branded gradient when a home has no photo (image-less builders) or when
                      the media-rights gate blanks images upstream, so the grid stays uniform. */}
                  <div className="relative h-44 bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500">
                    {home.images?.[0]?.startsWith("http") ? (
                      // eslint-disable-next-line @next/next/no-img-element
                      <img src={home.images[0]} alt={`${home.community.name} home in ${home.city}, ${home.state}`}
                        loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
                    ) : null}
                    <button type="button" aria-label="Save home" data-testid="fav-btn"
                      onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(home.id); }}
                      className="absolute right-3 top-3 z-10 text-lg leading-none drop-shadow transition hover:scale-110">
                      <span className={favs.has(home.id) ? "text-rose-500" : "text-white/90"}>{favs.has(home.id) ? "♥" : "♡"}</span>
                    </button>
                    {STATUS_BADGE[home.constructionStatus] && (
                      <span className={`absolute left-3 top-3 inline-block rounded-full px-2 py-0.5 text-[11px] font-medium shadow-sm ${STATUS_BADGE[home.constructionStatus]!.cls}`}>
                        {STATUS_BADGE[home.constructionStatus]!.label}
                      </span>
                    )}
                  </div>
                  <div className="p-4">
                    <div className="flex items-baseline gap-2">
                      <span className="font-display text-xl font-semibold text-brand-900">{fmtPrice(home.price)}</span>
                      {estMonthly(home.price) !== null && (
                        <span className="text-xs text-neutral-500">~${estMonthly(home.price)!.toLocaleString()}/mo</span>
                      )}
                    </div>
                    <div className="mt-0.5 truncate text-sm text-neutral-700">
                      {home.street ?? "Address on request"} · {home.city}, {home.state}
                    </div>
                    <div className="mt-1 text-sm text-neutral-500">
                      {home.beds ?? "—"} bd · {home.bathsTotal ?? "—"} ba · {home.sqft?.toLocaleString() ?? "—"} sqft
                      {home.estCompletionDate ? ` · ready ${new Date(home.estCompletionDate).toLocaleDateString(undefined, { month: "short", year: "numeric" })}` : ""}
                    </div>
                    <div className="mt-2 flex items-center justify-between">
                      <span className="text-xs text-neutral-500">{home.builder.name}</span>
                      <VerificationBadge label={home.verificationLabel} />
                    </div>
                  </div>
                </Link>
              ))}
              {!loading && (data?.homes.length ?? 0) === 0 && (
                <p className="col-span-full py-10 text-center text-neutral-500">
                  No homes match these filters.
                </p>
              )}
            </div>
            <div className="h-[540px] overflow-hidden rounded-2xl border border-neutral-200 shadow-[var(--shadow-card)]" data-testid="map">
              <MapView markers={markers} onMoveEnd={onMapMove} />
            </div>
          </div>

          {totalPages > 1 && (
            <div className="mt-6 flex items-center justify-center gap-3 text-sm">
              <button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}
                className="rounded-full border border-neutral-300 px-4 py-1.5 hover:border-brand-300 disabled:opacity-40">Prev</button>
              <span className="text-neutral-600">Page {page} of {totalPages}</span>
              <button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}
                className="rounded-full border border-neutral-300 px-4 py-1.5 hover:border-brand-300 disabled:opacity-40">Next</button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// ── Faceted-rail primitives ────────────────────────────────────────────────
function FacetSection({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <div className="border-t border-neutral-100 py-3 first:border-t-0">
      <h3 className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-brand-600">{title}</h3>
      {children}
    </div>
  );
}

function FacetCheck({
  label, count, checked, onToggle,
}: { label: string; count: number; checked: boolean; onToggle: () => void }) {
  return (
    <label className="flex cursor-pointer items-center justify-between gap-2 rounded-lg px-1 py-1 hover:bg-brand-50">
      <span className="flex items-center gap-2">
        <input type="checkbox" className="h-4 w-4 rounded accent-brand-700" checked={checked} onChange={onToggle} />
        <span className="truncate">{label}</span>
      </span>
      <span className="shrink-0 text-xs text-neutral-400">{count.toLocaleString()}</span>
    </label>
  );
}

// Shows the first `cap` options; the rest live inside a native <details> so the
// rail stays short with long lists (all 50 states, many builders).
function ChecklistOverflow({
  items, selected, onToggle, cap = 12, testid,
}: {
  items: { value: string; label: string; count: number }[];
  selected: string[];
  onToggle: (v: string) => void;
  cap?: number;
  testid?: string;
}) {
  const head = items.slice(0, cap);
  const rest = items.slice(cap);
  return (
    <div className="space-y-0.5" data-testid={testid}>
      {head.map((it) => (
        <FacetCheck key={it.value} label={it.label} count={it.count}
          checked={selected.includes(it.value)} onToggle={() => onToggle(it.value)} />
      ))}
      {rest.length > 0 && (
        <details className="group">
          <summary className="cursor-pointer list-none px-1 py-1 text-xs font-semibold text-brand-700 hover:text-brand-800">
            + {rest.length} more<span className="group-open:hidden"> ▾</span><span className="hidden group-open:inline"> ▴</span>
          </summary>
          <div className="space-y-0.5">
            {rest.map((it) => (
              <FacetCheck key={it.value} label={it.label} count={it.count}
                checked={selected.includes(it.value)} onToggle={() => onToggle(it.value)} />
            ))}
          </div>
        </details>
      )}
    </div>
  );
}