Repository Public
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 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