Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
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