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%
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 >139 ✕140 </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)`}{" "}208 ↓209 </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