Python 67%
TypeScript 18.2%
CSS 14.4%
1// -----------------------------------------------------------------------------2// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Rates.tsx : /rates — Mortgage Intelligence.5// Market view (best/median/variations), per-bank comparator with rate kind6// (posted vs special) and freshness, history, prime rates, source health.7// No invented rates: everything comes from the institutions' official8// pages, with provenance.9// -----------------------------------------------------------------------------10import { useEffect, useState } from "react";11import {12 MortgageBest, MortgageIntelligence, MortgageProviderHealth,13 fetchMortgageBest, fetchMortgageIntelligence, fetchMortgageProviders,14 fmtRate,15} from "../api";16import RateHistory from "../components/RateHistory";1718const TERMS: [number, string][] = [19 [12, "1 year"], [24, "2 years"], [36, "3 years"], [48, "4 years"],20 [60, "5 years"], [84, "7 years"], [120, "10 years"],21];22const KIND_EN: Record<string, string> = { posted: "posted", special: "special offer" };23const INSURED_EN: Record<string, string> = {24 insured: "insured", insurable: "insurable", uninsured: "uninsured", unknown: "",25};2627const termLabel = (m: number) => TERMS.find(([t]) => t === m)?.[1] ?? `${m} months`;28const freshness = (min: number) =>29 min < 60 ? `${min} min ago` : min < 48 * 6030 ? `${Math.round(min / 60)} h ago` : `${Math.round(min / 1440)} d ago`;31const varTxt = (v: number | null) =>32 v == null ? "—" : v === 0 ? "stable"33 : `${v > 0 ? "▲ +" : "▼ "}${v.toFixed(2)} pt`;3435export default function RatesPage() {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 = "Mortgage rates in Canada — live comparator | House-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>Live mortgage rates</h1>57 <p>58 The rates actually published by the big Canadian institutions59 (banks, Desjardins, virtual lenders and monolines), collected60 continuously by House-Ka. Every rate shows its <b>kind</b> (posted or61 special offer), its <b>official source</b> and its <b>freshness</b> —62 never an invented rate, never a stale one without a warning.63 </p>64 </header>6566 {intel && intel.products.length > 0 && (67 <section className="f-bloc">68 <h2>Market overview</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" ? "Fixed" : "Variable"} {termLabel(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 median {fmtRate(p.median)} · 30 d: {varTxt(p.var_30d)}83 </span>84 </button>85 ))}86 </div>87 {intel.prime_rates.length > 0 && (88 <p className="fine">89 Prime rates:{" "}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>Compare the 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">Fixed</option>109 <option value="variable">Variable</option>110 </select>111 </label>112 <label>113 <span>Term</span>114 <select value={term} onChange={(e) => setTerm(Number(e.target.value))}>115 {TERMS.map(([m, l]) => <option key={m} value={m}>{l}</option>)}116 </select>117 </label>118 </div>119120 {best == null ? (121 <p className="fine">No current rate for this product — try another term.</p>122 ) : (123 <>124 <div className="rooms-wrap">125 <table className="rooms mtg-comp">126 <thead>127 <tr><th>Institution</th><th>Product</th><th>Rate</th><th>Kind</th><th>Freshness</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">best</span>}</td>133 <td>{r.product_name}</td>134 <td><b>{fmtRate(r.rate)}</b>{r.apr != null ? ` (APR ${fmtRate(r.apr)})` : ""}</td>135 <td>136 {KIND_EN[r.kind] ?? r.kind}137 {INSURED_EN[r.insured_status] ? ` · ${INSURED_EN[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">official ↗</a>143 )}144 </td>145 </tr>146 ))}147 </tbody>148 </table>149 </div>150 <p className="fine">151 One comparable product per institution (the special offer wins152 over the posted rate). Incomparable products — insured vs153 uninsured, posted vs special — are never mixed in the same154 ranking without saying so.155 </p>156 </>157 )}158 </section>159160 <section className="f-bloc">161 <h2>Trend — {rateType} {termLabel(term)}</h2>162 <RateHistory rateType={rateType} termMonths={term} />163 </section>164165 {health.length > 0 && (166 <section className="f-bloc">167 <h2>Source freshness</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} current product(s) — last collection ${freshness(h.age_minutes)}`}>173 {h.institution}174 </span>175 ))}176 </div>177 <p className="fine">178 Green: recent successful collection · yellow: data kept but aging ·179 red: source in error. When a collection fails, the last valid rates180 stay displayed with their date.181 </p>182 </section>183 )}184185 <p className="fine">186 Indicative information only, without guarantee — actual conditions187 depend on your file. House-Ka is neither a lender nor a mortgage188 broker.189 </p>190 </div>191 );192}193