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%
10.4 KB · 182 lines tsx
Raw Blame History
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