// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // pages/Listing.tsx : fiche d'un logement — refonte mobile-first // Ordre mobile : galerie → prix + badge marché → chips clés → ancres → // description restructurée → inclusions → détails pratiques → quartier → // à proximité → pied de fiche. CTA source sticky en bas d'écran (mobile). // Desktop : deux colonnes (logement à gauche, quartier/synthèse à droite) // via wrappers `display:contents` + `order` (voir styles.css « fiche v2 »). // ----------------------------------------------------------------------------- import { useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api"; import QuartierBlock from "../components/QuartierBlock"; // Icônes et libellés des commodités de proximité (louka/poi.py) const POI_META: Record = { epicerie: { icon: "🛒", label: "Épicerie" }, depanneur: { icon: "🏪", label: "Dépanneur" }, pharmacie: { icon: "💊", label: "Pharmacie" }, ecole: { icon: "🏫", label: "École" }, garderie: { icon: "🧸", label: "Garderie" }, parc: { icon: "🌳", label: "Parc" }, bus: { icon: "🚌", label: "Bus" }, metro: { icon: "🚇", label: "Métro" }, gym: { icon: "🏋️", label: "Gym" }, cafe: { icon: "☕", label: "Café" }, clinique: { icon: "🩺", label: "Clinique" }, hopital: { icon: "🏥", label: "Hôpital" }, bibliotheque: { icon: "📚", label: "Bibliothèque" }, }; // Regroupement des POI en catégories repliables const POI_GROUPES: { titre: string; icone: string; cats: string[] }[] = [ { titre: "Courses", icone: "🛒", cats: ["epicerie", "depanneur"] }, { titre: "Transport", icone: "🚌", cats: ["bus", "metro"] }, { titre: "Études et famille", icone: "🎓", cats: ["ecole", "garderie", "bibliotheque"] }, { titre: "Santé", icone: "🏥", cats: ["pharmacie", "clinique", "hopital"] }, { titre: "Vie de quartier", icone: "☕", cats: ["cafe", "parc", "gym"] }, ]; // Badge « prix vs marché » — seuils configurables const SEUILS_MARCHE = { bonDeal: -0.15, dansLeMarche: 0.10 }; const PETS_LABEL: Record = { oui: "Animaux acceptés", non: "Animaux refusés", conditions: "Animaux sous conditions", }; const NBSP = " "; /** ≈ minutes de marche (vol d'oiseau × facteur de détour 1,3, 4,8 km/h) */ const fmtMarche = (m: number): string => `≈${NBSP}${Math.max(1, Math.round((m * 1.3) / 80))}${NBSP}min à pied`; function badgeMarche(price: number | null | undefined, loyerSecteur: number | null | undefined) { if (price == null || loyerSecteur == null || loyerSecteur <= 0) return null; const delta = (price - loyerSecteur) / loyerSecteur; const pct = `${delta > 0 ? "+" : "−"}${Math.abs(Math.round(delta * 100))}${NBSP}%`; if (delta <= SEUILS_MARCHE.bonDeal) return { cls: "deal-good", txt: `${pct} vs le secteur · Bon deal 🔥` }; if (delta <= SEUILS_MARCHE.dansLeMarche) return { cls: "deal-ok", txt: `${pct} vs le secteur · Dans le marché` }; return { cls: "deal-high", txt: `${pct} vs le secteur · Au-dessus du marché` }; } /** Badges dérivés des détails structurés (inclusions confirmées ✓). */ function badgesConfirmes(l: Listing): string[] { const d = l.details ?? {}; const out: string[] = []; const inc = d.inclusions ?? {}; if (inc.heating) out.push("Chauffage inclus"); if (inc.electricity) out.push("Électricité incluse"); if (inc.hot_water) out.push("Eau chaude incluse"); if (inc.internet) out.push("Internet inclus"); const app = d.appliances ?? {}; if (app.dishwasher) out.push("Lave-vaisselle"); if (app.washer_dryer) out.push("Laveuse-sécheuse"); if (app.fridge && app.stove) out.push("Électroménagers"); if (d.ac) out.push("Climatisation"); if (d.elevator) out.push("Ascenseur"); if (d.balcony) out.push("Balcon"); if (d.pool) out.push("Piscine"); if (d.gym) out.push("Gym"); if (d.laundry) out.push("Buanderie"); if (d.storage) out.push("Rangement"); if (d.parking?.available) out.push(`Stationnement${d.parking.type ? ` ${d.parking.type}` : ""}${d.parking.included ? " inclus" : ""}`); if (l.furnished) out.push("Meublé"); if (d.smoking === false) out.push("Non-fumeur"); return out; } // --- Galerie avec balayage natif (scroll-snap) + compteur + plein écran ----- function Galerie({ images, titre }: { images: string[]; titre: string }) { const [idx, setIdx] = useState(0); const [zoom, setZoom] = useState(false); const track = useRef(null); const onScroll = () => { const el = track.current; if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)); }; const goto = (i: number) => track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" }); if (images.length === 0) return
🏠
; return ( <>
{images.map((u, i) => ( {`${titre} setZoom(true)} /> ))}
{idx + 1}/{images.length} {idx > 0 && ( )} {idx < images.length - 1 && ( )}
{images.length > 1 && (
{images.map((u, i) => ( ))}
)} {zoom && (
setZoom(false)} role="dialog" aria-label="Photo agrandie">
)} ); } export default function ListingPage() { const { uid } = useParams<{ uid: string }>(); const [l, setL] = useState(null); const [error, setError] = useState(null); useEffect(() => { fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); if (!uid) return; fetchListing(uid).then(setL).catch((e) => setError(String(e))); window.scrollTo(0, 0); }, [uid]); if (error) return (
⚠️

Annonce introuvable

{error}

Retour aux logements
); if (!l) return (
); const dg = l.digest ?? null; const f = dg?.faits; const conf = dg?.confiance ?? {}; const deal = badgeMarche(l.price, l.quartier?.demographie?.loyer_moyen); const confirmes = badgesConfirmes(l); const autres = l.amenities.filter( (a) => !confirmes.some((b) => b.toLowerCase().includes(a.toLowerCase()))); const inc = l.details?.inclusions ?? {}; const zeroFrais = inc.heating && inc.electricity && inc.hot_water; const enLigneDepuis = l.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null; const hist = (l.price_history ?? []).filter((h) => h.price != null); const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price ? { de: hist[1].price!, a: hist[0].price! } : null; const updated = l.updated_at ? new Date(l.updated_at * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" }) : null; // chips clés (haute confiance seulement pour les faits extraits du texte) const chips: string[] = []; if (l.unit_type) chips.push(l.unit_type); const dispo = fmtAvailability(l.availability_date); if (dispo) chips.push(dispo === "Maintenant" ? "Libre maintenant" : `Dispo ${dispo}`); if (l.furnished) chips.push("Meublé"); if (l.pets) chips.push(PETS_LABEL[l.pets] ?? l.pets); if (l.area_sqft) chips.push(`${Math.round(l.area_sqft).toLocaleString("fr-CA")}${NBSP}pi²`); if (f?.nb_occupants_total && conf.nb_occupants_total !== "faible") chips.push(`${f.nb_occupants_total} occupants`); if (f?.salle_de_bain && conf.salle_de_bain !== "faible") chips.push(`Salle de bain ${f.salle_de_bain === "commune" ? "partagée" : "privée"}`); if (l.details?.floor != null) chips.push(`${l.details.floor}ᵉ étage`); const pois = l.poi ?? []; return (
{/* ------- colonne gauche (desktop) : galerie, description, pratique -- */}

Description

{dg ? ( <> {dg.en_bref &&

{dg.en_bref}

} {dg.sections.map((s) => (

{s.titre}

{s.texte}

))}
Voir le texte original de la source

{l.description}

) : ( l.description ?

{l.description}

:

La source ne fournit pas de description pour cette annonce.

)}

Détails pratiques

Gestionnaire
{sourceName(l.source)}
{l.price_label && (
Prix affiché
{l.price_label}
)} {f?.duree_bail_minimale_mois && (
Bail minimum
{f.duree_bail_minimale_mois} mois
)} {enLigneDepuis != null && (
En ligne depuis
{enLigneDepuis === 0 ? "aujourd'hui" : `${enLigneDepuis}${NBSP}jour${enLigneDepuis > 1 ? "s" : ""}`}
)} {updated && (
Synchronisé
{updated}
)}
{baissePrix && (
{baissePrix.a < baissePrix.de ? "📉" : "📈"} Prix passé de{" "} {fmtPrice(baissePrix.de)} à {fmtPrice(baissePrix.a)} {baissePrix.a < baissePrix.de && " — levier de négociation"}
)}
{/* ------- colonne droite (desktop) : synthèse, inclusions, quartier -- */}
{fmtPrice(l.price, l.price_label)} {l.price != null && /{NBSP}mois}
{deal &&
{deal.txt}
}

{l.title || l.address}

{[l.address !== l.title ? l.address : "", l.sector, l.city].filter(Boolean).join(" · ")}
{chips.map((c) => {c})}
Voir l'annonce chez {sourceName(l.source)} ↗ 📄 Télécharger la fiche (PDF)

Inclusions et commodités

{zeroFrais &&
💡 Chauffage, électricité et eau chaude inclus — 0{NBSP}$ de frais cachés
}
{confirmes.map((b) => ( ✓ {b} ))} {autres.map((a) => ( {a} ))}
{confirmes.length === 0 && autres.length === 0 && (

La source ne précise pas les inclusions.

)}
{l.quartier ? : null}
{pois.length > 0 && ( <>

À proximité

{POI_GROUPES.map((g, gi) => { const items = pois.filter((p) => g.cats.includes(p.cat)); if (items.length === 0) return null; return (
{g.icone} {g.titre} {items.length} · le + proche à {fmtDist(items[0].dist_m)}
    {items.map((p) => { const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat }; return (
  • {p.name} {fmtDist(p.dist_m)} · {fmtMarche(p.dist_m)}
  • ); })}
); })}
Temps de marche estimés (distance à vol d'oiseau ×{NBSP}1,3, 4,8{NBSP}km/h) — données OpenStreetMap.
)}
{updated && <>Dernière synchronisation : {updated}. } Les prix et disponibilités sont ceux affichés par la source — chaque fiche renvoie à l'annonce originale.
{/* CTA sticky mobile — toujours visible */}
{fmtPrice(l.price, l.price_label)}{l.price != null && /mois} Voir chez {sourceName(l.source)} ↗
); }