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