SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%

SEO programmatique — pages municipalités, sitemaps 3,7 M de fiches, métadonnées

- Fondations : metadataBase, gabarit de titres, og/twitter, canonical par
  page, JSON-LD Organization+WebSite (layout), title/description uniques
  sur toutes les pages statiques (pages clientes scindées en wrapper
  serveur + *Client.tsx).
- /estimation/[id] : generateMetadata (adresse, estimation, fourchette),
  JSON-LD SingleFamilyResidence/Residence/Place + BreadcrumbList,
  estimation dédupliquée via React cache().
- Pages programmatiques : /municipalites (index A-Z), /municipalite/[slug]
  (~1 097) et /municipalite/[slug]/[type] (~5 900) — stats agrégées
  (médiane, totaux, croissance 2021-2026), liens HTML vers les fiches,
  municipalités voisines (lib/municipalites.ts, cache mémoire).
- Sitemaps : /sitemap-index.xml (à soumettre dans GSC) → /sitemap.xml
  (7 043 pages) + /estimation/sitemap/0-83.xml (45 000 fiches chacun,
  plages de rowid). robots.ts (disallow /api/, /compte).
- noindex : /compte, /estimation/manuelle (espace d'URL paramétrique).
- Accueil : section « Explorer par municipalité » (maillage indexable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent 7a1206e

26 changed files +1,175 −15

modified src/app/HomeClient.tsx +1 −1
@@ -4,7 +4,7 @@ import SearchBox from "@/components/SearchBox";
4 4 import EstimateForm from "@/components/EstimateForm";
5 5 import { useLang } from "@/components/LangContext";
6 6
7 −export default function Home() {
7 +export default function HomeClient() {
8 8 const { lang, t } = useLang();
9 9 return (
10 10 <div className="pb-6 pt-10 sm:pt-14">
modified src/app/carte/page.tsx +5 −2
@@ -1,10 +1,13 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 import CartePage from "./CartePage";
3 3
4 −export const metadata = {
5 − title: "Vrai-Prix — Recherche par carte",
4 +import type { Metadata } from "next";
5 +
6 +export const metadata: Metadata = {
7 + title: "Recherche par carte — explorez 3,7 M de propriétés du Québec",
6 8 description:
7 9 "Explorez les propriétés du Québec sur la carte : avoisinage, estimations 2026 et géolocalisation — 3,7 millions d'unités d'évaluation.",
10 + alternates: { canonical: "/carte" },
8 11 };
9 12
10 13 export default function Page() {
modified src/app/conditions/ConditionsClient.tsx +1 −1
@@ -106,7 +106,7 @@ const EN = [
106 106 },
107 107 ];
108 108
109 −export default function ConditionsPage() {
109 +export default function ConditionsClient() {
110 110 return (
111 111 <LegalPage
112 112 crumb={{ fr: "Conditions d'utilisation", en: "Terms of service" }}
added src/app/conditions/page.tsx +14 −0
@@ -0,0 +1,14 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
3 +import ConditionsClient from "./ConditionsClient";
4 +
5 +export const metadata: Metadata = {
6 + title: "Conditions d'utilisation",
7 + description:
8 + "Conditions d'utilisation de Vrai-Prix : nature du service d'estimation statistique, exactitude et fourchettes, propriété intellectuelle, limites de responsabilité.",
9 + alternates: { canonical: "/conditions" },
10 +};
11 +
12 +export default function Page() {
13 + return <ConditionsClient />;
14 +}
modified src/app/confidentialite/ConfidentialiteClient.tsx +1 −1
@@ -102,7 +102,7 @@ const EN = [
102 102 },
103 103 ];
104 104
105 −export default function ConfidentialitePage() {
105 +export default function ConfidentialiteClient() {
106 106 const { lang } = useLang();
107 107 const fr = lang === "fr";
108 108 return (
added src/app/confidentialite/page.tsx +14 −0
@@ -0,0 +1,14 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
3 +import ConfidentialiteClient from "./ConfidentialiteClient";
4 +
5 +export const metadata: Metadata = {
6 + title: "Politique de confidentialité (Loi 25)",
7 + description:
8 + "Politique de confidentialité de Vrai-Prix : renseignements recueillis, finalités, conservation, hébergement au Québec et droits prévus par la Loi 25.",
9 + alternates: { canonical: "/confidentialite" },
10 +};
11 +
12 +export default function Page() {
13 + return <ConfidentialiteClient />;
14 +}
modified src/app/estimation/[id]/page.tsx +131 −2
@@ -1,17 +1,146 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
3 +import { cache } from "react";
2 4 import { notFound } from "next/navigation";
3 5 import { estimateByUnitId } from "@/lib/estimator";
4 6 import ResultView from "@/components/ResultView";
7 +import {
8 + EST_YEAR,
9 + SITE_URL,
10 + TYPE_LABELS,
11 + moneyFr,
12 + slugify,
13 +} from "@/lib/seo";
5 14
6 15 export const dynamic = "force-dynamic";
7 16
17 +// Une seule estimation par requête, partagée entre generateMetadata et la page.
18 +const getEstimate = cache((id: string) => estimateByUnitId(id));
19 +
20 +function displayAddress(unit: {
21 + adresse: string | null;
22 + specs: { apt: string | null };
23 + municipalite: string | null;
24 +}): string {
25 + const apt = unit.specs.apt ? `, app. ${unit.specs.apt}` : "";
26 + const addr = unit.adresse ?? "Propriété";
27 + return unit.municipalite ? `${addr}${apt}, ${unit.municipalite}` : `${addr}${apt}`;
28 +}
29 +
30 +export async function generateMetadata({
31 + params,
32 +}: {
33 + params: Promise<{ id: string }>;
34 +}): Promise<Metadata> {
35 + const { id } = await params;
36 + const decoded = decodeURIComponent(id);
37 + const data = getEstimate(decoded);
38 + if (!data?.unit) return { title: "Propriété introuvable" };
39 + const u = data.unit;
40 + const r = data.result;
41 + const typeLabel = TYPE_LABELS[u.typeProp]?.singulier ?? "propriété";
42 + const annee = u.anneeConstruction ? `, construite en ${u.anneeConstruction}` : "";
43 + const title = `${displayAddress(u)} — estimation ${EST_YEAR} : ${moneyFr(r.estimate)}`;
44 + const description = `Valeur estimée de cette ${typeLabel}${annee} : ${moneyFr(
45 + r.estimate
46 + )} (fourchette ${moneyFr(r.low)} – ${moneyFr(r.high)}, confiance ${
47 + r.confidenceLevel
48 + }). Valeur au rôle, historique 2021-${EST_YEAR} et comparables réels — calcul montré en entier.`;
49 + return {
50 + title,
51 + description,
52 + alternates: { canonical: `/estimation/${encodeURIComponent(decoded)}` },
53 + openGraph: { title, description, type: "website" },
54 + };
55 +}
56 +
57 +/** JSON-LD schema.org : la propriété comme lieu résidentiel + fil d'Ariane. */
58 +function unitJsonLd(data: NonNullable<ReturnType<typeof estimateByUnitId>>) {
59 + const u = data.unit!;
60 + const r = data.result;
61 + const schemaType =
62 + u.typeProp === "unifamilial" || u.typeProp === "chalet" || u.typeProp === "maison_mobile"
63 + ? "SingleFamilyResidence"
64 + : u.typeProp === "plex" || u.typeProp === "condo_ou_multi"
65 + ? "Residence"
66 + : "Place";
67 + const url = `${SITE_URL}/estimation/${encodeURIComponent(u.id)}`;
68 + const place: Record<string, unknown> = {
69 + "@type": schemaType,
70 + "@id": `${url}#place`,
71 + name: displayAddress(u),
72 + url,
73 + address: {
74 + "@type": "PostalAddress",
75 + streetAddress: u.adresse ?? undefined,
76 + addressLocality: u.municipalite ?? undefined,
77 + addressRegion: "QC",
78 + addressCountry: "CA",
79 + },
80 + geo: { "@type": "GeoCoordinates", latitude: u.lat, longitude: u.lng },
81 + additionalProperty: [
82 + {
83 + "@type": "PropertyValue",
84 + name: `Estimation Vrai-Prix ${EST_YEAR}`,
85 + value: r.estimate,
86 + unitText: "CAD",
87 + },
88 + {
89 + "@type": "PropertyValue",
90 + name: "Valeur au rôle d'évaluation",
91 + value: u.valeurRole ?? undefined,
92 + unitText: "CAD",
93 + },
94 + ],
95 + };
96 + if (u.aireEtagesM2) {
97 + place.floorSize = {
98 + "@type": "QuantitativeValue",
99 + value: u.aireEtagesM2,
100 + unitCode: "MTK",
101 + };
102 + }
103 + if (u.anneeConstruction) place.yearBuilt = u.anneeConstruction;
104 + const breadcrumb = {
105 + "@type": "BreadcrumbList",
106 + itemListElement: [
107 + { "@type": "ListItem", position: 1, name: "Accueil", item: SITE_URL },
108 + u.municipalite
109 + ? {
110 + "@type": "ListItem",
111 + position: 2,
112 + name: u.municipalite,
113 + item: `${SITE_URL}/municipalite/${slugify(u.municipalite)}`,
114 + }
115 + : null,
116 + {
117 + "@type": "ListItem",
118 + position: u.municipalite ? 3 : 2,
119 + name: u.adresse ?? u.id,
120 + item: url,
121 + },
122 + ].filter(Boolean),
123 + };
124 + return { "@context": "https://schema.org", "@graph": [place, breadcrumb] };
125 +}
126 +
8 127 export default async function EstimationPage({
9 128 params,
10 129 }: {
11 130 params: Promise<{ id: string }>;
12 131 }) {
13 132 const { id } = await params;
14 − const data = estimateByUnitId(decodeURIComponent(id));
133 + const data = getEstimate(decodeURIComponent(id));
15 134 if (!data) notFound();
16 − return <ResultView data={data} />;
135 + return (
136 + <>
137 + {data.unit && (
138 + <script
139 + type="application/ld+json"
140 + dangerouslySetInnerHTML={{ __html: JSON.stringify(unitJsonLd(data)) }}
141 + />
142 + )}
143 + <ResultView data={data} />
144 + </>
145 + );
17 146 }
modified src/app/estimation/manuelle/page.tsx +8 −0
@@ -1,10 +1,18 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
2 3 import { notFound } from "next/navigation";
3 4 import { estimateManual } from "@/lib/estimator";
4 5 import ResultView from "@/components/ResultView";
5 6
6 7 export const dynamic = "force-dynamic";
7 8
9 +// Page paramétrique (querystring libre) : exclue de l'index pour éviter
10 +// un espace d'URL infini ; les fiches /estimation/[id] restent indexables.
11 +export const metadata: Metadata = {
12 + title: "Estimation manuelle",
13 + robots: { index: false, follow: true },
14 +};
15 +
8 16 export default async function ManualEstimationPage({
9 17 searchParams,
10 18 }: {
added src/app/estimation/sitemap.ts +38 −0
@@ -0,0 +1,38 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Sitemaps des 3,7 M de fiches /estimation/[id], découpés par plages de rowid
3 +// (contigus 1..N dans la DB). Servis à /estimation/sitemap/[id].xml.
4 +import type { MetadataRoute } from "next";
5 +import { getDb } from "@/lib/db";
6 +import { SITE_URL } from "@/lib/seo";
7 +
8 +export const dynamic = "force-dynamic";
9 +
10 +/** 45 000 URLs par fichier (marge sous la limite de 50 000 du protocole). */
11 +const CHUNK = 45_000;
12 +
13 +function maxRowid(): number {
14 + return (
15 + getDb().prepare("SELECT MAX(rowid) AS m FROM units").get() as { m: number }
16 + ).m;
17 +}
18 +
19 +export async function generateSitemaps(): Promise<{ id: number }[]> {
20 + const n = Math.ceil(maxRowid() / CHUNK);
21 + return Array.from({ length: n }, (_, id) => ({ id }));
22 +}
23 +
24 +export default async function sitemap(props: {
25 + id: Promise<string>;
26 +}): Promise<MetadataRoute.Sitemap> {
27 + const id = Number(await props.id);
28 + const rows = getDb()
29 + .prepare(
30 + "SELECT id_provinc FROM units WHERE rowid > ? AND rowid <= ? ORDER BY rowid"
31 + )
32 + .all(id * CHUNK, (id + 1) * CHUNK) as { id_provinc: string }[];
33 + return rows.map((r) => ({
34 + url: `${SITE_URL}/estimation/${encodeURIComponent(r.id_provinc)}`,
35 + changeFrequency: "yearly",
36 + priority: 0.5,
37 + }));
38 +}
modified src/app/ka/page.tsx +2 −1
@@ -3,9 +3,10 @@ import type { Metadata } from "next";
3 3 import KaHero from "./KaHero";
4 4
5 5 export const metadata: Metadata = {
6 − title: "Ka — l'agent IA de Vrai-Prix",
6 + title: "Ka — l'agent IA qui évalue n'importe quelle propriété du Québec",
7 7 description:
8 8 "Ka évalue n'importe quelle propriété du Québec en conversant : recherche dans le registre, évaluation transparente, comparables, insights de marché — en direct.",
9 + alternates: { canonical: "/ka" },
9 10 };
10 11
11 12 export default function KaPage() {
modified src/app/layout.tsx +56 −2
@@ -3,10 +3,12 @@ import type { Metadata } from "next";
3 3 import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google";
4 4 import "./globals.css";
5 5 import { LangProvider, LangToggle } from "@/components/LangContext";
6 +import AccountButton from "@/components/AccountButton";
6 7 import CookieConsent from "@/components/CookieConsent";
7 8 import FooterNav from "@/components/FooterNav";
8 9 import HeaderNav from "@/components/HeaderNav";
9 10 import Link from "next/link";
11 +import { SITE_URL } from "@/lib/seo";
10 12
11 13 const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
12 14 const spaceGrotesk = Space_Grotesk({
@@ -19,9 +21,56 @@ const jetbrains = JetBrains_Mono({
19 21 });
20 22
21 23 export const metadata: Metadata = {
22 − title: "Vrai-Prix — la vraie valeur, sans boîte noire",
24 + metadataBase: new URL(SITE_URL),
25 + title: {
26 + default: "Vrai-Prix — la vraie valeur, sans boîte noire",
27 + template: "%s | Vrai-Prix",
28 + },
23 29 description:
24 − "Estimation immobilière transparente pour le Québec : modèle hédonique + comparables réels, fourchette honnête et indice de confiance.",
30 + "Estimation immobilière transparente pour le Québec : modèle hédonique + comparables réels, fourchette honnête et indice de confiance. 3,7 millions de propriétés, 1 100 municipalités.",
31 + applicationName: "Vrai-Prix",
32 + openGraph: {
33 + type: "website",
34 + siteName: "Vrai-Prix",
35 + locale: "fr_CA",
36 + url: SITE_URL,
37 + title: "Vrai-Prix — la vraie valeur, sans boîte noire",
38 + description:
39 + "Estimation immobilière transparente pour le Québec : modèle hédonique + comparables réels, fourchette honnête et indice de confiance.",
40 + },
41 + twitter: {
42 + card: "summary",
43 + title: "Vrai-Prix — la vraie valeur, sans boîte noire",
44 + description:
45 + "Estimation immobilière transparente pour le Québec : 3,7 millions de propriétés estimées, calcul montré en entier.",
46 + },
47 + formatDetection: { telephone: false },
48 +};
49 +
50 +/** JSON-LD global : l'organisation et le site (entités schema.org). */
51 +const ORG_JSONLD = {
52 + "@context": "https://schema.org",
53 + "@graph": [
54 + {
55 + "@type": "Organization",
56 + "@id": `${SITE_URL}/#org`,
57 + name: "Vrai-Prix",
58 + url: SITE_URL,
59 + email: "contact@spboucher.ai",
60 + founder: { "@type": "Person", name: "Simon-Pierre Boucher" },
61 + areaServed: { "@type": "AdministrativeArea", name: "Québec, Canada" },
62 + },
63 + {
64 + "@type": "WebSite",
65 + "@id": `${SITE_URL}/#site`,
66 + name: "Vrai-Prix",
67 + url: SITE_URL,
68 + inLanguage: "fr-CA",
69 + publisher: { "@id": `${SITE_URL}/#org` },
70 + description:
71 + "Estimation immobilière transparente pour le Québec : modèle hédonique + comparables réels, fourchette honnête et indice de confiance.",
72 + },
73 + ],
25 74 };
26 75
27 76 const TICKER = [
@@ -41,6 +90,10 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
41 90 <body
42 91 className={`${inter.variable} ${spaceGrotesk.variable} ${jetbrains.variable}`}
43 92 >
93 + <script
94 + type="application/ld+json"
95 + dangerouslySetInnerHTML={{ __html: JSON.stringify(ORG_JSONLD) }}
96 + />
44 97 <LangProvider>
45 98 {/* fond opaque sur mobile (backdrop-filter casse les enfants fixed sur WebKit) */}
46 99 <header className="sticky top-0 z-[1100] border-b-2 border-ink bg-paper md:bg-[rgba(245,243,238,0.88)] md:backdrop-blur-xl">
@@ -56,6 +109,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
56 109 </Link>
57 110 <div className="ml-auto flex items-center gap-2">
58 111 <HeaderNav />
112 + <AccountButton />
59 113 <LangToggle />
60 114 </div>
61 115 </div>
modified src/app/methodologie/MethodologieClient.tsx +1 −1
@@ -178,7 +178,7 @@ const EN: Section[] = [
178 178 },
179 179 ];
180 180
181 −export default function MethodologiePage() {
181 +export default function MethodologieClient() {
182 182 const { lang } = useLang();
183 183 const fr = lang === "fr";
184 184 const sections = fr ? FR : EN;
added src/app/methodologie/page.tsx +14 −0
@@ -0,0 +1,14 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
3 +import MethodologieClient from "./MethodologieClient";
4 +
5 +export const metadata: Metadata = {
6 + title: "Méthodologie — modèle hédonique et comparables expliqués",
7 + description:
8 + "Comment Vrai-Prix estime la valeur des propriétés du Québec : rôles d'évaluation MAMH (22 M d'observations), 745 119 ventes réelles, modèle hédonique LightGBM, comparables ajustés, erreur médiane 11 %, normes IAAO.",
9 + alternates: { canonical: "/methodologie" },
10 +};
11 +
12 +export default function Page() {
13 + return <MethodologieClient />;
14 +}
added src/app/municipalite/[slug]/[type]/page.tsx +184 −0
@@ -0,0 +1,184 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Page programmatique municipalité × type de propriété.
3 +import type { Metadata } from "next";
4 +import Link from "next/link";
5 +import { notFound } from "next/navigation";
6 +import { munBySlug, munStats, sampleUnits } from "@/lib/municipalites";
7 +import {
8 + EST_YEAR,
9 + SITE_URL,
10 + SLUG_TYPES,
11 + TYPE_LABELS,
12 + TYPE_SLUGS,
13 + moneyFr,
14 + numFr,
15 +} from "@/lib/seo";
16 +
17 +export const dynamic = "force-dynamic";
18 +
19 +function resolve(slug: string, typeSlug: string) {
20 + const mun = munBySlug(slug);
21 + const type = SLUG_TYPES[typeSlug];
22 + if (!mun || !type) return null;
23 + const stats = munStats(mun.nom);
24 + const t = stats?.parType.find((x) => x.type === type);
25 + if (!t || t.n === 0) return null;
26 + return { mun, type, t };
27 +}
28 +
29 +export async function generateMetadata({
30 + params,
31 +}: {
32 + params: Promise<{ slug: string; type: string }>;
33 +}): Promise<Metadata> {
34 + const { slug, type: typeSlug } = await params;
35 + const r = resolve(slug, typeSlug);
36 + if (!r) return { title: "Page introuvable" };
37 + const label = TYPE_LABELS[r.type].pluriel;
38 + const cap = label.charAt(0).toUpperCase() + label.slice(1);
39 + const title = `${cap} à ${r.mun.nom} — valeur médiane ${moneyFr(r.t.mediane2026)} (${EST_YEAR})`;
40 + const description = `${numFr(r.t.n)} ${label} évaluées à ${r.mun.nom} : valeur médiane estimée ${moneyFr(
41 + r.t.mediane2026
42 + )} en ${EST_YEAR}. Fiches détaillées avec fourchette, historique 2021-${EST_YEAR} et comparables réels.`;
43 + return {
44 + title,
45 + description,
46 + alternates: { canonical: `/municipalite/${slug}/${typeSlug}` },
47 + openGraph: { title, description, type: "website" },
48 + };
49 +}
50 +
51 +export default async function MunTypePage({
52 + params,
53 +}: {
54 + params: Promise<{ slug: string; type: string }>;
55 +}) {
56 + const { slug, type: typeSlug } = await params;
57 + const r = resolve(slug, typeSlug);
58 + if (!r) notFound();
59 + const { mun, type, t } = r;
60 + const stats = munStats(mun.nom)!;
61 + const label = TYPE_LABELS[type].pluriel;
62 + const cap = label.charAt(0).toUpperCase() + label.slice(1);
63 + const samples = sampleUnits(mun.nom, type, 30);
64 + const url = `${SITE_URL}/municipalite/${slug}/${typeSlug}`;
65 + const autres = stats.parType.filter((x) => x.type !== type && x.n > 0);
66 +
67 + const jsonLd = {
68 + "@context": "https://schema.org",
69 + "@type": "BreadcrumbList",
70 + itemListElement: [
71 + { "@type": "ListItem", position: 1, name: "Accueil", item: SITE_URL },
72 + { "@type": "ListItem", position: 2, name: "Municipalités", item: `${SITE_URL}/municipalites` },
73 + { "@type": "ListItem", position: 3, name: mun.nom, item: `${SITE_URL}/municipalite/${slug}` },
74 + { "@type": "ListItem", position: 4, name: cap, item: url },
75 + ],
76 + };
77 +
78 + return (
79 + <div className="pb-6 pt-10 sm:pt-14">
80 + <script
81 + type="application/ld+json"
82 + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
83 + />
84 + <nav className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">
85 + <Link href="/" className="hover:underline">
86 + Accueil
87 + </Link>{" "}
88 + /{" "}
89 + <Link href="/municipalites" className="hover:underline">
90 + Municipalités
91 + </Link>{" "}
92 + /{" "}
93 + <Link href={`/municipalite/${slug}`} className="hover:underline">
94 + {mun.nom}
95 + </Link>{" "}
96 + / <span className="text-ink">{cap}</span>
97 + </nav>
98 +
99 + <section className="mt-6">
100 + <span className="kicker">
101 + {mun.nom} · {numFr(t.n)} propriétés
102 + </span>
103 + <h1 className="vp-display mt-3 max-w-4xl text-[clamp(26px,4.8vw,50px)] font-bold uppercase leading-[1.0] tracking-[-0.03em]">
104 + {cap} à <span className="hl">{mun.nom}</span>
105 + </h1>
106 + <p className="mt-4 max-w-2xl text-[15.5px] text-ink-2">
107 + Valeur médiane estimée {moneyFr(t.mediane2026)} en {EST_YEAR}, sur{" "}
108 + {numFr(t.n)} unités au rôle d&apos;évaluation. Cherchez une adresse
109 + précise depuis{" "}
110 + <Link href="/" className="underline">
111 + l&apos;accueil
112 + </Link>{" "}
113 + pour obtenir sa fiche complète.
114 + </p>
115 + </section>
116 +
117 + <section className="mt-8 grid grid-cols-2 gap-3 lg:grid-cols-3">
118 + {[
119 + ["Propriétés", numFr(t.n)],
120 + [`Valeur médiane ${EST_YEAR}`, moneyFr(t.mediane2026)],
121 + [`Valeur totale ${EST_YEAR}`, moneyFr(t.total2026)],
122 + ].map(([label2, val]) => (
123 + <div key={label2} className="vp-card p-4">
124 + <p className="klabel">{label2}</p>
125 + <p className="vp-display mt-1 break-words text-[clamp(17px,2.2vw,26px)] font-bold tracking-[-0.02em]">
126 + {val}
127 + </p>
128 + </div>
129 + ))}
130 + </section>
131 +
132 + {samples.length > 0 && (
133 + <section className="mt-12">
134 + <span className="kicker">Aperçu du registre</span>
135 + <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">
136 + {cap} les plus valorisées
137 + </h2>
138 + <ul className="mt-5 grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
139 + {samples.map((u) => (
140 + <li key={u.id_provinc}>
141 + <Link
142 + href={`/estimation/${encodeURIComponent(u.id_provinc)}`}
143 + className="group flex items-baseline justify-between gap-3 border-b border-[rgba(20,24,20,0.12)] py-1.5 text-[13.5px]"
144 + >
145 + <span className="truncate group-hover:underline">
146 + {u.adresse}
147 + {u.apt ? `, app. ${u.apt}` : ""}
148 + </span>
149 + <span className="vp-mono shrink-0 text-[11px] text-ink-2">
150 + {moneyFr(u.est_2026)}
151 + </span>
152 + </Link>
153 + </li>
154 + ))}
155 + </ul>
156 + </section>
157 + )}
158 +
159 + {autres.length > 0 && (
160 + <section className="mt-12">
161 + <span className="kicker">Aussi à {mun.nom}</span>
162 + <ul className="mt-4 flex flex-wrap gap-2">
163 + {autres.map((x) => (
164 + <li key={x.type}>
165 + <Link
166 + href={`/municipalite/${slug}/${TYPE_SLUGS[x.type] ?? x.type}`}
167 + className="stat-chip hover:bg-lime"
168 + >
169 + {TYPE_LABELS[x.type]?.pluriel ?? x.type}
170 + <b>{numFr(x.n)}</b>
171 + </Link>
172 + </li>
173 + ))}
174 + <li>
175 + <Link href={`/municipalite/${slug}`} className="stat-chip hover:bg-lime">
176 + ← Tout {mun.nom}
177 + </Link>
178 + </li>
179 + </ul>
180 + </section>
181 + )}
182 + </div>
183 + );
184 +}
added src/app/municipalite/[slug]/page.tsx +233 −0
@@ -0,0 +1,233 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Page programmatique par municipalité : stats, répartition par type,
3 +// fiches en HTML (maillage vers /estimation/[id]) et municipalités voisines.
4 +import type { Metadata } from "next";
5 +import Link from "next/link";
6 +import { notFound } from "next/navigation";
7 +import {
8 + munBySlug,
9 + munStats,
10 + neighborMunicipalities,
11 + sampleUnits,
12 +} from "@/lib/municipalites";
13 +import {
14 + EST_YEAR,
15 + SITE_URL,
16 + TYPE_LABELS,
17 + TYPE_SLUGS,
18 + moneyFr,
19 + numFr,
20 +} from "@/lib/seo";
21 +
22 +export const dynamic = "force-dynamic";
23 +
24 +export async function generateMetadata({
25 + params,
26 +}: {
27 + params: Promise<{ slug: string }>;
28 +}): Promise<Metadata> {
29 + const { slug } = await params;
30 + const mun = munBySlug(slug);
31 + if (!mun) return { title: "Municipalité introuvable" };
32 + const s = munStats(mun.nom)!;
33 + const title = `Valeur des propriétés à ${mun.nom} — médiane ${moneyFr(s.mediane2026)} (${EST_YEAR})`;
34 + const description = `${numFr(s.n)} propriétés évaluées à ${mun.nom} : valeur médiane estimée ${moneyFr(
35 + s.mediane2026
36 + )}, croissance ${s.croissancePct == null ? "—" : `${s.croissancePct.toFixed(1).replace(".", ",")} %`} depuis 2021. Maisons, plex, condos, chalets et terrains — estimation gratuite par adresse.`;
37 + return {
38 + title,
39 + description,
40 + alternates: { canonical: `/municipalite/${slug}` },
41 + openGraph: { title, description, type: "website" },
42 + };
43 +}
44 +
45 +export default async function MunicipalitePage({
46 + params,
47 +}: {
48 + params: Promise<{ slug: string }>;
49 +}) {
50 + const { slug } = await params;
51 + const mun = munBySlug(slug);
52 + if (!mun) notFound();
53 + const s = munStats(mun.nom)!;
54 + const samples = sampleUnits(mun.nom, null, 24);
55 + const voisines = neighborMunicipalities(mun.nom, 10);
56 + const url = `${SITE_URL}/municipalite/${slug}`;
57 +
58 + const jsonLd = {
59 + "@context": "https://schema.org",
60 + "@graph": [
61 + {
62 + "@type": "City",
63 + "@id": `${url}#place`,
64 + name: mun.nom,
65 + url,
66 + geo: { "@type": "GeoCoordinates", latitude: mun.lat, longitude: mun.lng },
67 + containedInPlace: { "@type": "AdministrativeArea", name: "Québec, Canada" },
68 + },
69 + {
70 + "@type": "BreadcrumbList",
71 + itemListElement: [
72 + { "@type": "ListItem", position: 1, name: "Accueil", item: SITE_URL },
73 + { "@type": "ListItem", position: 2, name: "Municipalités", item: `${SITE_URL}/municipalites` },
74 + { "@type": "ListItem", position: 3, name: mun.nom, item: url },
75 + ],
76 + },
77 + ],
78 + };
79 +
80 + return (
81 + <div className="pb-6 pt-10 sm:pt-14">
82 + <script
83 + type="application/ld+json"
84 + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
85 + />
86 + <nav className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">
87 + <Link href="/" className="hover:underline">
88 + Accueil
89 + </Link>{" "}
90 + /{" "}
91 + <Link href="/municipalites" className="hover:underline">
92 + Municipalités
93 + </Link>{" "}
94 + / <span className="text-ink">{mun.nom}</span>
95 + </nav>
96 +
97 + <section className="mt-6">
98 + <span className="kicker">Municipalité · Québec</span>
99 + <h1 className="vp-display mt-3 max-w-4xl text-[clamp(28px,5.4vw,56px)] font-bold uppercase leading-[1.0] tracking-[-0.03em]">
100 + L&apos;immobilier à <span className="hl">{mun.nom}</span>
101 + </h1>
102 + <p className="mt-4 max-w-2xl text-[15.5px] text-ink-2">
103 + {numFr(s.n)} unités d&apos;évaluation au rôle foncier {EST_YEAR},
104 + estimées par le modèle Vrai-Prix (modèle hédonique + ventes réelles).
105 + Cherchez n&apos;importe quelle adresse de {mun.nom} depuis{" "}
106 + <Link href="/" className="underline">
107 + l&apos;accueil
108 + </Link>{" "}
109 + ou explorez{" "}
110 + <Link href="/carte" className="underline">
111 + la carte
112 + </Link>
113 + .
114 + </p>
115 + </section>
116 +
117 + {/* ---- Tuiles statistiques ---- */}
118 + <section className="mt-8 grid grid-cols-2 gap-3 lg:grid-cols-4">
119 + {[
120 + ["Propriétés évaluées", numFr(s.n)],
121 + [`Valeur médiane ${EST_YEAR}`, moneyFr(s.mediane2026)],
122 + [`Valeur totale ${EST_YEAR}`, moneyFr(s.total2026)],
123 + [
124 + "Croissance 2021 → 2026",
125 + s.croissancePct == null
126 + ? "—"
127 + : `${s.croissancePct >= 0 ? "+" : ""}${s.croissancePct.toFixed(1).replace(".", ",")} %`,
128 + ],
129 + ].map(([label, val]) => (
130 + <div key={label} className="vp-card p-4">
131 + <p className="klabel">{label}</p>
132 + <p className="vp-display mt-1 break-words text-[clamp(17px,2.2vw,26px)] font-bold tracking-[-0.02em]">
133 + {val}
134 + </p>
135 + </div>
136 + ))}
137 + </section>
138 +
139 + {/* ---- Répartition par type ---- */}
140 + <section className="mt-12">
141 + <span className="kicker">Par type de propriété</span>
142 + <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">
143 + Médianes par type à {mun.nom}
144 + </h2>
145 + <div className="mt-5 overflow-x-auto">
146 + <table className="w-full min-w-[560px] border-collapse text-[14px]">
147 + <thead>
148 + <tr className="border-b-2 border-ink text-left">
149 + <th className="klabel py-2 pr-4">Type</th>
150 + <th className="klabel py-2 pr-4 text-right">Propriétés</th>
151 + <th className="klabel py-2 pr-4 text-right">Médiane {EST_YEAR}</th>
152 + <th className="klabel py-2 text-right">Valeur totale</th>
153 + </tr>
154 + </thead>
155 + <tbody>
156 + {s.parType.map((t) => (
157 + <tr key={t.type} className="border-b border-[rgba(20,24,20,0.12)]">
158 + <td className="py-2.5 pr-4">
159 + <Link
160 + href={`/municipalite/${slug}/${TYPE_SLUGS[t.type] ?? t.type}`}
161 + className="font-medium underline decoration-[rgba(20,24,20,0.3)] underline-offset-2 hover:decoration-ink"
162 + >
163 + {TYPE_LABELS[t.type]?.pluriel ?? t.type}
164 + </Link>
165 + </td>
166 + <td className="vp-mono py-2.5 pr-4 text-right">{numFr(t.n)}</td>
167 + <td className="vp-mono py-2.5 pr-4 text-right">
168 + {moneyFr(t.mediane2026)}
169 + </td>
170 + <td className="vp-mono py-2.5 text-right">{moneyFr(t.total2026)}</td>
171 + </tr>
172 + ))}
173 + </tbody>
174 + </table>
175 + </div>
176 + </section>
177 +
178 + {/* ---- Propriétés les plus valorisées (liens indexables) ---- */}
179 + {samples.length > 0 && (
180 + <section className="mt-12">
181 + <span className="kicker">Aperçu du registre</span>
182 + <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">
183 + Propriétés les plus valorisées à {mun.nom}
184 + </h2>
185 + <ul className="mt-5 grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
186 + {samples.map((u) => (
187 + <li key={u.id_provinc}>
188 + <Link
189 + href={`/estimation/${encodeURIComponent(u.id_provinc)}`}
190 + className="group flex items-baseline justify-between gap-3 border-b border-[rgba(20,24,20,0.12)] py-1.5 text-[13.5px]"
191 + >
192 + <span className="truncate group-hover:underline">
193 + {u.adresse}
194 + {u.apt ? `, app. ${u.apt}` : ""}
195 + </span>
196 + <span className="vp-mono shrink-0 text-[11px] text-ink-2">
197 + {moneyFr(u.est_2026)}
198 + </span>
199 + </Link>
200 + </li>
201 + ))}
202 + </ul>
203 + <p className="mt-4 text-[13px] text-ink-2">
204 + Chaque adresse de {mun.nom} a sa fiche : estimation {EST_YEAR},
205 + fourchette P10-P90, historique 2021-{EST_YEAR} et comparables réels.
206 + </p>
207 + </section>
208 + )}
209 +
210 + {/* ---- Municipalités voisines ---- */}
211 + {voisines.length > 0 && (
212 + <section className="mt-12">
213 + <span className="kicker">À proximité</span>
214 + <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">
215 + Municipalités voisines
216 + </h2>
217 + <ul className="mt-4 flex flex-wrap gap-2">
218 + {voisines.map((v) => (
219 + <li key={v.slug}>
220 + <Link
221 + href={`/municipalite/${v.slug}`}
222 + className="stat-chip hover:bg-lime"
223 + >
224 + {v.nom}
225 + </Link>
226 + </li>
227 + ))}
228 + </ul>
229 + </section>
230 + )}
231 + </div>
232 + );
233 +}
added src/app/municipalites/page.tsx +63 −0
@@ -0,0 +1,63 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Index des 1 097 municipalités : point d'entrée du maillage programmatique.
3 +import type { Metadata } from "next";
4 +import Link from "next/link";
5 +import { allMunicipalities } from "@/lib/municipalites";
6 +import { numFr, slugify } from "@/lib/seo";
7 +
8 +export const metadata: Metadata = {
9 + title: "Toutes les municipalités du Québec — valeur des propriétés",
10 + description:
11 + "Valeur médiane, statistiques d'évaluation et propriétés estimées pour les 1 097 municipalités du Québec : Montréal, Québec, Laval, Gatineau, Longueuil et toutes les autres.",
12 + alternates: { canonical: "/municipalites" },
13 +};
14 +
15 +export default function MunicipalitesPage() {
16 + const all = allMunicipalities();
17 + const byLetter = new Map<string, typeof all>();
18 + for (const m of [...all].sort((a, b) => a.nom.localeCompare(b.nom, "fr"))) {
19 + const l = slugify(m.nom)[0]!.toUpperCase();
20 + if (!byLetter.has(l)) byLetter.set(l, []);
21 + byLetter.get(l)!.push(m);
22 + }
23 + return (
24 + <div className="pb-6 pt-10 sm:pt-14">
25 + <section>
26 + <span className="kicker">Explorer le Québec</span>
27 + <h1 className="vp-display mt-3 max-w-4xl text-[clamp(28px,5vw,52px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">
28 + La valeur des propriétés dans les{" "}
29 + <span className="hl">{numFr(all.length)} municipalités</span> du
30 + Québec
31 + </h1>
32 + <p className="mt-4 max-w-2xl text-[15.5px] text-ink-2">
33 + Chaque municipalité a sa page : nombre d&apos;unités d&apos;évaluation,
34 + valeur médiane estimée 2026, croissance depuis 2021, répartition par
35 + type de propriété et fiches détaillées, bâties sur les rôles
36 + d&apos;évaluation foncière du MAMH et 745 119 ventes réelles.
37 + </p>
38 + </section>
39 + {[...byLetter.entries()].map(([letter, ms]) => (
40 + <section key={letter} className="mt-10">
41 + <h2 className="vp-display border-b-2 border-ink pb-1 text-[22px] font-bold">
42 + {letter}
43 + </h2>
44 + <ul className="mt-3 grid grid-cols-2 gap-x-6 gap-y-1.5 sm:grid-cols-3 lg:grid-cols-4">
45 + {ms.map((m) => (
46 + <li key={m.slug}>
47 + <Link
48 + href={`/municipalite/${m.slug}`}
49 + className="group flex items-baseline justify-between gap-2 py-0.5 text-[13.5px]"
50 + >
51 + <span className="group-hover:underline">{m.nom}</span>
52 + <span className="vp-mono text-[10.5px] text-ink-2">
53 + {numFr(m.n)}
54 + </span>
55 + </Link>
56 + </li>
57 + ))}
58 + </ul>
59 + </section>
60 + ))}
61 + </div>
62 + );
63 +}
added src/app/page.tsx +60 −0
@@ -0,0 +1,60 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
3 +import Link from "next/link";
4 +import HomeClient from "./HomeClient";
5 +import stats from "@/data/stats.json";
6 +import { moneyFr, slugify } from "@/lib/seo";
7 +
8 +export const metadata: Metadata = {
9 + title: {
10 + absolute: "Vrai-Prix — estimation immobilière gratuite au Québec, sans boîte noire",
11 + },
12 + description:
13 + "Estimez gratuitement la valeur de n'importe quelle propriété du Québec : 3,7 millions d'unités d'évaluation, 745 119 ventes réelles, fourchette honnête P10-P90 et calcul montré en entier.",
14 + alternates: { canonical: "/" },
15 +};
16 +
17 +const TOP_VILLES = (
18 + stats as { par_ville: { ville: string; n: number; mediane: number | null }[] }
19 +).par_ville.slice(0, 24);
20 +
21 +export default function Home() {
22 + return (
23 + <>
24 + <HomeClient />
25 + {/* ---- Explorer par municipalité (maillage interne indexable) ---- */}
26 + <section className="mt-14">
27 + <span className="kicker">Explorer par municipalité</span>
28 + <h2 className="vp-display mt-3 text-[clamp(22px,3.6vw,34px)] font-bold uppercase leading-[1.05] tracking-[-0.03em]">
29 + La valeur des propriétés, ville par ville
30 + </h2>
31 + <p className="mt-3 max-w-2xl text-[15px] text-ink-2">
32 + Statistiques d&apos;évaluation, valeur médiane et propriétés estimées
33 + pour chacune des 1 097 municipalités du Québec.
34 + </p>
35 + <ul className="mt-6 grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-3 lg:grid-cols-4">
36 + {TOP_VILLES.map((v) => (
37 + <li key={v.ville}>
38 + <Link
39 + href={`/municipalite/${slugify(v.ville)}`}
40 + className="group flex items-baseline justify-between gap-2 border-b border-[rgba(20,24,20,0.12)] py-1.5 text-[14px]"
41 + >
42 + <span className="font-medium group-hover:underline">
43 + {v.ville}
44 + </span>
45 + <span className="vp-mono text-[11px] text-ink-2">
46 + {v.mediane != null ? moneyFr(v.mediane) : ""}
47 + </span>
48 + </Link>
49 + </li>
50 + ))}
51 + </ul>
52 + <p className="mt-5">
53 + <Link href="/municipalites" className="btn btn-ghost">
54 + Toutes les municipalités →
55 + </Link>
56 + </p>
57 + </section>
58 + </>
59 + );
60 +}
modified src/app/parc/ParcClient.tsx +1 −1
@@ -32,7 +32,7 @@ const CONF: Record<string, string> = {
32 32 D: "pill-conf-D",
33 33 };
34 34
35 −export default function ParcPage() {
35 +export default function ParcClient() {
36 36 const { lang, t } = useLang();
37 37 const fr = lang === "fr";
38 38 const [list, setList] = useState<Saved[]>([]);
added src/app/parc/page.tsx +14 −0
@@ -0,0 +1,14 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
3 +import ParcClient from "./ParcClient";
4 +
5 +export const metadata: Metadata = {
6 + title: "Parc immobilier — évaluez plusieurs propriétés d'un coup",
7 + description:
8 + "Regroupez jusqu'à 40 propriétés du Québec dans un parc immobilier : valeur totale estimée, fourchette cumulée, croissance 2021-2026 et rapport PDF multi-propriétés.",
9 + alternates: { canonical: "/parc" },
10 +};
11 +
12 +export default function Page() {
13 + return <ParcClient />;
14 +}
added src/app/robots.ts +14 −0
@@ -0,0 +1,14 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { MetadataRoute } from "next";
3 +import { SITE_URL } from "@/lib/seo";
4 +
5 +export default function robots(): MetadataRoute.Robots {
6 + return {
7 + rules: {
8 + userAgent: "*",
9 + allow: "/",
10 + disallow: ["/api/", "/compte"],
11 + },
12 + sitemap: `${SITE_URL}/sitemap-index.xml`,
13 + };
14 +}
added src/app/sitemap-index.xml/route.ts +32 −0
@@ -0,0 +1,32 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Index de sitemaps (protocole sitemaps.org) : le sitemap des pages
3 +// (/sitemap.xml) + les ~84 sitemaps des fiches (/estimation/sitemap/N.xml).
4 +// C'est l'URL à soumettre dans Google Search Console.
5 +import { getDb } from "@/lib/db";
6 +import { SITE_URL } from "@/lib/seo";
7 +
8 +export const dynamic = "force-dynamic";
9 +
10 +const CHUNK = 45_000;
11 +
12 +export async function GET(): Promise<Response> {
13 + const max = (
14 + getDb().prepare("SELECT MAX(rowid) AS m FROM units").get() as { m: number }
15 + ).m;
16 + const n = Math.ceil(max / CHUNK);
17 + const locs = [
18 + `${SITE_URL}/sitemap.xml`,
19 + ...Array.from({ length: n }, (_, i) => `${SITE_URL}/estimation/sitemap/${i}.xml`),
20 + ];
21 + const xml =
22 + `<?xml version="1.0" encoding="UTF-8"?>\n` +
23 + `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n` +
24 + locs.map((l) => ` <sitemap><loc>${l}</loc></sitemap>`).join("\n") +
25 + `\n</sitemapindex>\n`;
26 + return new Response(xml, {
27 + headers: {
28 + "Content-Type": "application/xml; charset=utf-8",
29 + "Cache-Control": "public, max-age=3600",
30 + },
31 + });
32 +}
added src/app/sitemap.ts +38 −0
@@ -0,0 +1,38 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Sitemap des pages : statiques + 1 097 municipalités + pages municipalité×type.
3 +// Les 3,7 M de fiches sont dans /estimation/sitemap/[id].xml (voir sitemap-index.xml).
4 +import type { MetadataRoute } from "next";
5 +import { allMunicipalities, allMunTypePairs } from "@/lib/municipalites";
6 +import { SITE_URL, TYPE_SLUGS, slugify } from "@/lib/seo";
7 +import stats from "@/data/stats.json";
8 +
9 +export const dynamic = "force-dynamic";
10 +
11 +const LASTMOD = new Date((stats as { generated: string }).generated);
12 +
13 +export default function sitemap(): MetadataRoute.Sitemap {
14 + const statics: MetadataRoute.Sitemap = [
15 + { url: `${SITE_URL}/`, lastModified: LASTMOD, changeFrequency: "weekly", priority: 1 },
16 + { url: `${SITE_URL}/municipalites`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.9 },
17 + { url: `${SITE_URL}/carte`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.8 },
18 + { url: `${SITE_URL}/stats`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.8 },
19 + { url: `${SITE_URL}/ka`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.7 },
20 + { url: `${SITE_URL}/parc`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.6 },
21 + { url: `${SITE_URL}/methodologie`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.7 },
22 + { url: `${SITE_URL}/conditions`, lastModified: LASTMOD, changeFrequency: "yearly", priority: 0.2 },
23 + { url: `${SITE_URL}/confidentialite`, lastModified: LASTMOD, changeFrequency: "yearly", priority: 0.2 },
24 + ];
25 + const munis: MetadataRoute.Sitemap = allMunicipalities().map((m) => ({
26 + url: `${SITE_URL}/municipalite/${m.slug}`,
27 + lastModified: LASTMOD,
28 + changeFrequency: "monthly",
29 + priority: 0.8,
30 + }));
31 + const types: MetadataRoute.Sitemap = allMunTypePairs().map((p) => ({
32 + url: `${SITE_URL}/municipalite/${slugify(p.nom)}/${TYPE_SLUGS[p.type] ?? p.type}`,
33 + lastModified: LASTMOD,
34 + changeFrequency: "monthly",
35 + priority: 0.6,
36 + }));
37 + return [...statics, ...munis, ...types];
38 +}
modified src/app/stats/page.tsx +6 −3
@@ -2,10 +2,13 @@
2 2 import StatsView, { type ProvStats } from "@/components/StatsView";
3 3 import stats from "@/data/stats.json";
4 4
5 −export const metadata = {
6 − title: "Vrai-Prix — Statistiques provinciales",
5 +import type { Metadata } from "next";
6 +
7 +export const metadata: Metadata = {
8 + title: "Statistiques provinciales — la valeur de l'immobilier québécois",
7 9 description:
8 − "La valeur totale de l'immobilier québécois, par municipalité et par type — 3,7 millions de propriétés estimées.",
10 + "La valeur totale de l'immobilier québécois : 2 000 milliards $ en 2026, répartition par millésime, par type et palmarès des 200 plus grandes municipalités.",
11 + alternates: { canonical: "/stats" },
9 12 };
10 13
11 14 export default function StatsPage() {
modified src/components/FooterNav.tsx +3 −0
@@ -20,6 +20,9 @@ export default function FooterNav() {
20 20 <Link href="/stats" className={cls}>
21 21 {fr ? "Statistiques" : "Statistics"}
22 22 </Link>
23 + <Link href="/municipalites" className={cls}>
24 + {fr ? "Municipalités" : "Municipalities"}
25 + </Link>
23 26 <Link href="/methodologie" className={cls}>
24 27 {fr ? "Méthodologie" : "Methodology"}
25 28 </Link>
added src/lib/municipalites.ts +177 −0
@@ -0,0 +1,177 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Données agrégées par municipalité pour les pages programmatiques SEO.
3 +// Les agrégats ne changent qu'au rebuild de la DB : cache mémoire par processus.
4 +
5 +import { getDb } from "./db";
6 +import { slugify } from "./seo";
7 +
8 +export interface MunSummary {
9 + nom: string;
10 + slug: string;
11 + n: number;
12 + lat: number;
13 + lng: number;
14 +}
15 +
16 +export interface TypeStats {
17 + type: string;
18 + n: number;
19 + total2026: number;
20 + mediane2026: number | null;
21 +}
22 +
23 +export interface MunStats {
24 + nom: string;
25 + slug: string;
26 + n: number;
27 + total2026: number;
28 + total2021: number;
29 + mediane2026: number | null;
30 + croissancePct: number | null;
31 + parType: TypeStats[];
32 +}
33 +
34 +export interface SampleUnit {
35 + id_provinc: string;
36 + adresse: string | null;
37 + apt: string | null;
38 + type_prop: string;
39 + est_2026: number | null;
40 + annee_construction: number | null;
41 + aire_etages_m2: number | null;
42 +}
43 +
44 +let munList: MunSummary[] | null = null;
45 +let munBySlugMap: Map<string, MunSummary> | null = null;
46 +const statsCache = new Map<string, MunStats>();
47 +
48 +/** Toutes les municipalités (nom, slug, nb d'unités, centroïde) — triées par taille. */
49 +export function allMunicipalities(): MunSummary[] {
50 + if (!munList) {
51 + const rows = getDb()
52 + .prepare(
53 + `SELECT municipalite AS nom, COUNT(*) AS n,
54 + AVG(lat) AS lat, AVG(lng) AS lng
55 + FROM units WHERE municipalite IS NOT NULL AND municipalite != ''
56 + GROUP BY municipalite ORDER BY n DESC`
57 + )
58 + .all() as { nom: string; n: number; lat: number; lng: number }[];
59 + munList = rows.map((r) => ({ ...r, slug: slugify(r.nom) }));
60 + munBySlugMap = new Map(munList.map((m) => [m.slug, m]));
61 + }
62 + return munList;
63 +}
64 +
65 +export function munBySlug(slug: string): MunSummary | undefined {
66 + allMunicipalities();
67 + return munBySlugMap!.get(slug);
68 +}
69 +
70 +function median2026(nom: string, type?: string): number | null {
71 + const db = getDb();
72 + const where = type
73 + ? "municipalite = ? AND type_prop = ?"
74 + : "municipalite = ?";
75 + const args: (string | number)[] = type ? [nom, type] : [nom];
76 + const cnt = (
77 + db.prepare(`SELECT COUNT(*) AS c FROM units WHERE ${where} AND est_2026 IS NOT NULL`).get(...args) as { c: number }
78 + ).c;
79 + if (!cnt) return null;
80 + const row = db
81 + .prepare(
82 + `SELECT est_2026 AS v FROM units WHERE ${where} AND est_2026 IS NOT NULL
83 + ORDER BY est_2026 LIMIT 1 OFFSET ?`
84 + )
85 + .get(...args, Math.floor(cnt / 2)) as { v: number } | undefined;
86 + return row?.v ?? null;
87 +}
88 +
89 +/** Statistiques complètes d'une municipalité (cache mémoire). */
90 +export function munStats(nom: string): MunStats | null {
91 + const cached = statsCache.get(nom);
92 + if (cached) return cached;
93 + const db = getDb();
94 + const base = db
95 + .prepare(
96 + `SELECT COUNT(*) AS n, SUM(est_2026) AS t26, SUM(est_2021) AS t21
97 + FROM units WHERE municipalite = ?`
98 + )
99 + .get(nom) as { n: number; t26: number | null; t21: number | null };
100 + if (!base.n) return null;
101 + const types = db
102 + .prepare(
103 + `SELECT type_prop AS type, COUNT(*) AS n, SUM(est_2026) AS total2026
104 + FROM units WHERE municipalite = ?
105 + GROUP BY type_prop ORDER BY n DESC`
106 + )
107 + .all(nom) as { type: string; n: number; total2026: number }[];
108 + const stats: MunStats = {
109 + nom,
110 + slug: slugify(nom),
111 + n: base.n,
112 + total2026: base.t26 ?? 0,
113 + total2021: base.t21 ?? 0,
114 + mediane2026: median2026(nom),
115 + croissancePct:
116 + base.t21 && base.t26 ? ((base.t26 - base.t21) / base.t21) * 100 : null,
117 + parType: types.map((t) => ({
118 + ...t,
119 + mediane2026: median2026(nom, t.type),
120 + })),
121 + };
122 + statsCache.set(nom, stats);
123 + return stats;
124 +}
125 +
126 +/** Échantillon de propriétés adressées (les plus valorisées) pour le maillage interne. */
127 +export function sampleUnits(
128 + nom: string,
129 + type: string | null,
130 + limit = 24
131 +): SampleUnit[] {
132 + const where = type
133 + ? "municipalite = ? AND type_prop = ?"
134 + : "municipalite = ?";
135 + const args: string[] = type ? [nom, type] : [nom];
136 + return getDb()
137 + .prepare(
138 + `SELECT id_provinc, adresse, apt, type_prop, est_2026,
139 + annee_construction, aire_etages_m2
140 + FROM units
141 + WHERE ${where} AND adresse IS NOT NULL AND est_2026 IS NOT NULL
142 + ORDER BY est_2026 DESC LIMIT ?`
143 + )
144 + .all(...args, limit) as SampleUnit[];
145 +}
146 +
147 +let typePairs: { nom: string; type: string }[] | null = null;
148 +
149 +/** Paires municipalité × type existantes (pour le sitemap des pages types). */
150 +export function allMunTypePairs(): { nom: string; type: string }[] {
151 + if (!typePairs) {
152 + typePairs = getDb()
153 + .prepare(
154 + `SELECT municipalite AS nom, type_prop AS type
155 + FROM units WHERE municipalite IS NOT NULL AND municipalite != ''
156 + GROUP BY municipalite, type_prop`
157 + )
158 + .all() as { nom: string; type: string }[];
159 + }
160 + return typePairs;
161 +}
162 +
163 +/** Les municipalités les plus proches du centroïde (maillage entre pages voisines). */
164 +export function neighborMunicipalities(nom: string, k = 10): MunSummary[] {
165 + const all = allMunicipalities();
166 + const me = all.find((m) => m.nom === nom);
167 + if (!me) return [];
168 + return all
169 + .filter((m) => m.nom !== nom)
170 + .map((m) => ({
171 + m,
172 + d: (m.lat - me.lat) ** 2 + 0.49 * (m.lng - me.lng) ** 2,
173 + }))
174 + .sort((a, b) => a.d - b.d)
175 + .slice(0, k)
176 + .map((x) => x.m);
177 +}
added src/lib/seo.ts +64 −0
@@ -0,0 +1,64 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Constantes et aides SEO : URL canonique du site, slugs, libellés de types.
3 +
4 +export const SITE_URL =
5 + process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.vrai-prix.com";
6 +
7 +export const SITE_NAME = "Vrai-Prix";
8 +
9 +/** Slug URL d'un nom de municipalité (unique sur les 1 097 noms — vérifié). */
10 +export function slugify(name: string): string {
11 + return name
12 + .normalize("NFD")
13 + // retire les diacritiques combinants (é → e)
14 + .replace(/[\u0300-\u036f]/g, "")
15 + .toLowerCase()
16 + .replace(/['’]/g, "-")
17 + .replace(/[^a-z0-9]+/g, "-")
18 + .replace(/^-+|-+$/g, "");
19 +}
20 +
21 +/** type_prop (DB) → segment d'URL. */
22 +export const TYPE_SLUGS: Record<string, string> = {
23 + unifamilial: "maison",
24 + plex: "plex",
25 + chalet: "chalet",
26 + condo_ou_multi: "condo",
27 + terrain: "terrain",
28 + maison_mobile: "maison-mobile",
29 + autre: "autre",
30 +};
31 +
32 +/** segment d'URL → type_prop (DB). */
33 +export const SLUG_TYPES: Record<string, string> = Object.fromEntries(
34 + Object.entries(TYPE_SLUGS).map(([k, v]) => [v, k])
35 +);
36 +
37 +/** Libellés français des types de propriété. */
38 +export const TYPE_LABELS: Record<string, { singulier: string; pluriel: string }> = {
39 + unifamilial: { singulier: "maison unifamiliale", pluriel: "maisons unifamiliales" },
40 + plex: { singulier: "plex", pluriel: "plex (2 à 5 logements)" },
41 + chalet: { singulier: "chalet", pluriel: "chalets et résidences secondaires" },
42 + condo_ou_multi: { singulier: "condo ou multirésidentiel", pluriel: "condos et multirésidentiel" },
43 + terrain: { singulier: "terrain", pluriel: "terrains vacants" },
44 + maison_mobile: { singulier: "maison mobile", pluriel: "maisons mobiles" },
45 + autre: { singulier: "autre propriété", pluriel: "autres propriétés" },
46 +};
47 +
48 +/** 425 000 → « 425 000 $ » (fr-CA, sans décimales). */
49 +export function moneyFr(v: number | null | undefined): string {
50 + if (v == null) return "—";
51 + return new Intl.NumberFormat("fr-CA", {
52 + style: "currency",
53 + currency: "CAD",
54 + maximumFractionDigits: 0,
55 + }).format(v);
56 +}
57 +
58 +/** 3747008 → « 3 747 008 » (fr-CA). */
59 +export function numFr(v: number): string {
60 + return v.toLocaleString("fr-CA");
61 +}
62 +
63 +/** Millésime courant des estimations (dernier millésime du rôle chargé). */
64 +export const EST_YEAR = 2026;
65