SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

feat: iconographie maison (Icons.tsx, ~30 icônes trait 1,7px) remplace tous les emojis + filtre prix bidon Kijiji (<5000$)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent 500e33f

10 changed files +260 −40

modified frontend/src/App.tsx +7 −6
@@ -3,6 +3,7 @@
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 // App.tsx : layout global (header + ticker en direct + footer encre) et routage
5 5 // -----------------------------------------------------------------------------
6 +import { Ico } from "./components/Icons";
6 7 import { useEffect, useState } from "react";
7 8 import { NavLink, Route, Routes, useLocation } from "react-router-dom";
8 9 import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";
@@ -43,10 +44,10 @@ function Ticker() {
43 44 }
44 45
45 46 const NAV_LINKS = [
46 − { to: "/", label: "Propriétés", icon: "🏠", end: true },
47 − { to: "/?view=carte", label: "Carte", icon: "◈", end: false, force: true },
48 − { to: "/stats", label: "Stats", icon: "📊", end: false },
49 − { to: "/agences", label: "Agences", icon: "🗂", end: false },
47 + { to: "/", label: "Propriétés", icon: "home", end: true },
48 + { to: "/?view=carte", label: "Carte", icon: "map", end: false, force: true },
49 + { to: "/stats", label: "Stats", icon: "chart", end: false },
50 + { to: "/agences", label: "Agences", icon: "building", end: false },
50 51 ];
51 52
52 53 function Header() {
@@ -98,9 +99,9 @@ function Header() {
98 99 className={({ isActive }) => `mm-link ${isActive && !l.force ? "active" : ""}`}
99 100 onClick={() => setOpen(false)}
100 101 >
101 − <span className="mm-ico" aria-hidden="true">{l.icon}</span>
102 + <span className="mm-ico" aria-hidden="true"><Ico name={l.icon} size={19} /></span>
102 103 {l.label}
103 − <span className="mm-arrow" aria-hidden="true">→</span>
104 + <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span>
104 105 </NavLink>
105 106 ))}
106 107 <div className="mm-foot">
added frontend/src/components/Icons.tsx +183 −0
@@ -0,0 +1,183 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/Icons.tsx : iconographie MAISON — traits 1,7 px sur grille 24,
5 +// dessinée pour Immo-Ka (zéro emoji, zéro pack générique). Chaque icône est
6 +// un tracé « stroke » net qui hérite de la couleur du texte (currentColor).
7 +// -----------------------------------------------------------------------------
8 +import { ReactNode } from "react";
9 +
10 +const P: Record<string, ReactNode> = {
11 + // --- navigation -----------------------------------------------------------
12 + home: (<>
13 + <path d="M3.5 10.6 12 3.4l8.5 7.2" />
14 + <path d="M5.6 9.4V20h12.8V9.4" />
15 + <path d="M10 20v-5.6h4V20" />
16 + </>),
17 + map: (<>
18 + <path d="M3.2 6.4 9 4.2l6 2.2 5.8-2.2v13.4L15 19.8l-6-2.2-5.8 2.2z" />
19 + <path d="M9 4.2v13.4M15 6.4v13.4" />
20 + </>),
21 + chart: (<>
22 + <path d="M4 20h16" />
23 + <path d="M7.2 20v-5.6M12 20V8.8M16.8 20V12" />
24 + </>),
25 + building: (<>
26 + <path d="M5 20V5.6A1.6 1.6 0 0 1 6.6 4h6.2a1.6 1.6 0 0 1 1.6 1.6V20" />
27 + <path d="M14.4 9.4h3.4A1.6 1.6 0 0 1 19.4 11v9" />
28 + <path d="M3.2 20h17.6" />
29 + <path d="M8 8h2.4M8 11.6h2.4M8 15.2h2.4M16.4 13h.9M16.4 16.2h.9" />
30 + </>),
31 +
32 + // --- specs propriété --------------------------------------------------------
33 + bed: (<>
34 + <path d="M3.4 6.8V18.6" />
35 + <path d="M3.4 15.4h17.2v3.2" />
36 + <path d="M3.4 12.2h6.2v3.2" />
37 + <circle cx="6.5" cy="9.9" r="1.35" />
38 + <path d="M11.4 12.2h5.8a3.4 3.4 0 0 1 3.4 3.2" />
39 + </>),
40 + bath: (<>
41 + <path d="M3.6 12.4h16.8v1.4a5.2 5.2 0 0 1-5.2 5.2H8.8a5.2 5.2 0 0 1-5.2-5.2z" />
42 + <path d="M5.8 12.4V5.9a2.1 2.1 0 0 1 4-1" />
43 + <path d="m7 19.4-1 2.1M17 19.4l1 2.1" />
44 + </>),
45 + drop: (<>
46 + <path d="M12 3.6s6 6.4 6 10.6a6 6 0 1 1-12 0C6 10 12 3.6 12 3.6z" />
47 + <path d="M9.4 14.2a2.7 2.7 0 0 0 2 2.6" />
48 + </>),
49 + area: (<>
50 + <path d="M4.4 19.6 19.6 4.4" />
51 + <path d="M4.4 14v5.6H10" />
52 + <path d="M19.6 10V4.4H14" />
53 + </>),
54 + land: (<>
55 + <path d="M4.4 9.6v9.8M9.5 9.6v9.8M14.5 9.6v9.8M19.6 9.6v9.8" />
56 + <path d="M3 12.6h18M3 16.4h18" />
57 + <path d="M4.4 9.6 12 5.2l7.6 4.4" />
58 + </>),
59 + calendar: (<>
60 + <rect x="4" y="5.6" width="16" height="14.8" rx="2" />
61 + <path d="M4 10.2h16M8.2 3.4v4M15.8 3.4v4" />
62 + </>),
63 + tag: (<>
64 + <path d="m12.9 3.6 7.5 7.5a1.8 1.8 0 0 1 0 2.5l-6.8 6.8a1.8 1.8 0 0 1-2.5 0L3.6 12.9V3.6z" />
65 + <circle cx="8" cy="8" r="1.5" />
66 + </>),
67 + camera: (<>
68 + <rect x="3.4" y="7" width="17.2" height="13" rx="2" />
69 + <path d="M8.6 7 10 4.4h4L15.4 7" />
70 + <circle cx="12" cy="13.2" r="3.4" />
71 + </>),
72 +
73 + // --- actions / états ----------------------------------------------------------
74 + search: (<>
75 + <circle cx="10.6" cy="10.6" r="6.2" />
76 + <path d="m15.3 15.3 5.3 5.3" />
77 + </>),
78 + arrow: (<>
79 + <path d="M4 12h15.2" />
80 + <path d="m13.8 6.4 5.4 5.6-5.4 5.6" />
81 + </>),
82 + check: (<path d="m5 12.8 4.3 4.4L19 7.4" />),
83 + alert: (<>
84 + <path d="M12 4.2 2.9 19.4h18.2z" />
85 + <path d="M12 10.2v4.4" />
86 + <path d="M12 17.4v.05" />
87 + </>),
88 + phone: (<path d="M5.2 4h3.6L10.4 8.4 8.3 10.1a12.5 12.5 0 0 0 5.6 5.6l1.7-2.1 4.4 1.6v3.6a1.8 1.8 0 0 1-1.9 1.8A16.4 16.4 0 0 1 3.4 5.9 1.8 1.8 0 0 1 5.2 4z" />),
89 + trendup: (<>
90 + <path d="m3.6 17.4 6-6.4 4 3.6 6.8-7.8" />
91 + <path d="M15.6 6.8h4.8v4.8" />
92 + </>),
93 + trenddown: (<>
94 + <path d="m3.6 6.8 6 6.4 4-3.6 6.8 7.8" />
95 + <path d="M15.6 17.4h4.8v-4.8" />
96 + </>),
97 + external: (<>
98 + <path d="M14.4 4h5.6v5.6" />
99 + <path d="M20 4 11.4 12.6" />
100 + <path d="M18.6 13.6V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7.4a2 2 0 0 1 2-2h4.4" />
101 + </>),
102 + pin: (<>
103 + <path d="M12 21.2S5.4 15.7 5.4 10.8a6.6 6.6 0 0 1 13.2 0c0 4.9-6.6 10.4-6.6 10.4z" />
104 + <circle cx="12" cy="10.6" r="2.3" />
105 + </>),
106 +
107 + // --- quartier ---------------------------------------------------------------------
108 + leaf: (<>
109 + <path d="M5 19.2C5 9.6 12 5 20 4.2c0 9-4.2 15-13.2 15z" />
110 + <path d="M5 19.2C7 14 11 10 15 8" />
111 + </>),
112 + thermo: (<>
113 + <path d="M10.4 4.2a1.9 1.9 0 0 1 3.8 0v8.8a4.2 4.2 0 1 1-3.8 0z" />
114 + <path d="M12.3 8.4v7" />
115 + </>),
116 + sun: (<>
117 + <circle cx="12" cy="12" r="4.2" />
118 + <path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.8 5.8l1.4 1.4M16.8 16.8l1.4 1.4M18.2 5.8l-1.4 1.4M7.2 16.8l-1.4 1.4" />
119 + </>),
120 + shield: (<>
121 + <path d="M12 3.4 5.2 5.9v5.9c0 4.6 3 7.6 6.8 8.8 3.8-1.2 6.8-4.2 6.8-8.8V5.9z" />
122 + <path d="m9.2 12 2 2 3.8-4" />
123 + </>),
124 + cart: (<>
125 + <circle cx="9.6" cy="19.6" r="1.35" />
126 + <circle cx="17" cy="19.6" r="1.35" />
127 + <path d="M3.4 4.4h2.2l2.5 10.8h9.6l2.7-7.8H7" />
128 + </>),
129 + bus: (<>
130 + <rect x="4.6" y="3.8" width="14.8" height="13.4" rx="2.4" />
131 + <path d="M4.6 10.4h14.8" />
132 + <path d="M7.6 20.2v-3M16.4 20.2v-3" />
133 + <path d="M8.3 14h.05M15.7 14h.05" />
134 + </>),
135 + tree: (<>
136 + <path d="M12 3.4 6.8 11.4h2.8L5.4 17.8h13.2l-4.2-6.4h2.8z" />
137 + <path d="M12 17.8v3.4" />
138 + </>),
139 + school: (<>
140 + <path d="m12 4.2 9.8 4.4L12 13 2.2 8.6z" />
141 + <path d="M6.6 10.8v5c0 1.6 2.4 3 5.4 3s5.4-1.4 5.4-3v-5" />
142 + <path d="M21.8 8.6v5.4" />
143 + </>),
144 + health: (<>
145 + <circle cx="12" cy="12" r="8.4" />
146 + <path d="M12 8.4v7.2M8.4 12h7.2" />
147 + </>),
148 + pill: (<>
149 + <rect x="3.2" y="8.6" width="17.6" height="6.8" rx="3.4" transform="rotate(-33 12 12)" />
150 + <path d="M12 8.6v6.8" transform="rotate(-33 12 12)" />
151 + </>),
152 + people: (<>
153 + <circle cx="9" cy="8" r="3.2" />
154 + <path d="M3.6 20a5.4 5.4 0 0 1 10.8 0" />
155 + <path d="M15.4 5.4a3.2 3.2 0 0 1 0 5.9M17 14.8a5.4 5.4 0 0 1 3.4 5.2" />
156 + </>),
157 +};
158 +
159 +export type IconName = keyof typeof P;
160 +
161 +export function Ico({ name, size = 18, className = "", stroke = 1.7 }:
162 + { name: IconName | string; size?: number; className?: string; stroke?: number }) {
163 + const paths = P[name as IconName];
164 + if (!paths) return null;
165 + return (
166 + <svg
167 + className={`ico ${className}`}
168 + width={size} height={size} viewBox="0 0 24 24"
169 + fill="none" stroke="currentColor" strokeWidth={stroke}
170 + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
171 + >
172 + {paths}
173 + </svg>
174 + );
175 +}
176 +
177 +/** Version « chaîne HTML » pour les popups MapLibre (hors React). */
178 +export function icoHTML(name: IconName, size = 30): string {
179 + const d: Record<string, string> = {
180 + home: '<path d="M3.5 10.6 12 3.4l8.5 7.2"/><path d="M5.6 9.4V20h12.8V9.4"/><path d="M10 20v-5.6h4V20"/>',
181 + };
182 + return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${d[name] ?? d.home}</svg>`;
183 +}
modified frontend/src/components/ListingCard.tsx +8 −7
@@ -5,14 +5,15 @@
5 5 // -----------------------------------------------------------------------------
6 6 import { Link } from "react-router-dom";
7 7 import { Listing, fmtArea, fmtPrice, sourceName } from "../api";
8 +import { Ico } 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;
11 − const meta: string[] = [];
12 − if (l.bedrooms != null) meta.push(`${l.bedrooms} ch.`);
13 − if (l.bathrooms != null) meta.push(`${l.bathrooms} sdb`);
12 + const meta: { ico: string; txt: string }[] = [];
13 + if (l.bedrooms != null) meta.push({ ico: "bed", txt: `${l.bedrooms} ch.` });
14 + if (l.bathrooms != null) meta.push({ ico: "bath", txt: `${l.bathrooms} sdb` });
14 15 const area = fmtArea(l.area_sqft);
15 − if (area) meta.push(area);
16 + if (area) meta.push({ ico: "area", txt: area });
16 17
17 18 return (
18 19 <Link to={`/propriete/${encodeURIComponent(l.uid)}`} className="card">
@@ -20,11 +21,11 @@ export default function ListingCard({ l }: { l: Listing }) {
20 21 {img ? (
21 22 <img src={img} alt={l.title || l.address} loading="lazy" />
22 23 ) : (
23 − <div className="noimg">🏠</div>
24 + <div className="noimg"><Ico name="home" size={34} /></div>
24 25 )}
25 26 {l.property_type && <span className="badge type">{l.property_type}</span>}
26 27 {l.images.length > 1 && (
27 − <span className="badge right">📷 {l.images.length}</span>
28 + <span className="badge right"><Ico name="camera" size={12} /> {l.images.length}</span>
28 29 )}
29 30 </div>
30 31 <div className="card-body">
@@ -38,7 +39,7 @@ export default function ListingCard({ l }: { l: Listing }) {
38 39 {meta.length > 0 && (
39 40 <div className="card-specs">
40 41 {meta.map((m) => (
41 − <span key={m}>{m}</span>
42 + <span key={m.ico}><Ico name={m.ico} size={13} /> {m.txt}</span>
42 43 ))}
43 44 </div>
44 45 )}
modified frontend/src/components/MapView.tsx +0 −0

Binary file not shown.

modified frontend/src/components/QuartierBlock.tsx +18 −17
@@ -6,6 +6,7 @@
6 6 // proximité StatCan, îlot de chaleur/fraîcheur INSPQ, criminalité.
7 7 // -----------------------------------------------------------------------------
8 8 import { Quartier } from "../api";
9 +import { Ico } from "./Icons";
9 10
10 11 const fmtMoney = (v: number | null | undefined) =>
11 12 v == null ? null : v.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $";
@@ -13,24 +14,24 @@ const fmtPct = (v: number | null | undefined) =>
13 14 v == null ? null : Math.round(v) + " %";
14 15
15 16 // scores PMD affichés (clé backend -> libellé)
16 −const PROX_LABELS: [string, string][] = [
17 − ["prox_epicerie", "Épiceries"],
18 − ["prox_transport", "Transport en commun"],
19 − ["prox_parc", "Parcs"],
20 − ["prox_ecole_prim", "Écoles primaires"],
21 − ["prox_sante", "Soins de santé"],
22 − ["prox_pharmacie", "Pharmacies"],
17 +const PROX_LABELS: [string, string, string][] = [
18 + ["prox_epicerie", "Épiceries", "cart"],
19 + ["prox_transport", "Transport en commun", "bus"],
20 + ["prox_parc", "Parcs", "tree"],
21 + ["prox_ecole_prim", "Écoles primaires", "school"],
22 + ["prox_sante", "Soins de santé", "health"],
23 + ["prox_pharmacie", "Pharmacies", "pill"],
23 24 ];
24 25
25 26 function chaleurBadge(classe: number, ecart: number | null) {
26 27 if (classe <= 3)
27 − return { txt: "Îlot de fraîcheur", cls: "q-badge cool", ico: "🌿" };
28 + return { txt: "Îlot de fraîcheur", cls: "q-badge cool", ico: "leaf" };
28 29 if (classe >= 7)
29 30 return {
30 31 txt: `Îlot de chaleur${ecart != null ? ` (+${ecart.toFixed(1)} °C)` : ""}`,
31 − cls: "q-badge hot", ico: "🌡",
32 + cls: "q-badge hot", ico: "thermo",
32 33 };
33 − return { txt: "Température de quartier moyenne", cls: "q-badge neutral", ico: "🌤" };
34 + return { txt: "Température de quartier moyenne", cls: "q-badge neutral", ico: "sun" };
34 35 }
35 36
36 37 export default function QuartierBlock({ q }: { q: Quartier }) {
@@ -72,11 +73,11 @@ export default function QuartierBlock({ q }: { q: Quartier }) {
72 73
73 74 {proxOk.length > 0 && (
74 75 <div className="q-prox">
75 − {proxOk.map(([k, label]) => {
76 + {proxOk.map(([k, label, ico]) => {
76 77 const v = Math.max(0, Math.min(1, prox[k]));
77 78 return (
78 79 <div className="q-bar" key={k}>
79 − <span className="q-bar-label">{label}</span>
80 + <span className="q-bar-label"><Ico name={ico} size={14} /> {label}</span>
80 81 <span className="q-bar-track">
81 82 <span className="q-bar-fill" style={{ width: `${Math.round(v * 100)}%` }} />
82 83 </span>
@@ -91,11 +92,11 @@ export default function QuartierBlock({ q }: { q: Quartier }) {
91 92 <div className="q-badges">
92 93 {q.chaleur && (() => {
93 94 const b = chaleurBadge(q.chaleur.classe, q.chaleur.ecart);
94 − return <span className={b.cls}>{b.ico} {b.txt}</span>;
95 + return <span className={b.cls}><Ico name={b.ico} size={14} /> {b.txt}</span>;
95 96 })()}
96 97 {q.crime?.type === "points" && (
97 98 <span className="q-badge neutral">
98 − 🛡 {q.crime.douze_mois} acte{q.crime.douze_mois > 1 ? "s" : ""} criminel{q.crime.douze_mois > 1 ? "s" : ""} à
99 + <Ico name="shield" size={14} /> {q.crime.douze_mois} acte{q.crime.douze_mois > 1 ? "s" : ""} criminel{q.crime.douze_mois > 1 ? "s" : ""} à
99 100 moins de 500 m (12 mois)
100 101 {q.crime.douze_mois_precedents > 0 && (
101 102 q.crime.douze_mois <= q.crime.douze_mois_precedents
@@ -111,15 +112,15 @@ export default function QuartierBlock({ q }: { q: Quartier }) {
111 112 const sous = delta <= 0;
112 113 return (
113 114 <span className={`q-badge ${sous ? "cool" : "neutral"}`}>
114 − 🛡 Criminalité {Math.abs(delta)} % {sous ? "sous" : "au-dessus de"} la
115 − moyenne canadienne{sous ? " ✅" : ""}
115 + <Ico name="shield" size={14} /> Criminalité {Math.abs(delta)} % {sous ? "sous" : "au-dessus de"} la
116 + moyenne canadienne
116 117 <small className="q-badge-sub">({c.ville} {c.annee} : {c.indice} · Canada : {c.indice_canada})</small>
117 118 </span>
118 119 );
119 120 }
120 121 return (
121 122 <span className="q-badge neutral">
122 − 🛡 Gravité de la criminalité ({c.ville}, {c.annee}) : <b>{c.indice}</b>
123 + <Ico name="shield" size={14} /> Gravité de la criminalité ({c.ville}, {c.annee}) : <b>{c.indice}</b>
123 124 </span>
124 125 );
125 126 })()}
modified frontend/src/pages/Home.tsx +3 −2
@@ -11,6 +11,7 @@ import {
11 11 registerSourceNames, sourceName,
12 12 } from "../api";
13 13 import ListingCard from "../components/ListingCard";
14 +import { Ico } from "../components/Icons";
14 15
15 16 const MapView = lazy(() => import("../components/MapView"));
16 17
@@ -316,7 +317,7 @@ export default function Home() {
316 317
317 318 {error && (
318 319 <div className="notice">
319 − <div className="big">⚠️</div>
320 + <div className="big"><Ico name="alert" size={40} /></div>
320 321 <h2>Impossible de charger les annonces</h2>
321 322 <p>{error}</p>
322 323 <button className="btn btn-primary" onClick={() => window.location.reload()}>Réessayer</button>
@@ -333,7 +334,7 @@ export default function Home() {
333 334
334 335 {!error && view === "liste" && listings !== null && listings.length === 0 && (
335 336 <div className="notice">
336 − <div className="big">🔍</div>
337 + <div className="big"><Ico name="search" size={40} /></div>
337 338 <h2>Aucune propriété ne correspond</h2>
338 339 <p>Essayez d'élargir vos critères
339 340 {activeFilters > 0 && <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>}.</p>
modified frontend/src/pages/Listing.tsx +18 −7
@@ -14,6 +14,7 @@ import {
14 14
15 15 const PropertyMap = lazy(() => import("../components/PropertyMap"));
16 16 import QuartierBlock from "../components/QuartierBlock";
17 +import { Ico } from "../components/Icons";
17 18
18 19 // --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran --------
19 20 function Galerie({ images, titre }: { images: string[]; titre: string }) {
@@ -40,7 +41,7 @@ function Galerie({ images, titre }: { images: string[]; titre: string }) {
40 41 }, [zoom, images.length]);
41 42
42 43 if (images.length === 0)
43 − return <div className="carousel"><div className="noimg carousel-empty">🏠</div></div>;
44 + return <div className="carousel"><div className="noimg carousel-empty"><Ico name="home" size={44} /></div></div>;
44 45
45 46 return (
46 47 <>
@@ -79,6 +80,13 @@ function Galerie({ images, titre }: { images: string[]; titre: string }) {
79 80
80 81 const DETAIL_HIDDEN = new Set(["pieces", "price_from"]);
81 82
83 +// icône de chaque tuile « spec » (Icons.tsx)
84 +const SPEC_ICONS: Record<string, string> = {
85 + "Type": "home", "Chambres": "bed", "Salles de bain": "bath",
86 + "Salles d'eau": "drop", "Superficie": "area", "Terrain": "land",
87 + "Année": "calendar", "MLS / Centris": "tag",
88 +};
89 +
82 90 export default function ListingPage() {
83 91 const { uid } = useParams<{ uid: string }>();
84 92 const [l, setL] = useState<Listing | null>(null);
@@ -95,7 +103,7 @@ export default function ListingPage() {
95 103 if (error)
96 104 return (
97 105 <div className="notice container">
98 − <div className="big">⚠️</div>
106 + <div className="big"><Ico name="alert" size={44} /></div>
99 107 <h2>Propriété introuvable</h2>
100 108 <p>{error}</p>
101 109 <Link className="btn btn-primary" to="/">Retour aux propriétés</Link>
@@ -187,7 +195,7 @@ export default function ListingPage() {
187 195 <section className="f-bloc" id="inclusions">
188 196 <h2>Inclusions</h2>
189 197 <div className="amenity-row">
190 − {l.features.map((f, i) => <span className="amenity" key={i}>✓ {f}</span>)}
198 + {l.features.map((f, i) => <span className="amenity" key={i}><Ico name="check" size={13} /> {f}</span>)}
191 199 </div>
192 200 </section>
193 201 )}
@@ -219,13 +227,16 @@ export default function ListingPage() {
219 227
220 228 <div className="specs-grid">
221 229 {specs.map((s) => (
222 − <div className="spec" key={s.k}><b>{s.v}</b><span>{s.k}</span></div>
230 + <div className="spec" key={s.k}>
231 + <span className="spec-ico"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={17} /></span>
232 + <b>{s.v}</b><span>{s.k}</span>
233 + </div>
223 234 ))}
224 235 </div>
225 236
226 237 {baisse && (
227 238 <div className={`prix-histo ${baisse.a < baisse.de ? "down" : ""}`}>
228 − {baisse.a < baisse.de ? "📉" : "📈"} Prix passé de {fmtPrice(baisse.de)} à <b>{fmtPrice(baisse.a)}</b>
239 + <Ico name={baisse.a < baisse.de ? "trenddown" : "trendup"} size={16} /> Prix passé de {fmtPrice(baisse.de)} à <b>{fmtPrice(baisse.a)}</b>
229 240 </div>
230 241 )}
231 242
@@ -269,12 +280,12 @@ export default function ListingPage() {
269 280 <div className="broker">
270 281 <div className="broker-k">Courtier</div>
271 282 {l.broker_name && <div className="broker-name">{l.broker_name}</div>}
272 − {l.broker_phone && <a className="broker-tel" href={`tel:${l.broker_phone.replace(/\s/g, "")}`}>📞 {l.broker_phone}</a>}
283 + {l.broker_phone && <a className="broker-tel" href={`tel:${l.broker_phone.replace(/\s/g, "")}`}><Ico name="phone" size={14} /> {l.broker_phone}</a>}
273 284 </div>
274 285 )}
275 286
276 287 <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
277 − Voir l'annonce chez {sourceName(l.source)} ↗
288 + Voir l'annonce chez {sourceName(l.source)} <Ico name="external" size={15} />
278 289 </a>
279 290 <div className="fine">
280 291 Agrégé par Immo-Ka — {sourceName(l.source)}{updated ? ` · synchronisé le ${updated}` : ""}.
modified frontend/src/pages/Sources.tsx +2 −1
@@ -3,6 +3,7 @@
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 // pages/Sources.tsx : registre des agences — éclaté par bannière → sous-agence
5 5 // -----------------------------------------------------------------------------
6 +import { Ico } from "../components/Icons";
6 7 import { useEffect, useState } from "react";
7 8 import { Link } from "react-router-dom";
8 9 import { Franchise, fetchAgencies } from "../api";
@@ -29,7 +30,7 @@ export default function SourcesPage() {
29 30 numéro Centris. Cliquez un compte pour voir les propriétés.
30 31 </p>
31 32
32 − {error && <div className="notice">⚠️ {error}</div>}
33 + {error && <div className="notice"> {error}</div>}
33 34 {!data && !error && <div className="notice">Chargement…</div>}
34 35
35 36 {data && (
modified frontend/src/styles.css +19 −0
@@ -351,6 +351,25 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
351 351 .viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
352 352 .stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; }
353 353
354 +/* ---- Iconographie maison (Icons.tsx) ---- */
355 +.ico { vertical-align: -3px; flex-shrink: 0; }
356 +.card-specs span { display: inline-flex; align-items: center; gap: 5px; }
357 +.card-specs .ico { color: var(--green); }
358 +.badge.right .ico { vertical-align: -2px; }
359 +.noimg .ico, .carousel-empty .ico { color: var(--ink-3); }
360 +.notice .big .ico { color: var(--lime); }
361 +.amenity .ico { color: var(--green); margin-right: 2px; }
362 +.q-badge .ico { vertical-align: -3px; margin-right: 2px; }
363 +.q-bar-label { display: inline-flex; align-items: center; gap: 7px; }
364 +.q-bar-label .ico { color: var(--green); }
365 +.mm-ico .ico { vertical-align: -4px; }
366 +.mv-noimg svg { color: var(--ink-3); }
367 +.broker-tel .ico { vertical-align: -2.5px; }
368 +.cta .ico { vertical-align: -2.5px; margin-left: 3px; }
369 +.prix-histo .ico { vertical-align: -3.5px; }
370 +.spec { position: relative; }
371 +.spec-ico { position: absolute; top: 10px; right: 10px; color: var(--lime); opacity: 0.85; }
372 +
354 373 /* ---- Survalorisation vs Vrai-Prix (jauges divergentes) ---- */
355 374 .vpg-axis { display: grid; grid-template-columns: 220px 1fr 74px 90px; gap: 10px; margin-bottom: 2px; }
356 375 .vpg-axis > div { grid-column: 2; display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); }
modified immoka/connectors/kijiji.py +2 −0
@@ -119,6 +119,8 @@ class KijijiConnector(BaseConnector):
119 119 pr = it.get("price") or {}
120 120 if isinstance(pr, dict) and pr.get("amount"):
121 121 price = round(pr["amount"] / 100.0, 0) # cents → $
122 + if price < 5000: # prix bidon fréquent sur Kijiji (1 $, 123 $…)
123 + price = None
122 124 loc = it.get("location") or {}
123 125 coords = loc.get("coordinates") or {}
124 126 address = (loc.get("address") or "").replace(", Canada", "")
125 127