Repository Public
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * /ventes — registre des ventes récentes au Québec.4 * Source : transactions publiées (flux « Transactions immobilières » du Journal5 * de Québec / Journal de Montréal, api.qub.ca), ingérées chaque jour par le6 * connecteur scripts/ingest-jdm.mjs et appariées à leur unité d'évaluation.7 * Filtres en formulaire GET (fonctionne sans JavaScript), pagination par liens.8 */9import type { Metadata } from "next";10import Link from "next/link";11import {12 PERIODE_LABELS,13 VENTE_PERIODES,14 VENTE_TYPES,15 VENTE_TYPE_LABELS,16 dateFrLong,17 parseVenteFilters,18 ventesOverview,19 ventesRecentes,20 vsRolePct,21} from "@/lib/ventes";22import { munBySlug } from "@/lib/municipalites";23import { SITE_URL, moneyFr, numFr, slugify } from "@/lib/seo";2425export const dynamic = "force-dynamic";2627export const metadata: Metadata = {28 title: "Ventes récentes au Québec — registre des transactions immobilières",29 description:30 "Les dernières ventes immobilières publiées au Québec, mises à jour chaque jour : prix de vente, adresse, type, écart avec l'évaluation municipale. Filtrez par municipalité, type et prix ; chaque vente mène à l'estimation Vrai-Prix de la propriété.",31 alternates: { canonical: "/ventes" },32 openGraph: {33 type: "website",34 title: "Ventes récentes au Québec — registre des transactions immobilières",35 description:36 "Les dernières ventes immobilières publiées au Québec, mises à jour chaque jour, appariées au rôle d'évaluation.",37 url: "/ventes",38 },39};4041type SP = Record<string, string | string[] | undefined>;4243function hrefWith(sp: SP, patch: Record<string, string | number | undefined>): string {44 const u = new URLSearchParams();45 for (const [k, v] of Object.entries(sp)) {46 const s = Array.isArray(v) ? v[0] : v;47 if (s) u.set(k, s);48 }49 for (const [k, v] of Object.entries(patch)) {50 if (v === undefined || v === "" || v === null) u.delete(k);51 else u.set(k, String(v));52 }53 const qs = u.toString();54 return qs ? `/ventes?${qs}` : "/ventes";55}5657function pct(p: number | null): string {58 if (p == null) return "—";59 return `${p >= 0 ? "+" : "−"}${Math.abs(p).toFixed(0)} %`;60}6162export default async function VentesPage({ searchParams }: { searchParams: Promise<SP> }) {63 const sp = await searchParams;64 const f = parseVenteFilters(sp);65 const page = ventesRecentes(f);66 const ov = ventesOverview();67 const url = `${SITE_URL}/ventes`;6869 const jsonLd = {70 "@context": "https://schema.org",71 "@graph": [72 {73 "@type": "Dataset",74 "@id": `${url}#dataset`,75 name: "Ventes immobilières récentes au Québec",76 description:77 "Transactions immobilières publiées au Québec, appariées aux unités d'évaluation du rôle foncier (MAMH).",78 url,79 isAccessibleForFree: true,80 creator: { "@type": "Organization", name: "Vrai-Prix", url: SITE_URL },81 temporalCoverage: ov.maxDate ? `2021-01-01/${ov.maxDate}` : undefined,82 spatialCoverage: { "@type": "Place", name: "Québec, Canada" },83 },84 {85 "@type": "BreadcrumbList",86 itemListElement: [87 { "@type": "ListItem", position: 1, name: "Accueil", item: SITE_URL },88 { "@type": "ListItem", position: 2, name: "Ventes récentes", item: url },89 ],90 },91 ],92 };9394 const tiles: [string, string][] = [95 ["Ventes · 30 jours", numFr(ov.n30)],96 ["Ventes · 90 jours", numFr(ov.n90)],97 ["Prix médian · 30 jours", moneyFr(ov.median30)],98 ["Dernière vente captée", ov.maxDate ? dateFrLong(ov.maxDate) : "—"],99 ];100101 const first = page.total ? (page.page - 1) * page.limit + 1 : 0;102 const last = Math.min(page.total, page.page * page.limit);103 const filtered = !!(f.q || f.type || f.min != null || f.max != null);104105 return (106 <div className="pb-6 pt-10 sm:pt-14">107 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />108 <nav className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">109 <Link href="/" className="hover:underline">110 Accueil111 </Link>{" "}112 / <span className="text-ink">Ventes récentes</span>113 </nav>114115 <section className="mt-6">116 <span className="kicker">Registre · ventes réelles</span>117 <h1 className="vp-display mt-3 max-w-4xl text-[clamp(28px,5.4vw,56px)] font-bold uppercase leading-[1.0] tracking-[-0.03em]">118 Les dernières ventes <span className="hl">au Québec</span>119 </h1>120 <p className="mt-4 max-w-2xl text-[15.5px] text-ink-2">121 Transactions immobilières publiées, captées chaque jour et appariées122 automatiquement à leur unité d'évaluation. Chaque vente mène à123 l'estimation complète de la propriété — et à l'écart entre124 le prix payé et la valeur au rôle. Il s'écoule plusieurs semaines125 entre la signature et la publication d'une vente.126 </p>127 </section>128129 {/* ---- Tuiles ---- */}130 <section className="mt-8 grid grid-cols-2 gap-3 lg:grid-cols-4">131 {tiles.map(([label, val]) => (132 <div key={label} className="vp-card p-4">133 <p className="klabel">{label}</p>134 <p className="vp-display mt-1 break-words text-[clamp(17px,2.2vw,26px)] font-bold tracking-[-0.02em]">135 {val}136 </p>137 </div>138 ))}139 </section>140141 {ov.byType30.length > 0 && (142 <ul className="mt-4 flex flex-wrap gap-x-5 gap-y-2">143 {ov.byType30.map((t) => (144 <li key={t.type} className="stat-chip">145 <b>{moneyFr(t.median)}</b> {VENTE_TYPE_LABELS[t.type] ?? t.type} · {numFr(t.n)} / 30 j146 </li>147 ))}148 </ul>149 )}150151 {/* ---- Filtres (GET, sans JS) ---- */}152 <section className="mt-10">153 <span className="kicker">Filtrer</span>154 <form method="get" action="/ventes" className="mt-4 grid gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-[1.4fr_1fr_1fr_1fr_1.2fr_auto]">155 <label className="block">156 <span className="klabel">Municipalité</span>157 <input158 type="search"159 name="q"160 defaultValue={f.q}161 placeholder="ex. Québec, Lévis, Gatineau"162 className="vp-input mt-1"163 autoComplete="off"164 />165 </label>166 <label className="block">167 <span className="klabel">Type</span>168 <select name="type" defaultValue={f.type} className="vp-input mt-1 cursor-pointer">169 <option value="">Tous les types</option>170 {VENTE_TYPES.map((t) => (171 <option key={t} value={t}>172 {VENTE_TYPE_LABELS[t]}173 </option>174 ))}175 </select>176 </label>177 <label className="block">178 <span className="klabel">Prix min ($)</span>179 <input name="min" defaultValue={f.min ?? ""} inputMode="numeric" placeholder="0" className="vp-input mt-1" />180 </label>181 <label className="block">182 <span className="klabel">Prix max ($)</span>183 <input name="max" defaultValue={f.max ?? ""} inputMode="numeric" placeholder="∞" className="vp-input mt-1" />184 </label>185 <label className="block">186 <span className="klabel">Période</span>187 <select name="periode" defaultValue={f.periode} className="vp-input mt-1 cursor-pointer">188 {Object.keys(VENTE_PERIODES).map((k) => (189 <option key={k} value={k}>190 {PERIODE_LABELS[k]}191 </option>192 ))}193 </select>194 </label>195 <div className="flex items-end gap-3">196 <button type="submit" className="btn btn-primary">197 Filtrer198 </button>199 {filtered && (200 <Link href="/ventes" className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2 underline underline-offset-2">201 Effacer202 </Link>203 )}204 </div>205 </form>206 </section>207208 {/* ---- Résultats ---- */}209 <section className="mt-8">210 <p className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">211 {page.total === 0212 ? "Aucune vente ne correspond à ces critères."213 : `${numFr(page.total)} ventes${page.since ? ` depuis le ${dateFrLong(page.since)}` : ""} · ${numFr(first)}–${numFr(last)} · page ${page.page} / ${page.pages}`}214 </p>215216 {page.rows.length > 0 && (217 <div className="mt-4 overflow-x-auto">218 <table className="w-full min-w-[760px] border-collapse text-[14px]">219 <thead>220 <tr className="border-b-2 border-ink text-left">221 <th className="klabel py-2 pr-4">Date</th>222 <th className="klabel py-2 pr-4">Adresse</th>223 <th className="klabel py-2 pr-4">Municipalité</th>224 <th className="klabel py-2 pr-4">Type</th>225 <th className="klabel py-2 pr-4 text-right">Prix de vente</th>226 <th className="klabel py-2 pr-4 text-right">Rôle</th>227 <th className="klabel py-2 text-right">vs rôle</th>228 </tr>229 </thead>230 <tbody>231 {page.rows.map((r) => {232 const p = vsRolePct(r.amount, r.valeur_role);233 const slug = r.city ? slugify(r.city) : "";234 const munOk = slug ? !!munBySlug(slug) : false;235 return (236 <tr key={r.id} className="border-b border-[rgba(20,24,20,0.12)]">237 <td className="vp-mono whitespace-nowrap py-2.5 pr-4 text-[12px] text-ink-2">{dateFrLong(r.date)}</td>238 <td className="py-2.5 pr-4">239 {r.id_provinc ? (240 <Link241 href={`/estimation/${encodeURIComponent(r.id_provinc)}`}242 className="font-medium underline decoration-[rgba(20,24,20,0.3)] underline-offset-2 hover:decoration-ink"243 >244 {r.street ?? "Adresse non publiée"}245 </Link>246 ) : (247 <span className="font-medium">{r.street ?? "Adresse non publiée"}</span>248 )}249 {(r.year_built || r.floor_area) && (250 <span className="vp-mono ml-2 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">251 {r.year_built ? `${r.year_built}` : ""}252 {r.year_built && r.floor_area ? " · " : ""}253 {r.floor_area ? `${Math.round(r.floor_area)} m²` : ""}254 </span>255 )}256 </td>257 <td className="py-2.5 pr-4">258 {munOk ? (259 <Link href={`/municipalite/${slug}`} className="hover:underline">260 {r.city}261 </Link>262 ) : (263 r.city ?? "—"264 )}265 </td>266 <td className="py-2.5 pr-4 text-ink-2">{VENTE_TYPE_LABELS[r.property_type ?? ""] ?? r.property_type ?? "—"}</td>267 <td className="vp-display py-2.5 pr-4 text-right text-[15px] font-bold tracking-[-0.01em]">{moneyFr(r.amount)}</td>268 <td className="vp-mono py-2.5 pr-4 text-right text-ink-2">{moneyFr(r.valeur_role)}</td>269 <td className={`vp-mono py-2.5 text-right ${p != null && p < 0 ? "text-[var(--danger)]" : "text-ink-2"}`}>{pct(p)}</td>270 </tr>271 );272 })}273 </tbody>274 </table>275 </div>276 )}277278 {page.pages > 1 && (279 <nav className="mt-6 flex flex-wrap items-center justify-between gap-3" aria-label="Pagination">280 {page.page > 1 ? (281 <Link href={hrefWith(sp, { page: page.page - 1 })} className="btn btn-ghost" rel="prev">282 ← Plus récentes283 </Link>284 ) : (285 <span />286 )}287 <span className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">288 page {page.page} / {page.pages}289 </span>290 {page.page < page.pages ? (291 <Link href={hrefWith(sp, { page: page.page + 1 })} className="btn btn-ghost" rel="next">292 Plus anciennes →293 </Link>294 ) : (295 <span />296 )}297 </nav>298 )}299 </section>300301 <section className="mt-12 max-w-3xl text-[13.5px] text-ink-2">302 <span className="kicker">À propos de ces données</span>303 <p className="mt-3">304 Les ventes proviennent des transactions publiées au registre foncier et305 reprises par le flux « Transactions immobilières » du Journal de Québec306 et du Journal de Montréal (Québecor). Chaque vente est captée307 quotidiennement, puis appariée à l'unité d'évaluation la plus308 proche du rôle foncier (MAMH) : l'écart « vs rôle » compare le prix309 payé à la valeur inscrite au rôle en vigueur. Les regroupements de types310 suivent ceux de la source : détaché, jumelé et en rangée forment311 « Unifamiliale » ; « Autre » englobe les terrains, les condos neufs et312 les ventes non classées. Données indicatives : consultez un313 professionnel avant toute décision, et la{" "}314 <Link href="/methodologie" className="underline">315 méthodologie316 </Link>{" "}317 pour les limites connues.318 </p>319 </section>320 </div>321 );322}323