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)
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 | |