| 1 |
1 |
// ----------------------------------------------------------------------------- |
| 2 |
2 |
// Lou-Ka — Agrégateur de logements à louer (province de Québec) |
| 3 |
3 |
// Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 |
|
−// pages/Listing.tsx : fiche d'un logement — galerie complète + détails |
|
4 |
+// pages/Listing.tsx : fiche d'un logement — refonte mobile-first |
|
5 |
+// Ordre mobile : galerie → prix + badge marché → chips clés → ancres → |
|
6 |
+// description restructurée → inclusions → détails pratiques → quartier → |
|
7 |
+// à proximité → pied de fiche. CTA source sticky en bas d'écran (mobile). |
|
8 |
+// Desktop : deux colonnes (logement à gauche, quartier/synthèse à droite) |
|
9 |
+// via wrappers `display:contents` + `order` (voir styles.css « fiche v2 »). |
| 5 |
10 |
// ----------------------------------------------------------------------------- |
| 6 |
|
−import { useEffect, useState } from "react"; |
|
11 |
+import { useEffect, useRef, useState } from "react"; |
| 7 |
12 |
import { Link, useParams } from "react-router-dom"; |
| 8 |
13 |
import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api"; |
| 9 |
14 |
import QuartierBlock from "../components/QuartierBlock"; |
| 25 |
30 |
bibliotheque: { icon: "📚", label: "Bibliothèque" }, |
| 26 |
31 |
}; |
| 27 |
32 |
|
|
33 |
+// Regroupement des POI en catégories repliables |
|
34 |
+const POI_GROUPES: { titre: string; icone: string; cats: string[] }[] = [ |
|
35 |
+ { titre: "Courses", icone: "🛒", cats: ["epicerie", "depanneur"] }, |
|
36 |
+ { titre: "Transport", icone: "🚌", cats: ["bus", "metro"] }, |
|
37 |
+ { titre: "Études et famille", icone: "🎓", cats: ["ecole", "garderie", "bibliotheque"] }, |
|
38 |
+ { titre: "Santé", icone: "🏥", cats: ["pharmacie", "clinique", "hopital"] }, |
|
39 |
+ { titre: "Vie de quartier", icone: "☕", cats: ["cafe", "parc", "gym"] }, |
|
40 |
+]; |
|
41 |
+ |
|
42 |
+// Badge « prix vs marché » — seuils configurables |
|
43 |
+const SEUILS_MARCHE = { bonDeal: -0.15, dansLeMarche: 0.10 }; |
|
44 |
+ |
| 28 |
45 |
const PETS_LABEL: Record<string, string> = { |
| 29 |
|
− oui: "Acceptés", non: "Refusés", conditions: "Sous conditions", |
|
46 |
+ oui: "Animaux acceptés", non: "Animaux refusés", conditions: "Animaux sous conditions", |
| 30 |
47 |
}; |
| 31 |
48 |
|
| 32 |
|
−/** Badges dérivés des détails structurés (inclusions, immeuble, stationnement). */ |
| 33 |
|
−function detailBadges(l: Listing): string[] { |
|
49 |
+const NBSP = " "; |
|
50 |
+ |
|
51 |
+/** ≈ minutes de marche (vol d'oiseau × facteur de détour 1,3, 4,8 km/h) */ |
|
52 |
+const fmtMarche = (m: number): string => |
|
53 |
+ `≈${NBSP}${Math.max(1, Math.round((m * 1.3) / 80))}${NBSP}min à pied`; |
|
54 |
+ |
|
55 |
+function badgeMarche(price: number | null | undefined, |
|
56 |
+ loyerSecteur: number | null | undefined) { |
|
57 |
+ if (price == null || loyerSecteur == null || loyerSecteur <= 0) return null; |
|
58 |
+ const delta = (price - loyerSecteur) / loyerSecteur; |
|
59 |
+ const pct = `${delta > 0 ? "+" : "−"}${Math.abs(Math.round(delta * 100))}${NBSP}%`; |
|
60 |
+ if (delta <= SEUILS_MARCHE.bonDeal) |
|
61 |
+ return { cls: "deal-good", txt: `${pct} vs le secteur · Bon deal 🔥` }; |
|
62 |
+ if (delta <= SEUILS_MARCHE.dansLeMarche) |
|
63 |
+ return { cls: "deal-ok", txt: `${pct} vs le secteur · Dans le marché` }; |
|
64 |
+ return { cls: "deal-high", txt: `${pct} vs le secteur · Au-dessus du marché` }; |
|
65 |
+} |
|
66 |
+ |
|
67 |
+/** Badges dérivés des détails structurés (inclusions confirmées ✓). */ |
|
68 |
+function badgesConfirmes(l: Listing): string[] { |
| 34 |
69 |
const d = l.details ?? {}; |
| 35 |
70 |
const out: string[] = []; |
| 36 |
71 |
const inc = d.inclusions ?? {}; |
| 56 |
91 |
return out; |
| 57 |
92 |
} |
| 58 |
93 |
|
|
94 |
+// --- Galerie avec balayage natif (scroll-snap) + compteur + plein écran ----- |
|
95 |
+function Galerie({ images, titre }: { images: string[]; titre: string }) { |
|
96 |
+ const [idx, setIdx] = useState(0); |
|
97 |
+ const [zoom, setZoom] = useState(false); |
|
98 |
+ const track = useRef<HTMLDivElement>(null); |
|
99 |
+ |
|
100 |
+ const onScroll = () => { |
|
101 |
+ const el = track.current; |
|
102 |
+ if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)); |
|
103 |
+ }; |
|
104 |
+ const goto = (i: number) => |
|
105 |
+ track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" }); |
|
106 |
+ |
|
107 |
+ if (images.length === 0) |
|
108 |
+ return <div className="carousel"><div className="noimg carousel-empty">🏠</div></div>; |
|
109 |
+ |
|
110 |
+ return ( |
|
111 |
+ <> |
|
112 |
+ <div className="carousel"> |
|
113 |
+ <div className="carousel-track" ref={track} onScroll={onScroll}> |
|
114 |
+ {images.map((u, i) => ( |
|
115 |
+ <img |
|
116 |
+ key={u} src={u} loading={i === 0 ? "eager" : "lazy"} |
|
117 |
+ alt={`${titre} — photo ${i + 1} de ${images.length}`} |
|
118 |
+ onClick={() => setZoom(true)} |
|
119 |
+ /> |
|
120 |
+ ))} |
|
121 |
+ </div> |
|
122 |
+ <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span> |
|
123 |
+ {idx > 0 && ( |
|
124 |
+ <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}>‹</button> |
|
125 |
+ )} |
|
126 |
+ {idx < images.length - 1 && ( |
|
127 |
+ <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}>›</button> |
|
128 |
+ )} |
|
129 |
+ </div> |
|
130 |
+ {images.length > 1 && ( |
|
131 |
+ <div className="thumbs"> |
|
132 |
+ {images.map((u, i) => ( |
|
133 |
+ <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)} |
|
134 |
+ aria-label={`Photo ${i + 1}`}> |
|
135 |
+ <img src={u} alt="" loading="lazy" /> |
|
136 |
+ </button> |
|
137 |
+ ))} |
|
138 |
+ </div> |
|
139 |
+ )} |
|
140 |
+ {zoom && ( |
|
141 |
+ <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie"> |
|
142 |
+ <img src={images[idx]} alt="" /> |
|
143 |
+ </div> |
|
144 |
+ )} |
|
145 |
+ </> |
|
146 |
+ ); |
|
147 |
+} |
|
148 |
+ |
| 59 |
149 |
export default function ListingPage() { |
| 60 |
150 |
const { uid } = useParams<{ uid: string }>(); |
| 61 |
151 |
const [l, setL] = useState<Listing | null>(null); |
| 62 |
152 |
const [error, setError] = useState<string | null>(null); |
| 63 |
|
− const [imgIdx, setImgIdx] = useState(0); |
| 64 |
|
− const [zoom, setZoom] = useState(false); |
| 65 |
153 |
|
| 66 |
154 |
useEffect(() => { |
| 67 |
155 |
fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); |
| 68 |
156 |
if (!uid) return; |
| 69 |
|
− fetchListing(uid) |
| 70 |
|
− .then((d) => { setL(d); setImgIdx(0); }) |
| 71 |
|
− .catch((e) => setError(String(e))); |
|
157 |
+ fetchListing(uid).then(setL).catch((e) => setError(String(e))); |
| 72 |
158 |
window.scrollTo(0, 0); |
| 73 |
159 |
}, [uid]); |
| 74 |
160 |
|
| 85 |
171 |
if (!l) |
| 86 |
172 |
return ( |
| 87 |
173 |
<div className="container detail"> |
| 88 |
|
− <div className="detail-grid"> |
|
174 |
+ <div className="fiche" aria-busy="true"> |
| 89 |
175 |
<div className="skel"><div className="sk-img" /></div> |
| 90 |
176 |
<div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div> |
| 91 |
177 |
</div> |
| 92 |
178 |
</div> |
| 93 |
179 |
); |
| 94 |
180 |
|
| 95 |
|
− const imgs = l.images ?? []; |
| 96 |
|
− const main = imgs[imgIdx]; |
| 97 |
|
− const badges = detailBadges(l); |
|
181 |
+ const dg = l.digest ?? null; |
|
182 |
+ const f = dg?.faits; |
|
183 |
+ const conf = dg?.confiance ?? {}; |
|
184 |
+ const deal = badgeMarche(l.price, l.quartier?.demographie?.loyer_moyen); |
|
185 |
+ const confirmes = badgesConfirmes(l); |
|
186 |
+ const autres = l.amenities.filter( |
|
187 |
+ (a) => !confirmes.some((b) => b.toLowerCase().includes(a.toLowerCase()))); |
|
188 |
+ const inc = l.details?.inclusions ?? {}; |
|
189 |
+ const zeroFrais = inc.heating && inc.electricity && inc.hot_water; |
|
190 |
+ |
|
191 |
+ const enLigneDepuis = l.first_seen |
|
192 |
+ ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null; |
|
193 |
+ const hist = (l.price_history ?? []).filter((h) => h.price != null); |
|
194 |
+ const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price |
|
195 |
+ ? { de: hist[1].price!, a: hist[0].price! } : null; |
|
196 |
+ |
| 98 |
197 |
const updated = l.updated_at |
| 99 |
198 |
? new Date(l.updated_at * 1000).toLocaleDateString("fr-CA", { |
| 100 |
|
− day: "numeric", month: "long", year: "numeric", |
| 101 |
|
− }) |
| 102 |
|
− : null; |
|
199 |
+ day: "numeric", month: "long", year: "numeric" }) : null; |
|
200 |
+ |
|
201 |
+ // chips clés (haute confiance seulement pour les faits extraits du texte) |
|
202 |
+ const chips: string[] = []; |
|
203 |
+ if (l.unit_type) chips.push(l.unit_type); |
|
204 |
+ const dispo = fmtAvailability(l.availability_date); |
|
205 |
+ if (dispo) chips.push(dispo === "Maintenant" ? "Libre maintenant" : `Dispo ${dispo}`); |
|
206 |
+ if (l.furnished) chips.push("Meublé"); |
|
207 |
+ if (l.pets) chips.push(PETS_LABEL[l.pets] ?? l.pets); |
|
208 |
+ if (l.area_sqft) chips.push(`${Math.round(l.area_sqft).toLocaleString("fr-CA")}${NBSP}pi²`); |
|
209 |
+ if (f?.nb_occupants_total && conf.nb_occupants_total !== "faible") |
|
210 |
+ chips.push(`${f.nb_occupants_total} occupants`); |
|
211 |
+ if (f?.salle_de_bain && conf.salle_de_bain !== "faible") |
|
212 |
+ chips.push(`Salle de bain ${f.salle_de_bain === "commune" ? "partagée" : "privée"}`); |
|
213 |
+ if (l.details?.floor != null) chips.push(`${l.details.floor}ᵉ étage`); |
|
214 |
+ |
|
215 |
+ const pois = l.poi ?? []; |
| 103 |
216 |
|
| 104 |
217 |
return ( |
| 105 |
218 |
<div className="container detail"> |
| 109 |
222 |
<span>{l.title || l.address}</span> |
| 110 |
223 |
</nav> |
| 111 |
224 |
|
| 112 |
|
− <div className="detail-grid"> |
| 113 |
|
− <div className="gallery"> |
| 114 |
|
− <div className="gallery-main" onClick={() => main && setZoom(true)}> |
| 115 |
|
− {main ? ( |
| 116 |
|
− <img src={main} alt={`${l.title} — photo ${imgIdx + 1}`} /> |
|
225 |
+ <div className="fiche"> |
|
226 |
+ {/* ------- colonne gauche (desktop) : galerie, description, pratique -- */} |
|
227 |
+ <div className="f-col"> |
|
228 |
+ <section className="f-bloc f-galerie" aria-label="Photos"> |
|
229 |
+ <Galerie images={l.images ?? []} titre={l.title || l.address} /> |
|
230 |
+ </section> |
|
231 |
+ |
|
232 |
+ <section className="f-bloc f-desc" id="description"> |
|
233 |
+ <h2>Description</h2> |
|
234 |
+ {dg ? ( |
|
235 |
+ <> |
|
236 |
+ {dg.en_bref && <p className="enbref">{dg.en_bref}</p>} |
|
237 |
+ {dg.sections.map((s) => ( |
|
238 |
+ <div key={s.titre} className="desc-section"> |
|
239 |
+ <h4>{s.titre}</h4> |
|
240 |
+ <p>{s.texte}</p> |
|
241 |
+ </div> |
|
242 |
+ ))} |
|
243 |
+ <details className="texte-original"> |
|
244 |
+ <summary>Voir le texte original de la source</summary> |
|
245 |
+ <p>{l.description}</p> |
|
246 |
+ </details> |
|
247 |
+ </> |
| 117 |
248 |
) : ( |
| 118 |
|
− <div className="noimg" style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontSize: 48 }}> |
| 119 |
|
− 🏠 |
| 120 |
|
− </div> |
|
249 |
+ l.description |
|
250 |
+ ? <p style={{ color: "var(--ink-2)" }}>{l.description}</p> |
|
251 |
+ : <p className="fine">La source ne fournit pas de description pour cette annonce.</p> |
| 121 |
252 |
)} |
| 122 |
|
− </div> |
| 123 |
|
− {imgs.length > 1 && ( |
| 124 |
|
− <div className="thumbs"> |
| 125 |
|
− {imgs.map((u, i) => ( |
| 126 |
|
− <button |
| 127 |
|
− key={u} |
| 128 |
|
− className={i === imgIdx ? "on" : ""} |
| 129 |
|
− onClick={() => setImgIdx(i)} |
| 130 |
|
− aria-label={`Photo ${i + 1}`} |
| 131 |
|
− > |
| 132 |
|
− <img src={u} alt="" loading="lazy" /> |
| 133 |
|
− </button> |
| 134 |
|
− ))} |
| 135 |
|
− </div> |
| 136 |
|
− )} |
| 137 |
|
− {l.description && ( |
| 138 |
|
− <p style={{ color: "var(--ink-2)", marginTop: 18 }}>{l.description}</p> |
| 139 |
|
− )} |
| 140 |
|
− {l.quartier && <QuartierBlock q={l.quartier} />} |
| 141 |
|
− </div> |
|
253 |
+ </section> |
| 142 |
254 |
|
| 143 |
|
− <aside className="panel"> |
| 144 |
|
− <div className="price"> |
| 145 |
|
− {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/ mois</small>} |
| 146 |
|
− </div> |
| 147 |
|
− <h1>{l.title || l.address}</h1> |
| 148 |
|
− <div className="loc"> |
| 149 |
|
− {[l.address !== l.title ? l.address : "", l.sector, l.city] |
| 150 |
|
− .filter(Boolean) |
| 151 |
|
− .join(" · ")} |
| 152 |
|
− </div> |
| 153 |
|
− |
| 154 |
|
− <div className="kv"> |
| 155 |
|
− {l.unit_type && ( |
| 156 |
|
− <div className="cell"><div className="k">Taille</div><div className="v">{l.unit_type}</div></div> |
| 157 |
|
− )} |
| 158 |
|
− {(fmtAvailability(l.availability_date) || l.availability) && ( |
| 159 |
|
− <div className="cell"> |
| 160 |
|
− <div className="k">Disponibilité</div> |
| 161 |
|
− <div className="v">{fmtAvailability(l.availability_date) ?? l.availability}</div> |
| 162 |
|
− </div> |
| 163 |
|
− )} |
| 164 |
|
− {l.area_sqft != null && ( |
| 165 |
|
− <div className="cell"> |
| 166 |
|
− <div className="k">Superficie</div> |
| 167 |
|
− <div className="v">{Math.round(l.area_sqft).toLocaleString("fr-CA")} pi²</div> |
|
255 |
+ <section className="f-bloc f-pratique"> |
|
256 |
+ <h2>Détails pratiques</h2> |
|
257 |
+ <div className="kv"> |
|
258 |
+ <div className="cell"><div className="k">Gestionnaire</div><div className="v">{sourceName(l.source)}</div></div> |
|
259 |
+ {l.price_label && ( |
|
260 |
+ <div className="cell"><div className="k">Prix affiché</div><div className="v">{l.price_label}</div></div> |
|
261 |
+ )} |
|
262 |
+ {f?.duree_bail_minimale_mois && ( |
|
263 |
+ <div className="cell"><div className="k">Bail minimum</div><div className="v">{f.duree_bail_minimale_mois} mois</div></div> |
|
264 |
+ )} |
|
265 |
+ {enLigneDepuis != null && ( |
|
266 |
+ <div className="cell"><div className="k">En ligne depuis</div> |
|
267 |
+ <div className="v">{enLigneDepuis === 0 ? "aujourd'hui" : `${enLigneDepuis}${NBSP}jour${enLigneDepuis > 1 ? "s" : ""}`}</div></div> |
|
268 |
+ )} |
|
269 |
+ {updated && ( |
|
270 |
+ <div className="cell"><div className="k">Synchronisé</div><div className="v">{updated}</div></div> |
|
271 |
+ )} |
|
272 |
+ </div> |
|
273 |
+ {baissePrix && ( |
|
274 |
+ <div className={`prix-histo ${baissePrix.a < baissePrix.de ? "down" : "up"}`}> |
|
275 |
+ {baissePrix.a < baissePrix.de ? "📉" : "📈"} Prix passé de{" "} |
|
276 |
+ {fmtPrice(baissePrix.de)} à <b>{fmtPrice(baissePrix.a)}</b> |
|
277 |
+ {baissePrix.a < baissePrix.de && " — levier de négociation"} |
| 168 |
278 |
</div> |
| 169 |
279 |
)} |
| 170 |
|
− {l.pets && ( |
| 171 |
|
− <div className="cell"><div className="k">Animaux</div><div className="v">{PETS_LABEL[l.pets] ?? l.pets}</div></div> |
| 172 |
|
− )} |
| 173 |
|
− {l.details?.floor != null && ( |
| 174 |
|
− <div className="cell"><div className="k">Étage</div><div className="v">{l.details.floor}ᵉ</div></div> |
| 175 |
|
− )} |
| 176 |
|
− <div className="cell"><div className="k">Gestionnaire</div><div className="v">{sourceName(l.source)}</div></div> |
| 177 |
|
− {l.price_label && ( |
| 178 |
|
− <div className="cell"><div className="k">Prix affiché</div><div className="v">{l.price_label}</div></div> |
|
280 |
+ </section> |
|
281 |
+ </div> |
|
282 |
+ |
|
283 |
+ {/* ------- colonne droite (desktop) : synthèse, inclusions, quartier -- */} |
|
284 |
+ <div className="f-col"> |
|
285 |
+ <section className="f-bloc f-hero"> |
|
286 |
+ <div className="price"> |
|
287 |
+ {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/{NBSP}mois</small>} |
|
288 |
+ </div> |
|
289 |
+ {deal && <div className={`deal-badge ${deal.cls}`}>{deal.txt}</div>} |
|
290 |
+ <h1>{l.title || l.address}</h1> |
|
291 |
+ <div className="loc"> |
|
292 |
+ {[l.address !== l.title ? l.address : "", l.sector, l.city].filter(Boolean).join(" · ")} |
|
293 |
+ </div> |
|
294 |
+ <div className="chips-scroll" role="list" aria-label="Caractéristiques clés"> |
|
295 |
+ {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)} |
|
296 |
+ </div> |
|
297 |
+ <nav className="ancres" aria-label="Sections de la fiche"> |
|
298 |
+ <a href="#description">Description</a> |
|
299 |
+ <a href="#inclusions">Inclusions</a> |
|
300 |
+ <a href="#quartier">Quartier</a> |
|
301 |
+ <a href="#proximite">À proximité</a> |
|
302 |
+ </nav> |
|
303 |
+ <a className="cta cta-desktop" href={l.url} target="_blank" rel="noopener noreferrer"> |
|
304 |
+ Voir l'annonce chez {sourceName(l.source)} ↗ |
|
305 |
+ </a> |
|
306 |
+ </section> |
|
307 |
+ |
|
308 |
+ <section className="f-bloc f-incl" id="inclusions"> |
|
309 |
+ <h2>Inclusions et commodités</h2> |
|
310 |
+ {zeroFrais && <div className="deal-badge deal-good">💡 Chauffage, électricité et eau chaude inclus — 0{NBSP}$ de frais cachés</div>} |
|
311 |
+ <div className="amenity-row"> |
|
312 |
+ {confirmes.map((b) => ( |
|
313 |
+ <span className="amenity confirmed" key={`c-${b}`}>✓ {b}</span> |
|
314 |
+ ))} |
|
315 |
+ {autres.map((a) => ( |
|
316 |
+ <span className="amenity unconfirmed" key={a} title="Mentionné par la source, sans confirmation structurée">{a}</span> |
|
317 |
+ ))} |
|
318 |
+ </div> |
|
319 |
+ {confirmes.length === 0 && autres.length === 0 && ( |
|
320 |
+ <p className="fine">La source ne précise pas les inclusions.</p> |
| 179 |
321 |
)} |
| 180 |
|
− </div> |
|
322 |
+ </section> |
| 181 |
323 |
|
| 182 |
|
− {(badges.length > 0 || l.amenities.length > 0) && ( |
| 183 |
|
− <> |
| 184 |
|
− <div className="k" style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.07em", color: "var(--ink-3)", fontWeight: 700, marginBottom: 8 }}> |
| 185 |
|
− Inclusions et commodités |
| 186 |
|
− </div> |
| 187 |
|
− <div className="amenity-row"> |
| 188 |
|
− {badges.map((b) => ( |
| 189 |
|
− <span className="amenity" key={`d-${b}`}>✓ {b}</span> |
| 190 |
|
− ))} |
| 191 |
|
− {l.amenities |
| 192 |
|
− .filter((a) => !badges.some((b) => b.toLowerCase().includes(a.toLowerCase()))) |
| 193 |
|
− .map((a) => ( |
| 194 |
|
− <span className="amenity" key={a}>{a}</span> |
| 195 |
|
− ))} |
| 196 |
|
− </div> |
| 197 |
|
− </> |
| 198 |
|
− )} |
|
324 |
+ <section className="f-bloc f-quartier" id="quartier"> |
|
325 |
+ {l.quartier ? <QuartierBlock q={l.quartier} /> : null} |
|
326 |
+ </section> |
| 199 |
327 |
|
| 200 |
|
− {(l.poi?.length ?? 0) > 0 && ( |
| 201 |
|
− <> |
| 202 |
|
− <div className="k" style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.07em", color: "var(--ink-3)", fontWeight: 700, margin: "16px 0 8px" }}> |
| 203 |
|
− À proximité |
| 204 |
|
− </div> |
| 205 |
|
− <ul className="poi-list"> |
| 206 |
|
− {l.poi!.map((p) => { |
| 207 |
|
− const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat }; |
|
328 |
+ <section className="f-bloc f-poi" id="proximite"> |
|
329 |
+ {pois.length > 0 && ( |
|
330 |
+ <> |
|
331 |
+ <h2>À proximité</h2> |
|
332 |
+ {POI_GROUPES.map((g, gi) => { |
|
333 |
+ const items = pois.filter((p) => g.cats.includes(p.cat)); |
|
334 |
+ if (items.length === 0) return null; |
| 208 |
335 |
return ( |
| 209 |
|
− <li key={p.cat} title={meta.label}> |
| 210 |
|
− <span className="poi-ico" aria-hidden="true">{meta.icon}</span> |
| 211 |
|
− <span className="poi-name">{p.name}</span> |
| 212 |
|
− <span className="poi-dist">{fmtDist(p.dist_m)}</span> |
| 213 |
|
− </li> |
|
336 |
+ <details className="poi-groupe" key={g.titre} open={gi === 0}> |
|
337 |
+ <summary> |
|
338 |
+ <span>{g.icone} {g.titre}</span> |
|
339 |
+ <span className="poi-resume"> |
|
340 |
+ {items.length} · le + proche à {fmtDist(items[0].dist_m)} |
|
341 |
+ </span> |
|
342 |
+ </summary> |
|
343 |
+ <ul className="poi-list"> |
|
344 |
+ {items.map((p) => { |
|
345 |
+ const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat }; |
|
346 |
+ return ( |
|
347 |
+ <li key={p.cat} title={meta.label}> |
|
348 |
+ <span className="poi-ico" aria-hidden="true">{meta.icon}</span> |
|
349 |
+ <span className="poi-name">{p.name}</span> |
|
350 |
+ <span className="poi-dist">{fmtDist(p.dist_m)} · {fmtMarche(p.dist_m)}</span> |
|
351 |
+ </li> |
|
352 |
+ ); |
|
353 |
+ })} |
|
354 |
+ </ul> |
|
355 |
+ </details> |
| 214 |
356 |
); |
| 215 |
357 |
})} |
| 216 |
|
− </ul> |
| 217 |
|
− <div className="fine" style={{ marginTop: 6 }}> |
| 218 |
|
− Distances à vol d'oiseau — données OpenStreetMap. |
| 219 |
|
− </div> |
| 220 |
|
− </> |
| 221 |
|
− )} |
| 222 |
|
− |
| 223 |
|
− <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer"> |
| 224 |
|
− Voir l'annonce chez {sourceName(l.source)} ↗ |
| 225 |
|
− </a> |
| 226 |
|
− <div className="fine"> |
| 227 |
|
− {updated && <>Dernière synchronisation : {updated}. </>} |
| 228 |
|
− Les prix et disponibilités sont ceux affichés par la source. |
| 229 |
|
− </div> |
| 230 |
|
− </aside> |
|
358 |
+ <div className="fine"> |
|
359 |
+ Temps de marche estimés (distance à vol d'oiseau ×{NBSP}1,3, 4,8{NBSP}km/h) — données OpenStreetMap. |
|
360 |
+ </div> |
|
361 |
+ </> |
|
362 |
+ )} |
|
363 |
+ </section> |
|
364 |
+ </div> |
| 231 |
365 |
</div> |
| 232 |
366 |
|
| 233 |
|
− {zoom && main && ( |
| 234 |
|
− <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie"> |
| 235 |
|
− <img src={main} alt="" /> |
| 236 |
|
− </div> |
| 237 |
|
− )} |
|
367 |
+ <div className="fine f-foot"> |
|
368 |
+ {updated && <>Dernière synchronisation : {updated}. </>} |
|
369 |
+ Les prix et disponibilités sont ceux affichés par la source — chaque fiche |
|
370 |
+ renvoie à l'annonce originale. |
|
371 |
+ </div> |
|
372 |
+ |
|
373 |
+ {/* CTA sticky mobile — toujours visible */} |
|
374 |
+ <div className="cta-sticky"> |
|
375 |
+ <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}{l.price != null && <small>/mois</small>}</span> |
|
376 |
+ <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer"> |
|
377 |
+ Voir chez {sourceName(l.source)} ↗ |
|
378 |
+ </a> |
|
379 |
+ </div> |
| 238 |
380 |
</div> |
| 239 |
381 |
); |
| 240 |
382 |
} |