SPB Git

Repository Public

HomeClient.tsx 21.4 KB 460 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      {/* ================= 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&nbsp;{fr ? "mois" : "mo"}&nbsp;301                  <b className="vp-display text-[14px]">302                    <Pct v={p.pct12m} />303                  </b>304                </span>305                <span>306                  2021→&nbsp;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
Files main
Menu

Theme