Repository Public
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 {/* ================= ATELIERS — à vendre + méthode du coût ================= */}216 <section className="mt-8 grid gap-4 lg:grid-cols-2">217 <Link href="/a-vendre" className="vp-card vp-card-hover flex flex-col border-l-4 border-l-[var(--accent)] p-5">218 <span className="kicker">{fr ? "Nouveau — maisons à vendre" : "New — homes for sale"}</span>219 <h2 className="vp-display mt-2 text-[20px] font-bold uppercase tracking-[-0.02em]">220 {fr ? "Évaluer une propriété réellement à vendre" : "Value a property actually for sale"}221 </h2>222 <p className="mt-2 flex-1 text-[13.5px] leading-relaxed text-ink-2">223 {fr224 ? "Plus de 70 000 propriétés à vendre au Québec (base Immo-Ka), chacune déjà mesurée par le moteur Vrai-Prix. Ouvrez une fiche, comparez la mesure au prix demandé, puis choisissez vos propres comparables et ajustez-les en dollars."225 : "Over 70,000 properties for sale in Québec (Immo-Ka database), each already measured by the Vrai-Prix engine. Open a listing, compare the measure with the asking price, then pick your own comparables and adjust them in dollars."}226 </p>227 <span className="vp-mono mt-3 text-[11px] font-bold uppercase tracking-[0.08em] text-accent">228 {fr ? "Voir les propriétés à vendre" : "See properties for sale"} →229 </span>230 </Link>231 <Link href="/cout" className="vp-card vp-card-hover flex flex-col border-l-4 border-l-[var(--accent-bright)] p-5">232 <span className="kicker">{fr ? "Nouveau — méthode du coût" : "New — cost approach"}</span>233 <h2 className="vp-display mt-2 text-[20px] font-bold uppercase tracking-[-0.02em]">234 {fr ? "Combien coûterait ce bâtiment à reconstruire ?" : "What would this building cost to rebuild?"}235 </h2>236 <p className="mt-2 flex-1 text-[13.5px] leading-relaxed text-ink-2">237 {fr238 ? "Coût de remplacement à neuf décomposé par assemblages — matériaux sourcés (Canac, BMR, Patrick Morin), heures de métier aux taux APCHQ/CCQ, indices StatCan, facteurs régionaux —, puis dépréciation et valeur du terrain. Chaque hypothèse et chaque source sont visibles."239 : "Replacement cost new broken down by assemblies — sourced materials (Canac, BMR, Patrick Morin), trade hours at APCHQ/CCQ rates, StatCan indexes, regional factors —, then depreciation and land value. Every assumption and every source is visible."}240 </p>241 <span className="vp-mono mt-3 text-[11px] font-bold uppercase tracking-[0.08em] text-accent">242 {fr ? "Explorer les coûts" : "Explore costs"} →243 </span>244 </Link>245 </section>246247 {/* ================= MARCHÉ — data visualization éditoriale ================= */}248 <section className="mt-12">249 <SectionHead250 num="01"251 kicker={fr ? "Indice Vrai-Prix" : "Vrai-Prix index"}252 title={fr ? "Le pouls du marché québécois" : "The pulse of the Québec market"}253 lede={254 fr255 ? "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."256 : "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."257 }258 />259260 {/* contrôles : période (une rangée, au-dessus du graphique) */}261 <div className="mt-5 flex flex-wrap items-end justify-between gap-x-6 gap-y-2 border-b border-[var(--line)]">262 <div className="flex gap-5">263 {PERIODS.map((p) => (264 <button265 key={p.key}266 type="button"267 className="vp-tab"268 aria-pressed={period === p.key}269 onClick={() => setPeriod(p.key)}270 >271 {p[lang]}272 </button>273 ))}274 </div>275 {/* légende — l'identité ne repose jamais sur la couleur seule */}276 <div className="flex flex-wrap gap-x-4 gap-y-1 pb-2">277 {chartData.map((s) => (278 <span key={s.key} className="vp-mono flex items-center gap-1.5 text-[10px] uppercase tracking-[0.08em] text-ink-2">279 <span className="inline-block h-[2px] w-[14px]" style={{ background: s.color }} />280 {s.label}281 </span>282 ))}283 </div>284 </div>285286 <div className="mt-4">287 <MarketPulseChart data={chartData} lang={lang} months={months} />288 </div>289290 {/* lecture par type : variations en colonnes de données */}291 <div className="mt-5 grid gap-x-8 gap-y-3 border-t border-[var(--line)] pt-4 sm:grid-cols-3">292 {pulse.map((p) => (293 <div key={p.type} className="flex items-baseline justify-between gap-4 sm:block">294 <p className="vp-mono flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-[0.08em] text-ink">295 <span className="inline-block h-[2px] w-[12px]" style={{ background: PULSE_COLORS[p.type] }} />296 {PULSE_LABELS[p.type][lang]}297 </p>298 <p className="vp-mono mt-1 flex gap-4 text-[11px] text-ink-2">299 <span>300 12 {fr ? "mois" : "mo"} 301 <b className="vp-display text-[14px]">302 <Pct v={p.pct12m} />303 </b>304 </span>305 <span>306 2021→ 307 <b className="vp-display text-[14px]">308 <Pct v={p.since2021} digits={0} />309 </b>310 </span>311 </p>312 </div>313 ))}314 </div>315 </section>316317 {/* ================= VENTES RÉELLES — flux prix-d'abord ================= */}318 {recent.length > 0 && (319 <section id="ventes" className="mt-14 scroll-mt-20">320 <SectionHead321 num="02"322 kicker={fr ? "Flux en continu" : "Live feed"}323 title={fr ? "Dernières ventes réelles captées" : "Latest real sales captured"}324 lede={325 fr326 ? "Transactions publiées, appariées automatiquement à leur unité d'évaluation. Chaque vente mène à l'estimation complète de la propriété."327 : "Published transactions, automatically matched to their assessment unit. Each sale leads to the property's full estimate."328 }329 />330 <div className="mt-5">331 {recent.map((s) => {332 const inner = (333 <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 sm:flex-nowrap">334 {/* le prix d'abord — la donnée mène */}335 <p className="vp-display w-full text-[22px] font-bold leading-none tracking-[-0.02em] sm:w-[168px] sm:shrink-0 sm:text-[20px]">336 {money(s.amount)}337 </p>338 <div className="min-w-0 flex-1">339 <p className="truncate text-[14.5px] font-semibold leading-snug">340 {s.street}341 {s.city ? `, ${s.city}` : ""}342 </p>343 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">344 {dateFmt(s.date)}345 {s.propertyType && s.propertyType !== "indéterminé" ? ` · ${s.propertyType}` : ""}346 {s.vsRolePct != null && Math.abs(s.vsRolePct) < 400 && (347 <>348 {" · "}349 <span className={s.vsRolePct >= 0 ? "text-ink-2" : "text-[var(--danger)]"}>350 {s.vsRolePct >= 0 ? "+" : "−"}351 {Math.abs(s.vsRolePct).toFixed(0)} % {fr ? "vs rôle" : "vs roll"}352 </span>353 </>354 )}355 </p>356 </div>357 {s.idProvinc && (358 <p className="vp-mono shrink-0 text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent">359 {fr ? "Voir l'estimation" : "See the estimate"} →360 </p>361 )}362 </div>363 );364 return s.idProvinc ? (365 <Link key={s.id} href={`/estimation/${encodeURIComponent(s.idProvinc)}`} className="feed-row">366 {inner}367 </Link>368 ) : (369 <div key={s.id} className="feed-row">370 {inner}371 </div>372 );373 })}374 </div>375 <div className="mt-5">376 <Link href="/ventes" className="btn btn-ghost">377 {fr ? "Toutes les ventes récentes — registre complet" : "All recent sales — full registry"} →378 </Link>379 </div>380 </section>381 )}382383 {/* ================= LE CALCUL, MONTRÉ — promesse centrale ================= */}384 <section className="mt-14">385 <SectionHead386 num="03"387 kicker={fr ? "Sans boîte noire" : "No black box"}388 title={fr ? "Voici exactement comment le prix est calculé" : "Here is exactly how the price is computed"}389 />390 <div className="mt-6 grid gap-10 lg:grid-cols-[1.2fr_1fr]">391 <ol className="tl">392 {(fr393 ? [394 ["Propriété identifiée", "3,7 M d'unités d'évaluation géoréférencées (MAMH)."],395 ["Rôle d'évaluation récupéré", "Valeurs terrain + bâtiment, six millésimes 2021-2026."],396 ["Ventes comparables sélectionnées", "Rayon adaptatif, même famille de type, aire ±20 %."],397 ["Caractéristiques ajustées", "Marché, superficie, âge — chaque ajustement affiché en dollars."],398 ["Estimation calculée", "65 % modèle hédonique + 35 % comparables pondérés."],399 ["Intervalle de confiance produit", "Fourchette P10-P90 et indice A-D, toujours affichés."],400 ]401 : [402 ["Property identified", "3.7M georeferenced assessment units (MAMH)."],403 ["Assessment roll retrieved", "Land + building values, six vintages 2021-2026."],404 ["Comparable sales selected", "Adaptive radius, same type family, area ±20%."],405 ["Characteristics adjusted", "Market, area, age — every adjustment shown in dollars."],406 ["Estimate computed", "65% hedonic model + 35% weighted comparables."],407 ["Confidence interval produced", "P10-P90 range and A-D index, always shown."],408 ]409 ).map(([title, body], i, arr) => (410 <li key={title} className="tl-step" data-accent={i === arr.length - 1 ? "true" : undefined}>411 <p className="vp-display flex items-baseline gap-2.5 text-[15px] font-bold uppercase tracking-[-0.01em]">412 <span className="vp-mono text-[10.5px] font-bold text-accent">0{i + 1}</span>413 {title}414 </p>415 <p className="mt-1 text-[13px] leading-snug text-ink-2">{body}</p>416 </li>417 ))}418 </ol>419 <div>420 {(lang === "fr"421 ? [422 ["Le calcul, montré", "Chaque ajustement de chaque comparable est affiché en dollars. Pas de score magique."],423 ["Fourchette honnête", "Un prix unique est un mensonge statistique. On donne P10-P90 et un indice de confiance A-D."],424 ["Données réelles", "Rôles fonciers officiels (MAMH) et ventes publiées — locales et récentes en priorité."],425 ]426 : [427 ["The math, shown", "Every adjustment on every comparable is displayed in dollars. No magic score."],428 ["Honest range", "A single price is a statistical lie. We give P10-P90 and an A-D confidence index."],429 ["Real data", "Official assessment rolls (MAMH) and published sales — local and recent first."],430 ]431 ).map(([title, body]) => (432 <div key={title} className="border-t border-[var(--line)] py-4 first:border-t-0 first:pt-0">433 <h3 className="vp-display text-[15px] font-bold uppercase tracking-[-0.01em]">{title}</h3>434 <p className="mt-1.5 text-[13px] leading-snug text-ink-2">{body}</p>435 </div>436 ))}437 <p className="vp-mono mt-2 border-t border-[var(--line)] pt-4 text-[11px] uppercase tracking-[0.06em]">438 <Link href="/methodologie" className="border-b border-accent pb-px text-accent-deep hover:text-accent">439 {fr ? "Méthodologie complète — niveau rapport technique" : "Full methodology — technical-report depth"} →440 </Link>441 </p>442 </div>443 </div>444 </section>445446 {/* ================= PLAN B — estimation sans adresse ================= */}447 <section className="mt-14">448 <SectionHead449 num="04"450 kicker={fr ? "Plan B" : "Plan B"}451 title={t("manualTitle")}452 />453 <div className="mt-6 max-w-3xl">454 <EstimateForm />455 </div>456 </section>457 </div>458 );459}460