SPB Git

Repository Public

AVendreClient.tsx 14.2 KB 303 lines tsx

1// Vrai-Prix — onglet « À vendre » : recherche, filtres et grille d'annonces.2"use client";3import { useCallback, useEffect, useMemo, useRef, useState } from "react";4import Link from "next/link";5import { usePathname, useRouter, useSearchParams } from "next/navigation";6import { useLang } from "@/components/LangContext";7import ListingCardView, { groupLabel } from "@/components/avendre/ListingCardView";8import { money, num, pct } from "@/components/avendre/fmt";9import type { AVendreOverview, SearchResult, SortKey, TypeGroupKey } from "@/lib/immoka";1011const SORT_LABELS: Record<SortKey, { fr: string; en: string }> = {12  recent: { fr: "Plus récentes", en: "Newest" },13  price_asc: { fr: "Prix croissant", en: "Price ↑" },14  price_desc: { fr: "Prix décroissant", en: "Price ↓" },15  deal: { fr: "Estimation ≫ prix (aubaines ?)", en: "Estimate ≫ price (bargains?)" },16  premium: { fr: "Prix ≫ estimation (primes ?)", en: "Price ≫ estimate (premiums?)" },17  est_desc: { fr: "Estimation décroissante", en: "Estimate ↓" },18};1920interface Filters {21  q: string;22  muni: string;23  group: TypeGroupKey | "";24  min: string;25  max: string;26  beds: string;27  eval: boolean;28  sort: SortKey;29  page: number;30}3132function readFilters(sp: URLSearchParams): Filters {33  return {34    q: sp.get("q") ?? "",35    muni: sp.get("muni") ?? "",36    group: (sp.get("group") as TypeGroupKey | null) ?? "",37    min: sp.get("min") ?? "",38    max: sp.get("max") ?? "",39    beds: sp.get("beds") ?? "",40    eval: sp.get("eval") === "1",41    sort: (sp.get("sort") as SortKey | null) ?? "recent",42    page: Math.max(1, Number(sp.get("page") ?? 1) || 1),43  };44}4546function toQuery(f: Filters): string {47  const sp = new URLSearchParams();48  if (f.q) sp.set("q", f.q);49  if (f.muni) sp.set("muni", f.muni);50  if (f.group) sp.set("group", f.group);51  if (f.min) sp.set("min", f.min);52  if (f.max) sp.set("max", f.max);53  if (f.beds) sp.set("beds", f.beds);54  if (f.eval) sp.set("eval", "1");55  if (f.sort !== "recent") sp.set("sort", f.sort);56  if (f.page > 1) sp.set("page", String(f.page));57  return sp.toString();58}5960function Kpi({ label, value, hint }: { label: string; value: string; hint?: string }) {61  return (62    <div className="border-t border-[var(--line)] py-3">63      <p className="klabel">{label}</p>64      <p className="vp-display mt-1 text-[24px] font-bold leading-none tracking-[-0.02em]">{value}</p>65      {hint && <p className="mt-1 text-[11.5px] leading-snug text-ink-3">{hint}</p>}66    </div>67  );68}6970export default function AVendreClient({ overview }: { overview: AVendreOverview }) {71  const { lang } = useLang();72  const fr = lang === "fr";73  const router = useRouter();74  const pathname = usePathname();75  const sp = useSearchParams();76  const [f, setF] = useState<Filters>(() => readFilters(new URLSearchParams(sp.toString())));77  // résultat + requête qui l'a produit : « en chargement » = la requête courante n'est pas encore servie78  const [res, setRes] = useState<(SearchResult & { qs: string }) | null>(null);79  const loading = res == null || res.qs !== toQuery(f);80  const [qDraft, setQDraft] = useState(f.q);81  const abortRef = useRef<AbortController | null>(null);8283  // URL ↔ état (partageable, retour arrière naturel)84  const apply = useCallback(85    (patch: Partial<Filters>, resetPage = true) => {86      setF((prev) => {87        const next = { ...prev, ...patch, page: resetPage ? 1 : (patch.page ?? prev.page) };88        const qs = toQuery(next);89        router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });90        return next;91      });92    },93    [router, pathname]94  );9596  // recherche texte : débounce 350 ms97  useEffect(() => {98    const id = setTimeout(() => {99      if (qDraft !== f.q) apply({ q: qDraft });100    }, 350);101    return () => clearTimeout(id);102  }, [qDraft, f.q, apply]);103104  useEffect(() => {105    abortRef.current?.abort();106    const ctl = new AbortController();107    abortRef.current = ctl;108    const qs = toQuery(f);109    fetch(`/api/avendre${qs ? `?${qs}` : ""}`, { signal: ctl.signal })110      .then((r) => r.json())111      .then((d: SearchResult) => {112        if (!ctl.signal.aborted) setRes({ ...d, qs });113      })114      .catch(() => {});115    return () => ctl.abort();116  }, [f]);117118  const pages = res ? Math.max(1, Math.ceil(res.total / res.per)) : 1;119  const groups = useMemo(() => res?.facets.groups ?? overview.groups, [res, overview]);120  const munis = res?.facets.munis ?? overview.topMunis;121122  return (123    <div className="space-y-10 py-8">124      {/* ================= masthead ================= */}125      <section className="rise">126        <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">127          <nav className="vp-mono flex flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">128            <Link href="/" className="text-ink-2 hover:text-accent-deep">129              Vrai&nbsp;Prix130            </Link>131            <span aria-hidden="true">/</span>132            <span>{fr ? "À vendre" : "For sale"}</span>133          </nav>134          <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">135            {fr ? "Source : Immo-Ka · copie du" : "Source: Immo-Ka · snapshot of"} {overview.copiedAt ?? "—"}136          </span>137        </div>138        <div className="mt-6 grid gap-x-12 gap-y-8 lg:grid-cols-[1.6fr_1fr]">139          <div>140            <span className="kicker">{fr ? "Atelier d'évaluation" : "Valuation workshop"}</span>141            <h1 className="vp-display mt-2 text-[clamp(28px,4.5vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">142              {fr ? "Évaluer une propriété à vendre" : "Value a property for sale"}143            </h1>144            <p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-ink-2">145              {fr146                ? "Toutes les propriétés réellement à vendre au Québec, déjà mesurées par le moteur Vrai-Prix. Choisissez-en une, sélectionnez vos propres comparables, ajustez-les en dollars, réconciliez votre valeur — puis confrontez-la à la mesure Vrai-Prix et au prix demandé."147                : "Every property actually for sale in Québec, already measured by the Vrai-Prix engine. Pick one, select your own comparables, adjust them in dollars, reconcile your value — then confront it with the Vrai-Prix measure and the asking price."}148            </p>149          </div>150          <aside className="grid grid-cols-2 gap-x-6 lg:grid-cols-1 lg:border-l lg:border-[var(--line)] lg:pl-10">151            <Kpi label={fr ? "Propriétés à vendre" : "Properties for sale"} value={num(overview.total, lang)} />152            <Kpi153              label={fr ? "Déjà évaluées par le moteur" : "Already valued by the engine"}154              value={num(overview.evaluated, lang)}155              hint={fr ? "jumelage spatial au rôle d'évaluation" : "spatially matched to the assessment roll"}156            />157            <Kpi label={fr ? "Prix demandé médian" : "Median asking price"} value={money(overview.medianPrice, lang)} />158            <Kpi159              label={fr ? "Ratio médian estimation / prix" : "Median estimate / price ratio"}160              value={overview.medianRatio != null ? overview.medianRatio.toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 3 }) : "—"}161              hint={fr ? "< 1 : le marché demande plus que la mesure" : "< 1: sellers ask more than the measure"}162            />163          </aside>164        </div>165      </section>166167      {/* ================= filtres ================= */}168      <section className="space-y-4">169        <div className="grid gap-4 md:grid-cols-[2fr_1fr_1fr]">170          <label className="block">171            <span className="klabel">{fr ? "Adresse, ville, secteur, n° MLS" : "Address, city, sector, MLS #"}</span>172            <input173              value={qDraft}174              onChange={(e) => setQDraft(e.target.value)}175              placeholder={fr ? "ex. Gatineau, rue Principale, Sherbrooke…" : "e.g. Gatineau, Main street, Sherbrooke…"}176              className="vp-input mt-1"177              type="search"178            />179          </label>180          <label className="block">181            <span className="klabel">{fr ? "Municipalité" : "Municipality"}</span>182            <select value={f.muni} onChange={(e) => apply({ muni: e.target.value })} className="vp-input mt-1 cursor-pointer">183              <option value="">{fr ? "Toutes" : "All"}</option>184              {f.muni && !munis.some((m) => m.name === f.muni) && <option value={f.muni}>{f.muni}</option>}185              {munis.map((m) => (186                <option key={m.name} value={m.name}>187                  {m.name} ({num(m.n, lang)})188                </option>189              ))}190            </select>191          </label>192          <label className="block">193            <span className="klabel">{fr ? "Tri" : "Sort"}</span>194            <select value={f.sort} onChange={(e) => apply({ sort: e.target.value as SortKey })} className="vp-input mt-1 cursor-pointer">195              {(Object.keys(SORT_LABELS) as SortKey[]).map((k) => (196                <option key={k} value={k}>197                  {fr ? SORT_LABELS[k].fr : SORT_LABELS[k].en}198                </option>199              ))}200            </select>201          </label>202        </div>203204        {/* groupes de types */}205        <div className="flex flex-wrap gap-x-4 gap-y-1 border-b border-[var(--line)]">206          <button type="button" className="vp-tab" aria-pressed={f.group === ""} onClick={() => apply({ group: "" })}>207            {fr ? "Tous" : "All"}208          </button>209          {groups.map((g) => (210            <button key={g.key} type="button" className="vp-tab" aria-pressed={f.group === g.key} onClick={() => apply({ group: g.key })}>211              {groupLabel(g.key, fr)} <span className="text-ink-3">{num(g.n, lang)}</span>212            </button>213          ))}214        </div>215216        <div className="grid gap-4 sm:grid-cols-2 md:grid-cols-4">217          <label className="block">218            <span className="klabel">{fr ? "Prix min ($)" : "Min price ($)"}</span>219            <input value={f.min} onChange={(e) => apply({ min: e.target.value.replace(/\D/g, "") })} inputMode="numeric" className="vp-input mt-1" placeholder="0" />220          </label>221          <label className="block">222            <span className="klabel">{fr ? "Prix max ($)" : "Max price ($)"}</span>223            <input value={f.max} onChange={(e) => apply({ max: e.target.value.replace(/\D/g, "") })} inputMode="numeric" className="vp-input mt-1" placeholder="∞" />224          </label>225          <label className="block">226            <span className="klabel">{fr ? "Chambres (min)" : "Bedrooms (min)"}</span>227            <select value={f.beds} onChange={(e) => apply({ beds: e.target.value })} className="vp-input mt-1 cursor-pointer">228              <option value="">—</option>229              {[1, 2, 3, 4, 5].map((n) => (230                <option key={n} value={n}>231                  {n}+232                </option>233              ))}234            </select>235          </label>236          <label className="flex items-end gap-2 pb-2 text-[13px]">237            <input type="checkbox" checked={f.eval} onChange={(e) => apply({ eval: e.target.checked })} className="h-4 w-4 accent-[var(--accent)]" />238            {fr ? "Seulement les annonces déjà évaluées" : "Only listings already valued"}239          </label>240        </div>241      </section>242243      {/* ================= résultats ================= */}244      <section>245        <div className="flex flex-wrap items-baseline justify-between gap-2 border-b-2 border-ink pb-2">246          <p className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">247            {res ? (248              <>249                <b className="text-ink">{num(res.total, lang)}</b> {fr ? "propriétés" : "properties"}250                {res.summary.medianPrice != null && (251                  <>252                    {" · "}253                    {fr ? "prix médian" : "median price"} <b className="text-ink">{money(res.summary.medianPrice, lang)}</b>254                  </>255                )}256                {res.summary.medianRatio != null && (257                  <>258                    {" · "}259                    {fr ? "estimation vs prix (médiane)" : "estimate vs price (median)"}{" "}260                    <b className="text-ink">{pct((res.summary.medianRatio - 1) * 100, lang, 1)}</b>261                  </>262                )}263              </>264            ) : (265              fr ? "Chargement…" : "Loading…"266            )}267          </p>268          <p className="vp-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-3">269            {fr ? "page" : "page"} {f.page} / {pages}270          </p>271        </div>272273        <div className={`mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 ${loading ? "opacity-60" : ""}`}>274          {res?.items.map((c) => <ListingCardView key={c.uid} c={c} fr={fr} />)}275        </div>276        {res && res.items.length === 0 && (277          <p className="py-16 text-center text-ink-3">{fr ? "Aucune propriété ne correspond à ces filtres." : "No property matches these filters."}</p>278        )}279280        {pages > 1 && (281          <div className="mt-8 flex items-center justify-center gap-3">282            <button type="button" className="btn btn-ghost" disabled={f.page <= 1} onClick={() => apply({ page: f.page - 1 }, false)}>283              ← {fr ? "Précédent" : "Previous"}284            </button>285            <span className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">286              {f.page} / {pages}287            </span>288            <button type="button" className="btn btn-ghost" disabled={f.page >= pages} onClick={() => apply({ page: f.page + 1 }, false)}>289              {fr ? "Suivant" : "Next"} →290            </button>291          </div>292        )}293      </section>294295      <p className="vp-mono border-t border-[var(--line)] pt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">296        {fr297          ? "Annonces : copie de la base Immo-Ka (groupe Ka), agrégées de sources publiques ; photos et descriptions appartiennent aux courtiers et vendeurs. Estimations Vrai-Prix à titre indicatif — pas une évaluation professionnelle (OEAQ)."298          : "Listings: copy of the Immo-Ka database (Ka group), aggregated from public sources; photos and descriptions belong to brokers and sellers. Vrai-Prix estimates are educational — not a professional appraisal (OEAQ)."}299      </p>300    </div>301  );302}303
Files main
Menu

Theme