SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
7.6 KB · 137 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/MarketPriceCard.tsx : « Prix et marché » — estimation Vrai-Prix5//   (valeur, fourchette, confiance) avec jauge SVG (fourchette, estimation,6//   prix demandé), écart en %, prix au pi², rôle d'évaluation foncière7//   (terrain + bâtiment, année, écart), historique du prix demandé observé par8//   Immo-Ka et temps sur le marché, méthodologie en accordéon. Toutes les9//   données arrivent avec l'annonce : aucun chargement différé.10// -----------------------------------------------------------------------------11import { Listing, fmtDate, fmtPrice } from "../api";12import { Ico } from "../components/Icons";13import { PriceCapsule } from "./PropertyHero";14import { ComparaisonPrix, estLocation, evaluationMunicipale } from "./synthese";15import { Accordion, SectionCard, StatTile, NBSP } from "./ui";1617const CONF: Record<string, string> = { A: "Confiance élevée", B: "Confiance bonne", C: "Confiance moyenne", D: "Estimation indicative" };1819function Gauge({ lo, hi, est, ask }: { lo: number; hi: number; est: number; ask: number | null }) {20  const W = 320, H = 54;21  const min = Math.min(lo, ask ?? lo) * 0.97, max = Math.max(hi, ask ?? hi) * 1.03;22  const x = (v: number) => 8 + ((Math.min(Math.max(v, min), max) - min) / (max - min)) * (W - 16);23  const anchor = (px: number) => (px < 56 ? "start" : px > W - 56 ? "end" : "middle");24  return (25    <div className="ik-gauge">26      <svg viewBox={`0 0 ${W} ${H}`} role="img"27           aria-label={`Fourchette Vrai-Prix de ${fmtPrice(lo)} à ${fmtPrice(hi)}, estimation ${fmtPrice(est)}${ask != null ? `, prix demandé ${fmtPrice(ask)}` : ""}`}>28        <rect className="ik-gauge-track" x="8" y="24" width={W - 16} height="8" rx="4" />29        <rect className="ik-gauge-box" x={x(lo)} y="22" width={Math.max(4, x(hi) - x(lo))} height="12" rx="4" />30        <line className="ik-gauge-med" x1={x(est)} x2={x(est)} y1="16" y2="40" />31        <text className="ik-gauge-txt" x={x(est)} y="11" textAnchor={anchor(x(est))}>estimation {fmtPrice(est)}</text>32        {ask != null && (33          <>34            <circle className="ik-gauge-me" cx={x(ask)} cy="28" r="6.5" />35            <text className="ik-gauge-txt me" x={x(ask)} y="52" textAnchor={anchor(x(ask))}>prix demandé {fmtPrice(ask)}</text>36          </>37        )}38      </svg>39      <div className="ik-gauge-lbls"><span>{fmtPrice(lo)}</span><span>{fmtPrice(hi)}</span></div>40    </div>41  );42}4344export default function MarketPriceCard({ l, cmp }: { l: Listing; cmp: ComparaisonPrix | null }) {45  if (l.price == null) return null;46  const location = estLocation(l);47  const vp = l.vraiprix && l.vraiprix.value != null && !location ? l.vraiprix : null;48  const ev = evaluationMunicipale(l);49  const hist = (l.price_history ?? []).filter((h) => h.price != null);50  const pi2 = l.area_sqft != null && l.area_sqft > 200 && !location ? Math.round(l.price / l.area_sqft) : null;51  const evPct = ev && !location ? Math.round(((l.price - ev.total) / ev.total) * 100) : null;52  const jours = l.days_on_market ?? null;5354  return (55    <SectionCard id="prix" title="Prix et marché" icon={<Ico name="scale" size={18} />}56                 aside={cmp && <PriceCapsule cmp={cmp} short />}>57      {vp ? (58        <>59          <div className="ik-kpis cols-3">60            <StatTile accent value={fmtPrice(l.price)} label="Prix demandé" />61            <StatTile value={fmtPrice(vp.value)} label="Estimation Vrai-Prix" />62            <StatTile value={vp.low != null && vp.high != null ? `${fmtPrice(vp.low)} – ${fmtPrice(vp.high)}` : "—"} label="Fourchette estimée" />63          </div>64          {vp.low != null && vp.high != null && vp.high > vp.low && (65            <Gauge lo={vp.low} hi={vp.high} est={vp.value as number} ask={cmp ? l.price : null} />66          )}67          {!cmp && (68            <p className="ik-note info">Prix demandé trop éloigné de l'estimation pour être comparé (type de bien ou annonce atypique).</p>69          )}70          <div className="ik-meta">71            <span>{(vp.confidence && CONF[vp.confidence]) || "Estimation automatisée"}{vp.confidence ? ` (${vp.confidence})` : ""}</span>72            {pi2 != null && <span><b>{pi2.toLocaleString("fr-CA")}{NBSP}$</b> par pi² habitable</span>}73            {jours != null && jours > 0 && <span><b>{jours}</b> jour{jours > 1 ? "s" : ""} sur le marché (observé)</span>}74          </div>75        </>76      ) : (77        <>78          <div className="ik-kpis cols-3">79            <StatTile accent value={fmtPrice(l.price)} label={location ? "Loyer mensuel" : "Prix demandé"} />80            {pi2 != null && <StatTile value={`${pi2.toLocaleString("fr-CA")}${NBSP}$`} label="Par pi² habitable" />}81            {jours != null && jours > 0 && <StatTile value={jours} label="Jours sur le marché (observé)" />}82          </div>83          {!location && <p className="ik-fine meth">Pas d'estimation Vrai-Prix pour cette annonce (adresse non appariée au rôle ou type de bien non couvert).</p>}84        </>85      )}8687      {ev && !location && (88        <div className="ik-role">89          <h3 className="ik-card-sub ik-subtitle">Rôle d'évaluation foncière{ev.annee ? ` · ${ev.annee}` : ""}</h3>90          <div className="ik-kpis cols-3">91            <StatTile value={fmtPrice(ev.total)} label="Évaluation municipale" anim={false} />92            {ev.terrain != null && <StatTile value={fmtPrice(ev.terrain)} label="Terrain" anim={false} />}93            {ev.batiment != null && <StatTile value={fmtPrice(ev.batiment)} label="Bâtiment" anim={false} />}94          </div>95          {evPct != null && Math.abs(evPct) <= 300 && (96            <p className="ik-fine" style={{ marginTop: 8 }}>97              Prix demandé <b>{Math.abs(evPct)}{NBSP}% {evPct >= 0 ? "au-dessus" : "sous"}</b> l'évaluation municipale — le rôle triennal98              reflète la valeur au 1ᵉʳ juillet de l'année de référence, pas le marché actuel.99            </p>100          )}101        </div>102      )}103104      {hist.length >= 2 && (105        <>106          <h3 className="ik-card-sub ik-subtitle">Historique du prix demandé</h3>107          <ul className="ik-tl">108            {hist.slice(0, 6).map((h, i) => {109              const prev = hist[i + 1];110              const cls = prev && prev.price != null ? (h.price! < prev.price ? "baisse" : "hausse") : "";111              return (112                <li key={h.ts} className={cls}>113                  <span className="ik-tl-date">{fmtDate(h.ts)}</span>114                  {prev && prev.price != null115                    ? <>{h.price! < prev.price ? "Baissé" : "Monté"} de {fmtPrice(prev.price)} à <b>{fmtPrice(h.price!)}</b></>116                    : <>Premier prix observé : <b>{fmtPrice(h.price!)}</b></>}117                </li>118              );119            })}120          </ul>121        </>122      )}123124      <Accordion title="Méthodologie" small>125        <p>126          L'estimation Vrai-Prix (service Groupe KA) est produite par un modèle statistique fondé sur les ventes127          comparables récentes, les caractéristiques du bien et le rôle d'évaluation foncière ; la fourchette exprime128          l'incertitude du modèle et la lettre de confiance (A à D) sa fiabilité. Ce n'est pas une évaluation129          agréée. L'historique de prix et le temps sur le marché sont ceux observés par les synchronisations130          Immo-Ka (première observation, pas la date d'inscription à la source).131          {vp && <> <a href={vp.url} target="_blank" rel="noopener noreferrer">Analyse détaillée sur Vrai-Prix ↗</a></>}132        </p>133      </Accordion>134    </SectionCard>135  );136}137