Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2"use client";3import { useState } from "react";4import Link from "next/link";5import SearchBox from "@/components/SearchBox";6import EstimateForm from "@/components/EstimateForm";7import { locNum, MarketPulseChart } from "@/components/MetricViz";8import { useLang } from "@/components/LangContext";910/** Indice de marché d'un grand type — série + variations (calculé côté serveur). */11export interface PulseItem {12 type: "unifamilial" | "condo" | "plex";13 series: { month: string; idx: number }[];14 pct12m: number | null;15 since2021: number | null;16}1718/** Vente réelle récente appariée à une unité d'évaluation. */19export interface RecentSaleItem {20 id: string;21 date: string;22 amount: number;23 street: string | null;24 city: string | null;25 propertyType: string | null;26 idProvinc: string | null;27 vsRolePct: number | null;28}2930const PULSE_LABELS: Record<PulseItem["type"], { fr: string; en: string }> = {31 unifamilial: { fr: "Unifamiliale", en: "Single-family" },32 condo: { fr: "Condo", en: "Condo" },33 plex: { fr: "Plex", en: "Plex" },34};35/* attribution FIXE des couleurs de série (jamais recyclée) */36const PULSE_COLORS: Record<PulseItem["type"], string> = {37 unifamilial: "var(--chart-1)",38 condo: "var(--chart-2)",39 plex: "var(--chart-3)",40};4142/** Variation signée — l'encre porte le positif, le rouge danger le négatif. */43function Pct({ v, digits = 1 }: { v: number | null; digits?: number }) {44 if (v == null) return <span className="text-ink-3">—</span>;45 return (46 <span className={v >= 0 ? "text-ink" : "text-[var(--danger)]"}>47 {v >= 0 ? "+" : "−"}48 {Math.abs(v).toFixed(digits)} %49 </span>50 );51}5253/** En-tête de section éditorial : filet fort, numéro mono rouge, titre display. */54function SectionHead({55 num,56 kicker,57 title,58 lede,59}: {60 num: string;61 kicker: string;62 title: string;63 lede?: string;64}) {65 return (66 <header className="sec">67 <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">68 <span className="sec-num">69 {num} — {kicker}70 </span>71 </div>72 <h2 className="vp-display mt-2 max-w-3xl text-[clamp(24px,3.6vw,36px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">73 {title}74 </h2>75 {lede && <p className="mt-3 max-w-2xl text-[14.5px] leading-relaxed text-ink-2">{lede}</p>}76 </header>77 );78}7980const PERIODS = [81 { key: "12", months: 12, fr: "12 mois", en: "12 mo" },82 { key: "24", months: 24, fr: "24 mois", en: "24 mo" },83 { key: "all", months: null as number | null, fr: "Depuis 2021", en: "Since 2021" },84] as const;8586export default function HomeClient({87 pulse,88 recent,89 salesLast30d,90}: {91 pulse: PulseItem[];92 recent: RecentSaleItem[];93 salesLast30d: number;94}) {95 const { lang, t } = useLang();96 const fr = lang === "fr";97 const [period, setPeriod] = useState<(typeof PERIODS)[number]["key"]>("24");98 const money = (v: number) =>99 new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {100 style: "currency",101 currency: "CAD",102 maximumFractionDigits: 0,103 }).format(v);104 const dateFmt = (d: string) =>105 new Date(`${d}T12:00:00`).toLocaleDateString(fr ? "fr-CA" : "en-CA", {106 day: "numeric",107 month: "short",108 });109110 const months = PERIODS.find((p) => p.key === period)?.months ?? 24;111 const chartData = pulse.map((p) => ({112 key: p.type,113 label: PULSE_LABELS[p.type][lang],114 color: PULSE_COLORS[p.type],115 series: p.series,116 }));117118 const proofs: [string, string][] = [119 ["3 747 008", fr ? "propriétés estimées" : "estimated properties"],120 ["745 119", fr ? "ventes réelles 2021-2026" : "real sales 2021-2026"],121 [locNum(salesLast30d, lang, { maxFrac: 0 }), fr ? "ventes captées / 30 jours" : "sales captured / 30 days"],122 ["1 100", fr ? "municipalités couvertes" : "municipalities covered"],123 ];124125 return (126 <div className="pb-6">127 {/* ================= HÉRO — terminal encre pleine largeur ================= */}128 <section className="bleed vp-dark">129 <div className="mx-auto max-w-6xl px-4 pb-10 pt-12 sm:px-6 sm:pb-14 sm:pt-16">130 <span className="kicker !text-[var(--accent-bright)] [&::before]:!bg-[var(--accent-bright)]">131 {fr132 ? "Terminal public de valeur immobilière — Québec"133 : "Public real-estate value terminal — Québec"}134 </span>135 <h1 className="vp-display mt-4 text-[clamp(38px,8vw,92px)] font-bold uppercase leading-[0.95] tracking-[-0.035em]">136 {fr ? (137 <>138 La <span className="outline-txt">vraie</span> valeur.139 <br />140 <span className="text-[var(--accent-bright)]">Sans boîte noire.</span>141 </>142 ) : (143 <>144 The <span className="outline-txt">true</span> value.145 <br />146 <span className="text-[var(--accent-bright)]">No black box.</span>147 </>148 )}149 </h1>150 <p className="mt-5 max-w-xl text-[15.5px] leading-relaxed text-[rgba(245,243,238,0.72)]">151 {t("sub")}152 </p>153154 {/* -------- la recherche : l'action centrale de la page -------- */}155 <div className="mt-9 max-w-3xl">156 <p className="vp-mono mb-1 text-[10.5px] font-medium uppercase tracking-[0.16em] text-[rgba(245,243,238,0.55)]">157 {fr ? "Entrez une adresse au Québec" : "Enter a Québec address"}158 </p>159 <SearchBox variant="hero" />160 <p className="vp-mono mt-2.5 text-[10px] uppercase tracking-[0.06em] text-[rgba(245,243,238,0.4)]">161 {t("dataNote")}162 </p>163 </div>164165 {/* -------- preuves de profondeur de données, intégrées -------- */}166 <div className="mt-10 grid grid-cols-2 gap-x-6 gap-y-5 border-t border-[rgba(245,243,238,0.16)] pt-6 sm:grid-cols-4">167 {proofs.map(([num, label], i) => (168 <div key={label}>169 <p className="vp-display flex items-baseline gap-2 text-[clamp(20px,2.6vw,28px)] font-bold leading-none tracking-[-0.02em] text-[var(--paper-on-ink)]">170 {num}171 {i === 2 && (172 <span173 aria-hidden="true"174 className="inline-block h-[7px] w-[7px] rounded-full bg-[var(--accent-bright)] [animation:pulse_2.4s_ease_infinite]"175 />176 )}177 </p>178 <p className="vp-mono mt-1.5 text-[9.5px] uppercase tracking-[0.12em] text-[rgba(245,243,238,0.5)]">179 {label}180 </p>181 </div>182 ))}183 </div>184 </div>185 </section>186187 {/* ============ le principe, en une ligne (pas de boîtes) ============ */}188 <section className="border-b border-[var(--line)] py-6">189 <div className="grid gap-x-8 gap-y-4 sm:grid-cols-3">190 {(fr191 ? [192 ["01", "Cherchez l'adresse", "3,7 M d'unités d'évaluation, recherche tolérante aux fautes."],193 ["02", "Deux sources croisées", "Modèle hédonique (690 k ventes) + comparables réels ajustés en dollars."],194 ["03", "Fourchette + confiance", "P10-P90, indice A-D — le calcul montré en entier."],195 ]196 : [197 ["01", "Search the address", "3.7M assessment units, typo-tolerant search."],198 ["02", "Two sources, crossed", "Hedonic model (690k sales) + real comparables adjusted in dollars."],199 ["03", "Range + confidence", "P10-P90, A-D index — the full math, shown."],200 ]201 ).map(([num, title, body]) => (202 <div key={num} className="flex items-start gap-3">203 <span className="vp-mono pt-[3px] text-[11px] font-bold text-accent">{num}</span>204 <p className="text-[13px] leading-snug text-ink-2">205 <span className="vp-display block text-[14px] font-bold uppercase tracking-[-0.01em] text-ink">206 {title}207 </span>208 {body}209 </p>210 </div>211 ))}212 </div>213 </section>214215 {/* ================= MARCHÉ — data visualization éditoriale ================= */}216 <section className="mt-12">217 <SectionHead218 num="01"219 kicker={fr ? "Indice Vrai-Prix" : "Vrai-Prix index"}220 title={fr ? "Le pouls du marché québécois" : "The pulse of the Québec market"}221 lede={222 fr223 ? "L'indice mensuel calculé sur les ventes réelles — celui-là même qui ajuste les comparables de chaque estimation. Trois grands types, une même échelle : base 100 au début de la période."224 : "The monthly index built from real sales — the same one adjusting every estimate's comparables. Three major types, one scale: base 100 at the period start."225 }226 />227228 {/* contrôles : période (une rangée, au-dessus du graphique) */}229 <div className="mt-5 flex flex-wrap items-end justify-between gap-x-6 gap-y-2 border-b border-[var(--line)]">230 <div className="flex gap-5">231 {PERIODS.map((p) => (232 <button233 key={p.key}234 type="button"235 className="vp-tab"236 aria-pressed={period === p.key}237 onClick={() => setPeriod(p.key)}238 >239 {p[lang]}240 </button>241 ))}242 </div>243 {/* légende — l'identité ne repose jamais sur la couleur seule */}244 <div className="flex flex-wrap gap-x-4 gap-y-1 pb-2">245 {chartData.map((s) => (246 <span key={s.key} className="vp-mono flex items-center gap-1.5 text-[10px] uppercase tracking-[0.08em] text-ink-2">247 <span className="inline-block h-[2px] w-[14px]" style={{ background: s.color }} />248 {s.label}249 </span>250 ))}251 </div>252 </div>253254 <div className="mt-4">255 <MarketPulseChart data={chartData} lang={lang} months={months} />256 </div>257258 {/* lecture par type : variations en colonnes de données */}259 <div className="mt-5 grid gap-x-8 gap-y-3 border-t border-[var(--line)] pt-4 sm:grid-cols-3">260 {pulse.map((p) => (261 <div key={p.type} className="flex items-baseline justify-between gap-4 sm:block">262 <p className="vp-mono flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-[0.08em] text-ink">263 <span className="inline-block h-[2px] w-[12px]" style={{ background: PULSE_COLORS[p.type] }} />264 {PULSE_LABELS[p.type][lang]}265 </p>266 <p className="vp-mono mt-1 flex gap-4 text-[11px] text-ink-2">267 <span>268 12 {fr ? "mois" : "mo"} 269 <b className="vp-display text-[14px]">270 <Pct v={p.pct12m} />271 </b>272 </span>273 <span>274 2021→ 275 <b className="vp-display text-[14px]">276 <Pct v={p.since2021} digits={0} />277 </b>278 </span>279 </p>280 </div>281 ))}282 </div>283 </section>284285 {/* ================= VENTES RÉELLES — flux prix-d'abord ================= */}286 {recent.length > 0 && (287 <section id="ventes" className="mt-14 scroll-mt-20">288 <SectionHead289 num="02"290 kicker={fr ? "Flux en continu" : "Live feed"}291 title={fr ? "Dernières ventes réelles captées" : "Latest real sales captured"}292 lede={293 fr294 ? "Transactions publiées, appariées automatiquement à leur unité d'évaluation. Chaque vente mène à l'estimation complète de la propriété."295 : "Published transactions, automatically matched to their assessment unit. Each sale leads to the property's full estimate."296 }297 />298 <div className="mt-5">299 {recent.map((s) => {300 const inner = (301 <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 sm:flex-nowrap">302 {/* le prix d'abord — la donnée mène */}303 <p className="vp-display w-full text-[22px] font-bold leading-none tracking-[-0.02em] sm:w-[168px] sm:shrink-0 sm:text-[20px]">304 {money(s.amount)}305 </p>306 <div className="min-w-0 flex-1">307 <p className="truncate text-[14.5px] font-semibold leading-snug">308 {s.street}309 {s.city ? `, ${s.city}` : ""}310 </p>311 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">312 {dateFmt(s.date)}313 {s.propertyType && s.propertyType !== "indéterminé" ? ` · ${s.propertyType}` : ""}314 {s.vsRolePct != null && Math.abs(s.vsRolePct) < 400 && (315 <>316 {" · "}317 <span className={s.vsRolePct >= 0 ? "text-ink-2" : "text-[var(--danger)]"}>318 {s.vsRolePct >= 0 ? "+" : "−"}319 {Math.abs(s.vsRolePct).toFixed(0)} % {fr ? "vs rôle" : "vs roll"}320 </span>321 </>322 )}323 </p>324 </div>325 {s.idProvinc && (326 <p className="vp-mono shrink-0 text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent">327 {fr ? "Voir l'estimation" : "See the estimate"} →328 </p>329 )}330 </div>331 );332 return s.idProvinc ? (333 <Link key={s.id} href={`/estimation/${encodeURIComponent(s.idProvinc)}`} className="feed-row">334 {inner}335 </Link>336 ) : (337 <div key={s.id} className="feed-row">338 {inner}339 </div>340 );341 })}342 </div>343 </section>344 )}345346 {/* ================= LE CALCUL, MONTRÉ — promesse centrale ================= */}347 <section className="mt-14">348 <SectionHead349 num="03"350 kicker={fr ? "Sans boîte noire" : "No black box"}351 title={fr ? "Voici exactement comment le prix est calculé" : "Here is exactly how the price is computed"}352 />353 <div className="mt-6 grid gap-10 lg:grid-cols-[1.2fr_1fr]">354 <ol className="tl">355 {(fr356 ? [357 ["Propriété identifiée", "3,7 M d'unités d'évaluation géoréférencées (MAMH)."],358 ["Rôle d'évaluation récupéré", "Valeurs terrain + bâtiment, six millésimes 2021-2026."],359 ["Ventes comparables sélectionnées", "Rayon adaptatif, même famille de type, aire ±20 %."],360 ["Caractéristiques ajustées", "Marché, superficie, âge — chaque ajustement affiché en dollars."],361 ["Estimation calculée", "65 % modèle hédonique + 35 % comparables pondérés."],362 ["Intervalle de confiance produit", "Fourchette P10-P90 et indice A-D, toujours affichés."],363 ]364 : [365 ["Property identified", "3.7M georeferenced assessment units (MAMH)."],366 ["Assessment roll retrieved", "Land + building values, six vintages 2021-2026."],367 ["Comparable sales selected", "Adaptive radius, same type family, area ±20%."],368 ["Characteristics adjusted", "Market, area, age — every adjustment shown in dollars."],369 ["Estimate computed", "65% hedonic model + 35% weighted comparables."],370 ["Confidence interval produced", "P10-P90 range and A-D index, always shown."],371 ]372 ).map(([title, body], i, arr) => (373 <li key={title} className="tl-step" data-accent={i === arr.length - 1 ? "true" : undefined}>374 <p className="vp-display flex items-baseline gap-2.5 text-[15px] font-bold uppercase tracking-[-0.01em]">375 <span className="vp-mono text-[10.5px] font-bold text-accent">0{i + 1}</span>376 {title}377 </p>378 <p className="mt-1 text-[13px] leading-snug text-ink-2">{body}</p>379 </li>380 ))}381 </ol>382 <div>383 {(lang === "fr"384 ? [385 ["Le calcul, montré", "Chaque ajustement de chaque comparable est affiché en dollars. Pas de score magique."],386 ["Fourchette honnête", "Un prix unique est un mensonge statistique. On donne P10-P90 et un indice de confiance A-D."],387 ["Données réelles", "Rôles fonciers officiels (MAMH) et ventes publiées — locales et récentes en priorité."],388 ]389 : [390 ["The math, shown", "Every adjustment on every comparable is displayed in dollars. No magic score."],391 ["Honest range", "A single price is a statistical lie. We give P10-P90 and an A-D confidence index."],392 ["Real data", "Official assessment rolls (MAMH) and published sales — local and recent first."],393 ]394 ).map(([title, body]) => (395 <div key={title} className="border-t border-[var(--line)] py-4 first:border-t-0 first:pt-0">396 <h3 className="vp-display text-[15px] font-bold uppercase tracking-[-0.01em]">{title}</h3>397 <p className="mt-1.5 text-[13px] leading-snug text-ink-2">{body}</p>398 </div>399 ))}400 <p className="vp-mono mt-2 border-t border-[var(--line)] pt-4 text-[11px] uppercase tracking-[0.06em]">401 <Link href="/methodologie" className="border-b border-accent pb-px text-accent-deep hover:text-accent">402 {fr ? "Méthodologie complète — niveau rapport technique" : "Full methodology — technical-report depth"} →403 </Link>404 </p>405 </div>406 </div>407 </section>408409 {/* ================= PLAN B — estimation sans adresse ================= */}410 <section className="mt-14">411 <SectionHead412 num="04"413 kicker={fr ? "Plan B" : "Plan B"}414 title={t("manualTitle")}415 />416 <div className="mt-6 max-w-3xl">417 <EstimateForm />418 </div>419 </section>420 </div>421 );422}423