SPB Git

Repository Public

ParcClient.tsx 15.8 KB 372 lines tsx

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 FavButton from "@/components/FavButton";9import type { PortfolioResult } from "@/lib/estimator";10import type { TKey } from "@/lib/i18n";1112const KEY = "vp-parc";1314interface Saved {15  id: string;16  adresse: string;17  municipalite: string;18}1920const fmt = (v: number | null | undefined, lang: string) =>21  v == null22    ? "—"23    : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {24        style: "currency",25        currency: "CAD",26        maximumFractionDigits: 0,27      }).format(v);2829const CONF: Record<string, string> = {30  A: "pill-conf-A",31  B: "pill-conf-B",32  C: "pill-conf-C",33  D: "pill-conf-D",34};3536export default function ParcClient() {37  const { lang, t } = useLang();38  const fr = lang === "fr";39  const [list, setList] = useState<Saved[]>([]);40  const [data, setData] = useState<PortfolioResult | null>(null);41  const [loading, setLoading] = useState(false);42  const [hydrated, setHydrated] = useState(false);4344  useEffect(() => {45    const id = setTimeout(() => {46      try {47        const raw = window.localStorage.getItem(KEY);48        if (raw) setList(JSON.parse(raw) as Saved[]);49      } catch {}50      setHydrated(true);51    }, 0);52    return () => clearTimeout(id);53  }, []);5455  const persist = (l: Saved[]) => {56    setList(l);57    window.localStorage.setItem(KEY, JSON.stringify(l));58    setData(null);59  };6061  const add = (s: Suggestion) => {62    if (list.some((x) => x.id === s.id) || list.length >= 40) return;63    persist([64      ...list,65      {66        id: s.id,67        adresse: `${s.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}`,68        municipalite: s.municipalite ?? "",69      },70    ]);71  };7273  const evaluate = async () => {74    setLoading(true);75    try {76      const res = await fetch("/api/portfolio", {77        method: "POST",78        headers: { "Content-Type": "application/json" },79        body: JSON.stringify({ ids: list.map((x) => x.id) }),80      });81      if (res.ok) setData((await res.json()) as PortfolioResult);82    } finally {83      setLoading(false);84    }85  };8687  const a = data?.aggregates;8889  return (90    <div className="pb-6 pt-10 sm:pt-14">91      {/* héros */}92      <section>93        <span className="kicker">{fr ? "Investisseurs · propriétaires multiples" : "Investors · multi-owners"}</span>94        <h1 className="vp-display mt-3 max-w-4xl text-[clamp(30px,5.6vw,62px)] font-bold uppercase leading-[0.98] tracking-[-0.035em]">95          {fr ? (96            <>97              La valeur de votre <span className="hl">parc immobilier</span>,98              <br />99              <span className="outline-txt">au complet.</span>100            </>101          ) : (102            <>103              Your <span className="hl">property portfolio</span>,104              <br />105              <span className="outline-txt">fully valued.</span>106            </>107          )}108        </h1>109        <p className="mt-4 max-w-xl text-[16px] text-ink-2">110          {fr111            ? "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é."112            : "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."}113        </p>114      </section>115116      {/* constructeur de parc */}117      <section className="mt-9">118        <div className="vp-card p-4 sm:p-5">119          <p className="klabel mb-2 pl-0.5">120            {fr ? `Ajouter une propriété (${list.length}/40)` : `Add a property (${list.length}/40)`}121          </p>122          <SearchBox123            onPick={add}124            placeholder={fr ? "Tapez une adresse puis choisissez-la…" : "Type an address, then pick it…"}125          />126          {hydrated && list.length > 0 && (127            <div className="mt-4 flex flex-wrap gap-2">128              {list.map((p, i) => (129                <span130                  key={p.id}131                  className="stat-chip !py-1.5"132                  style={{ animationDelay: `${i * 40}ms` }}133                >134                  <b>{i + 1}</b> {p.adresse} · {p.municipalite}135                  <button136                    aria-label={fr ? "Retirer" : "Remove"}137                    onClick={() => persist(list.filter((x) => x.id !== p.id))}138                    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"139                  >140                    ✕141                  </button>142                </span>143              ))}144            </div>145          )}146          <div className="mt-4 flex flex-wrap gap-2.5">147            <button148              className="btn btn-primary"149              disabled={list.length === 0 || loading}150              onClick={evaluate}151            >152              {loading153                ? fr ? "Évaluation en cours…" : "Valuing…"154                : fr ? `Évaluer mon parc (${list.length})` : `Value my portfolio (${list.length})`}155            </button>156            {list.length > 0 && (157              <button className="btn btn-ghost" onClick={() => persist([])}>158                {fr ? "Vider la liste" : "Clear list"}159              </button>160            )}161          </div>162        </div>163      </section>164165      {/* résultats */}166      {data && a && (167        <div className="mt-12 space-y-10">168          {/* total */}169          <section className="vp-card p-6 sm:p-8">170            <span className="kicker">{fr ? "Valeur totale estimée du parc" : "Total estimated portfolio value"}</span>171            <p className="vp-display mt-3 text-[clamp(40px,7vw,68px)] font-bold leading-none tracking-[-0.04em]">172              <CountUp173                value={a.totalEstimate}174                format={(v) =>175                  new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {176                    style: "currency",177                    currency: "CAD",178                    maximumFractionDigits: 0,179                  }).format(Math.round(v / 100) * 100)180                }181              />182            </p>183            <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">184              <div>185                <p className="klabel">{fr ? "Fourchette cumulée" : "Cumulative range"}</p>186                <p className="vp-display text-[18px] font-bold">187                  {fmt(a.totalLow, lang)} <span className="text-ink-3">→</span> {fmt(a.totalHigh, lang)}188                </p>189              </div>190              <div>191                <p className="klabel">{fr ? "Confiance pondérée" : "Weighted confidence"}</p>192                <span className={`pill ${CONF[a.confidenceLevel]}`}>193                  {a.confidenceLevel} · {a.confidencePct} %194                </span>195              </div>196              <div>197                <p className="klabel">{fr ? "Évaluation municipale" : "Municipal assessment"}</p>198                <p className="vp-display text-[18px] font-bold text-ink-2">{fmt(a.totalRole, lang)}</p>199              </div>200            </div>201            <a202              href={`/api/report/portfolio?ids=${data.items.map((i) => i.unit!.id).join(",")}`}203              className="btn btn-primary mt-6"204              download205            >206              {fr207                ? `Télécharger le rapport de parc (${1 + data.items.length} pages PDF)`208                : `Download the portfolio report (${1 + data.items.length} PDF pages)`}{" "}209              ↓210            </a>211          </section>212213          {/* tuiles registre */}214          <section>215            <span className="kicker">{fr ? "Votre parc, en chiffres du registre" : "Your portfolio, in registry numbers"}</span>216            <div className="mt-4 grid grid-cols-2 gap-3.5 sm:grid-cols-4">217              {(218                [219                  [fr ? "Propriétés" : "Properties", String(a.count), true],220                  [fr ? "Écart vs rôle" : "vs assessment", a.ecartRolePct != null ? `${a.ecartRolePct >= 0 ? "+" : ""}${a.ecartRolePct.toFixed(0)} %` : "—", false],221                  [fr ? "Croissance 2021→2026" : "Growth 2021→2026", a.growthPct != null ? `${a.growthPct >= 0 ? "+" : ""}${a.growthPct.toFixed(0)} %` : "—", true],222                  [fr ? "Logements" : "Dwellings", String(a.totalDwellings), false],223                  [fr ? "Aire habitable" : "Living area", `${a.totalFloorArea.toLocaleString(fr ? "fr-CA" : "en-CA")} m²`, false],224                  [fr ? "Terrain total" : "Total land", `${a.totalLandArea.toLocaleString(fr ? "fr-CA" : "en-CA")} m²`, false],225                  [fr ? "Année moyenne" : "Avg. year built", a.avgYearBuilt ? String(a.avgYearBuilt) : "—", false],226                  [fr ? "Municipalités" : "Municipalities", String(a.municipalities.length), false],227                ] as [string, string, boolean][]228              ).map(([k, v, hero]) => (229                <div230                  key={k}231                  className={`vp-card vp-card-hover p-4 ${hero ? "!bg-ink" : ""}`}232                >233                  <p className={`vp-display text-[clamp(20px,2.6vw,28px)] font-bold leading-tight tracking-[-0.03em] ${hero ? "text-lime" : "text-ink"}`}>234                    {v}235                  </p>236                  <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"}`}>237                    {k}238                  </p>239                </div>240              ))}241            </div>242          </section>243244          {/* historique du parc + répartitions */}245          <section className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">246            <div className="vp-card p-5 sm:p-6">247              <p className="klabel mb-3">{fr ? "Valeur du parc par année" : "Portfolio value by year"}</p>248              <div className="flex flex-col gap-2">249                {a.history.map((h) => {250                  const max = Math.max(...a.history.map((x) => x.total), 1);251                  return (252                    <div key={h.year} className="hbar-row grid grid-cols-[46px_1fr_auto] items-center gap-3">253                      <span className="vp-mono text-[12px] font-bold">{h.year}</span>254                      <span className="hbar-track !h-[22px]">255                        <span256                          className="hbar-fill !rounded-r-[6px]"257                          style={{ width: `${(h.total / max) * 100}%`, background: h.year === 2026 ? "var(--ink)" : undefined }}258                        />259                      </span>260                      <span className="vp-mono text-[12px] font-bold">261                        {fmt(h.total, lang)}262                        {h.nCovered < a.count && (263                          <span className="text-ink-3"> ({h.nCovered}/{a.count})</span>264                        )}265                      </span>266                    </div>267                  );268                })}269              </div>270            </div>271            <div className="vp-card p-5 sm:p-6">272              <p className="klabel mb-3">{fr ? "Répartition" : "Breakdown"}</p>273              <div className="space-y-2">274                {a.municipalities.slice(0, 4).map((m) => (275                  <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">276                    <span className="vp-display text-[14px] font-bold">{m.name}</span>277                    <span className="vp-mono text-[11px] text-ink-2">278                      {m.count} · {fmt(m.total, lang)}279                    </span>280                  </div>281                ))}282                {a.types.map((tp) => (283                  <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">284                    <span className="text-[13px] font-semibold text-ink-2">{t(tp.type as TKey)}</span>285                    <span className="vp-mono text-[11px] text-ink-2">286                      {tp.count} · {fmt(tp.total, lang)}287                    </span>288                  </div>289                ))}290              </div>291            </div>292          </section>293294          {/* cartes propriétés */}295          <section>296            <span className="kicker">{fr ? "Chaque propriété du parc" : "Every property in the portfolio"}</span>297            <div className="mt-4 grid gap-4 sm:grid-cols-2">298              {data.items.map((it, i) => {299                const u = it.unit!;300                const r = it.result;301                return (302                  <Link303                    key={u.id}304                    href={`/estimation/${u.id}`}305                    className="vp-card vp-card-hover rise block p-5"306                    style={{ animationDelay: `${i * 70}ms` }}307                  >308                    <div className="flex items-start justify-between gap-3">309                      <div>310                        <p className="vp-mono text-[10px] font-bold text-green">311                          {String(i + 1).padStart(2, "0")}312                        </p>313                        <p className="vp-display mt-0.5 text-[16px] font-bold uppercase leading-tight">314                          {u.adresse}315                        </p>316                        <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">317                          {u.municipalite} · {u.anneeConstruction ?? "—"} ·{" "}318                          {u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"}319                        </p>320                      </div>321                      <span className="flex shrink-0 items-center gap-2">322                        {/* ♥ Mon univers Ka — favori unifié Groupe KA */}323                        <FavButton324                          item={{325                            item_id: u.id,326                            title: u.adresse ?? u.id,327                            subtitle: u.municipalite ?? "",328                            price_label: fmt(r.estimate, lang),329                            url: `https://www.vrai-prix.com/estimation/${encodeURIComponent(u.id)}`,330                          }}331                        />332                        <span className={`pill ${CONF[r.confidenceLevel]}`}>333                          {r.confidenceLevel}334                        </span>335                      </span>336                    </div>337                    <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">338                      <div>339                        <p className="vp-display text-[22px] font-bold tracking-[-0.02em]">340                          {fmt(r.estimate, lang)}341                        </p>342                        <p className="vp-mono text-[10.5px] text-ink-3">343                          {fr ? "rôle" : "roll"} {fmt(u.valeurRole, lang)}344                        </p>345                      </div>346                      <Sparkline history={u.history} />347                    </div>348                    <div className="mt-3">349                      <ValueSplit350                        land={u.specs.valeurTerrain}351                        building={u.specs.valeurBatiment}352                        previous={null}353                        total={u.valeurRole}354                        lang={lang}355                        labels={{356                          land: fr ? "Terrain" : "Land",357                          building: fr ? "Bâtiment" : "Building",358                          previous: "",359                        }}360                      />361                    </div>362                  </Link>363                );364              })}365            </div>366          </section>367        </div>368      )}369    </div>370  );371}372
Files main
Menu

Theme