Repository Public
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