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/PropertyAmenities.tsx : inclusions et commodités — grille compacte5// d'items avec icône contextuelle (AmenityIco), confirmés (✓ données6// structurées) puis mentionnés ; 8 visibles, « Voir les N commodités ».7// -----------------------------------------------------------------------------8import { useState } from "react";9import { Listing } from "../api";10import AmenityIco from "../components/AmenityIco";11import { IcoCheck } from "../components/Icons";12import { MoreButton, SectionCard, NBSP } from "./ui";1314/** Badges dérivés des détails structurés (inclusions confirmées ✓). */15export function badgesConfirmes(l: Listing): string[] {16 const d = l.details ?? {};17 const out: string[] = [];18 const inc = d.inclusions ?? {};19 if (inc.heating) out.push("Chauffage inclus");20 if (inc.electricity) out.push("Électricité incluse");21 if (inc.hot_water) out.push("Eau chaude incluse");22 if (inc.internet) out.push("Internet inclus");23 if (inc.cable) out.push("Câble inclus");24 const app = d.appliances ?? {};25 if (app.dishwasher) out.push("Lave-vaisselle");26 if (app.washer_dryer) out.push("Laveuse-sécheuse");27 if (app.fridge && app.stove) out.push("Électroménagers");28 if (d.ac) out.push("Climatisation");29 if (d.elevator) out.push("Ascenseur");30 if (d.balcony) out.push("Balcon");31 if (d.pool) out.push("Piscine");32 if (d.gym) out.push("Gym");33 if (d.laundry) out.push("Buanderie");34 if (d.storage) out.push("Rangement");35 if (d.parking?.available)36 out.push(`Stationnement${d.parking.type ? ` ${d.parking.type}` : ""}${d.parking.included ? " inclus" : ""}`);37 if (l.furnished) out.push("Meublé");38 if (d.smoking === false) out.push("Non-fumeur");39 return out;40}4142/** Libellés anglais fréquents des sources → français ; sert aussi au dédoublonnage43 * avec les inclusions confirmées (« Balcony » ≈ « Balcon »). */44const FR: [RegExp, string][] = [45 [/^balcony$/i, "Balcon"], [/^storage$/i, "Rangement"], [/^(in-?unit|onsite|on-site) laundry$/i, "Buanderie"],46 [/^laundry$/i, "Buanderie"], [/^dishwasher$/i, "Lave-vaisselle"], [/^hardwood floors?$/i, "Planchers de bois franc"],47 [/^high ceilings?$/i, "Plafonds hauts"], [/^parking$/i, "Stationnement"], [/^gym|fitness/i, "Gym"],48 [/^pool$/i, "Piscine"], [/^elevator$/i, "Ascenseur"], [/^furnished$/i, "Meublé"], [/^heating$/i, "Chauffage"],49 [/^air conditioning|a\/c$/i, "Climatisation"], [/^pets? (allowed|friendly)$/i, "Animaux acceptés"],50 [/^wheelchair access(ible)?$/i, "Accès fauteuil roulant"], [/^doorman$/i, "Portier"], [/^garage$/i, "Garage"],51];52const fr = (a: string) => { for (const [re, t] of FR) if (re.test(a.trim())) return t; return a; };53const norm = (s: string) => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/s\b/g, "");5455export default function PropertyAmenities({ l }: { l: Listing }) {56 const [all, setAll] = useState(false);57 const confirmes = badgesConfirmes(l);58 const autres = Array.from(new Set((l.amenities ?? []).map(fr))).filter(59 (a) => !confirmes.some((b) => norm(b).includes(norm(a)) || norm(a).includes(norm(b))));60 const items = [61 ...confirmes.map((t) => ({ t, ok: true })),62 ...autres.map((t) => ({ t, ok: false })),63 ];64 const inc = l.details?.inclusions ?? {};65 const zeroFrais = inc.heating && inc.electricity && inc.hot_water;66 const LIMIT = 8;67 const shown = all ? items : items.slice(0, LIMIT);6869 return (70 <SectionCard id="inclusions" title="Inclusions et commodités"71 sub={items.length ? `${confirmes.length} confirmée${confirmes.length > 1 ? "s" : ""} par la source${autres.length ? ` · ${autres.length} mentionnée${autres.length > 1 ? "s" : ""}` : ""}` : undefined}>72 {zeroFrais && <p className="lk-note good" style={{ marginTop: 0, marginBottom: 12 }}>Chauffage, électricité et eau chaude inclus — 0{NBSP}$ de frais énergétiques cachés.</p>}73 {items.length === 0 ? (74 <p className="lk-fine">La source ne précise pas les inclusions.</p>75 ) : (76 <div className="lk-amen" role="list">77 {shown.map((it) => (78 <div className={`lk-amen-it ${it.ok ? "confirmed" : "unconfirmed"}`} role="listitem" key={it.t}79 title={it.ok ? "Confirmé par les données structurées de la source" : "Mentionné par la source, sans confirmation structurée"}>80 <span className="lk-amen-ico" aria-hidden="true"><AmenityIco label={it.t} size={15} fallback={it.ok ? "check" : "spark"} /></span>81 <span className="lk-amen-txt">{it.t}</span>82 {it.ok && <IcoCheck size={14} className="lk-amen-conf" aria-label="confirmé" />}83 </div>84 ))}85 </div>86 )}87 {items.length > LIMIT && (88 <MoreButton onClick={() => setAll(!all)} expanded={all}>89 {all ? "Réduire" : `Voir les ${items.length} commodités`}90 </MoreButton>91 )}92 </SectionCard>93 );94}95