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%
9.9 KB · 281 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// pages/Bannieres.tsx : annuaire des bannières (agences) immobilières du Québec4// issu du registre OACIQ — ~690 bannières, 17 357 courtiers. Filtrable par5// région et par texte ; chaque ligne se déplie pour lister les courtiers de la6// bannière (/api/bannieres puis /api/bannieres/{id} à la demande).7// -----------------------------------------------------------------------------8import { Fragment, useEffect, useMemo, useState } from "react";910type Banniere = {11  id: string;12  oaciq_id: string | null;13  name: string;14  regions: string[];15  brokers_n: number;16  covered_by: string | null;17  site: string | null;18};1920type Broker = { name: string; city: string; region: string };2122type Doc = {23  generated: string | null;24  total: number;25  brokers_total: number;26  count: number;27  regions: { region: string; n: number }[];28  bannieres: Banniere[];29};3031const host = (u: string) => {32  try {33    return new URL(u).hostname.replace(/^www\./, "");34  } catch {35    return u;36  }37};3839export default function BannieresPage() {40  const [doc, setDoc] = useState<Doc | null>(null);41  const [error, setError] = useState<string | null>(null);42  const [q, setQ] = useState("");43  const [region, setRegion] = useState("");44  const [open, setOpen] = useState<string | null>(null);45  const [brokers, setBrokers] = useState<Record<string, Broker[]>>({});4647  useEffect(() => {48    fetch("/api/bannieres")49      .then((r) => {50        if (!r.ok) throw new Error("HTTP " + r.status);51        return r.json();52      })53      .then(setDoc)54      .catch((e) => setError(String(e)));55  }, []);5657  const filtered = useMemo(() => {58    if (!doc) return [];59    const needle = q.trim().toLowerCase();60    return doc.bannieres.filter(61      (b) =>62        (!region || b.regions.includes(region)) &&63        (!needle || b.name.toLowerCase().includes(needle)),64    );65  }, [doc, q, region]);6667  const toggle = (id: string) => {68    if (open === id) {69      setOpen(null);70      return;71    }72    setOpen(id);73    if (!brokers[id]) {74      fetch(`/api/bannieres/${id}`)75        .then((r) => {76          if (!r.ok) throw new Error("HTTP " + r.status);77          return r.json();78        })79        .then((b) => setBrokers((prev) => ({ ...prev, [id]: b.brokers ?? [] })))80        .catch(() => setBrokers((prev) => ({ ...prev, [id]: [] })));81    }82  };8384  const filtering = q.trim() !== "" || region !== "";8586  return (87    <div className="container sources">88      <span className="kicker">Annuaire — courtage immobilier</span>89      <h1>Bannières &amp; courtiers du Québec</h1>90      <p className="sub">91        Toutes les bannières de courtage immobilier de la province selon le92        registre public de l'OACIQ : <b>{doc ? doc.total : "…"}</b> bannières93        et <b>{doc ? doc.brokers_total.toLocaleString("fr-CA") : "…"}</b>{" "}94        courtiers titulaires d'un permis, dans les 17 régions administratives.95        Cliquez sur une bannière pour voir la liste de ses courtiers96        {doc?.generated ? ` — recensement du ${doc.generated}` : ""}.97      </p>9899      {error && <div className="notice">⚠️ {error}</div>}100      {!doc && !error && <div className="notice">Chargement…</div>}101102      {doc && (103        <>104          <div105            style={{106              display: "flex",107              flexWrap: "wrap",108              gap: 10,109              alignItems: "center",110              margin: "6px 0 10px",111            }}112          >113            <input114              type="search"115              value={q}116              onChange={(e) => setQ(e.target.value)}117              placeholder="Rechercher une bannière…"118              aria-label="Rechercher une bannière"119              style={{120                flex: "1 1 240px",121                maxWidth: 380,122                padding: "10px 14px",123                border: "1.5px solid var(--line)",124                borderRadius: 10,125                font: "inherit",126                background: "var(--surface)",127                color: "inherit",128              }}129            />130            <select131              value={region}132              onChange={(e) => setRegion(e.target.value)}133              aria-label="Filtrer par région administrative"134              style={{135                padding: "10px 12px",136                border: "1.5px solid var(--line)",137                borderRadius: 10,138                font: "inherit",139                background: "var(--surface)",140                color: "inherit",141                maxWidth: "100%",142              }}143            >144              <option value="">Toutes les régions ({doc.total})</option>145              {doc.regions.map((r) => (146                <option key={r.region} value={r.region}>147                  {r.region} ({r.n})148                </option>149              ))}150            </select>151            {filtering && (152              <span style={{ color: "var(--ink-2)", fontSize: 14 }}>153                {filtered.length} bannière{filtered.length > 1 ? "s" : ""}154              </span>155            )}156          </div>157158          {filtered.length === 0 && (159            <div className="notice">160              Aucune bannière ne correspond à cette recherche.161            </div>162          )}163164          <div className="src-wrap" style={{ marginTop: 12 }}>165            <table className="src-table">166              <thead>167                <tr>168                  <th></th>169                  <th>Bannière</th>170                  <th>Courtiers</th>171                  <th>Régions</th>172                  <th>Site web</th>173                  <th>Suivi Immo-Ka</th>174                </tr>175              </thead>176              <tbody>177                {filtered.map((b) => (178                  <Fragment key={b.id}>179                    <tr180                      onClick={() => toggle(b.id)}181                      style={{ cursor: "pointer" }}182                    >183                      <td style={{ width: 24, color: "var(--ink-2)" }}>184                        {open === b.id ? "▾" : "▸"}185                      </td>186                      <td>187                        <b>{b.name}</b>188                      </td>189                      <td style={{ whiteSpace: "nowrap" }}>{b.brokers_n}</td>190                      <td style={{ fontSize: 13 }}>191                        {b.regions.length > 3192                          ? `${b.regions.slice(0, 3).join(", ")} +${b.regions.length - 3}`193                          : b.regions.join(", ")}194                      </td>195                      <td>196                        {b.site ? (197                          <a198                            href={b.site}199                            target="_blank"200                            rel="noopener noreferrer nofollow"201                            onClick={(e) => e.stopPropagation()}202                          >203                            {host(b.site)}204                          </a>205                        ) : (206                          "—"207                        )}208                      </td>209                      <td style={{ whiteSpace: "nowrap" }}>210                        {b.covered_by ? (211                          <span title={`Connecteur : ${b.covered_by}`}>212                            ✓ annonces suivies213                          </span>214                        ) : (215                          "—"216                        )}217                      </td>218                    </tr>219                    {open === b.id && (220                      <tr>221                        <td222                          colSpan={6}223                          style={{ background: "var(--surface-2)" }}224                        >225                          {!brokers[b.id] ? (226                            <span style={{ color: "var(--ink-2)" }}>227                              Chargement des courtiers…228                            </span>229                          ) : brokers[b.id].length === 0 ? (230                            <span style={{ color: "var(--ink-2)" }}>231                              Aucun courtier trouvé.232                            </span>233                          ) : (234                            <div235                              style={{236                                display: "grid",237                                gridTemplateColumns:238                                  "repeat(auto-fill, minmax(230px, 1fr))",239                                gap: "6px 18px",240                                padding: "6px 0",241                              }}242                            >243                              {brokers[b.id].map((c, i) => (244                                <div key={i} style={{ fontSize: 13 }}>245                                  <b>{c.name}</b>246                                  <span style={{ color: "var(--ink-2)" }}>247                                    {" "}248                                    — {c.city}249                                  </span>250                                </div>251                              ))}252                            </div>253                          )}254                        </td>255                      </tr>256                    )}257                  </Fragment>258                ))}259              </tbody>260            </table>261          </div>262263          <p264            className="sub"265            style={{ marginTop: 34, fontSize: 13, maxWidth: 720 }}266          >267            Annuaire informatif compilé à partir du registre public des268            titulaires de permis de l'OACIQ (Organisme d'autoréglementation du269            courtage immobilier du Québec). Pour vérifier le dossier270            professionnel d'un courtier (permis, mesures disciplinaires),271            consultez directement le registre officiel sur oaciq.com. « Annonces272            suivies » indique que les inscriptions de la bannière sont agrégées273            sur Immo-Ka. Le Groupe KA n'a aucun lien d'affaires avec les274            bannières listées.275          </p>276        </>277      )}278    </div>279  );280}281