SPB Git

spb/vrai-prix

Public

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

18.4 KB · 423 lines tsx
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)}&nbsp;%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&nbsp;{fr ? "mois" : "mo"}&nbsp;269                  <b className="vp-display text-[14px]">270                    <Pct v={p.pct12m} />271                  </b>272                </span>273                <span>274                  2021→&nbsp;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
Files · d2a75de81caf185050f2d57989b8d9e762d7ffd3
Menu

Theme