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/PropertyDossier.tsx : « Dossier de l'annonce » — ce qu'Immo-Ka observe5// réellement, en accordéons compacts : suivi Immo-Ka (première observation,6// jours en ligne, variations de prix), autres publications de la même7// propriété (doublons rattachés par la déduplication), courtier et agence,8// identifiants de la source. Chaque volet a un état vide propre.9// -----------------------------------------------------------------------------10import { Listing, fmtDate, fmtPrice, sourceName } from "../api";11import { Ico } from "../components/Icons";12import { Accordion, SectionCard, StatTile, StatusBadge, NBSP } from "./ui";1314function Vide({ children }: { children: React.ReactNode }) { return <p style={{ color: "var(--ik-muted)", margin: 0 }}>{children}</p>; }1516export default function PropertyDossier({ l }: { l: Listing }) {17 const hist = (l.price_history ?? []).filter((h) => h.price != null);18 const jours = l.days_on_market ?? (l.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null);19 const variation = hist.length >= 2 ? (hist[0].price! - hist[hist.length - 1].price!) / hist[hist.length - 1].price! : null;20 const dups = l.duplicates ?? [];21 const d = l.details ?? {};22 const listedAt = typeof d.listed_at === "string" ? d.listed_at : null;23 const origin = typeof d.listing_origin_url === "string" ? d.listing_origin_url : null;2425 return (26 <SectionCard id="dossier" title="Dossier de l'annonce" icon={<Ico name="folder" size={18} />}27 sub="Ce qu'Immo-Ka observe réellement : suivi, publications, courtier, source">28 <Accordion title={<><Ico name="history" size={15} className="ik-acc-ico" />Suivi Immo-Ka</>}29 meta={jours != null ? (jours === 0 ? "publiée aujourd'hui" : `${jours}${NBSP}j en ligne`) : undefined}>30 <div className="ik-kpis cols-3" style={{ marginBottom: 10 }}>31 <StatTile value={l.first_seen ? fmtDate(l.first_seen) : "—"} label="Première observation" anim={false} />32 <StatTile value={jours != null ? (jours === 0 ? "Aujourd'hui" : `${jours}${NBSP}j`) : "—"} label="Sur le marché (observé)" anim={false} />33 <StatTile value={Math.max(0, hist.length - 1)} label="Changement(s) de prix" anim={false} />34 </div>35 {variation != null && variation !== 0 && (36 <p style={{ margin: "0 0 8px" }}>37 Prix initial observé {fmtPrice(hist[hist.length - 1].price!)} → <b>{fmtPrice(hist[0].price!)}</b>{" "}38 <StatusBadge tone={variation < 0 ? "good" : "warn"}>{variation > 0 ? "+" : "−"}{Math.abs(Math.round(variation * 100))}{NBSP}%</StatusBadge>39 </p>40 )}41 {listedAt && <p style={{ margin: "0 0 8px" }}>Date d'inscription indiquée par la source : {listedAt}.</p>}42 <p className="ik-fine">Première observation, jours en ligne et variations sont mesurés par les synchronisations Immo-Ka43 (plusieurs fois par jour) — ils ne remplacent pas la date d'inscription officielle Centris.</p>44 </Accordion>4546 <Accordion title={<><Ico name="layers" size={15} className="ik-acc-ico" />Aussi publiée sur</>}47 meta={dups.length ? `${dups.length} plateforme${dups.length > 1 ? "s" : ""}` : "aucune"}>48 {dups.length === 0 ? (49 <Vide>Aucune autre publication de cette propriété repérée parmi les sources Immo-Ka.</Vide>50 ) : (51 <>52 <p style={{ margin: "0 0 8px" }}>53 Cette propriété a été repérée sur {dups.length} autre{dups.length > 1 ? "s" : ""} plateforme{dups.length > 1 ? "s" : ""} —54 Immo-Ka affiche la version la plus complète.55 </p>56 <ul className="ik-list">57 {dups.map((x) => (58 <li className="ik-item" key={x.uid}>59 <span className="ik-item-ico" aria-hidden="true"><Ico name="building" size={17} /></span>60 <div className="ik-item-main">61 <div className="ik-item-t">{sourceName(x.source)}</div>62 <div className="ik-item-s">{x.broker_name || x.agency || x.price_label || ""}</div>63 </div>64 <div className="ik-item-r">65 <a className="ik-link" href={x.url} target="_blank" rel="noopener noreferrer">Voir ↗</a>66 </div>67 </li>68 ))}69 </ul>70 </>71 )}72 </Accordion>7374 <Accordion title={<><Ico name="people" size={15} className="ik-acc-ico" />Courtier et agence</>}75 meta={l.broker_name || sourceName(l.source)}>76 <p style={{ margin: "0 0 6px" }}>77 <b style={{ fontSize: 15 }}>{l.broker_name || "Courtier non précisé"}</b>78 {l.agency ? <> — {l.agency}</> : null}79 </p>80 <p style={{ margin: "0 0 6px" }}>Annonce publiée par <b>{sourceName(l.source)}</b>81 {l.broker_phone && <> · <a href={`tel:${l.broker_phone.replace(/\s/g, "")}`}>{l.broker_phone}</a></>}.82 </p>83 <p className="ik-fine">Immo-Ka est un agrégateur indépendant : la relation contractuelle se fait avec le courtier ou l'agence84 de l'annonce originale. Consultez le registre de l'OACIQ pour vérifier un permis de courtier.</p>85 </Accordion>8687 <Accordion title={<><Ico name="tag" size={15} className="ik-acc-ico" />Identifiants et source</>} meta={l.mls || l.external_id}>88 <div className="ik-facts-rows" style={{ marginTop: 0 }}>89 {l.mls && <div className="ik-kv"><span className="k">Nº Centris / MLS</span><span className="v">{l.mls}</span></div>}90 <div className="ik-kv"><span className="k">Identifiant source</span><span className="v">{l.external_id}</span></div>91 <div className="ik-kv"><span className="k">Identifiant Immo-Ka</span><span className="v">{l.uid}</span></div>92 <div className="ik-kv"><span className="k">Annonce originale</span><span className="v"><a href={l.url} target="_blank" rel="noopener noreferrer">{sourceName(l.source)} ↗</a></span></div>93 {origin && origin !== l.url && <div className="ik-kv"><span className="k">Fiche d'origine</span><span className="v"><a href={origin} target="_blank" rel="noopener noreferrer">Ouvrir ↗</a></span></div>}94 </div>95 </Accordion>96 </SectionCard>97 );98}99