SPB Git

spb/vrai-prix

Public

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

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 (Aug 13, 2026) parent 7a1206e Browse files

26 changed files +1,175 −15

modified src/app/HomeClient.tsx +1 −1
@@ -4,7 +4,7 @@ import SearchBox from "@/components/SearchBox";
4 import EstimateForm from "@/components/EstimateForm"; 4 import EstimateForm from "@/components/EstimateForm";
5 import { useLang } from "@/components/LangContext"; 5 import { useLang } from "@/components/LangContext";
6 6
7−export default function Home() { 7+export default function HomeClient() {
8 const { lang, t } = useLang(); 8 const { lang, t } = useLang();
9 return ( 9 return (
10 <div className="pb-6 pt-10 sm:pt-14"> 10 <div className="pb-6 pt-10 sm:pt-14">
modified src/app/carte/page.tsx +5 −2
@@ -1,10 +1,13 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 import CartePage from "./CartePage"; 2 import CartePage from "./CartePage";
3 3
4−export const metadata = { 4+import type { Metadata } from "next";
5− title: "Vrai-Prix — Recherche par carte", 5+
6+export const metadata: Metadata = {
7+ title: "Recherche par carte — explorez 3,7 M de propriétés du Québec",
6 description: 8 description:
7 "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.", 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 export default function Page() { 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 return ( 110 return (
111 <LegalPage 111 <LegalPage
112 crumb={{ fr: "Conditions d'utilisation", en: "Terms of service" }} 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 const { lang } = useLang(); 106 const { lang } = useLang();
107 const fr = lang === "fr"; 107 const fr = lang === "fr";
108 return ( 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 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+import type { Metadata } from "next";
3+import { cache } from "react";
2 import { notFound } from "next/navigation"; 4 import { notFound } from "next/navigation";
3 import { estimateByUnitId } from "@/lib/estimator"; 5 import { estimateByUnitId } from "@/lib/estimator";
4 import ResultView from "@/components/ResultView"; 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 export const dynamic = "force-dynamic"; 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 export default async function EstimationPage({ 127 export default async function EstimationPage({
9 params, 128 params,
10 }: { 129 }: {
11 params: Promise<{ id: string }>; 130 params: Promise<{ id: string }>;
12 }) { 131 }) {
13 const { id } = await params; 132 const { id } = await params;
14− const data = estimateByUnitId(decodeURIComponent(id)); 133+ const data = getEstimate(decodeURIComponent(id));
15 if (!data) notFound(); 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 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+import type { Metadata } from "next";
2 import { notFound } from "next/navigation"; 3 import { notFound } from "next/navigation";
3 import { estimateManual } from "@/lib/estimator"; 4 import { estimateManual } from "@/lib/estimator";
4 import ResultView from "@/components/ResultView"; 5 import ResultView from "@/components/ResultView";
5 6
6 export const dynamic = "force-dynamic"; 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 export default async function ManualEstimationPage({ 16 export default async function ManualEstimationPage({
9 searchParams, 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 import KaHero from "./KaHero"; 3 import KaHero from "./KaHero";
4 4
5 export const metadata: Metadata = { 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 description: 7 description:
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.", 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 export default function KaPage() { 12 export default function KaPage() {
modified src/app/layout.tsx +56 −2
@@ -3,10 +3,12 @@ import type { Metadata } from "next";
3 import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google"; 3 import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google";
4 import "./globals.css"; 4 import "./globals.css";
5 import { LangProvider, LangToggle } from "@/components/LangContext"; 5 import { LangProvider, LangToggle } from "@/components/LangContext";
6+import AccountButton from "@/components/AccountButton";
6 import CookieConsent from "@/components/CookieConsent"; 7 import CookieConsent from "@/components/CookieConsent";
7 import FooterNav from "@/components/FooterNav"; 8 import FooterNav from "@/components/FooterNav";
8 import HeaderNav from "@/components/HeaderNav"; 9 import HeaderNav from "@/components/HeaderNav";
9 import Link from "next/link"; 10 import Link from "next/link";
11+import { SITE_URL } from "@/lib/seo";
10 12
11 const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); 13 const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
12 const spaceGrotesk = Space_Grotesk({ 14 const spaceGrotesk = Space_Grotesk({
@@ -19,9 +21,56 @@ const jetbrains = JetBrains_Mono({
19 }); 21 });
20 22
21 export const metadata: Metadata = { 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 description: 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 const TICKER = [ 76 const TICKER = [
@@ -41,6 +90,10 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
41 <body 90 <body
42 className={`${inter.variable} ${spaceGrotesk.variable} ${jetbrains.variable}`} 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 <LangProvider> 97 <LangProvider>
45 {/* fond opaque sur mobile (backdrop-filter casse les enfants fixed sur WebKit) */} 98 {/* fond opaque sur mobile (backdrop-filter casse les enfants fixed sur WebKit) */}
46 <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"> 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 </Link> 109 </Link>
57 <div className="ml-auto flex items-center gap-2"> 110 <div className="ml-auto flex items-center gap-2">
58 <HeaderNav /> 111 <HeaderNav />
112+ <AccountButton />
59 <LangToggle /> 113 <LangToggle />
60 </div> 114 </div>
61 </div> 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 const { lang } = useLang(); 182 const { lang } = useLang();
183 const fr = lang === "fr"; 183 const fr = lang === "fr";
184 const sections = fr ? FR : EN; 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 D: "pill-conf-D", 32 D: "pill-conf-D",
33 }; 33 };
34 34
35−export default function ParcPage() { 35+export default function ParcClient() {
36 const { lang, t } = useLang(); 36 const { lang, t } = useLang();
37 const fr = lang === "fr"; 37 const fr = lang === "fr";
38 const [list, setList] = useState<Saved[]>([]); 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 import StatsView, { type ProvStats } from "@/components/StatsView"; 2 import StatsView, { type ProvStats } from "@/components/StatsView";
3 import stats from "@/data/stats.json"; 3 import stats from "@/data/stats.json";
4 4
5−export const metadata = { 5+import type { Metadata } from "next";
6− title: "Vrai-Prix — Statistiques provinciales", 6+
7+export const metadata: Metadata = {
8+ title: "Statistiques provinciales — la valeur de l'immobilier québécois",
7 description: 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 export default function StatsPage() { 14 export default function StatsPage() {
modified src/components/FooterNav.tsx +3 −0
@@ -20,6 +20,9 @@ export default function FooterNav() {
20 <Link href="/stats" className={cls}> 20 <Link href="/stats" className={cls}>
21 {fr ? "Statistiques" : "Statistics"} 21 {fr ? "Statistiques" : "Statistics"}
22 </Link> 22 </Link>
23+ <Link href="/municipalites" className={cls}>
24+ {fr ? "Municipalités" : "Municipalities"}
25+ </Link>
23 <Link href="/methodologie" className={cls}> 26 <Link href="/methodologie" className={cls}>
24 {fr ? "Méthodologie" : "Methodology"} 27 {fr ? "Méthodologie" : "Methodology"}
25 </Link> 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
Menu

Theme