SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%

Fiche logement : disponibilité normalisée, superficie, animaux, étage, badges structurés

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 2 days ago (Aug 8, 2026) parent 7323079

Showing 2 changed files with +94 and −6

modified frontend/src/api.ts +36 −0
@@ -4,6 +4,23 @@
4 4 // api.ts : types + client API robuste (timeout, erreurs typées)
5 5 // -----------------------------------------------------------------------------
6 6
7 +export interface ListingDetails {
8 + inclusions?: Record<string, boolean>;
9 + appliances?: Record<string, boolean>;
10 + parking?: { available?: boolean; type?: string; included?: boolean; price?: number };
11 + contact?: { phone?: string; email?: string };
12 + ac?: boolean;
13 + elevator?: boolean;
14 + balcony?: boolean;
15 + pool?: boolean;
16 + gym?: boolean;
17 + laundry?: boolean;
18 + storage?: boolean;
19 + smoking?: boolean;
20 + floor?: number;
21 + price_from?: boolean;
22 +}
23 +
7 24 export interface Listing {
8 25 uid: string;
9 26 source: string;
@@ -17,9 +34,16 @@ export interface Listing {
17 34 price: number | null;
18 35 price_label: string;
19 36 availability: string;
37 + availability_date: string | null; // ISO "2026-07-01" ou "now"
38 + area_sqft: number | null;
39 + pets: string | null; // "oui" | "non" | "conditions"
40 + furnished: boolean | null;
20 41 description: string;
21 42 amenities: string[];
43 + details: ListingDetails;
22 44 images: string[];
45 + lat: number | null;
46 + lng: number | null;
23 47 last_seen: number;
24 48 updated_at: number;
25 49 active: number;
@@ -125,3 +149,15 @@ export const fmtPrice = (p: number | null, label?: string) =>
125 149 p != null
126 150 ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"
127 151 : label || "Prix sur demande";
152 +
153 +/** "now" -> « Maintenant », "2026-12-01" -> « 1ᵉʳ décembre 2026 » */
154 +export function fmtAvailability(iso: string | null): string | null {
155 + if (!iso) return null;
156 + if (iso === "now") return "Maintenant";
157 + const [y, m, d] = iso.split("-").map(Number);
158 + if (!y || !m || !d) return null;
159 + const txt = new Date(y, m - 1, d).toLocaleDateString("fr-CA", {
160 + day: "numeric", month: "long", year: "numeric",
161 + });
162 + return txt.replace(/^1 /, "1ᵉʳ ");
163 +}
modified frontend/src/pages/Listing.tsx +58 −6
@@ -5,7 +5,38 @@
5 5 // -----------------------------------------------------------------------------
6 6 import { useEffect, useState } from "react";
7 7 import { Link, useParams } from "react-router-dom";
8 import { Listing, fetchListing, fetchSources, fmtPrice, registerSourceNames, sourceName } from "../api";
8 +import { Listing, fetchListing, fetchSources, fmtAvailability, fmtPrice, registerSourceNames, sourceName } from "../api";
9 +
10 +const PETS_LABEL: Record<string, string> = {
11 + oui: "Acceptés", non: "Refusés", conditions: "Sous conditions",
12 +};
13 +
14 +/** Badges dérivés des détails structurés (inclusions, immeuble, stationnement). */
15 +function detailBadges(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 + const app = d.appliances ?? {};
24 + if (app.dishwasher) out.push("Lave-vaisselle");
25 + if (app.washer_dryer) out.push("Laveuse-sécheuse");
26 + if (app.fridge && app.stove) out.push("Électroménagers");
27 + if (d.ac) out.push("Climatisation");
28 + if (d.elevator) out.push("Ascenseur");
29 + if (d.balcony) out.push("Balcon");
30 + if (d.pool) out.push("Piscine");
31 + if (d.gym) out.push("Gym");
32 + if (d.laundry) out.push("Buanderie");
33 + if (d.storage) out.push("Rangement");
34 + if (d.parking?.available)
35 + out.push(`Stationnement${d.parking.type ? ` ${d.parking.type}` : ""}${d.parking.included ? " inclus" : ""}`);
36 + if (l.furnished) out.push("Meublé");
37 + if (d.smoking === false) out.push("Non-fumeur");
38 + return out;
39 +}
9 40
10 41 export default function ListingPage() {
11 42 const { uid } = useParams<{ uid: string }>();
@@ -45,6 +76,7 @@ export default function ListingPage() {
45 76
46 77 const imgs = l.images ?? [];
47 78 const main = imgs[imgIdx];
79 + const badges = detailBadges(l);
48 80 const updated = l.updated_at
49 81 ? new Date(l.updated_at * 1000).toLocaleDateString("fr-CA", {
50 82 day: "numeric", month: "long", year: "numeric",
@@ -104,8 +136,23 @@ export default function ListingPage() {
104 136 {l.unit_type && (
105 137 <div className="cell"><div className="k">Taille</div><div className="v">{l.unit_type}</div></div>
106 138 )}
107 {l.availability && (
108 <div className="cell"><div className="k">Disponibilité</div><div className="v">{l.availability}</div></div>
139 + {(fmtAvailability(l.availability_date) || l.availability) && (
140 + <div className="cell">
141 + <div className="k">Disponibilité</div>
142 + <div className="v">{fmtAvailability(l.availability_date) ?? l.availability}</div>
143 + </div>
144 + )}
145 + {l.area_sqft != null && (
146 + <div className="cell">
147 + <div className="k">Superficie</div>
148 + <div className="v">{Math.round(l.area_sqft).toLocaleString("fr-CA")} pi²</div>
149 + </div>
150 + )}
151 + {l.pets && (
152 + <div className="cell"><div className="k">Animaux</div><div className="v">{PETS_LABEL[l.pets] ?? l.pets}</div></div>
153 + )}
154 + {l.details?.floor != null && (
155 + <div className="cell"><div className="k">Étage</div><div className="v">{l.details.floor}ᵉ</div></div>
109 156 )}
110 157 <div className="cell"><div className="k">Gestionnaire</div><div className="v">{sourceName(l.source)}</div></div>
111 158 {l.price_label && (
@@ -113,15 +160,20 @@ export default function ListingPage() {
113 160 )}
114 161 </div>
115 162
116 {l.amenities.length > 0 && (
163 + {(badges.length > 0 || l.amenities.length > 0) && (
117 164 <>
118 165 <div className="k" style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.07em", color: "var(--ink-3)", fontWeight: 700, marginBottom: 8 }}>
119 166 Inclusions et commodités
120 167 </div>
121 168 <div className="amenity-row">
122 {l.amenities.map((a) => (
123 <span className="amenity" key={a}>{a}</span>
169 + {badges.map((b) => (
170 + <span className="amenity" key={`d-${b}`}>✓ {b}</span>
124 171 ))}
172 + {l.amenities
173 + .filter((a) => !badges.some((b) => b.toLowerCase().includes(a.toLowerCase())))
174 + .map((a) => (
175 + <span className="amenity" key={a}>{a}</span>
176 + ))}
125 177 </div>
126 178 </>
127 179 )}
128 180