Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (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 (louka/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 {13 IcoBaby, IcoBook, IcoBus, IcoCart, IcoCoffee, IcoDumbbell, IcoHospital, IcoMapPin, IcoPill,14 IcoSchool, IcoStore, IcoTrain, IcoTrees,15} from "../components/Icons";16import BottomSheet from "./BottomSheet";17import { ErrorState, MoreButton, SectionCard, SkeletonLines, SourceLine, fmtMarche, NBSP } from "./ui";18import { Res } from "./useFicheData";1920const POI_META: Record<string, { ico: ReactNode; label: string }> = {21 epicerie: { ico: <IcoCart size={17} />, label: "Épicerie" },22 depanneur: { ico: <IcoStore size={17} />, label: "Dépanneur" },23 pharmacie: { ico: <IcoPill size={17} />, label: "Pharmacie" },24 ecole: { ico: <IcoSchool size={17} />, label: "École" },25 garderie: { ico: <IcoBaby size={17} />, label: "Garderie" },26 parc: { ico: <IcoTrees size={17} />, label: "Parc" },27 bus: { ico: <IcoBus size={17} />, label: "Arrêt de bus" },28 metro: { ico: <IcoTrain size={17} />, label: "Métro" },29 gym: { ico: <IcoDumbbell size={17} />, label: "Gym" },30 cafe: { ico: <IcoCoffee size={17} />, label: "Café" },31 clinique: { ico: <IcoHospital size={17} />, label: "Clinique" },32 hopital: { ico: <IcoHospital size={17} />, label: "Hôpital" },33 bibliotheque: { ico: <IcoBook size={17} />, label: "Bibliothèque" },34};35const GROUPES: { titre: string; cats: string[] }[] = [36 { titre: "Transport", cats: ["metro", "bus"] },37 { titre: "Courses", cats: ["epicerie", "depanneur", "pharmacie"] },38 { titre: "Études et famille", cats: ["ecole", "garderie", "bibliotheque"] },39 { titre: "Santé", cats: ["clinique", "hopital"] },40 { titre: "Vie de quartier", cats: ["cafe", "parc", "gym"] },41];42// bannières : [couleur, monogramme, texte]43const BANNIERE_CAT: Record<string, string> = {44 metro_station: "Station de métro", rem_station: "Station REM", arret_bus: "Arrêt de bus", gare_train: "Gare de train",45 costco: "Épicerie · entrepôt", walmart: "Grande surface", metro: "Épicerie", iga: "Épicerie", maxi: "Épicerie",46 superc: "Épicerie", provigo: "Épicerie", canadiantire: "Quincaillerie", dollarama: "Magasin à 1 $", saq: "Alcools",47 pharmaprix: "Pharmacie", jeancoutu: "Pharmacie", homedepot: "Rénovation", rona: "Rénovation",48};49const catDe = (c: CommerceItem) => BANNIERE_CAT[c.id] ?? c.commerce;50const BANNIERES: Record<string, [string, string, string?]> = {51 metro_station: ["#0083C9", "M"], rem_station: ["#84BD00", "R"], arret_bus: ["#4E5357", "B"], gare_train: ["#6E5B3F", "T"],52 costco: ["#005DAA", "C"], walmart: ["#0071CE", "W"], metro: ["#EF3E42", "M"], iga: ["#D50032", "IGA"],53 maxi: ["#0079C1", "Mx"], superc: ["#E4002B", "SC"], provigo: ["#DA291C", "P"], canadiantire: ["#D6001C", "CT"],54 dollarama: ["#00B140", "D", "#FFDD00"], saq: ["#892034", "SAQ"], pharmaprix: ["#E11B22", "Ph"],55 jeancoutu: ["#003DA5", "JC"], homedepot: ["#F96302", "HD"], rona: ["#1B4298", "R"],56};5758export function Pastille({ id }: { id: string }) {59 const [bg, mono, fg] = BANNIERES[id] ?? ["#777", "•"];60 const fs = mono.length >= 3 ? 9 : mono.length === 2 ? 11 : 14;61 const rond = ["metro_station", "rem_station", "arret_bus", "gare_train"].includes(id);62 return (63 <svg className="cm-ico" viewBox="0 0 28 28" width="26" height="26" aria-hidden="true">64 {rond ? <circle cx="14" cy="14" r="13" fill={bg} /> : <rect x="1" y="1" width="26" height="26" rx="7" fill={bg} />}65 <text x="14" y="14" textAnchor="middle" dominantBaseline="central" fontSize={fs} fontWeight="800" fontFamily="inherit" fill={fg ?? "#fff"}>{mono}</text>66 </svg>67 );68}6970interface Carte { key: string; cat: string; nom: string; sous?: string; dist: number; ico: ReactNode; }7172function CCard({ c }: { c: Carte }) {73 return (74 <div className="lk-ccard" role="listitem">75 <div className="lk-ccard-top">76 <span className="lk-ccard-c">{c.cat}</span>77 <span aria-hidden="true">{c.ico}</span>78 </div>79 <div className="lk-ccard-d">{fmtDist(c.dist)}</div>80 <div className="lk-ccard-n" title={c.nom}>{c.nom}</div>81 <div className="lk-ccard-m">≈{NBSP}{fmtMarche(c.dist)} à pied{c.sous ? ` · ${c.sous}` : ""}</div>82 </div>83 );84}8586export default function NearbyPlaces({ pois, commerces, onRetry }: { pois: Poi[]; commerces: Res<CommercesNearby>; onRetry: () => void }) {87 const [sheet, setSheet] = useState(false);88 const cm = commerces.status === "ok" ? commerces.data : null;89 const transit: CommerceItem[] = cm?.transit ?? [];90 const bannieres: CommerceItem[] = cm?.commerces ?? [];9192 // cartes « Transport » : métro/bus/REM/train (bannières transit) + POI bus/métro93 const transport: Carte[] = [94 ...transit.map((t) => ({ key: `t-${t.id}`, cat: catDe(t), nom: t.nom, dist: t.dist_m, ico: <Pastille id={t.id} /> })),95 ...pois.filter((p) => (p.cat === "metro" || p.cat === "bus") && !transit.some((t) => t.dist_m === p.dist_m))96 .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 })),97 ].sort((a, b) => a.dist - b.dist);98 // cartes « Courses et services » : bannières + POI hors transport99 const services: Carte[] = [100 ...bannieres.map((b) => ({ key: `b-${b.id}`, cat: catDe(b), nom: b.nom, dist: b.dist_m, ico: <Pastille id={b.id} /> })),101 ...pois.filter((p) => p.cat !== "metro" && p.cat !== "bus")102 .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 ?? <IcoMapPin size={17} /> })),103 ].sort((a, b) => a.dist - b.dist);104 const total = transport.length + services.length;105 const loading = commerces.status === "loading" || commerces.status === "idle";106107 if (total === 0 && !loading && commerces.status !== "error") return null;108109 // « les plus pertinents » : métro/épicerie/pharmacie/parc/école les plus proches110 const prio = ["Station de métro", "Métro", "Épicerie", "Pharmacie", "Parc", "École", "Arrêt de bus"];111 const top = [...transport, ...services]112 .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)113 .filter((c, i, arr) => arr.findIndex((x) => x.cat === c.cat) === i)114 .slice(0, 4);115116 return (117 <>118 <SectionCard id="proximite" title="À proximité" icon={<IcoMapPin size={18} />}119 sub={total ? `${total} lieux repérés · temps de marche estimés` : undefined}>120 {loading && total === 0 && <SkeletonLines n={3} />}121 {commerces.status === "error" && <ErrorState onRetry={onRetry}>Commerces et transport temporairement indisponibles.</ErrorState>}122 {top.length > 0 && (123 <ul className="lk-list">124 {top.map((c) => (125 <li className="lk-item" key={c.key}>126 <span className="lk-item-ico" aria-hidden="true">{c.ico}</span>127 <div className="lk-item-main">128 <div className="lk-item-t">{c.nom}</div>129 <div className="lk-item-s">{c.cat}</div>130 </div>131 <div className="lk-item-r">132 <div className="lk-item-v">{fmtDist(c.dist)}</div>133 <div className="lk-item-m">≈{NBSP}{fmtMarche(c.dist)}</div>134 </div>135 </li>136 ))}137 </ul>138 )}139 {services.length > 0 && (140 <>141 <h3 className="lk-card-sub" style={{ margin: "14px 0 8px", fontWeight: 600, color: "var(--lk-text-2)" }}>Courses et services</h3>142 <div className="lk-carousel" role="list" aria-label="Courses et services à proximité">143 {services.slice(0, 12).map((c) => <CCard c={c} key={c.key} />)}144 </div>145 </>146 )}147 {total > 4 && <MoreButton onClick={() => setSheet(true)}>Voir les {total} lieux à proximité</MoreButton>}148 <SourceLine name="OpenStreetMap · Mapbox Search"149 date={`distances à vol d'oiseau, marche ≈ distance × 1,3 à 4,8${NBSP}km/h`} />150 </SectionCard>151152 {transport.length > 0 && (153 <SectionCard id="transport" title="Transport" icon={<IcoTrain size={18} />}154 sub="Stations et arrêts les plus proches">155 <div className="lk-carousel" role="list" aria-label="Transport en commun à proximité">156 {transport.slice(0, 10).map((c) => <CCard c={c} key={c.key} />)}157 </div>158 </SectionCard>159 )}160161 <BottomSheet open={sheet} onClose={() => setSheet(false)} title="Lieux à proximité" sub={`${total} lieux · distances à vol d'oiseau`} tall>162 {GROUPES.map((g) => {163 const items = [164 ...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 })),165 ...(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} /> })) : []),166 ...(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} /> })) : []),167 ].sort((a, b) => a.dist - b.dist);168 if (items.length === 0) return null;169 return (170 <div key={g.titre} style={{ marginBottom: 14 }}>171 <h4 className="lk-card-sub" style={{ fontWeight: 700, color: "var(--lk-text)", margin: "0 0 4px" }}>{g.titre} <small style={{ fontWeight: 500 }}>· {items.length}</small></h4>172 <ul className="lk-list">173 {items.map((c) => (174 <li className="lk-item" key={c.key + c.dist}>175 <span className="lk-item-ico" aria-hidden="true">{c.ico}</span>176 <div className="lk-item-main"><div className="lk-item-t">{c.nom}</div><div className="lk-item-s">{c.cat}</div></div>177 <div className="lk-item-r"><div className="lk-item-v">{fmtDist(c.dist)}</div><div className="lk-item-m">≈{NBSP}{fmtMarche(c.dist)}</div></div>178 </li>179 ))}180 </ul>181 </div>182 );183 })}184 </BottomSheet>185 </>186 );187}188