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// fiche/PropertyQuickFacts.tsx : « La propriété » — grille compacte icône +5// valeur + libellé (type, chambres, salles de bain, salles d'eau, superficie,6// terrain, année, stationnement, MLS), rangées pratiques (agence, courtier,7// en ligne depuis, synchronisé), puis TOUTES les caractéristiques publiées8// par la source et le tableau des pièces en accordéons (aucune donnée9// supprimée par rapport à la fiche v2).10// -----------------------------------------------------------------------------11import { ReactNode } from "react";12import { Listing, Room, fmtArea, fmtPrice, sourceName } from "../api";13import { Ico } from "../components/Icons";14import { Accordion, SectionCard, NBSP, relTime } from "./ui";1516// clés techniques de `details` jamais montrées dans « Caractéristiques »17const DETAIL_HIDDEN = new Set([18 "pieces", "price_from", "cover_thumb", "photo_captions", "img_audited",19 "needs_image_review", "postal_code", "region", "transaction",20 "prix_pi2", "prix_m2", "listing_origin_url", "listed_at",21]);22// clés déjà représentées dans la grille (évite le doublon dans le tableau)23const IN_GRID = new Set(["Type de propriété", "Année de construction", "Superficie habitable", "Superficie du terrain", "Stationnement (total)"]);2425export default function PropertyQuickFacts({ l }: { l: Listing }) {26 const d = l.details ?? {};27 const facts: { ico: ReactNode; v: string; l: string }[] = [];28 if (l.property_type) facts.push({ ico: <Ico name="home" size={17} />, v: l.property_type, l: "Type" });29 if (l.bedrooms != null) facts.push({ ico: <Ico name="bed" size={17} />, v: `${Math.round(l.bedrooms)}`, l: `Chambre${l.bedrooms > 1 ? "s" : ""}` });30 if (l.bathrooms != null) facts.push({ ico: <Ico name="bath" size={17} />, v: `${l.bathrooms}`, l: `Salle${l.bathrooms > 1 ? "s" : ""} de bain` });31 if (l.powder_rooms != null && l.powder_rooms > 0) facts.push({ ico: <Ico name="drop" size={17} />, v: `${l.powder_rooms}`, l: `Salle${l.powder_rooms > 1 ? "s" : ""} d'eau` });32 if (l.area_sqft != null) facts.push({ ico: <Ico name="area" size={17} />, v: fmtArea(l.area_sqft)!, l: "Superficie habitable" });33 if (l.lot_sqft != null) facts.push({ ico: <Ico name="land" size={17} />, v: fmtArea(l.lot_sqft)!, l: "Terrain" });34 if (l.year_built != null) facts.push({ ico: <Ico name="calendar" size={17} />, v: String(l.year_built), l: "Année de construction" });35 const park = d["Stationnement (total)"] ?? d["Stationnement"];36 if (park != null && String(park).trim()) facts.push({ ico: <Ico name="car" size={17} />, v: String(park), l: "Stationnement" });37 const pieces = d["Nb de pièces"];38 if (pieces != null && String(pieces).trim()) facts.push({ ico: <Ico name="layers" size={17} />, v: String(pieces), l: "Pièces" });39 if (l.mls) facts.push({ ico: <Ico name="tag" size={17} />, v: l.mls, l: "Nº Centris / MLS" });4041 const enLigne = l.days_on_market ?? (l.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null);42 const rows: [string, ReactNode][] = [["Agence / source", sourceName(l.source)]];43 if (l.agency && l.agency !== sourceName(l.source)) rows.push(["Bannière", l.agency]);44 if (l.broker_name) rows.push(["Courtier inscripteur", l.broker_name]);45 if (l.broker_phone) rows.push(["Téléphone", <a href={`tel:${l.broker_phone.replace(/\s/g, "")}`}>{l.broker_phone}</a>]);46 if (l.price_label && l.price != null && l.price_label.replace(/\s/g, "") !== fmtPrice(l.price).replace(/\s/g, ""))47 rows.push(["Prix affiché par la source", l.price_label]);48 if (enLigne != null) rows.push(["En ligne sur Immo-Ka", enLigne === 0 ? "depuis aujourd'hui" : `depuis ${enLigne}${NBSP}jour${enLigne > 1 ? "s" : ""}`]);49 const maj = relTime(l.updated_at);50 if (maj) rows.push(["Synchronisé", maj]);5152 const detEntries = Object.entries(d)53 .filter(([k, v]) => !DETAIL_HIDDEN.has(k) && !IN_GRID.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim());54 const rooms: Room[] = Array.isArray(d.pieces) ? (d.pieces as Room[]) : [];5556 if (facts.length === 0 && rows.length <= 1 && detEntries.length === 0 && rooms.length === 0) return null;5758 return (59 <SectionCard id="propriete" title="La propriété">60 {facts.length > 0 && (61 <div className="ik-facts" role="list">62 {facts.map((x) => (63 <div className="ik-fact" role="listitem" key={x.l + x.v}>64 <span className="ik-fact-ico" aria-hidden="true">{x.ico}</span>65 <span className="ik-fact-txt">66 <div className="ik-fact-v" title={x.v}>{x.v}</div>67 <div className="ik-fact-l">{x.l}</div>68 </span>69 </div>70 ))}71 </div>72 )}73 <div className="ik-facts-rows">74 {rows.map(([k, v]) => (75 <div className="ik-kv" key={k}><span className="k">{k}</span><span className="v">{v}</span></div>76 ))}77 </div>78 {detEntries.length > 0 && (79 <Accordion title="Toutes les caractéristiques publiées" meta={`${detEntries.length}`}>80 <div className="ik-facts-rows" style={{ marginTop: 0 }}>81 {detEntries.map(([k, v]) => (82 <div className="ik-kv" key={k}>83 <span className="k">{k}</span>84 {/^https?:\/\//.test(String(v))85 ? <span className="v"><a href={String(v)} target="_blank" rel="noopener noreferrer">Ouvrir ↗</a></span>86 : <span className="v">{String(v)}</span>}87 </div>88 ))}89 </div>90 </Accordion>91 )}92 {rooms.length > 0 && (93 <Accordion title="Pièces et dimensions" meta={`${rooms.length} pièce${rooms.length > 1 ? "s" : ""}`}>94 <div className="ik-table-wrap">95 <table className="ik-table">96 <thead><tr><th>Pièce</th><th>Niveau</th><th>Dimensions</th><th>Revêtement</th></tr></thead>97 <tbody>98 {rooms.map((r, i) => (99 <tr key={i}>100 <td>{r.nom || "—"}</td><td>{r.niveau || "—"}</td>101 <td>{r.dimensions || "—"}</td><td>{r.revetement || "—"}</td>102 </tr>103 ))}104 </tbody>105 </table>106 </div>107 </Accordion>108 )}109 </SectionCard>110 );111}112