SPB Git

Repository Public

page.tsx 13.8 KB 323 lines tsx

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&apos;évaluation. Chaque vente mène à123          l&apos;estimation complète de la propriété — et à l&apos;écart entre124          le prix payé et la valeur au rôle. Il s&apos;écoule plusieurs semaines125          entre la signature et la publication d&apos;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&apos;unité d&apos;évaluation la plus308          proche du rôle foncier (MAMH) : l&apos;é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
Files main
Menu

Theme