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%
8.1 KB · 193 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// pages/Taux.tsx : /taux-hypothecaires — Mortgage Intelligence.5//   Vue marché (meilleur/médian/variations), comparateur par banque avec6//   nature du taux (affiché vs spécial) et fraîcheur, historique, taux7//   préférentiels, santé des sources. Aucun taux inventé : tout vient des8//   pages officielles des institutions, avec provenance.9// -----------------------------------------------------------------------------10import { useEffect, useState } from "react";11import {12  MortgageBest, MortgageIntelligence, MortgageProviderHealth,13  fetchMortgageBest, fetchMortgageIntelligence, fetchMortgageProviders,14  fmtRate,15} from "../api";16import TauxHistorique from "../components/TauxHistorique";1718const TERMES: [number, string][] = [19  [12, "1 an"], [24, "2 ans"], [36, "3 ans"], [48, "4 ans"],20  [60, "5 ans"], [84, "7 ans"], [120, "10 ans"],21];22const KIND_FR: Record<string, string> = { posted: "affiché", special: "offre spéciale" };23const INSURED_FR: Record<string, string> = {24  insured: "assuré", insurable: "assurable", uninsured: "non assuré", unknown: "",25};2627const termeLabel = (m: number) => TERMES.find(([t]) => t === m)?.[1] ?? `${m} mois`;28const freshness = (min: number) =>29  min < 60 ? `il y a ${min} min` : min < 48 * 6030    ? `il y a ${Math.round(min / 60)} h` : `il y a ${Math.round(min / 1440)} j`;31const varTxt = (v: number | null) =>32  v == null ? "—" : v === 0 ? "stable"33    : `${v > 0 ? "▲ +" : "▼ "}${v.toFixed(2).replace(".", ",")} pt`;3435export default function TauxPage() {36  const [intel, setIntel] = useState<MortgageIntelligence | null>(null);37  const [rateType, setRateType] = useState<"fixed" | "variable">("fixed");38  const [term, setTerm] = useState(60);39  const [best, setBest] = useState<MortgageBest | null>(null);40  const [health, setHealth] = useState<MortgageProviderHealth[]>([]);4142  useEffect(() => {43    document.title = "Taux hypothécaires au Canada — comparateur en direct | Immo-Ka";44    fetchMortgageIntelligence().then(setIntel).catch(() => setIntel(null));45    fetchMortgageProviders().then((r) => setHealth(r.providers)).catch(() => {});46  }, []);4748  useEffect(() => {49    setBest(null);50    fetchMortgageBest(rateType, term).then(setBest).catch(() => setBest(null));51  }, [rateType, term]);5253  return (54    <div className="container taux-page">55      <header className="taux-head">56        <h1>Taux hypothécaires en direct</h1>57        <p>58          Les taux réellement publiés par les grandes institutions canadiennes59          (banques, Desjardins, prêteurs virtuels et monolines), collectés en60          continu par Immo-Ka. Chaque taux indique sa <b>nature</b> (affiché ou61          offre spéciale), sa <b>source officielle</b> et sa <b>fraîcheur</b> —62          jamais de taux inventé ni périmé sans avertissement.63        </p>64      </header>6566      {intel && intel.products.length > 0 && (67        <section className="f-bloc">68          <h2>Vue d'ensemble du marché</h2>69          <div className="taux-grid">70            {intel.products.map((p) => (71              <button72                key={`${p.rate_type}-${p.term_months}`}73                className={`taux-card ${p.rate_type === rateType && p.term_months === term ? "on" : ""}`}74                onClick={() => { setRateType(p.rate_type as "fixed" | "variable"); setTerm(p.term_months); }}75              >76                <span className="taux-card-k">77                  {p.rate_type === "fixed" ? "Fixe" : "Variable"} {termeLabel(p.term_months)}78                </span>79                <span className="taux-card-v">{fmtRate(p.best)}</span>80                <span className="taux-card-sub">{p.best_institution}</span>81                <span className="taux-card-sub">82                  médiane {fmtRate(p.median)} · 30 j : {varTxt(p.var_30d)}83                </span>84              </button>85            ))}86          </div>87          {intel.prime_rates.length > 0 && (88            <p className="fine">89              Taux préférentiels :{" "}90              {intel.prime_rates.map((p, i) => (91                <span key={i}>92                  {i > 0 && " · "}93                  {p.institution} <b>{fmtRate(p.rate)}</b>94                </span>95              ))}96            </p>97          )}98        </section>99      )}100101      <section className="f-bloc">102        <h2>Comparer les institutions</h2>103        <div className="taux-filtres">104          <label>105            <span>Type</span>106            <select value={rateType}107              onChange={(e) => setRateType(e.target.value as "fixed" | "variable")}>108              <option value="fixed">Fixe</option>109              <option value="variable">Variable</option>110            </select>111          </label>112          <label>113            <span>Terme</span>114            <select value={term} onChange={(e) => setTerm(Number(e.target.value))}>115              {TERMES.map(([m, l]) => <option key={m} value={m}>{l}</option>)}116            </select>117          </label>118        </div>119120        {best == null ? (121          <p className="fine">Aucun taux courant pour ce produit — essayez un autre terme.</p>122        ) : (123          <>124            <div className="rooms-wrap">125              <table className="rooms mtg-comp">126                <thead>127                  <tr><th>Institution</th><th>Produit</th><th>Taux</th><th>Nature</th><th>Fraîcheur</th><th>Source</th></tr>128                </thead>129                <tbody>130                  {best.per_institution.map((r, i) => (131                    <tr key={r.provider} className={i === 0 ? "taux-best" : ""}>132                      <td>{r.institution}{i === 0 && <span className="taux-badge">meilleur</span>}</td>133                      <td>{r.product_name}</td>134                      <td><b>{fmtRate(r.rate)}</b>{r.apr != null ? ` (TAP ${fmtRate(r.apr)})` : ""}</td>135                      <td>136                        {KIND_FR[r.kind] ?? r.kind}137                        {INSURED_FR[r.insured_status] ? ` · ${INSURED_FR[r.insured_status]}` : ""}138                      </td>139                      <td className={r.stale ? "mtg-stale" : ""}>{freshness(r.age_minutes)}</td>140                      <td>141                        {r.source_url && (142                          <a href={r.source_url} target="_blank" rel="noopener noreferrer">officielle ↗</a>143                        )}144                      </td>145                    </tr>146                  ))}147                </tbody>148              </table>149            </div>150            <p className="fine">151              Un seul produit comparable par institution (l'offre spéciale prime152              sur le taux affiché). Les produits incomparables — assuré vs non153              assuré, affiché vs spécial — ne sont jamais mélangés dans un même154              classement sans l'indiquer.155            </p>156          </>157        )}158      </section>159160      <section className="f-bloc">161        <h2>Évolution — {rateType === "fixed" ? "fixe" : "variable"} {termeLabel(term)}</h2>162        <TauxHistorique rateType={rateType} termMonths={term} />163      </section>164165      {health.length > 0 && (166        <section className="f-bloc">167          <h2>Fraîcheur des sources</h2>168          <div className="taux-sante">169            {health.map((h) => (170              <span key={h.provider}171                className={`taux-src taux-src-${h.level.toLowerCase()}`}172                title={`${h.current_products} produit(s) courant(s) — dernière collecte ${freshness(h.age_minutes)}`}>173                {h.institution}174              </span>175            ))}176          </div>177          <p className="fine">178            Vert : collecte récente réussie · jaune : donnée conservée mais179            vieillissante · rouge : source en erreur. En cas d'échec d'une180            collecte, les derniers taux valides restent affichés avec leur date.181          </p>182        </section>183      )}184185      <p className="fine">186        Informations indicatives seulement, sans garantie — les conditions187        réelles dépendent de votre dossier. Immo-Ka n'est ni un prêteur ni un188        courtier hypothécaire.189      </p>190    </div>191  );192}193