SPB Git

Repository Public

HomeClient.tsx 12.6 KB 303 lines tsx

1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2"use client";3import Link from "next/link";4import SearchBox from "@/components/SearchBox";5import EstimateForm from "@/components/EstimateForm";6import { IndexSpark, locNum } from "@/components/MetricViz";7import { useLang } from "@/components/LangContext";89/** Indice de marché d'un grand type — série + variations (calculé côté serveur). */10export interface PulseItem {11  type: "unifamilial" | "condo" | "plex";12  series: { month: string; idx: number }[];13  pct12m: number | null;14  since2021: number | null;15}1617/** Vente réelle récente appariée à une unité d'évaluation. */18export interface RecentSaleItem {19  id: string;20  date: string;21  amount: number;22  street: string | null;23  city: string | null;24  propertyType: string | null;25  idProvinc: string | null;26  vsRolePct: number | null;27}2829const PULSE_LABELS: Record<PulseItem["type"], { fr: string; en: string }> = {30  unifamilial: { fr: "Unifamiliale", en: "Single-family" },31  condo: { fr: "Condo", en: "Condo" },32  plex: { fr: "Plex", en: "Plex" },33};3435function TrendBadge({ pct, label }: { pct: number | null; label: string }) {36  if (pct == null) return null;37  return (38    <span39      className={`vp-mono rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${40        pct >= 0 ? "bg-lime text-ink" : "bg-danger text-white"41      }`}42    >43      {label} {pct >= 0 ? "+" : ""}44      {pct.toFixed(1)} %45    </span>46  );47}4849export default function HomeClient({50  pulse,51  recent,52  salesLast30d,53}: {54  pulse: PulseItem[];55  recent: RecentSaleItem[];56  salesLast30d: number;57}) {58  const { lang, t } = useLang();59  const fr = lang === "fr";60  const money = (v: number) =>61    new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {62      style: "currency",63      currency: "CAD",64      maximumFractionDigits: 0,65    }).format(v);66  const dateFmt = (d: string) =>67    new Date(`${d}T12:00:00`).toLocaleDateString(fr ? "fr-CA" : "en-CA", {68      day: "numeric",69      month: "short",70    });7172  return (73    <div className="pb-6 pt-10 sm:pt-14">74      {/* ---- Héros ---- */}75      <section>76        <span className="kicker">77          {lang === "fr" ? "Québec · 2021-2026" : "Québec · 2021-2026"}78        </span>79        <h1 className="vp-display mt-3 max-w-4xl text-[clamp(34px,6.4vw,72px)] font-bold uppercase leading-[0.98] tracking-[-0.035em]">80          {lang === "fr" ? (81            <>82              La <span className="outline-txt">vraie</span> valeur.83              <br />84              <span className="hl">Sans boîte noire.</span>85            </>86          ) : (87            <>88              The <span className="outline-txt">true</span> value.89              <br />90              <span className="hl">No black box.</span>91            </>92          )}93        </h1>94        <p className="mt-5 max-w-xl text-[16.5px] text-ink-2">{t("sub")}</p>9596        <div className="mt-7 flex gap-2.5 overflow-x-auto pb-1.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">97          <span className="stat-chip">98            <span className="pulse" />99            <b>3 747 008</b> {lang === "fr" ? "propriétés" : "properties"}100          </span>101          <span className="stat-chip">102            <b>745 119</b> {lang === "fr" ? "ventes réelles" : "real sales"}103          </span>104          <span className="stat-chip">105            <b>{locNum(salesLast30d, lang, { maxFrac: 0 })}</b>{" "}106            {lang === "fr" ? "ventes captées / 30 j" : "sales captured / 30 d"}107          </span>108          <span className="stat-chip">109            <b>1 100</b> {lang === "fr" ? "municipalités" : "municipalities"}110          </span>111          <span className="stat-chip">112            MdAPE <b>11 %</b>113          </span>114        </div>115      </section>116117      {/* ---- Recherche ---- */}118      <section className="mt-9">119        <div className="vp-card p-4 sm:p-5">120          <p className="klabel mb-2 pl-0.5">121            {lang === "fr" ? "Adresse de la propriété" : "Property address"}122          </p>123          <SearchBox />124        </div>125        <p className="vp-mono mt-2.5 pl-1 text-[11px] uppercase tracking-[0.06em] text-ink-3">126          {t("dataNote")}127        </p>128      </section>129130      {/* ---- Comment ça marche (bande compacte) ---- */}131      <section className="mt-10 grid gap-3 sm:grid-cols-3">132        {(fr133          ? [134              ["01", "Cherchez l'adresse", "3,7 M d'unités d'évaluation, recherche tolérante aux fautes."],135              ["02", "Le moteur croise deux sources", "Modèle hédonique (690 k ventes) + comparables réels ajustés en dollars."],136              ["03", "Fourchette + confiance", "P10-P90, indice A-D, rapport PDF — le calcul montré en entier."],137            ]138          : [139              ["01", "Search the address", "3.7M assessment units, typo-tolerant search."],140              ["02", "Two sources, crossed", "Hedonic model (690k sales) + real comparables adjusted in dollars."],141              ["03", "Range + confidence", "P10-P90, A-D index, PDF report — the full math, shown."],142            ]143        ).map(([num, title, body], i) => (144          <div key={num} className="flex items-start gap-3 rounded-[10px] border-[1.5px] border-dashed border-[rgba(20,24,20,0.3)] bg-surface-2 p-4">145            <span className="vp-mono flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-[1.5px] border-ink bg-ink text-[12px] font-bold text-lime">146              {num}147            </span>148            <div>149              <p className="vp-display text-[14.5px] font-bold uppercase tracking-[-0.01em]">150                {title}151                {i < 2 && <span className="ml-2 text-ink-3">→</span>}152              </p>153              <p className="mt-1 text-[12.5px] leading-snug text-ink-2">{body}</p>154            </div>155          </div>156        ))}157      </section>158159      {/* ---- Pouls du marché ---- */}160      <section className="mt-14">161        <span className="kicker">{fr ? "Indice Vrai-Prix" : "Vrai-Prix index"}</span>162        <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">163          {fr ? "Le pouls du marché québécois" : "The pulse of the Québec market"}164        </h2>165        <p className="mt-2 max-w-2xl text-[14px] text-ink-2">166          {fr167            ? "L'indice mensuel calculé sur les ventes réelles — celui-là même qui ajuste les comparables de chaque estimation. Base 100 il y a 24 mois."168            : "The monthly index built from real sales — the same one adjusting every estimate's comparables. Base 100 twenty-four months ago."}169        </p>170        <div className="mt-4 grid gap-4 sm:grid-cols-3">171          {pulse.map((p) => (172            <div key={p.type} className="vp-card vp-card-hover p-5">173              <div className="flex items-center justify-between gap-2">174                <p className="vp-display text-[16px] font-bold uppercase tracking-[-0.01em]">175                  {PULSE_LABELS[p.type][lang]}176                </p>177                <TrendBadge pct={p.pct12m} label={fr ? "12 mois" : "12 mo"} />178              </div>179              <div className="mt-3">180                <IndexSpark series={p.series} />181              </div>182              {p.since2021 != null && (183                <p className="vp-mono mt-2 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">184                  {fr ? "Depuis 2021" : "Since 2021"} :{" "}185                  <b className="text-ink">186                    {p.since2021 >= 0 ? "+" : ""}187                    {p.since2021.toFixed(0)} %188                  </b>189                </p>190              )}191            </div>192          ))}193        </div>194      </section>195196      {/* ---- Dernières ventes captées ---- */}197      {recent.length > 0 && (198        <section className="mt-14">199          <span className="kicker">200            <span className="pulse" style={{ width: 8, height: 8 }} />201            {fr ? "Flux en continu" : "Live feed"}202          </span>203          <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">204            {fr ? "Dernières ventes réelles captées" : "Latest real sales captured"}205          </h2>206          <p className="mt-2 max-w-2xl text-[14px] text-ink-2">207            {fr208              ? "Transactions publiées, appariées automatiquement à leur unité d'évaluation. Cliquez pour voir l'estimation complète de la propriété vendue."209              : "Published transactions, automatically matched to their assessment unit. Click for the sold property's full estimate."}210          </p>211          <div className="src-wrap mt-4">212            <table className="src-table">213              <thead>214                <tr>215                  <th>{fr ? "Date" : "Date"}</th>216                  <th>{fr ? "Propriété" : "Property"}</th>217                  <th className="text-right">{fr ? "Prix de vente" : "Sale price"}</th>218                  <th className="text-right"></th>219                </tr>220              </thead>221              <tbody>222                {recent.map((s) => (223                  <tr key={s.id}>224                    <td className="vp-mono whitespace-nowrap text-[12px] font-bold uppercase">225                      {dateFmt(s.date)}226                    </td>227                    <td>228                      <p className="font-semibold leading-tight">229                        {s.street}230                        {s.city ? `, ${s.city}` : ""}231                      </p>232                      {s.propertyType && s.propertyType !== "indéterminé" && (233                        <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">234                          {s.propertyType}235                        </p>236                      )}237                    </td>238                    <td className="text-right">239                      <span className="vp-display font-bold">{money(s.amount)}</span>240                      {s.vsRolePct != null && Math.abs(s.vsRolePct) < 400 && (241                        <p className="vp-mono mt-0.5 text-[10px] text-ink-3">242                          {s.vsRolePct >= 0 ? "+" : "−"}243                          {Math.abs(s.vsRolePct).toFixed(0)} %{" "}244                          {fr ? "vs rôle" : "vs roll"}245                        </p>246                      )}247                    </td>248                    <td className="text-right">249                      {s.idProvinc && (250                        <Link251                          href={`/estimation/${encodeURIComponent(s.idProvinc)}`}252                          className="vp-mono whitespace-nowrap rounded-[5px] border-[1.5px] border-ink bg-surface-2 px-2.5 py-1.5 text-[11px] font-bold uppercase tracking-[0.04em] transition-colors hover:bg-lime"253                        >254                          {fr ? "Estimer" : "Estimate"} →255                        </Link>256                      )}257                    </td>258                  </tr>259                ))}260              </tbody>261            </table>262          </div>263        </section>264      )}265266      {/* ---- Formulaire manuel ---- */}267      <section className="mt-14">268        <span className="kicker">{lang === "fr" ? "Plan B" : "Plan B"}</span>269        <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">270          {t("manualTitle")}271        </h2>272        <div className="vp-card mt-4 p-4 sm:p-6">273          <EstimateForm />274        </div>275      </section>276277      {/* ---- Manifeste transparence ---- */}278      <section className="mt-14 grid gap-4 sm:grid-cols-3">279        {(lang === "fr"280          ? [281              ["01", "Le calcul, montré", "Chaque ajustement de chaque comparable est affiché en dollars. Pas de score magique."],282              ["02", "Fourchette honnête", "Un prix unique est un mensonge statistique. On donne P10-P90 et un indice de confiance A-D."],283              ["03", "Données réelles", "Rôles fonciers officiels (MAMH) et ventes publiées — locales et récentes en priorité."],284            ]285          : [286              ["01", "The math, shown", "Every adjustment on every comparable is displayed in dollars. No magic score."],287              ["02", "Honest range", "A single price is a statistical lie. We give P10-P90 and an A-D confidence index."],288              ["03", "Real data", "Official assessment rolls (MAMH) and published sales — local and recent first."],289            ]290        ).map(([num, title, body]) => (291          <div key={num} className="vp-card vp-card-hover p-5">292            <p className="vp-mono text-[11px] font-bold text-green">{num}</p>293            <h3 className="vp-display mt-1.5 text-[17px] font-bold uppercase tracking-[-0.01em]">294              {title}295            </h3>296            <p className="mt-2 text-[13.5px] text-ink-2">{body}</p>297          </div>298        ))}299      </section>300    </div>301  );302}303
Files aa2744158e2df4b687568d64ef56172c3be22f70
Menu

Theme