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/NearbyPlaces.tsx : « À proximité » et « Transport » — carrousels de5// cartes horizontales (transport · courses · services) construits à partir6// des POI OpenStreetMap (immoka/poi.py) et des grandes bannières (Mapbox),7// les 4 lieux les plus pertinents en liste, puis « Voir les N lieux »8// → bottom sheet groupé par catégorie.9// -----------------------------------------------------------------------------10import { ReactNode, useState } from "react";11import { CommerceItem, CommercesNearby, Poi, fmtDist } from "../api";12import { Ico } from "../components/Icons";13import BottomSheet from "./BottomSheet";14import { ErrorState, MoreButton, SectionCard, SkeletonLines, SourceLine, fmtMarche, NBSP } from "./ui";15import { Res } from "./useFicheData";1617const POI_META: Record<string, { ico: ReactNode; label: string }> = {18 epicerie: { ico: <Ico name="cart" size={17} />, label: "Épicerie" },19 depanneur: { ico: <Ico name="store" size={17} />, label: "Dépanneur" },20 pharmacie: { ico: <Ico name="pill" size={17} />, label: "Pharmacie" },21 ecole: { ico: <Ico name="school" size={17} />, label: "École" },22 garderie: { ico: <Ico name="baby" size={17} />, label: "Garderie" },23 parc: { ico: <Ico name="tree" size={17} />, label: "Parc" },24 bus: { ico: <Ico name="bus" size={17} />, label: "Arrêt de bus" },25 metro: { ico: <Ico name="train" size={17} />, label: "Métro" },26 gym: { ico: <Ico name="dumbbell" size={17} />, label: "Gym" },27 cafe: { ico: <Ico name="coffee" size={17} />, label: "Café" },28 clinique: { ico: <Ico name="health" size={17} />, label: "Clinique" },29 hopital: { ico: <Ico name="health" size={17} />, label: "Hôpital" },30 bibliotheque: { ico: <Ico name="book" size={17} />, label: "Bibliothèque" },31};32const GROUPES: { titre: string; cats: string[] }[] = [33 { titre: "Transport", cats: ["metro", "bus"] },34 { titre: "Courses", cats: ["epicerie", "depanneur", "pharmacie"] },35 { titre: "Études et famille", cats: ["ecole", "garderie", "bibliotheque"] },36 { titre: "Santé", cats: ["clinique", "hopital"] },37 { titre: "Vie de quartier", cats: ["cafe", "parc", "gym"] },38];39const BANNIERE_CAT: Record<string, string> = {40 metro_station: "Station de métro", rem_station: "Station REM", arret_bus: "Arrêt de bus", gare_train: "Gare de train",41 costco: "Épicerie · entrepôt", walmart: "Grande surface", metro: "Épicerie", iga: "Épicerie", maxi: "Épicerie",42 superc: "Épicerie", provigo: "Épicerie", canadiantire: "Quincaillerie", dollarama: "Magasin à 1 $", saq: "Alcools",43 pharmaprix: "Pharmacie", jeancoutu: "Pharmacie", homedepot: "Rénovation", rona: "Rénovation",44};45const catDe = (c: CommerceItem) => BANNIERE_CAT[c.id] ?? c.commerce;46const BANNIERES: Record<string, [string, string, string?]> = {47 metro_station: ["#0083C9", "M"], rem_station: ["#84BD00", "R"], arret_bus: ["#4E5357", "B"], gare_train: ["#6E5B3F", "T"],48 costco: ["#005DAA", "C"], walmart: ["#0071CE", "W"], metro: ["#EF3E42", "M"], iga: ["#D50032", "IGA"],49 maxi: ["#0079C1", "Mx"], superc: ["#E4002B", "SC"], provigo: ["#DA291C", "P"], canadiantire: ["#D6001C", "CT"],50 dollarama: ["#00B140", "D", "#FFDD00"], saq: ["#892034", "SAQ"], pharmaprix: ["#E11B22", "Ph"],51 jeancoutu: ["#003DA5", "JC"], homedepot: ["#F96302", "HD"], rona: ["#1B4298", "R"],52};5354export function Pastille({ id }: { id: string }) {55 const [bg, mono, fg] = BANNIERES[id] ?? ["#777", "•"];56 const fs = mono.length >= 3 ? 9 : mono.length === 2 ? 11 : 14;57 const rond = ["metro_station", "rem_station", "arret_bus", "gare_train"].includes(id);58 return (59 <svg className="cm-ico" viewBox="0 0 28 28" width="26" height="26" aria-hidden="true">60 {rond ? <circle cx="14" cy="14" r="13" fill={bg} /> : <rect x="1" y="1" width="26" height="26" rx="7" fill={bg} />}61 <text x="14" y="14" textAnchor="middle" dominantBaseline="central" fontSize={fs} fontWeight="800" fontFamily="inherit" fill={fg ?? "#fff"}>{mono}</text>62 </svg>63 );64}6566interface Carte { key: string; cat: string; nom: string; sous?: string; dist: number; ico: ReactNode; }6768function CCard({ c }: { c: Carte }) {69 return (70 <div className="ik-ccard" role="listitem">71 <div className="ik-ccard-top">72 <span className="ik-ccard-c">{c.cat}</span>73 <span aria-hidden="true">{c.ico}</span>74 </div>75 <div className="ik-ccard-d">{fmtDist(c.dist)}</div>76 <div className="ik-ccard-n" title={c.nom}>{c.nom}</div>77 <div className="ik-ccard-m">≈{NBSP}{fmtMarche(c.dist)} à pied{c.sous ? ` · ${c.sous}` : ""}</div>78 </div>79 );80}8182export default function NearbyPlaces({ pois, commerces, onRetry }: { pois: Poi[]; commerces: Res<CommercesNearby>; onRetry: () => void }) {83 const [sheet, setSheet] = useState(false);84 const cm = commerces.status === "ok" ? commerces.data : null;85 const transit: CommerceItem[] = cm?.transit ?? [];86 const bannieres: CommerceItem[] = cm?.commerces ?? [];8788 const transport: Carte[] = [89 ...transit.map((t) => ({ key: `t-${t.id}`, cat: catDe(t), nom: t.nom, dist: t.dist_m, ico: <Pastille id={t.id} /> })),90 ...pois.filter((p) => (p.cat === "metro" || p.cat === "bus") && !transit.some((t) => t.dist_m === p.dist_m))91 .map((p) => ({ key: `p-${p.cat}`, cat: POI_META[p.cat].label, nom: p.name, dist: p.dist_m, ico: POI_META[p.cat].ico })),92 ].sort((a, b) => a.dist - b.dist);93 const services: Carte[] = [94 ...bannieres.map((b) => ({ key: `b-${b.id}`, cat: catDe(b), nom: b.nom, dist: b.dist_m, ico: <Pastille id={b.id} /> })),95 ...pois.filter((p) => p.cat !== "metro" && p.cat !== "bus")96 .map((p) => ({ key: `p-${p.cat}`, cat: POI_META[p.cat]?.label ?? p.cat, nom: p.name, dist: p.dist_m, ico: POI_META[p.cat]?.ico ?? <Ico name="pin" size={17} /> })),97 ].sort((a, b) => a.dist - b.dist);98 const total = transport.length + services.length;99 const loading = commerces.status === "loading" || commerces.status === "idle";100101 if (total === 0 && !loading && commerces.status !== "error") return null;102103 // « les plus pertinents » : métro/épicerie/pharmacie/école/parc les plus proches104 const prio = ["Station de métro", "Métro", "Épicerie", "École", "Pharmacie", "Parc", "Arrêt de bus"];105 const top = [...transport, ...services]106 .sort((a, b) => (prio.findIndex((p) => a.cat.startsWith(p)) + 1 || 99) - (prio.findIndex((p) => b.cat.startsWith(p)) + 1 || 99) || a.dist - b.dist)107 .filter((c, i, arr) => arr.findIndex((x) => x.cat === c.cat) === i)108 .slice(0, 4);109110 return (111 <>112 <SectionCard id="proximite" title="À proximité" icon={<Ico name="pin" size={18} />}113 sub={total ? `${total} lieux repérés · temps de marche estimés` : undefined}>114 {loading && total === 0 && <SkeletonLines n={3} />}115 {commerces.status === "error" && <ErrorState onRetry={onRetry}>Commerces et transport temporairement indisponibles.</ErrorState>}116 {top.length > 0 && (117 <ul className="ik-list">118 {top.map((c) => (119 <li className="ik-item" key={c.key}>120 <span className="ik-item-ico" aria-hidden="true">{c.ico}</span>121 <div className="ik-item-main">122 <div className="ik-item-t">{c.nom}</div>123 <div className="ik-item-s">{c.cat}</div>124 </div>125 <div className="ik-item-r">126 <div className="ik-item-v">{fmtDist(c.dist)}</div>127 <div className="ik-item-m">≈{NBSP}{fmtMarche(c.dist)}</div>128 </div>129 </li>130 ))}131 </ul>132 )}133 {services.length > 0 && (134 <>135 <h3 className="ik-card-sub ik-subtitle">Courses et services</h3>136 <div className="ik-carousel" role="list" aria-label="Courses et services à proximité">137 {services.slice(0, 12).map((c) => <CCard c={c} key={c.key} />)}138 </div>139 </>140 )}141 {total > 4 && <MoreButton onClick={() => setSheet(true)}>Voir les {total} lieux à proximité</MoreButton>}142 <SourceLine name="OpenStreetMap · Mapbox Search"143 date={`distances à vol d'oiseau, marche ≈ distance × 1,3 à 4,8${NBSP}km/h`} />144 </SectionCard>145146 {transport.length > 0 && (147 <SectionCard id="transport" title="Transport" icon={<Ico name="train" size={18} />}148 sub="Stations et arrêts les plus proches">149 <div className="ik-carousel" role="list" aria-label="Transport en commun à proximité">150 {transport.slice(0, 10).map((c) => <CCard c={c} key={c.key} />)}151 </div>152 </SectionCard>153 )}154155 <BottomSheet open={sheet} onClose={() => setSheet(false)} title="Lieux à proximité" sub={`${total} lieux · distances à vol d'oiseau`} tall>156 {GROUPES.map((g) => {157 const items = [158 ...pois.filter((p) => g.cats.includes(p.cat)).map((p) => ({ key: `p-${p.cat}`, cat: POI_META[p.cat]?.label ?? p.cat, nom: p.name, dist: p.dist_m, ico: POI_META[p.cat]?.ico })),159 ...(g.titre === "Transport" ? transit.map((t) => ({ key: `t-${t.id}`, cat: catDe(t), nom: t.nom, dist: t.dist_m, ico: <Pastille id={t.id} /> })) : []),160 ...(g.titre === "Courses" ? bannieres.map((b) => ({ key: `b-${b.id}`, cat: catDe(b), nom: b.nom, dist: b.dist_m, ico: <Pastille id={b.id} /> })) : []),161 ].sort((a, b) => a.dist - b.dist);162 if (items.length === 0) return null;163 return (164 <div key={g.titre} style={{ marginBottom: 14 }}>165 <h4 className="ik-card-sub" style={{ fontWeight: 700, color: "var(--ik-text)", margin: "0 0 4px" }}>{g.titre} <small style={{ fontWeight: 500 }}>· {items.length}</small></h4>166 <ul className="ik-list">167 {items.map((c) => (168 <li className="ik-item" key={c.key + c.dist}>169 <span className="ik-item-ico" aria-hidden="true">{c.ico}</span>170 <div className="ik-item-main"><div className="ik-item-t">{c.nom}</div><div className="ik-item-s">{c.cat}</div></div>171 <div className="ik-item-r"><div className="ik-item-v">{fmtDist(c.dist)}</div><div className="ik-item-m">≈{NBSP}{fmtMarche(c.dist)}</div></div>172 </li>173 ))}174 </ul>175 </div>176 );177 })}178 </BottomSheet>179 </>180 );181}182