SPB Git

spb/vrai-prix Public

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

TypeScript 96.7% CSS 3.2%
15.1 KB · 359 lines tsx
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2"use client";3import { useEffect, useState } from "react";4import Link from "next/link";5import SearchBox, { type Suggestion } from "@/components/SearchBox";6import { CountUp, locNum, Sparkline, ValueSplit } from "@/components/MetricViz";7import { useLang } from "@/components/LangContext";8import type { PortfolioResult } from "@/lib/estimator";9import type { TKey } from "@/lib/i18n";1011const KEY = "vp-parc";1213interface Saved {14  id: string;15  adresse: string;16  municipalite: string;17}1819const fmt = (v: number | null | undefined, lang: string) =>20  v == null21    ? "—"22    : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {23        style: "currency",24        currency: "CAD",25        maximumFractionDigits: 0,26      }).format(v);2728const CONF: Record<string, string> = {29  A: "pill-conf-A",30  B: "pill-conf-B",31  C: "pill-conf-C",32  D: "pill-conf-D",33};3435export default function ParcPage() {36  const { lang, t } = useLang();37  const fr = lang === "fr";38  const [list, setList] = useState<Saved[]>([]);39  const [data, setData] = useState<PortfolioResult | null>(null);40  const [loading, setLoading] = useState(false);41  const [hydrated, setHydrated] = useState(false);4243  useEffect(() => {44    const id = setTimeout(() => {45      try {46        const raw = window.localStorage.getItem(KEY);47        if (raw) setList(JSON.parse(raw) as Saved[]);48      } catch {}49      setHydrated(true);50    }, 0);51    return () => clearTimeout(id);52  }, []);5354  const persist = (l: Saved[]) => {55    setList(l);56    window.localStorage.setItem(KEY, JSON.stringify(l));57    setData(null);58  };5960  const add = (s: Suggestion) => {61    if (list.some((x) => x.id === s.id) || list.length >= 40) return;62    persist([63      ...list,64      {65        id: s.id,66        adresse: `${s.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}`,67        municipalite: s.municipalite ?? "",68      },69    ]);70  };7172  const evaluate = async () => {73    setLoading(true);74    try {75      const res = await fetch("/api/portfolio", {76        method: "POST",77        headers: { "Content-Type": "application/json" },78        body: JSON.stringify({ ids: list.map((x) => x.id) }),79      });80      if (res.ok) setData((await res.json()) as PortfolioResult);81    } finally {82      setLoading(false);83    }84  };8586  const a = data?.aggregates;8788  return (89    <div className="pb-6 pt-10 sm:pt-14">90      {/* héros */}91      <section>92        <span className="kicker">{fr ? "Investisseurs · propriétaires multiples" : "Investors · multi-owners"}</span>93        <h1 className="vp-display mt-3 max-w-4xl text-[clamp(30px,5.6vw,62px)] font-bold uppercase leading-[0.98] tracking-[-0.035em]">94          {fr ? (95            <>96              La valeur de votre <span className="hl">parc immobilier</span>,97              <br />98              <span className="outline-txt">au complet.</span>99            </>100          ) : (101            <>102              Your <span className="hl">property portfolio</span>,103              <br />104              <span className="outline-txt">fully valued.</span>105            </>106          )}107        </h1>108        <p className="mt-4 max-w-xl text-[16px] text-ink-2">109          {fr110            ? "Ajoutez chacune de vos adresses. Vrai-Prix évalue chaque propriété, agrège votre parc — valeur totale, fourchette, croissance 2021→2026 — et produit un rapport PDF consolidé."111            : "Add each of your addresses. Vrai-Prix values every property, aggregates your portfolio — total value, range, 2021→2026 growth — and produces a consolidated PDF report."}112        </p>113      </section>114115      {/* constructeur de parc */}116      <section className="mt-9">117        <div className="vp-card p-4 sm:p-5">118          <p className="klabel mb-2 pl-0.5">119            {fr ? `Ajouter une propriété (${list.length}/40)` : `Add a property (${list.length}/40)`}120          </p>121          <SearchBox122            onPick={add}123            placeholder={fr ? "Tapez une adresse puis choisissez-la…" : "Type an address, then pick it…"}124          />125          {hydrated && list.length > 0 && (126            <div className="mt-4 flex flex-wrap gap-2">127              {list.map((p, i) => (128                <span129                  key={p.id}130                  className="stat-chip !py-1.5"131                  style={{ animationDelay: `${i * 40}ms` }}132                >133                  <b>{i + 1}</b> {p.adresse} · {p.municipalite}134                  <button135                    aria-label={fr ? "Retirer" : "Remove"}136                    onClick={() => persist(list.filter((x) => x.id !== p.id))}137                    className="vp-mono ml-1 cursor-pointer rounded-full border-[1.5px] border-ink px-1.5 text-[10px] font-bold hover:bg-ink hover:text-lime"138                  >139140                  </button>141                </span>142              ))}143            </div>144          )}145          <div className="mt-4 flex flex-wrap gap-2.5">146            <button147              className="btn btn-primary"148              disabled={list.length === 0 || loading}149              onClick={evaluate}150            >151              {loading152                ? fr ? "Évaluation en cours…" : "Valuing…"153                : fr ? `Évaluer mon parc (${list.length})` : `Value my portfolio (${list.length})`}154            </button>155            {list.length > 0 && (156              <button className="btn btn-ghost" onClick={() => persist([])}>157                {fr ? "Vider la liste" : "Clear list"}158              </button>159            )}160          </div>161        </div>162      </section>163164      {/* résultats */}165      {data && a && (166        <div className="mt-12 space-y-10">167          {/* total */}168          <section className="vp-card p-6 sm:p-8">169            <span className="kicker">{fr ? "Valeur totale estimée du parc" : "Total estimated portfolio value"}</span>170            <p className="vp-display mt-3 text-[clamp(40px,7vw,68px)] font-bold leading-none tracking-[-0.04em]">171              <CountUp172                value={a.totalEstimate}173                format={(v) =>174                  new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {175                    style: "currency",176                    currency: "CAD",177                    maximumFractionDigits: 0,178                  }).format(Math.round(v / 100) * 100)179                }180              />181            </p>182            <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">183              <div>184                <p className="klabel">{fr ? "Fourchette cumulée" : "Cumulative range"}</p>185                <p className="vp-display text-[18px] font-bold">186                  {fmt(a.totalLow, lang)} <span className="text-ink-3">→</span> {fmt(a.totalHigh, lang)}187                </p>188              </div>189              <div>190                <p className="klabel">{fr ? "Confiance pondérée" : "Weighted confidence"}</p>191                <span className={`pill ${CONF[a.confidenceLevel]}`}>192                  {a.confidenceLevel} · {a.confidencePct} %193                </span>194              </div>195              <div>196                <p className="klabel">{fr ? "Évaluation municipale" : "Municipal assessment"}</p>197                <p className="vp-display text-[18px] font-bold text-ink-2">{fmt(a.totalRole, lang)}</p>198              </div>199            </div>200            <a201              href={`/api/report/portfolio?ids=${data.items.map((i) => i.unit!.id).join(",")}`}202              className="btn btn-primary mt-6"203              download204            >205              {fr206                ? `Télécharger le rapport de parc (${1 + data.items.length} pages PDF)`207                : `Download the portfolio report (${1 + data.items.length} PDF pages)`}{" "}208209            </a>210          </section>211212          {/* tuiles registre */}213          <section>214            <span className="kicker">{fr ? "Votre parc, en chiffres du registre" : "Your portfolio, in registry numbers"}</span>215            <div className="mt-4 grid grid-cols-2 gap-3.5 sm:grid-cols-4">216              {(217                [218                  [fr ? "Propriétés" : "Properties", String(a.count), true],219                  [fr ? "Écart vs rôle" : "vs assessment", a.ecartRolePct != null ? `${a.ecartRolePct >= 0 ? "+" : ""}${a.ecartRolePct.toFixed(0)} %` : "—", false],220                  [fr ? "Croissance 2021→2026" : "Growth 2021→2026", a.growthPct != null ? `${a.growthPct >= 0 ? "+" : ""}${a.growthPct.toFixed(0)} %` : "—", true],221                  [fr ? "Logements" : "Dwellings", String(a.totalDwellings), false],222                  [fr ? "Aire habitable" : "Living area", `${a.totalFloorArea.toLocaleString(fr ? "fr-CA" : "en-CA")} m²`, false],223                  [fr ? "Terrain total" : "Total land", `${a.totalLandArea.toLocaleString(fr ? "fr-CA" : "en-CA")} m²`, false],224                  [fr ? "Année moyenne" : "Avg. year built", a.avgYearBuilt ? String(a.avgYearBuilt) : "—", false],225                  [fr ? "Municipalités" : "Municipalities", String(a.municipalities.length), false],226                ] as [string, string, boolean][]227              ).map(([k, v, hero]) => (228                <div229                  key={k}230                  className={`vp-card vp-card-hover p-4 ${hero ? "!bg-ink" : ""}`}231                >232                  <p className={`vp-display text-[clamp(20px,2.6vw,28px)] font-bold leading-tight tracking-[-0.03em] ${hero ? "text-lime" : "text-ink"}`}>233                    {v}234                  </p>235                  <p className={`vp-mono mt-1.5 text-[10px] font-bold uppercase tracking-[0.1em] ${hero ? "text-[rgba(255,81,72,0.7)]" : "text-ink-3"}`}>236                    {k}237                  </p>238                </div>239              ))}240            </div>241          </section>242243          {/* historique du parc + répartitions */}244          <section className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">245            <div className="vp-card p-5 sm:p-6">246              <p className="klabel mb-3">{fr ? "Valeur du parc par année" : "Portfolio value by year"}</p>247              <div className="flex flex-col gap-2">248                {a.history.map((h) => {249                  const max = Math.max(...a.history.map((x) => x.total), 1);250                  return (251                    <div key={h.year} className="hbar-row grid grid-cols-[46px_1fr_auto] items-center gap-3">252                      <span className="vp-mono text-[12px] font-bold">{h.year}</span>253                      <span className="hbar-track !h-[22px]">254                        <span255                          className="hbar-fill !rounded-r-[6px]"256                          style={{ width: `${(h.total / max) * 100}%`, background: h.year === 2026 ? "var(--ink)" : undefined }}257                        />258                      </span>259                      <span className="vp-mono text-[12px] font-bold">260                        {fmt(h.total, lang)}261                        {h.nCovered < a.count && (262                          <span className="text-ink-3"> ({h.nCovered}/{a.count})</span>263                        )}264                      </span>265                    </div>266                  );267                })}268              </div>269            </div>270            <div className="vp-card p-5 sm:p-6">271              <p className="klabel mb-3">{fr ? "Répartition" : "Breakdown"}</p>272              <div className="space-y-2">273                {a.municipalities.slice(0, 4).map((m) => (274                  <div key={m.name} className="flex items-baseline justify-between gap-3 border-b border-dashed border-[rgba(20,24,20,0.14)] pb-1.5">275                    <span className="vp-display text-[14px] font-bold">{m.name}</span>276                    <span className="vp-mono text-[11px] text-ink-2">277                      {m.count} · {fmt(m.total, lang)}278                    </span>279                  </div>280                ))}281                {a.types.map((tp) => (282                  <div key={tp.type} className="flex items-baseline justify-between gap-3 border-b border-dashed border-[rgba(20,24,20,0.14)] pb-1.5 last:border-0">283                    <span className="text-[13px] font-semibold text-ink-2">{t(tp.type as TKey)}</span>284                    <span className="vp-mono text-[11px] text-ink-2">285                      {tp.count} · {fmt(tp.total, lang)}286                    </span>287                  </div>288                ))}289              </div>290            </div>291          </section>292293          {/* cartes propriétés */}294          <section>295            <span className="kicker">{fr ? "Chaque propriété du parc" : "Every property in the portfolio"}</span>296            <div className="mt-4 grid gap-4 sm:grid-cols-2">297              {data.items.map((it, i) => {298                const u = it.unit!;299                const r = it.result;300                return (301                  <Link302                    key={u.id}303                    href={`/estimation/${u.id}`}304                    className="vp-card vp-card-hover rise block p-5"305                    style={{ animationDelay: `${i * 70}ms` }}306                  >307                    <div className="flex items-start justify-between gap-3">308                      <div>309                        <p className="vp-mono text-[10px] font-bold text-green">310                          {String(i + 1).padStart(2, "0")}311                        </p>312                        <p className="vp-display mt-0.5 text-[16px] font-bold uppercase leading-tight">313                          {u.adresse}314                        </p>315                        <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">316                          {u.municipalite} · {u.anneeConstruction ?? "—"} ·{" "}317                          {u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"}318                        </p>319                      </div>320                      <span className={`pill ${CONF[r.confidenceLevel]} shrink-0`}>321                        {r.confidenceLevel}322                      </span>323                    </div>324                    <div className="mt-3 flex items-end justify-between gap-3 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3">325                      <div>326                        <p className="vp-display text-[22px] font-bold tracking-[-0.02em]">327                          {fmt(r.estimate, lang)}328                        </p>329                        <p className="vp-mono text-[10.5px] text-ink-3">330                          {fr ? "rôle" : "roll"} {fmt(u.valeurRole, lang)}331                        </p>332                      </div>333                      <Sparkline history={u.history} />334                    </div>335                    <div className="mt-3">336                      <ValueSplit337                        land={u.specs.valeurTerrain}338                        building={u.specs.valeurBatiment}339                        previous={null}340                        total={u.valeurRole}341                        lang={lang}342                        labels={{343                          land: fr ? "Terrain" : "Land",344                          building: fr ? "Bâtiment" : "Building",345                          previous: "",346                        }}347                      />348                    </div>349                  </Link>350                );351              })}352            </div>353          </section>354        </div>355      )}356    </div>357  );358}359