SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

Web — pagination 12 annonces/page + icônes SVG maison (fin des emojis génériques)

- Pager.tsx : pagination éditoriale (précédent/suivant, numéros avec ellipses,
  rappel « X–Y sur N »), sous la grille ET sous la liste de la vue carte
- Home.tsx : 12 annonces/page via limit/offset API, page dans l'URL (?page=N),
  retour page 1 au changement de filtre, défilement doux vers les résultats
- Icons.tsx : jeu d'icônes SVG au trait « éditorial sharp » (maison, immeuble,
  caméra, patte, sofa, éclair, liste, carte, curseurs, alerte, boussole…)
- Emojis remplacés partout (cartes, chips, bascule liste/carte, FAB filtres,
  nav mobile, onglets Stats, fiche, carrousel vide, popups carte, 404)
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent dcb5008

10 changed files +382 −43

modified frontend/src/App.tsx +9 −6
@@ -7,6 +7,9 @@ import { useEffect, useState } from "react";
7 7 import { NavLink, Route, Routes, useLocation } from "react-router-dom";
8 8 import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";
9 9 import CookieConsent from "./components/CookieConsent";
10 +import {
11 + IcoChart, IcoCompass, IcoFolder, IcoHouse, IcoLock, IcoMap,
12 +} from "./components/Icons";
10 13 import Home from "./pages/Home";
11 14 import ListingPage from "./pages/Listing";
12 15 import BotPage from "./pages/Bot";
@@ -52,11 +55,11 @@ function Ticker() {
52 55 }
53 56
54 57 const NAV_LINKS = [
55 − { to: "/", label: "Logements", icon: "🏠", end: true },
56 − { to: "/?view=carte", label: "Carte", icon: "◈", end: false, force: true },
57 − { to: "/stats", label: "Stats", icon: "📊", end: false },
58 − { to: "/sources", label: "Sources", icon: "🗂", end: false },
59 − { to: "/confidentialite", label: "Confidentialité", icon: "🔒", end: false },
58 + { to: "/", label: "Logements", icon: <IcoHouse size={17} />, end: true },
59 + { to: "/?view=carte", label: "Carte", icon: <IcoMap size={17} />, end: false, force: true },
60 + { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false },
61 + { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false },
62 + { to: "/confidentialite", label: "Confidentialité", icon: <IcoLock size={17} />, end: false },
60 63 ];
61 64
62 65 function Header() {
@@ -176,7 +179,7 @@ export default function App() {
176 179 path="*"
177 180 element={
178 181 <div className="notice container">
179 − <div className="big">🧭</div>
182 + <div className="big"><IcoCompass size={40} /></div>
180 183 <h2>Page introuvable</h2>
181 184 <p>Le lien demandé n'existe pas.</p>
182 185 </div>
modified frontend/src/api.ts +3 −1
@@ -171,9 +171,11 @@ export interface ListingFilters {
171 171 q?: string;
172 172 }
173 173
174 −export function fetchListings(f: ListingFilters) {
174 +export function fetchListings(f: ListingFilters, limit?: number, offset?: number) {
175 175 const params = new URLSearchParams();
176 176 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
177 + if (limit != null) params.set("limit", String(limit));
178 + if (offset) params.set("offset", String(offset));
177 179 return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`);
178 180 }
179 181
added frontend/src/components/Icons.tsx +165 −0
@@ -0,0 +1,165 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/Icons.tsx : icônes SVG maison « éditorial sharp »
5 +// Tracés au trait (stroke) 2 px, coins vifs (square/miter), currentColor —
6 +// remplacent les emojis génériques pour coller à l'identité de l'app.
7 +// -----------------------------------------------------------------------------
8 +import { SVGProps } from "react";
9 +
10 +type P = SVGProps<SVGSVGElement> & { size?: number };
11 +
12 +function Base({ size = 16, children, ...rest }: P) {
13 + return (
14 + <svg
15 + width={size} height={size} viewBox="0 0 24 24" fill="none"
16 + stroke="currentColor" strokeWidth="2" strokeLinecap="square"
17 + strokeLinejoin="miter" aria-hidden="true" focusable="false" {...rest}
18 + >
19 + {children}
20 + </svg>
21 + );
22 +}
23 +
24 +/** Maison au trait — pignon marqué + porte décalée (signature Lou-Ka) */
25 +export const IcoHouse = (p: P) => (
26 + <Base {...p}>
27 + <path d="M4 11 12 4l8 7" />
28 + <path d="M6 10v10h12V10" />
29 + <path d="M13.5 20v-6h-3v6" />
30 + </Base>
31 +);
32 +
33 +/** Immeuble à logements — fenêtres en grille */
34 +export const IcoBuilding = (p: P) => (
35 + <Base {...p}>
36 + <path d="M5 20V5h10v15" />
37 + <path d="M15 9h4v11" />
38 + <path d="M3 20h18" />
39 + <path d="M8 8.5h1.5M11 8.5h1.5M8 12h1.5M11 12h1.5M8 15.5h1.5M11 15.5h1.5" strokeWidth="1.7" />
40 + </Base>
41 +);
42 +
43 +export const IcoCamera = (p: P) => (
44 + <Base {...p}>
45 + <path d="M3 7h4l2-2.5h6L17 7h4v13H3z" />
46 + <circle cx="12" cy="13" r="3.4" />
47 + </Base>
48 +);
49 +
50 +export const IcoPaw = (p: P) => (
51 + <Base {...p}>
52 + <path d="M12 12.5c-3 0-5 2.2-5 4.4 0 1.6 1.2 2.6 2.6 2.6 1 0 1.6-.4 2.4-.4s1.4.4 2.4.4c1.4 0 2.6-1 2.6-2.6 0-2.2-2-4.4-5-4.4z" />
53 + <path d="M7.2 10.2c.5 1-.1 2-1 2s-1.8-.8-1.8-1.9c0-1 .8-1.6 1.5-1.4.6.1 1 .6 1.3 1.3z" strokeWidth="1.7" />
54 + <path d="M16.8 10.2c-.5 1 .1 2 1 2s1.8-.8 1.8-1.9c0-1-.8-1.6-1.5-1.4-.6.1-1 .6-1.3 1.3z" strokeWidth="1.7" />
55 + <path d="M10.4 7.5c.3 1.1-.3 2.1-1.2 2.1-1 0-1.7-1-1.6-2.1.1-1 .9-1.7 1.7-1.5.6.2 1 .8 1.1 1.5z" strokeWidth="1.7" />
56 + <path d="M13.6 7.5c-.3 1.1.3 2.1 1.2 2.1 1 0 1.7-1 1.6-2.1-.1-1-.9-1.7-1.7-1.5-.6.2-1 .8-1.1 1.5z" strokeWidth="1.7" />
57 + </Base>
58 +);
59 +
60 +export const IcoSofa = (p: P) => (
61 + <Base {...p}>
62 + <path d="M5 11V7.5A1.5 1.5 0 0 1 6.5 6h11A1.5 1.5 0 0 1 19 7.5V11" />
63 + <path d="M3 13.5A1.9 1.9 0 0 1 5 11.5c1.1 0 2 .9 2 2V14h10v-.5c0-1.1.9-2 2-2a1.9 1.9 0 0 1 2 2V18H3z" />
64 + <path d="M5 18v1.8M19 18v1.8" />
65 + </Base>
66 +);
67 +
68 +export const IcoBolt = (p: P) => (
69 + <Base {...p}>
70 + <path d="M13 2 5 13.5h5.5L11 22l8-11.5h-5.5z" />
71 + </Base>
72 +);
73 +
74 +export const IcoList = (p: P) => (
75 + <Base {...p}>
76 + <path d="M4 6h2M4 12h2M4 18h2" />
77 + <path d="M9.5 6H20M9.5 12H20M9.5 18H20" />
78 + </Base>
79 +);
80 +
81 +/** Carte pliée (plan) */
82 +export const IcoMap = (p: P) => (
83 + <Base {...p}>
84 + <path d="M3 6.5 9 4l6 2.5L21 4v13.5L15 20l-6-2.5L3 20z" />
85 + <path d="M9 4v13.5M15 6.5V20" />
86 + </Base>
87 +);
88 +
89 +export const IcoPin = (p: P) => (
90 + <Base {...p}>
91 + <path d="M12 21s-6.5-6-6.5-11a6.5 6.5 0 0 1 13 0c0 5-6.5 11-6.5 11z" />
92 + <circle cx="12" cy="9.7" r="2.2" />
93 + </Base>
94 +);
95 +
96 +/** Curseurs de filtres */
97 +export const IcoSliders = (p: P) => (
98 + <Base {...p}>
99 + <path d="M4 8h10M18 8h2M4 16h2M10 16h10" />
100 + <path d="M14 5.8v4.4M6 13.8v4.4" />
101 + </Base>
102 +);
103 +
104 +export const IcoSearch = (p: P) => (
105 + <Base {...p}>
106 + <circle cx="11" cy="11" r="7" />
107 + <path d="m20 20-3.5-3.5" />
108 + </Base>
109 +);
110 +
111 +export const IcoAlert = (p: P) => (
112 + <Base {...p}>
113 + <path d="M12 3 2.5 20h19z" />
114 + <path d="M12 9.5v4.5" />
115 + <path d="M12 17.2v.1" strokeWidth="2.6" />
116 + </Base>
117 +);
118 +
119 +export const IcoCompass = (p: P) => (
120 + <Base {...p}>
121 + <circle cx="12" cy="12" r="9" />
122 + <path d="m15.5 8.5-2.2 5-4.8 2 2.2-5z" />
123 + </Base>
124 +);
125 +
126 +export const IcoChart = (p: P) => (
127 + <Base {...p}>
128 + <path d="M4 20V4" />
129 + <path d="M4 20h16" />
130 + <path d="M8.5 16v-5M13 16V8M17.5 16v-3.5" />
131 + </Base>
132 +);
133 +
134 +export const IcoFolder = (p: P) => (
135 + <Base {...p}>
136 + <path d="M3 6h6l2 2.5h10V19H3z" />
137 + </Base>
138 +);
139 +
140 +export const IcoLock = (p: P) => (
141 + <Base {...p}>
142 + <rect x="5" y="11" width="14" height="9" />
143 + <path d="M8 11V7.8A3.8 3.8 0 0 1 12 4a3.8 3.8 0 0 1 4 3.8V11" />
144 + </Base>
145 +);
146 +
147 +export const IcoDoc = (p: P) => (
148 + <Base {...p}>
149 + <path d="M6 3h8l4 4v14H6z" />
150 + <path d="M14 3v4h4" />
151 + <path d="M9 12h6M9 15.5h6" />
152 + </Base>
153 +);
154 +
155 +export const IcoChevronLeft = (p: P) => (
156 + <Base {...p}>
157 + <path d="m14.5 5-7 7 7 7" />
158 + </Base>
159 +);
160 +
161 +export const IcoChevronRight = (p: P) => (
162 + <Base {...p}>
163 + <path d="m9.5 5 7 7-7 7" />
164 + </Base>
165 +);
modified frontend/src/components/ListingCard.tsx +3 −2
@@ -5,6 +5,7 @@
5 5 // -----------------------------------------------------------------------------
6 6 import { Link } from "react-router-dom";
7 7 import { Listing, fmtPrice, sourceName } from "../api";
8 +import { IcoBuilding, IcoCamera } from "./Icons";
8 9
9 10 export default function ListingCard({ l }: { l: Listing }) {
10 11 const img = l.images && l.images.length > 0 ? l.images[0] : null;
@@ -14,11 +15,11 @@ export default function ListingCard({ l }: { l: Listing }) {
14 15 {img ? (
15 16 <img src={img} alt={l.title} loading="lazy" />
16 17 ) : (
17 − <div className="noimg">🏠</div>
18 + <div className="noimg"><IcoBuilding size={38} strokeWidth={1.6} /></div>
18 19 )}
19 20 {l.unit_type && <span className="badge type">{l.unit_type}</span>}
20 21 {l.images.length > 1 && (
21 − <span className="badge right">📷 {l.images.length}</span>
22 + <span className="badge right"><IcoCamera size={12} /> {l.images.length}</span>
22 23 )}
23 24 </div>
24 25 <div className="card-body">
modified frontend/src/components/MapView.tsx +5 −1
@@ -40,7 +40,11 @@ function popupHTML(p: Record<string, unknown>): string {
40 40 : (p.price_label as string) || "Prix sur demande";
41 41 const img = p.image
42 42 ? `<img src="${p.image}" alt="" loading="lazy"/>`
43 − : `<div class="mv-noimg">🏠</div>`;
43 + : `<div class="mv-noimg"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"
44 + stroke="currentColor" stroke-width="1.6" stroke-linecap="square">
45 + <path d="M5 20V5h10v15"/><path d="M15 9h4v11"/><path d="M3 20h18"/>
46 + <path d="M8 8.5h1.5M11 8.5h1.5M8 12h1.5M11 12h1.5M8 15.5h1.5M11 15.5h1.5"/>
47 + </svg></div>`;
44 48 const meta = [p.unit_type, p.area_sqft ? `${Math.round(Number(p.area_sqft))} pi²` : "",
45 49 fmtDispo(p.availability_date as string | null)]
46 50 .filter(Boolean).join(" · ");
added frontend/src/components/Pager.tsx +73 −0
@@ -0,0 +1,73 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/Pager.tsx : pagination éditoriale (12 annonces/page)
5 +// Précédent / numéros avec ellipses / Suivant + rappel « X–Y sur N ».
6 +// -----------------------------------------------------------------------------
7 +import { IcoChevronLeft, IcoChevronRight } from "./Icons";
8 +
9 +/** 1 … 4 [5] 6 … 12 — fenêtre autour de la page courante, bornes toujours visibles */
10 +function pageItems(page: number, pages: number): (number | "…")[] {
11 + if (pages <= 7) return Array.from({ length: pages }, (_, i) => i + 1);
12 + const win = new Set<number>([1, 2, page - 1, page, page + 1, pages - 1, pages]);
13 + const items: (number | "…")[] = [];
14 + let prev = 0;
15 + for (let n = 1; n <= pages; n++) {
16 + if (!win.has(n)) continue;
17 + if (prev && n - prev > 1) items.push("…");
18 + items.push(n);
19 + prev = n;
20 + }
21 + return items;
22 +}
23 +
24 +export default function Pager({ page, pageSize, total, onPage }: {
25 + page: number;
26 + pageSize: number;
27 + total: number;
28 + onPage: (p: number) => void;
29 +}) {
30 + const pages = Math.max(1, Math.ceil(total / pageSize));
31 + if (pages <= 1) return null;
32 + const from = (page - 1) * pageSize + 1;
33 + const to = Math.min(page * pageSize, total);
34 + return (
35 + <nav className="pager" aria-label="Pagination des résultats">
36 + <span className="pager-info">
37 + {from}–{to} <em>sur</em> {total.toLocaleString("fr-CA")}
38 + </span>
39 + <div className="pager-nav">
40 + <button
41 + className="pager-btn pager-arrow"
42 + disabled={page <= 1}
43 + onClick={() => onPage(page - 1)}
44 + aria-label="Page précédente"
45 + >
46 + <IcoChevronLeft size={14} />
47 + </button>
48 + {pageItems(page, pages).map((it, i) =>
49 + it === "…" ? (
50 + <span key={`e${i}`} className="pager-ellipsis" aria-hidden="true">…</span>
51 + ) : (
52 + <button
53 + key={it}
54 + className={`pager-btn ${it === page ? "on" : ""}`}
55 + aria-current={it === page ? "page" : undefined}
56 + onClick={() => it !== page && onPage(it)}
57 + >
58 + {it}
59 + </button>
60 + ),
61 + )}
62 + <button
63 + className="pager-btn pager-arrow"
64 + disabled={page >= pages}
65 + onClick={() => onPage(page + 1)}
66 + aria-label="Page suivante"
67 + >
68 + <IcoChevronRight size={14} />
69 + </button>
70 + </div>
71 + </nav>
72 + );
73 +}
modified frontend/src/pages/Home.tsx +62 −25
@@ -5,7 +5,7 @@
5 5 // Filtres : ville, quartier, taille, loyer min/max, dispo, animaux, meublé,
6 6 // superficie, gestionnaire, recherche — avec pastilles de filtres actifs.
7 7 // -----------------------------------------------------------------------------
8 −import { Suspense, lazy, useEffect, useMemo, useState } from "react";
8 +import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";
9 9 import { useSearchParams } from "react-router-dom";
10 10 import {
11 11 Facets, Listing, ListingFilters, Stats,
@@ -13,10 +13,15 @@ import {
13 13 registerSourceNames, sourceName,
14 14 } from "../api";
15 15 import ListingCard from "../components/ListingCard";
16 +import Pager from "../components/Pager";
17 +import {
18 + IcoAlert, IcoBolt, IcoList, IcoMap, IcoPaw, IcoSearch, IcoSliders, IcoSofa,
19 +} from "../components/Icons";
16 20
17 21 // La carte (MapLibre, ~220 ko) n'est chargée que si l'utilisateur l'ouvre
18 22 const MapView = lazy(() => import("../components/MapView"));
19 23
24 +const PAGE_SIZE = 12; // annonces par page (grille « liste »)
20 25 const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"];
21 26 const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000];
22 27 const AREA_STEPS = [400, 600, 800, 1000, 1200];
@@ -88,22 +93,52 @@ export default function Home() {
88 93 .catch(() => setSectors([]));
89 94 }, [city]);
90 95
96 + // pagination — 12 annonces/page, page mémorisée dans l'URL (?page=N)
97 + const [page, setPage] = useState(() => {
98 + const p = Number(params.get("page"));
99 + return Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1;
100 + });
101 + const resultsRef = useRef<HTMLDivElement | null>(null);
102 + const filtersKey = useMemo(() => JSON.stringify(filters), [filters]);
103 + const firstRun = useRef(true);
104 + useEffect(() => {
105 + // tout changement de filtre ramène à la page 1 (sauf au montage initial,
106 + // pour respecter un lien partagé /?city=…&page=3)
107 + if (firstRun.current) { firstRun.current = false; return; }
108 + setPage(1);
109 + }, [filtersKey]);
110 + useEffect(() => {
111 + // refléter la page dans l'URL sans re-render du routeur
112 + const url = new URL(window.location.href);
113 + if (page > 1) url.searchParams.set("page", String(page));
114 + else url.searchParams.delete("page");
115 + window.history.replaceState(null, "", url);
116 + }, [page]);
117 +
118 + const goToPage = (p: number) => {
119 + setPage(p);
120 + resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
121 + };
122 +
91 123 useEffect(() => {
92 124 let cancelled = false;
93 125 setListings(null);
94 126 setError(null);
95 − fetchListings(filters)
127 + fetchListings(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE)
96 128 .then((r) => {
97 129 if (!cancelled) {
98 130 setListings(r.listings);
99 131 setTotal(r.total);
132 + // page devenue hors bornes (filtre resserré) : revenir à la dernière
133 + const last = Math.max(1, Math.ceil(r.total / PAGE_SIZE));
134 + if (page > last) setPage(last);
100 135 }
101 136 })
102 137 .catch((e) => !cancelled && setError(String(e)));
103 138 return () => {
104 139 cancelled = true;
105 140 };
106 − }, [filters]);
141 + }, [filters, page]);
107 142
108 143 const availableTypes = useMemo(() => {
109 144 const set = new Set(facets?.unit_types ?? []);
@@ -186,9 +221,7 @@ export default function Home() {
186 221 {/* — rangée principale : recherche, ville, quartier, loyer, + filtres — */}
187 222 <div className="f-primary">
188 223 <div className="f-search">
189 − <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
190 − <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
191 − </svg>
224 + <IcoSearch size={15} strokeWidth={2.4} />
192 225 <input
193 226 id="f-q" placeholder="Adresse, quartier, rue…" value={q}
194 227 onChange={(e) => setQ(e.target.value)}
@@ -277,10 +310,10 @@ export default function Home() {
277 310 <div className="f-group">
278 311 <label>Animaux</label>
279 312 <div className="seg" role="group">
280 − {[["", "Peu importe"], ["oui", "🐾 Acceptés"]].map(([v, l]) => (
313 + {[["", "Peu importe"], ["oui", "Acceptés"]].map(([v, l]) => (
281 314 <button key={v} className={pets === v ? "on" : ""}
282 315 onClick={() => setPets(v)}>
283 − {l}
316 + {v === "oui" && <IcoPaw size={13} />} {l}
284 317 </button>
285 318 ))}
286 319 </div>
@@ -335,22 +368,22 @@ export default function Home() {
335 368 ))}
336 369 <span className="chip-sep" aria-hidden="true" />
337 370 <button
338 − className={`chip ${dispo === "now" ? "on" : ""}`}
371 + className={`chip chip-ico ${dispo === "now" ? "on" : ""}`}
339 372 onClick={() => setDispo(dispo === "now" ? "" : "now")}
340 373 >
341 − ⚡ Dispo maintenant
374 + <IcoBolt size={13} /> Dispo maintenant
342 375 </button>
343 376 <button
344 − className={`chip ${pets === "oui" ? "on" : ""}`}
377 + className={`chip chip-ico ${pets === "oui" ? "on" : ""}`}
345 378 onClick={() => setPets(pets === "oui" ? "" : "oui")}
346 379 >
347 − 🐾 Animaux ok
380 + <IcoPaw size={13} /> Animaux ok
348 381 </button>
349 382 <button
350 − className={`chip ${furnished === "1" ? "on" : ""}`}
383 + className={`chip chip-ico ${furnished === "1" ? "on" : ""}`}
351 384 onClick={() => setFurnished(furnished === "1" ? "" : "1")}
352 385 >
353 − 🛋 Meublé
386 + <IcoSofa size={13} /> Meublé
354 387 </button>
355 388 </div>
356 389
@@ -368,7 +401,7 @@ export default function Home() {
368 401 </div>
369 402 )}
370 403
371 − <div className="results-head">
404 + <div className="results-head" ref={resultsRef}>
372 405 <h2>Logements disponibles</h2>
373 406 <div className="results-tools">
374 407 {listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>}
@@ -378,14 +411,14 @@ export default function Home() {
378 411 className={view === "liste" ? "on" : ""}
379 412 onClick={() => setView("liste")}
380 413 >
381 − ☰ Liste
414 + <IcoList size={13} /> Liste
382 415 </button>
383 416 <button
384 417 role="tab" aria-selected={view === "carte"}
385 418 className={view === "carte" ? "on" : ""}
386 419 onClick={() => setView("carte")}
387 420 >
388 − ◈ Carte
421 + <IcoMap size={13} /> Carte
389 422 </button>
390 423 </div>
391 424 </div>
@@ -393,7 +426,7 @@ export default function Home() {
393 426
394 427 {error && (
395 428 <div className="notice">
396 − <div className="big">⚠️</div>
429 + <div className="big"><IcoAlert size={40} /></div>
397 430 <h2>Impossible de charger les annonces</h2>
398 431 <p>{error}</p>
399 432 <button className="btn btn-primary" onClick={() => window.location.reload()}>
@@ -416,7 +449,7 @@ export default function Home() {
416 449
417 450 {!error && view === "liste" && listings !== null && listings.length === 0 && (
418 451 <div className="notice">
419 − <div className="big">🔍</div>
452 + <div className="big"><IcoSearch size={40} /></div>
420 453 <h2>Aucun logement ne correspond</h2>
421 454 <p>
422 455 Essayez d'élargir vos critères
@@ -433,6 +466,7 @@ export default function Home() {
433 466 {(listings ?? []).map((l) => (
434 467 <ListingCard key={l.uid} l={l} />
435 468 ))}
469 + <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} />
436 470 </div>
437 471 <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>
438 472 <MapView filters={filters} />
@@ -441,11 +475,14 @@ export default function Home() {
441 475 )}
442 476
443 477 {!error && view === "liste" && listings !== null && listings.length > 0 && (
444 − <div className="grid">
445 − {listings.map((l) => (
446 − <ListingCard key={l.uid} l={l} />
447 − ))}
448 − </div>
478 + <>
479 + <div className="grid">
480 + {listings.map((l) => (
481 + <ListingCard key={l.uid} l={l} />
482 + ))}
483 + </div>
484 + <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} />
485 + </>
449 486 )}
450 487
451 488 {/* Bouton flottant mobile — ouvre la feuille de filtres */}
@@ -454,7 +491,7 @@ export default function Home() {
454 491 onClick={() => setSheetOpen(true)}
455 492 aria-label="Ouvrir les filtres"
456 493 >
457 − ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}
494 + <IcoSliders size={15} /> Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}
458 495 </button>
459 496 </div>
460 497 );
modified frontend/src/pages/Listing.tsx +8 −3
@@ -12,6 +12,7 @@ import { useEffect, useRef, useState } from "react";
12 12 import { Link, useParams } from "react-router-dom";
13 13 import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api";
14 14 import QuartierBlock from "../components/QuartierBlock";
15 +import { IcoAlert, IcoBuilding, IcoDoc } from "../components/Icons";
15 16
16 17 // Icônes et libellés des commodités de proximité (louka/poi.py)
17 18 const POI_META: Record<string, { icon: string; label: string }> = {
@@ -105,7 +106,11 @@ function Galerie({ images, titre }: { images: string[]; titre: string }) {
105 106 track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
106 107
107 108 if (images.length === 0)
108 − return <div className="carousel"><div className="noimg carousel-empty">🏠</div></div>;
109 + return (
110 + <div className="carousel">
111 + <div className="noimg carousel-empty"><IcoBuilding size={52} strokeWidth={1.4} /></div>
112 + </div>
113 + );
109 114
110 115 return (
111 116 <>
@@ -161,7 +166,7 @@ export default function ListingPage() {
161 166 if (error)
162 167 return (
163 168 <div className="notice container">
164 − <div className="big">⚠️</div>
169 + <div className="big"><IcoAlert size={40} /></div>
165 170 <h2>Annonce introuvable</h2>
166 171 <p>{error}</p>
167 172 <Link className="btn btn-primary" to="/">Retour aux logements</Link>
@@ -306,7 +311,7 @@ export default function ListingPage() {
306 311 </a>
307 312 <a className="btn btn-ghost btn-pdf"
308 313 href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download>
309 − 📄 Télécharger la fiche (PDF)
314 + <IcoDoc size={14} /> Télécharger la fiche (PDF)
310 315 </a>
311 316 </section>
312 317
modified frontend/src/pages/Stats.tsx +6 −5
@@ -7,6 +7,7 @@
7 7 // -----------------------------------------------------------------------------
8 8 import { useEffect, useMemo, useState } from "react";
9 9 import { Link, useSearchParams } from "react-router-dom";
10 +import { IcoChart, IcoFolder, IcoHouse, IcoMap } from "../components/Icons";
10 11 import {
11 12 DetailedStats, GroupStat,
12 13 fetchDetailedStats, fetchSources, registerSourceNames, sourceName,
@@ -17,11 +18,11 @@ const fmt = (n: number | null | undefined) =>
17 18 const fmt$ = (n: number | null | undefined) => (n == null ? "—" : `${fmt(n)} $`);
18 19
19 20 const ONGLETS = [
20 − { id: "ensemble", label: "Vue d'ensemble", icon: "◎" },
21 − { id: "loyers", label: "Loyers", icon: "$" },
22 − { id: "regions", label: "Régions & villes", icon: "◈" },
23 − { id: "offre", label: "L'offre", icon: "🏠" },
24 − { id: "gestionnaires", label: "Gestionnaires", icon: "🗂" },
21 + { id: "ensemble", label: "Vue d'ensemble", icon: <IcoChart size={14} /> },
22 + { id: "loyers", label: "Loyers", icon: <span className="mono">$</span> },
23 + { id: "regions", label: "Régions & villes", icon: <IcoMap size={14} /> },
24 + { id: "offre", label: "L'offre", icon: <IcoHouse size={14} /> },
25 + { id: "gestionnaires", label: "Gestionnaires", icon: <IcoFolder size={14} /> },
25 26 ] as const;
26 27 type OngletId = (typeof ONGLETS)[number]["id"];
27 28
modified frontend/src/styles.css +48 −0
@@ -1071,3 +1071,51 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1071 1071 font-size: 11px; margin-left: 8px;
1072 1072 }
1073 1073 .alertes { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--ink-2); }
1074 +
1075 +/* ================= Pagination (12 annonces/page) ================= */
1076 +.pager {
1077 + display: flex; align-items: center; justify-content: space-between;
1078 + gap: 16px; flex-wrap: wrap; margin: 26px 0 10px; padding-top: 18px;
1079 + border-top: 2px solid var(--ink);
1080 +}
1081 +.pager-info {
1082 + font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
1083 + letter-spacing: 0.04em;
1084 +}
1085 +.pager-info em { font-style: normal; color: var(--ink-3); }
1086 +.pager-nav { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
1087 +.pager-btn {
1088 + min-width: 38px; height: 38px; padding: 0 10px;
1089 + display: inline-flex; align-items: center; justify-content: center;
1090 + background: var(--surface); border: 1.5px solid var(--ink);
1091 + border-radius: var(--r-ctl); cursor: pointer;
1092 + font-family: var(--font-mono); font-size: 13px; font-weight: 600;
1093 + color: var(--ink); transition: all 0.12s ease;
1094 +}
1095 +.pager-btn:hover:not(:disabled):not(.on) {
1096 + background: var(--lime-soft); transform: translate(-1px, -1px);
1097 + box-shadow: 3px 3px 0 var(--ink);
1098 +}
1099 +.pager-btn.on { background: var(--ink); color: var(--lime); cursor: default; }
1100 +.pager-btn:disabled { opacity: 0.35; cursor: not-allowed; border-color: var(--line-strong); }
1101 +.pager-ellipsis { color: var(--ink-3); font-family: var(--font-mono); padding: 0 2px; }
1102 +.map-list .pager { margin: 10px 0 4px; }
1103 +@media (max-width: 640px) {
1104 + .pager { justify-content: center; }
1105 + .pager-info { width: 100%; text-align: center; order: 2; }
1106 +}
1107 +
1108 +/* ================= Icônes SVG inline — alignement ================= */
1109 +.chip-ico { display: inline-flex; align-items: center; gap: 6px; }
1110 +.chip-ico svg { flex: none; }
1111 +.view-toggle button, .seg button { display: inline-flex; align-items: center; gap: 6px; }
1112 +.fab { align-items: center; gap: 8px; }
1113 +.fab svg { flex: none; }
1114 +.mm-ico { display: inline-flex; align-items: center; }
1115 +.notice .big { display: flex; justify-content: center; color: var(--ink-3); }
1116 +.notice .big svg { display: block; }
1117 +.card-img .noimg { color: var(--ink-3); }
1118 +.badge.right { display: inline-flex; align-items: center; gap: 4px; }
1119 +.btn-pdf { display: inline-flex; align-items: center; gap: 7px; }
1120 +.f-search svg { flex: none; color: var(--ink-3); }
1121 +.mv-noimg { display: flex; align-items: center; justify-content: center; color: var(--ink-3); }
1074 1122