SEO : métadonnées complètes (Open Graph fr_CA, twitter:card, canonical), JSON-LD Organization+WebSite, robots.txt et sitemap.xml, titres/descriptions par page, lang fr-CA
- layout : metadataBase, template de titre « %s | ValoPlex », openGraph (type website, locale fr_CA, siteName), twitter summary_large_image, canonical './', html lang=fr-CA, JSON-LD (Organization + WebSite inLanguage fr-CA) - robots.ts : allow /, disallow /api/, référence au sitemap - sitemap.ts : 7 routes publiques statiques avec priorités - layouts de métadonnées pour les pages client (méthodologie, parc, conditions, confidentialité) - /ka et /stats : titres courts (template) + canonical - /estimation/[id] : generateMetadata dynamique (adresse, municipalité) avec cache() partagé - /estimation/manuelle : metadata + noindex (espace de paramètres infini) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
11 changed files +188 −7
added
app/src/app/conditions/layout.tsx
+17 −0
@@ -0,0 +1,17 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +import type { Metadata } from "next"; | ||||
| 3 | + | ||||
| 4 | +export const metadata: Metadata = { | ||||
| 5 | + title: "Conditions d'utilisation", | ||||
| 6 | + description: | ||||
| 7 | + "Conditions d'utilisation de ValoPlex : nature du service d'estimation statistique, limites des estimations, sources de données publiques (MAMH) et responsabilités.", | ||||
| 8 | + alternates: { canonical: "/conditions" }, | ||||
| 9 | +}; | ||||
| 10 | + | ||||
| 11 | +export default function ConditionsLayout({ | ||||
| 12 | + children, | ||||
| 13 | +}: { | ||||
| 14 | + children: React.ReactNode; | ||||
| 15 | +}) { | ||||
| 16 | + return children; | ||||
| 17 | +} | ||||
added
app/src/app/confidentialite/layout.tsx
+17 −0
@@ -0,0 +1,17 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +import type { Metadata } from "next"; | ||||
| 3 | + | ||||
| 4 | +export const metadata: Metadata = { | ||||
| 5 | + title: "Politique de confidentialité", | ||||
| 6 | + description: | ||||
| 7 | + "Politique de confidentialité de ValoPlex (Loi 25, Québec) : renseignements recueillis, finalités, durée de conservation, témoins (cookies) et vos droits.", | ||||
| 8 | + alternates: { canonical: "/confidentialite" }, | ||||
| 9 | +}; | ||||
| 10 | + | ||||
| 11 | +export default function ConfidentialiteLayout({ | ||||
| 12 | + children, | ||||
| 13 | +}: { | ||||
| 14 | + children: React.ReactNode; | ||||
| 15 | +}) { | ||||
| 16 | + return children; | ||||
| 17 | +} | ||||
modified
app/src/app/estimation/[id]/page.tsx
+26 −1
@@ -1,17 +1,42 @@ | |||||
| 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||
| 2 | +import { cache } from "react"; | ||||
| 3 | +import type { Metadata } from "next"; | ||||
| 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"; | ||
| 5 | 7 | ||||
| 6 | export const dynamic = "force-dynamic"; | 8 | export const dynamic = "force-dynamic"; | ||
| 7 | 9 | ||||
| 10 | +// Mémoïsé par requête : partagé entre generateMetadata et le rendu de la page. | ||||
| 11 | +const getEstimate = cache((id: string) => estimateByUnitId(id)); | ||||
| 12 | + | ||||
| 13 | +export async function generateMetadata({ | ||||
| 14 | + params, | ||||
| 15 | +}: { | ||||
| 16 | + params: Promise<{ id: string }>; | ||||
| 17 | +}): Promise<Metadata> { | ||||
| 18 | + const { id } = await params; | ||||
| 19 | + const data = getEstimate(decodeURIComponent(id)); | ||||
| 20 | + if (!data?.unit) return { title: "Estimation" }; | ||||
| 21 | + const ou = [data.unit.adresse, data.unit.municipalite] | ||||
| 22 | + .filter(Boolean) | ||||
| 23 | + .join(", "); | ||||
| 24 | + return { | ||||
| 25 | + title: ou ? `Estimation — ${ou}` : "Estimation", | ||||
| 26 | + description: ou | ||||
| 27 | + ? `La valeur estimée de ${ou} : fourchette calibrée, indice de confiance, comparables de plex ajustés porte par porte et calcul montré en entier.` | ||||
| 28 | + : undefined, | ||||
| 29 | + alternates: { canonical: `/estimation/${encodeURIComponent(id)}` }, | ||||
| 30 | + }; | ||||
| 31 | +} | ||||
| 32 | + | ||||
| 8 | export default async function EstimationPage({ | 33 | export default async function EstimationPage({ | ||
| 9 | params, | 34 | params, | ||
| 10 | }: { | 35 | }: { | ||
| 11 | params: Promise<{ id: string }>; | 36 | params: Promise<{ id: string }>; | ||
| 12 | }) { | 37 | }) { | ||
| 13 | const { id } = await params; | 38 | const { id } = await params; | ||
| 14 | − const data = estimateByUnitId(decodeURIComponent(id)); | 39 | + const data = getEstimate(decodeURIComponent(id)); | ||
| 15 | if (!data) notFound(); | 40 | if (!data) notFound(); | ||
| 16 | return <ResultView data={data} />; | 41 | return <ResultView data={data} />; | ||
| 17 | } | 42 | } | ||
modified
app/src/app/estimation/manuelle/page.tsx
+9 −0
@@ -1,10 +1,19 @@ | |||||
| 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 | +export const metadata: Metadata = { | ||||
| 10 | + title: "Estimation manuelle", | ||||
| 11 | + description: | ||||
| 12 | + "Estimez un plex du Québec à partir de ses caractéristiques : municipalité, nombre de portes, superficie et année de construction.", | ||||
| 13 | + // Espace de paramètres infini → on n'indexe pas ces pages de résultat. | ||||
| 14 | + robots: { index: false, follow: true }, | ||||
| 15 | +}; | ||||
| 16 | + | ||||
| 8 | export default async function ManualEstimationPage({ | 17 | export default async function ManualEstimationPage({ | ||
| 9 | searchParams, | 18 | searchParams, | ||
| 10 | }: { | 19 | }: { | ||
modified
app/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 ValoPlex", | 6 | + title: "Ka — l'agent IA plex", | ||
| 7 | description: | 7 | description: | ||
| 8 | "Ka évalue n'importe quel plex du Québec en conversant : recherche dans le registre, évaluation par porte, pro forma investisseur complet, comparables — en direct.", | 8 | "Ka évalue n'importe quel plex du Québec en conversant : recherche dans le registre, évaluation par porte, pro forma investisseur complet, comparables — en direct.", | ||
| 9 | + alternates: { canonical: "/ka" }, | ||||
| 9 | }; | 10 | }; | ||
| 10 | 11 | ||||
| 11 | export default function KaPage() { | 12 | export default function KaPage() { | ||
modified
app/src/app/layout.tsx
+45 −4
@@ -18,10 +18,47 @@ const jetbrains = JetBrains_Mono({ | |||||
| 18 | variable: "--font-jetbrains", | 18 | variable: "--font-jetbrains", | ||
| 19 | }); | 19 | }); | ||
| 20 | 20 | ||||
| 21 | +const SITE_URL = "https://www.valoplex.com"; | ||||
| 22 | +const SITE_TITLE = "ValoPlex — la vraie valeur, sans boîte noire"; | ||||
| 23 | +const SITE_DESCRIPTION = | ||||
| 24 | + "Le moteur d'évaluation spécialisé plex du Québec : modèle hédonique avancé + ventes de plex comparables, fourchette calibrée et indice de confiance."; | ||||
| 25 | + | ||||
| 21 | export const metadata: Metadata = { | 26 | export const metadata: Metadata = { | ||
| 22 | − title: "ValoPlex — la vraie valeur, sans boîte noire", | 27 | + metadataBase: new URL(SITE_URL), | ||
| 23 | − description: | 28 | + title: { | ||
| 24 | − "Le moteur d'évaluation spécialisé plex du Québec : modèle hédonique avancé + ventes de plex comparables, fourchette calibrée et indice de confiance.", | 29 | + default: SITE_TITLE, | ||
| 30 | + template: "%s | ValoPlex", | ||||
| 31 | + }, | ||||
| 32 | + description: SITE_DESCRIPTION, | ||||
| 33 | + alternates: { canonical: "./" }, | ||||
| 34 | + openGraph: { | ||||
| 35 | + type: "website", | ||||
| 36 | + locale: "fr_CA", | ||||
| 37 | + url: SITE_URL, | ||||
| 38 | + siteName: "ValoPlex", | ||||
| 39 | + title: SITE_TITLE, | ||||
| 40 | + description: SITE_DESCRIPTION, | ||||
| 41 | + }, | ||||
| 42 | + twitter: { card: "summary_large_image" }, | ||||
| 43 | +}; | ||||
| 44 | + | ||||
| 45 | +const JSON_LD = { | ||||
| 46 | + "@context": "https://schema.org", | ||||
| 47 | + "@graph": [ | ||||
| 48 | + { | ||||
| 49 | + "@type": "Organization", | ||||
| 50 | + name: "ValoPlex (Groupe KA)", | ||||
| 51 | + url: SITE_URL, | ||||
| 52 | + email: "contact@spboucher.ai", | ||||
| 53 | + }, | ||||
| 54 | + { | ||||
| 55 | + "@type": "WebSite", | ||||
| 56 | + name: "ValoPlex", | ||||
| 57 | + url: SITE_URL, | ||||
| 58 | + description: SITE_DESCRIPTION, | ||||
| 59 | + inLanguage: "fr-CA", | ||||
| 60 | + }, | ||||
| 61 | + ], | ||||
| 25 | }; | 62 | }; | ||
| 26 | 63 | ||||
| 27 | const TICKER = [ | 64 | const TICKER = [ | ||
@@ -37,10 +74,14 @@ const TICKER = [ | |||||
| 37 | 74 | ||||
| 38 | export default function RootLayout({ children }: LayoutProps<"/">) { | 75 | export default function RootLayout({ children }: LayoutProps<"/">) { | ||
| 39 | return ( | 76 | return ( | ||
| 40 | − <html lang="fr"> | 77 | + <html lang="fr-CA"> | ||
| 41 | <body | 78 | <body | ||
| 42 | className={`${inter.variable} ${spaceGrotesk.variable} ${jetbrains.variable}`} | 79 | className={`${inter.variable} ${spaceGrotesk.variable} ${jetbrains.variable}`} | ||
| 43 | > | 80 | > | ||
| 81 | + <script | ||||
| 82 | + type="application/ld+json" | ||||
| 83 | + dangerouslySetInnerHTML={{ __html: JSON.stringify(JSON_LD) }} | ||||
| 84 | + /> | ||||
| 44 | <LangProvider> | 85 | <LangProvider> | ||
| 45 | {/* fond opaque sur mobile (backdrop-filter casse les enfants fixed sur WebKit) */} | 86 | {/* 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"> | 87 | <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"> | ||
added
app/src/app/methodologie/layout.tsx
+17 −0
@@ -0,0 +1,17 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +import type { Metadata } from "next"; | ||||
| 3 | + | ||||
| 4 | +export const metadata: Metadata = { | ||||
| 5 | + title: "Méthodologie", | ||||
| 6 | + description: | ||||
| 7 | + "Comment ValoPlex évalue les plex du Québec : modèle hédonique en ratio prix/rôle, comparables ajustés porte par porte, fourchette P10-P90 calibrée à 80 % et indice de confiance — le calcul montré en entier.", | ||||
| 8 | + alternates: { canonical: "/methodologie" }, | ||||
| 9 | +}; | ||||
| 10 | + | ||||
| 11 | +export default function MethodologieLayout({ | ||||
| 12 | + children, | ||||
| 13 | +}: { | ||||
| 14 | + children: React.ReactNode; | ||||
| 15 | +}) { | ||||
| 16 | + return children; | ||||
| 17 | +} | ||||
added
app/src/app/parc/layout.tsx
+17 −0
@@ -0,0 +1,17 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +import type { Metadata } from "next"; | ||||
| 3 | + | ||||
| 4 | +export const metadata: Metadata = { | ||||
| 5 | + title: "Parc immobilier", | ||||
| 6 | + description: | ||||
| 7 | + "Suivez la valeur de votre parc de plex au Québec : regroupez vos immeubles, obtenez la valeur totale, la valeur par porte, la répartition par municipalité et le rapport investisseur PDF.", | ||||
| 8 | + alternates: { canonical: "/parc" }, | ||||
| 9 | +}; | ||||
| 10 | + | ||||
| 11 | +export default function ParcLayout({ | ||||
| 12 | + children, | ||||
| 13 | +}: { | ||||
| 14 | + children: React.ReactNode; | ||||
| 15 | +}) { | ||||
| 16 | + return children; | ||||
| 17 | +} | ||||
added
app/src/app/robots.ts
+13 −0
@@ -0,0 +1,13 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +import type { MetadataRoute } from "next"; | ||||
| 3 | + | ||||
| 4 | +export default function robots(): MetadataRoute.Robots { | ||||
| 5 | + return { | ||||
| 6 | + rules: { | ||||
| 7 | + userAgent: "*", | ||||
| 8 | + allow: "/", | ||||
| 9 | + disallow: ["/api/"], | ||||
| 10 | + }, | ||||
| 11 | + sitemap: "https://www.valoplex.com/sitemap.xml", | ||||
| 12 | + }; | ||||
| 13 | +} | ||||
added
app/src/app/sitemap.ts
+23 −0
@@ -0,0 +1,23 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +import type { MetadataRoute } from "next"; | ||||
| 3 | + | ||||
| 4 | +const BASE = "https://www.valoplex.com"; | ||||
| 5 | + | ||||
| 6 | +export default function sitemap(): MetadataRoute.Sitemap { | ||||
| 7 | + const lastModified = new Date(); | ||||
| 8 | + const routes: { path: string; priority: number }[] = [ | ||||
| 9 | + { path: "/", priority: 1 }, | ||||
| 10 | + { path: "/ka", priority: 0.9 }, | ||||
| 11 | + { path: "/methodologie", priority: 0.8 }, | ||||
| 12 | + { path: "/stats", priority: 0.8 }, | ||||
| 13 | + { path: "/parc", priority: 0.7 }, | ||||
| 14 | + { path: "/conditions", priority: 0.3 }, | ||||
| 15 | + { path: "/confidentialite", priority: 0.3 }, | ||||
| 16 | + ]; | ||||
| 17 | + return routes.map(({ path, priority }) => ({ | ||||
| 18 | + url: `${BASE}${path === "/" ? "" : path}`, | ||||
| 19 | + lastModified, | ||||
| 20 | + changeFrequency: "monthly" as const, | ||||
| 21 | + priority, | ||||
| 22 | + })); | ||||
| 23 | +} | ||||
modified
app/src/app/stats/page.tsx
+2 −1
@@ -3,9 +3,10 @@ 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 | export const metadata = { | ||
| 6 | − title: "ValoPlex — Statistiques provinciales", | 6 | + title: "Statistiques provinciales", | ||
| 7 | description: | 7 | 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.", | 8 | "La valeur totale de l'immobilier québécois, par municipalité et par type — 3,7 millions de propriétés estimées.", | ||
| 9 | + alternates: { canonical: "/stats" }, | ||||
| 9 | }; | 10 | }; | ||
| 10 | 11 | ||||
| 11 | export default function StatsPage() { | 12 | export default function StatsPage() { | ||
| 12 | 13 | ||||