SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
7.9 KB · 193 lines tsx
Raw Blame History
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