SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%

Rent-Ka v1 — English rental aggregator for Canada outside Québec (fork of Lou-Ka)

- 81 connectors: 65 Ontario (ungated) + 11 national landlords widened to ROC + 5 portals
- QC layers removed (Hydro, rent registry, TAL, air, flood, gas, census, transit, short-term)
- English canonical unit types, bilingual quality rules, Nominatim-only geocoding
- New identity: cobalt / Bricolage Grotesque, English frontend, /listing /city routes
- Seeded with 5,861 ON listings from Lou-Ka (4,7k published)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 27 days ago (Aug 27, 2026)

115 changed files +20,001 −0

added .claude/settings.local.json +5 −0
@@ -0,0 +1,5 @@
1 +{
2 + "enabledMcpjsonServers": [
3 + "cluster"
4 + ]
5 +}
added .env.example +24 −0
@@ -0,0 +1,24 @@
1 +# -----------------------------------------------------------------------------
2 +# Rent-Ka — environment variables (template — copy to .env, DO NOT COMMIT .env)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# -----------------------------------------------------------------------------
5 +
6 +# Connectors / scraping
7 +SCRAPFLY_KEY=
8 +APIFY_TOKEN=
9 +OXYLABS_PROXY_USER=
10 +OXYLABS_PROXY_PASS=
11 +BRIGHTDATA_API_KEY=
12 +BRIGHTDATA_ZONE=
13 +
14 +# Session / auth
15 +SESSION_SECRET=
16 +RENTKA_BASE_URL=https://www.rent-ka.com
17 +GOOGLE_CLIENT_ID=
18 +GOOGLE_CLIENT_SECRET=
19 +KA_SSO_SECRET=
20 +KA_HUB_URL=https://www.groupe-ka.com
21 +
22 +# SerpApi — Google Maps resolution + manager reviews (rentka/managers.py)
23 +# Server-side ONLY: never exposed to the frontend nor written to logs.
24 +SERPAPI_API_KEY=
added .gitignore +42 −0
@@ -0,0 +1,42 @@
1 +.env
2 +.venv/
3 +__pycache__/
4 +*.pyc
5 +data/rentka.db
6 +frontend/node_modules/
7 +frontend/dist/
8 +.DS_Store
9 +reponse.txt
10 +data/staging-*.db
11 +data/quartier.db
12 +
13 +# app iOS native — dépôt séparé (git.spboucher.ai/lou-ka-ios)
14 +/ios/
15 +data/uploads/
16 +data/imgcache/
17 +data/rentka.db-shm
18 +data/rentka.db-wal
19 +data/rentka.db.backup-*
20 +backup-connectors-*/
21 +node_modules/
22 +data/imgcache/
23 +data/rentka.db-shm
24 +data/rentka.db-wal
25 +data/rentka.db.backup-*
26 +data/louka_ct.db
27 +data/louka_ct.db-shm
28 +data/louka_ct.db-wal
29 +frontend/tsconfig.tsbuildinfo
30 +data/rdl.db
31 +data/inondation.db
32 +data/air.db
33 +data/gaz.db
34 +data/commerces.db
35 +data/transit.db
36 +data/hydro.db
37 +data/tal.db
38 +data/*.db
39 +data/*.db-shm
40 +data/*.db-wal
41 +data/imgcache/
42 +node_modules/
added README.md +87 −0
@@ -0,0 +1,87 @@
1 +<!-- Author: Simon-Pierre Boucher — contact@spboucher.ai -->
2 +
3 +# Rent-Ka
4 +
5 +**www.rent-ka.com** — Apartments and homes for rent across **Canada outside
6 +Québec** (Ontario first, national expansion in progress). A Groupe KA
7 +service, sister site of [Lou-Ka](https://www.lou-ka.com) (Québec rentals)
8 +and [House-Ka](https://www.house-ka.com) (Canadian homes for sale).
9 +
10 +Rent-Ka continuously aggregates rental listings published by Canadian
11 +property managers, REITs and national portals: every listing keeps its
12 +photos, price, availability and a direct link to the original ad.
13 +
14 +## Architecture
15 +
16 +Forked from Lou-Ka on 2026-08-27 (the Ontario expansion paused there — 65
17 +sources, ~5,850 listings — moved here and went live, with every Québec-only
18 +layer removed). The Python package is `rentka/`.
19 +
20 +- **Backend** — FastAPI + SQLite (`data/rentka.db`): connectors → `ingest`
21 + → geocoding (Nominatim) → dedup → `quality` (completeness score +
22 + publication gate + Québec exclusion) → API.
23 +- **Connectors** (81) — three families:
24 + - **Platform generators** (53): LiftSystem/Rentsync (`lift_*`), RentCafe
25 + (`rc_*`), Rentsync gateway (`rsgw_*`), Buildium (`bld_*`), AppFolio
26 + (`apf_*`) — one JSON registry per platform in `data/*_clients.json`;
27 + adding a property manager anywhere in Canada = adding one client row
28 + (optional `"province"` key, default `"ON"`).
29 + - **National landlords** (12): Boardwalk, CAPREIT, Centurion, Cogir,
30 + Hazelview, InterRent, MetCap, Minto, Morguard, Realstar, Akelius,
31 + Killam… — whole portfolio outside Québec (AB/SK/BC/MB/NS/NB included).
32 + - **National portals** (5): Rentals.ca, Zumper, Kijiji, Apartments.com,
33 + Facebook Marketplace — major metros in every province.
34 +- **Frontend** — React/Vite, **English (en-CA)**, cobalt skin. Routes: `/`,
35 + `/listing/{uid}`, `/city/{slug}[/{type}]`, `/cities`, `/stats`,
36 + `/sources`, `/privacy`, `/terms`. Map = Ka Maps (`@groupe-ka/ka-maps`,
37 + expected at `../../ka-maps`).
38 +- **SEO** — `rentka/seo.py` renders server-side HTML (meta, JSON-LD,
39 + sitemaps) in English.
40 +- **Enrichment kept** (Canada-wide, no Québec dependency): KA Scores
41 + (OSM Walk/Transit/Bike/Quiet/Services), fair rental value, building
42 + passport, listing history, re-listed-unit detection, Winter Score,
43 + photo audit, manager profiles (SerpApi).
44 +- **Removed vs Lou-Ka** — everything Québec-only: Hydro-Québec estimates,
45 + rent registry (registre des loyers), TAL history, BDZI flood, RSQAQ air,
46 + gazquebec, quartier (QC census), real-cost breakdown, movers directory,
47 + the whole short-term subsystem, and ~300 QC connectors.
48 +
49 +## Canonical unit types (English)
50 +
51 +`Studio | 1 bedroom | 2 bedrooms | 3 bedrooms | 4 bedrooms | 5+ bedrooms |
52 +Loft | Penthouse | Condo | Townhouse | House | Room` — see
53 +`rentka/normalize.py`. Québec «n½» input converts automatically
54 +(n½ = n−2 bedrooms), and a central FR→EN label funnel in
55 +`schema.finalize()` translates amenities/availability/price labels coming
56 +from fr-ca page templates — the DB is uniformly English.
57 +
58 +## Run
59 +
60 +```bash
61 +python run.py sync [source ...] # sync listings
62 +python run.py watch [minutes] # sync loop (default 60 min)
63 +python run.py serve [port] # API + frontend (default 8125)
64 +python run.py list # registered connectors
65 +python run.py geocode [n] # geocode listings missing coordinates
66 +python run.py quality # recompute publication gate
67 +```
68 +
69 +pm2 (node M4M36): `rent-ka-web` (serve 8125) · `rent-ka-sync` (watch 60) ·
70 +`rent-ka-ngrok` (`ngrok http --url=www.rent-ka.com 8125`).
71 +
72 +## Tests
73 +
74 +```bash
75 +python -m pytest tests/
76 +```
77 +
78 +Connector snapshot tests replay recorded fixtures (`tests/fixtures/`);
79 +re-record with `python run.py record <source>` after changing a connector.
80 +
81 +## Expansion playbook
82 +
83 +The fastest way to add coverage is the platform registries: find property
84 +managers whose sites run LiftSystem/RentCafe/Rentsync/Buildium/AppFolio
85 +(signatures documented in `gestion-immobiliere-ontario.md` §10) and append
86 +a client entry with its `province`. `docs/canada-sources.md` tracks the
87 +national source inventory.
added SPEC-RENTKA.md +137 −0
@@ -0,0 +1,137 @@
1 +# SPEC — Transformation lou-ka → Rent-Ka (LOCKED DECISIONS)
2 +
3 +Rent-Ka = English rental-listings aggregator for ALL of Canada EXCEPT Québec
4 +(Ontario first — 5,861 ON listings already seeded in the node DB; national
5 +expansion via platform client registries comes right after launch).
6 +Sister sites: Lou-Ka (QC rentals), House-Ka (ROC homes for sale).
7 +
8 +## Identity
9 +- Site name: **Rent-Ka** · wordmark **Rent·Ka** · domain **www.rent-ka.com**
10 +- `BASE_URL = "https://www.rent-ka.com"`, `SITE_NAME = "Rent-Ka"`
11 +- FastAPI: title "Rent-Ka API", description "Rental listings aggregator — Canada (outside Québec)"
12 +- Tagline (frontend + SEO): **"Apartments and homes for rent across Canada."**
13 +- UA: `RentKaBot/1.0 (+https://www.rent-ka.com/bot; contact@spboucher.ai)` (replaces every LouKaBot)
14 +- Language: en-CA everywhere (backend SEO strings, frontend copy, `<html lang="en-CA">`)
15 +- Python package: rename `louka/` → `rentka/` (all imports, `uvicorn.run("rentka.web:app", ...)`)
16 +- DB path: `data/rentka.db` (db.py DB_PATH)
17 +- Env prefix: `LOUKA_*` → `RENTKA_*` everywhere. `LOUKA_ONTARIO` gate is DELETED (not renamed).
18 + `LOUKA_BASE_URL` → `RENTKA_BASE_URL`.
19 +- kaid site id: `kaid.init("rent-ka")`; hubfav URL `https://www.rent-ka.com/listing/{uid}`; KA_CLIENT_ID default "rent-ka".
20 +- ecosystem.json (frontend/src/ka/ecosystem.json): ADD entry (backend web.py:940/983 looks it up — required):
21 + `{"id":"rent-ka","wordmark":"Rent·Ka","domain":"www.rent-ka.com","accent":"#2456e6","accentSoft":"#e9efff","accentDeep":"#1738a8","onAccent":"#ffffff","tagline":"Rentals across Canada"}`
22 +
23 +## Routes (English — backend seo.py/web.py AND frontend App.tsx/KAID_CONF must match)
24 +- Listing page: **`/listing/{uid}`** (was `/logement/{uid}`)
25 +- Cities index: **`/cities`** (was `/villes`); city page: **`/city/{slug}`** and **`/city/{slug}/{type_slug}`**
26 +- Keep: `/stats`, `/sources`, `/contact`, `/bot`, `/g/{sourceId}`
27 +- `/confidentialite` → **`/privacy`**, `/conditions` → **`/terms`**
28 +- `/profil` → `/profile`, `/favoris` → `/favorites`, `/bienvenue` → `/welcome`,
29 + `/gestion` → `/manage`, `/passerelle/:uid` → `/gateway/:uid`,
30 + `/juste-valeur` → `/fair-value`, `/ka-scores` stays `/ka-scores`
31 +- DELETED routes/pages: `/court-terme*` (short-term subsystem), `/demenageurs` (movers, QC dataset)
32 +- web.py `_CLIENT_ROUTES = {"profile","favorites","manage","welcome","bot","contact","fair-value","ka-scores"}`,
33 + `_CLIENT_PREFIXES = ("u/","gateway/")`
34 +- Sitemaps: `sitemap-pages.xml`, `sitemap-cities.xml`, `sitemap-listings-{n}.xml` (drop sitemap-ct-*)
35 +- index.html KAID_CONF: `{card:"a[href^='/listing/']",detail:"^/listing/(.+)$",entity:"listing"}`
36 +
37 +## Canonical unit types (English enum — replaces the QC "n½" system)
38 +`Studio | 1 bedroom | 2 bedrooms | 3 bedrooms | 4 bedrooms | 5+ bedrooms | Loft | Penthouse | Condo | Townhouse | House | Room`
39 +- normalize.normalize_unit_type: map French AND English input INTO this enum
40 + (reverse of today: `3½` → "1 bedroom", `4½` → "2 bedrooms", `n½` → `(n-2) bedrooms`, `6½+` → "4 bedrooms";
41 + "bachelor" → Studio; "maison de ville|townhouse|mdv" → Townhouse; "chambre|room" → Room)
42 +- bedrooms_from_unit_type: Studio/Loft→0, Room→1, "n bedroom(s)"→n, "5+ bedrooms"→5, Penthouse/Condo/Townhouse/House→None
43 +- Slugs (seo.slugify): "1-bedroom", "2-bedrooms", "5-plus-bedrooms", "studio", "room", … (kill the `½ → -1-2` special case)
44 +- quality.py rule 5: `unit != "Room"` (was "Chambre")
45 +
46 +## Geography / scope
47 +- Scope = Canada minus QC. quality.evaluate: DELETE `pause-ontario` rule; ADD
48 + `if d.get("province") == "QC": reasons.append("outside-scope-quebec")`.
49 +- schema.Listing default province: `"ON"` (connectors should set explicitly; platform
50 + registries gain an optional per-client `"province"` key, default "ON").
51 +- schema.finalize + geocode: per-province bbox dict for all 12 (BC,AB,SK,MB,ON,NB,NS,PE,NL,YT,NT,NU),
52 + fallback = Canada bbox (41.6–83.2 N, −141.0…−52.5 E).
53 +- geocode.py: REMOVE all Adresses Québec (MERN) code, `_BBOX_QUEBEC`, `_VILLES_QUEBEC`;
54 + keep Nominatim (1 r/s) as primary; `run_batch()` becomes a loop over Nominatim `resolve()`
55 + (keep the batch CLI signature working). state= full province name from a PROVINCE_NAMES dict.
56 + Cache keys: prefix `"{prov.lower()}|"` for every province.
57 +- web.py `/api/stats`: replace the `_VILLES_GM` Montréal buckets with per-province counts:
58 + response gains `"provinces": {"ON": n, "BC": n, ...}` (keep "total"/existing top-level keys
59 + the frontend Ticker needs — frontend Ticker will read `provinces`).
60 +- web.py: expose `province` in listing JSON (list + detail + geojson props) and as a
61 + filter param `province=` on /api/listings & /api/search; add it to /api/facets.
62 +
63 +## Features KEPT (translate UI strings to English where user-facing)
64 +core pipeline (schema/db/ingest/normalize/dedup/quality/seo/web), fairvalue,
65 +kascores + environment + poi (pure OSM, national), building ("Building passport"),
66 +recycled ("Re-listed units"), history, imgcheck, managers (+serpapi_client),
67 +marketstats, statsdash, statsfiche (prune deleted-layer panels), kapdf (shared, keep verbatim),
68 +hiver.py → keep, rename user-facing label to **"Winter Score"** (works Canada-wide),
69 +KA ID/accounts/auth/profile/hubfav/hubprofile (site id rent-ka), fixtures.py,
70 +textmine.py → KEEP and make BILINGUAL: keep French regexes, add English equivalents for the
71 +high-value facts (heat/hydro/electricity/water included, parking, pets, furnished, laundry,
72 +A/C, elevator, balcony, dishwasher, no smoking, lease length, available date).
73 +
74 +## Features DELETED (backend module + web.py route + pdfgen section + statsextra block + frontend component)
75 +- hydro.py (/api/hydro, HydroEstimation.tsx) — Hydro-Québec
76 +- rdl.py (/api/rdl, RegistreLoyers.tsx) — QC rent registry
77 +- tal.py (/api/tal, HistoriqueTAL.tsx) — QC tribunal
78 +- gaz.py (/api/gaz, EssenceProche.tsx) — gazquebec
79 +- air.py (/api/air, QualiteAir.tsx) — RSQAQ (maybe AQHI later)
80 +- inondation.py (/api/inondation, RisqueInondation.tsx) — BDZI
81 +- quartier.py (quartier block in /api/listings/{uid}, QuartierBlock.tsx) — QC census DB (rebuild later)
82 +- commerces.py (/api/commerces, CommercesProches.tsx) + transit.db — QC banners/transit
83 +- coutreel.py (/api/listings/{uid}/cout-reel, CoutReel.tsx) — backbone was Hydro-Québec
84 +- demenageurs.py (+ /demenageurs SSR + Demenageurs.tsx + data/demenageurs.json)
85 +- shortterm/ ENTIRELY (+ data/sources_ct.json, data/hotels_engines.json, ct_web router,
86 + seo ctdb import + /court-terme SSR + sitemap-ct, frontend Ct*.tsx, CourtTerme*.tsx, ctapi.ts, kamaps/ctAdapter.ts)
87 +- MetroLignes.tsx usage in MapView.tsx + MapSearch.tsx (Montréal métro overlay)
88 +- ingest.watch: remove hydro.precompute and tal.precompute steps (keep the rest incl. quartier→REMOVE too since module deleted)
89 +
90 +## Connectors
91 +- KEEP the 65 ON connectors: remove the `_ONTARIO = ...` line and every `disabled = not _ONTARIO`
92 + / `"disabled": not _ONTARIO` (connector simply enabled). Platform generators
93 + (rentcafe/appfolio/rentsyncgw/buildium/liftsystem) keep reading their data/*.json registries;
94 + add support for optional per-client `"province"` (default "ON").
95 +- KEEP + WIDEN the 11 national connectors (akelius, boardwalk, capreit, centurion, cogir,
96 + hazelview, interrent, metcap, minto, morguard, realstar): invert the gate — ROC is the default,
97 + DELETE the QC branches/city lists entirely; cover every non-QC province the landlord operates in
98 + (their sites list cities — keep existing ON lists, extend with the connector's own city discovery
99 + when it has one; set province per city).
100 +- KEEP national portals (not gated): apartments_com, rentals_ca, zumper, kijiji, fb_marketplace +
101 + actors/. Change their default regions/cities env fallbacks from QC to ROC metros:
102 + Toronto, Ottawa, Mississauga, Hamilton, London, Kitchener-Waterloo, Windsor, Vancouver, Victoria,
103 + Surrey, Burnaby, Calgary, Edmonton, Winnipeg, Saskatoon, Regina, Halifax, Moncton, Fredericton,
104 + Charlottetown, St. John's. (env names now RENTKA_*)
105 +- DELETE all other (~300) QC-only connector modules AND their data/sources.json entries.
106 + sources.json keeps: the 65 ON entries (status back to "actif", note_pause removed),
107 + the national/portal entries, minus all QC ones. Update `_comment`.
108 +- KEEP helpers: base.py, _resilient.py, _detailutil.py; DELETE _livya.py/_planpoint.py if only
109 + QC connectors import them (check first).
110 +- tests/: keep test_{normalize,quality,search,fairvalue,kascores,textmine}.py UPDATED to the new
111 + enum/rules; delete test fixtures + recorded cassettes of deleted connectors (tests/fixtures is 842 MB —
112 + purge everything belonging to deleted sources).
113 +
114 +## Frontend design (DIFFERENT from Lou-Ka orange and House-Ka pine/Fraunces)
115 +- Keep the shared KA token system (frontend/src/ka/tokens.css UNTOUCHED — shared asset).
116 +- styles.css overrides: accent = **cobalt** `--accent:#2456e6; --accent-soft:#e9efff; --accent-deep:#1738a8; --on-accent:#ffffff`
117 +- Display font: **"Bricolage Grotesque"** (Google Fonts, weights 400..800) replaces Space Grotesk
118 + in index.html + `--font-display` override in styles.css. Body stays Inter.
119 +- Header wordmark `Rent·Ka` (the ·Ka styled with accent), tagline "Apartments and homes for rent across Canada."
120 +- The frontend should feel like its OWN site, not a translated Lou-Ka: new Home hero
121 + (search + "Browse by province" section with the province counts from /api/stats.provinces,
122 + featured cities grid), reordered nav, English MobileTabBar (Search/Map/Saved/Profile).
123 + Keep the detail-page section ORDER standard (gallery → price/address → description →
124 + amenities → practical details → analyses → map → KA Scores → footer; DOM order = visual
125 + order on ALL breakpoints, NEVER `order:`/column-reverse — Groupe KA standard 2026-08-19).
126 +- Listing page keeps (in order): gallery, price+FairValueBadge, description, amenities,
127 + practical details, PriceAnalysis, HistoriqueLouka→"Listing history", ImmeubleBloc→"Building
128 + passport", GestionnaireBloc→"Property manager", recycled block, ListingMap3D, KaScoresBlock,
129 + HiverScore→"Winter Score". All QC blocks removed (list above).
130 +- Ticker: per-province counts (ON · BC · AB · …) from /api/stats.provinces.
131 +- Footer: keep KaFooter with siteId="rent-ka" (entry added to ecosystem.json).
132 +- ALL user-facing text in English (en-CA). Currency format `$1,495`. Dates en-CA.
133 +
134 +## Deployment (for reference — done on node M4M36 afterwards)
135 +- pm2: rent-ka-web (`run.py serve 8125`), rent-ka-sync (`run.py watch 60`),
136 + rent-ka-ngrok (`ngrok http --url=www.rent-ka.com 8125`)
137 +- git origin gitsrv:srv/git/rent-ka.git
added actors/ka-apartments-com/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: Dockerfile (ka-apartments-com)
4 +# Desc: Acteur Apify KA — Dockerfile
5 +# ==============================================================================
6 +FROM apify/actor-python:3.12
7 +COPY requirements.txt ./
8 +RUN pip install --no-cache-dir -r requirements.txt
9 +COPY . ./
10 +CMD ["python3", "-m", "src"]
added actors/ka-apartments-com/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1 +{
2 + "actorSpecification": 1,
3 + "name": "ka-apartments-com",
4 + "title": "KA Apartments.com — locations QC",
5 + "description": "Logements à louer sur Apartments.com, province de Québec (recherche /qc/ paginée + fiches détail en français : GPS, description, plans d'unités, commodités, galerie). Déblocage anti-bot via Bright Data Web Unlocker (jeton requis).",
6 + "version": "0.1",
7 + "buildTag": "latest",
8 + "input": "./input_schema.json",
9 + "dockerfile": "./Dockerfile"
10 +}
added actors/ka-apartments-com/.actor/input_schema.json +79 −0
@@ -0,0 +1,79 @@
1 +{
2 + "title": "KA Apartments.com — locations QC",
3 + "type": "object",
4 + "schemaVersion": 1,
5 + "properties": {
6 + "searchUrls": {
7 + "title": "URLs de recherche",
8 + "type": "array",
9 + "editor": "stringList",
10 + "description": "Pages de recherche Apartments.com à paginer (défaut : /qc/, toute la province).",
11 + "default": ["https://www.apartments.com/qc/"]
12 + },
13 + "maxPages": {
14 + "title": "Pages max par recherche",
15 + "type": "integer",
16 + "description": "Plafond de pagination par URL de recherche (arrêt auto quand le site retombe sur la page 1).",
17 + "default": 25,
18 + "minimum": 1,
19 + "maximum": 30
20 + },
21 + "getDetails": {
22 + "title": "Visiter les fiches détail",
23 + "type": "boolean",
24 + "description": "Visiter les fiches (version française) : GPS, description, plans d'unités, commodités, galerie.",
25 + "default": true
26 + },
27 + "maxDetails": {
28 + "title": "Fiches détail max",
29 + "type": "integer",
30 + "description": "Budget de fiches détail par run.",
31 + "default": 250,
32 + "minimum": 0
33 + },
34 + "skipDetailIds": {
35 + "title": "IDs à ne pas revisiter",
36 + "type": "array",
37 + "editor": "stringList",
38 + "description": "IDs de propriétés dont la fiche est déjà en cache côté appelant.",
39 + "default": []
40 + },
41 + "extraDetailIds": {
42 + "title": "IDs de rattrapage",
43 + "type": "array",
44 + "editor": "stringList",
45 + "description": "data-url encodés « id|url » de fiches à visiter même si absentes de la recherche.",
46 + "default": []
47 + },
48 + "brightdataToken": {
49 + "title": "Jeton API Bright Data",
50 + "type": "string",
51 + "editor": "textfield",
52 + "isSecret": true,
53 + "description": "Jeton Bearer de l'API Bright Data (Web Unlocker) — requis."
54 + },
55 + "brightdataZone": {
56 + "title": "Zone Bright Data",
57 + "type": "string",
58 + "editor": "textfield",
59 + "description": "Nom de la zone Web Unlocker.",
60 + "default": "web_unlocker1"
61 + },
62 + "concurrency": {
63 + "title": "Requêtes simultanées",
64 + "type": "integer",
65 + "description": "Nombre de requêtes Bright Data en parallèle.",
66 + "default": 4,
67 + "minimum": 1,
68 + "maximum": 10
69 + },
70 + "requestDelay": {
71 + "title": "Délai entre requêtes (s)",
72 + "type": "integer",
73 + "description": "Pause après chaque requête réussie (0 = aucune).",
74 + "default": 0,
75 + "minimum": 0
76 + }
77 + },
78 + "required": ["brightdataToken"]
79 +}
added actors/ka-apartments-com/requirements.txt +4 −0
@@ -0,0 +1,4 @@
1 +# ka-apartments-com — le déblocage anti-bot est délégué à Bright Data
2 +# (simple POST HTTPS), donc pas de curl_cffi ici.
3 +apify
4 +httpx
added actors/ka-apartments-com/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: src/__main__.py (ka-apartments-com)
4 +# Desc: Point d'entrée de l'acteur (asyncio.run).
5 +# ==============================================================================
6 +import asyncio
7 +
8 +from .main import main
9 +
10 +asyncio.run(main())
added actors/ka-apartments-com/src/main.py +270 −0
@@ -0,0 +1,270 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: src/main.py (ka-apartments-com)
4 +# Desc: Apartments.com — logements à louer, province de Québec.
5 +# Phase 1 : pagination des pages de recherche (/qc/ = toute la
6 +# province, HTML server-rendered ; arrêt quand le <title> ne porte
7 +# plus « - Page N », le site retombant silencieusement sur la page 1).
8 +# Phase 2 : fiches détail en français (/fr/<slug>/<id>/) : GPS,
9 +# description, plans d'unités (pricingGridItem), commodités, galerie.
10 +# Items poussés : kind="listing" (placard) et kind="detail".
11 +# ==============================================================================
12 +from __future__ import annotations
13 +
14 +import asyncio
15 +import html as htmllib
16 +import re
17 +
18 +from apify import Actor
19 +
20 +from .net import BrightData
21 +
22 +# --- recherche (placards) -----------------------------------------------------
23 +
24 +_ARTICLE_RX = re.compile(
25 + r"<article\b(?P<attrs>[^>]*\bdata-listingid=\"[^\"]+\"[^>]*)>"
26 + r"(?P<body>.*?)</article>", re.S)
27 +_ATTR_RX = re.compile(r"data-(listingid|url|streetaddress)=\"([^\"]*)\"")
28 +_TITLE_TAG_RX = re.compile(r"<title>([^<]*)</title>")
29 +_PLACARD_TITLE_RX = re.compile(r"js-placardTitle[^\"]*\"[^>]*>([^<]+)<")
30 +_ADDRESS_RX = re.compile(r"property-address[^\"]*\"[^>]*>([^<]+)<")
31 +_BED_RENT_RX = re.compile(
32 + r"bedTextBox\">([^<]*)<.{0,400}?priceTextBox\">\s*<span[^>]*>([^<]*)<",
33 + re.S)
34 +_IMG_COUNT_RX = re.compile(r"js-spnImgCount\">(\d+)")
35 +_PHONE_RX = re.compile(r"phone-data=\"(\d{7,15})\"")
36 +_AMENITIES_BLOCK_RX = re.compile(
37 + r"property-amenities\"[^>]*>(.*?)</p>", re.S)
38 +_SPAN_RX = re.compile(r"<span[^>]*>([^<]+)</span>")
39 +_IMG_RX = re.compile(r"https://images1\.apartments\.com/i2/[^\"'\s?]+")
40 +_QC_SLUG_RX = re.compile(r"apartments\.com/[^\"]*-qc[-/]")
41 +
42 +
43 +def _clean(text: str) -> str:
44 + return re.sub(r"\s+", " ", htmllib.unescape(text or "")).strip()
45 +
46 +
47 +def parse_search(page_html: str) -> list[dict]:
48 + """Placards d'une page de recherche → dicts (propriétés QC seulement)."""
49 + out: list[dict] = []
50 + for m in _ARTICLE_RX.finditer(page_html):
51 + attrs = dict(_ATTR_RX.findall(m.group("attrs")))
52 + pid, url = attrs.get("listingid", ""), attrs.get("url", "")
53 + if not pid or not url:
54 + continue
55 + body = m.group("body")
56 + title_m = _PLACARD_TITLE_RX.search(body)
57 + addr_m = _ADDRESS_RX.search(body)
58 + title = _clean(title_m.group(1)) if title_m else ""
59 + address = _clean(addr_m.group(1)) if addr_m else ""
60 + if not address and ", QC" in title:
61 + address = title # gabarit silverpropres : adresse dans le titre
62 + # nearby hors-Québec glissé en bas de page → on filtre
63 + if ", QC" not in address and not _QC_SLUG_RX.search(url):
64 + continue
65 + rents = [{"beds": _clean(b), "price": _clean(p)}
66 + for b, p in _BED_RENT_RX.findall(body)]
67 + img_count_m = _IMG_COUNT_RX.search(body)
68 + phone_m = _PHONE_RX.search(body)
69 + amen_m = _AMENITIES_BLOCK_RX.search(body)
70 + amenities = [_clean(s) for s in _SPAN_RX.findall(amen_m.group(1))] \
71 + if amen_m else []
72 + img_m = _IMG_RX.search(body)
73 + out.append({
74 + "id": pid,
75 + "url": url,
76 + "title": title,
77 + "address": address,
78 + "street": _clean(attrs.get("streetaddress", "")),
79 + "rents": rents,
80 + "img_count": int(img_count_m.group(1)) if img_count_m else 0,
81 + "phone": phone_m.group(1) if phone_m else "",
82 + "amenities": [a for a in amenities if a],
83 + "image": img_m.group(0) if img_m else "",
84 + })
85 + return out
86 +
87 +
88 +def page_title(page_html: str) -> str:
89 + m = _TITLE_TAG_RX.search(page_html)
90 + return _clean(m.group(1)) if m else ""
91 +
92 +
93 +# --- fiche détail (version française) ------------------------------------------
94 +
95 +_META_LAT_RX = re.compile(
96 + r"<meta property=\"place:location:latitude\" content=\"([^\"]+)\"")
97 +_META_LNG_RX = re.compile(
98 + r"<meta property=\"place:location:longitude\" content=\"([^\"]+)\"")
99 +_PROP_NAME_RX = re.compile(r"id=\"propertyName\"[^>]*>([^<]+)")
100 +_ADDR_BLOCK_RX = re.compile(
101 + r"delivery-address\"><span>([^<]+)</span>.*?<span>([^<]+)</span>,\s*"
102 + r"<span class=\"stateZipContainer\">\s*<span>([^<]+)</span>\s*"
103 + r"<span>([^<]*)</span>", re.S)
104 +_DESC_SECTION_RX = re.compile(
105 + r"<section[^>]*id=\"descriptionSection\"[^>]*>(.*?)</section>", re.S)
106 +_MODEL_WRAPPER_RX = re.compile(
107 + r"priceGridModelWrapper[^\"]*\"\s*data-rentalkey=\"([^\"]*)\"(.*?)"
108 + r"(?=priceGridModelWrapper|$)", re.S)
109 +_MODEL_NAME_RX = re.compile(r"modelName\">([^<]+)<")
110 +_RENT_LABEL_RX = re.compile(r"rentLabel\">\s*([^<]+)")
111 +_DETAILS_WRAPPER_RX = re.compile(
112 + r"detailsTextWrapper\">(.*?)</span>\s*</span>", re.S)
113 +_AVAIL_RX = re.compile(r"availabilityInfo\">([^<]+)<")
114 +_BEDS_BATHS_RX = re.compile(r"data-beds=\"([^\"]*)\"\s*data-baths=\"([^\"]*)\"")
115 +_SPEC_INFO_RX = re.compile(r"specInfo\">\s*<span>([^<]+)</span>")
116 +_TAG_RX = re.compile(r"<[^>]+>")
117 +
118 +
119 +def _to_float(text: str) -> float | None:
120 + try:
121 + return float(text.strip())
122 + except (TypeError, ValueError):
123 + return None
124 +
125 +
126 +def parse_detail(page_html: str) -> dict:
127 + out: dict = {}
128 + name_m = _PROP_NAME_RX.search(page_html)
129 + if name_m:
130 + out["name"] = _clean(name_m.group(1))
131 + addr_m = _ADDR_BLOCK_RX.search(page_html)
132 + if addr_m:
133 + out["street"] = _clean(addr_m.group(1))
134 + out["city"] = _clean(addr_m.group(2))
135 + out["state"] = _clean(addr_m.group(3))
136 + out["zip"] = _clean(addr_m.group(4))
137 + lat_m, lng_m = _META_LAT_RX.search(page_html), _META_LNG_RX.search(page_html)
138 + if lat_m and lng_m:
139 + out["lat"] = _to_float(lat_m.group(1))
140 + out["lng"] = _to_float(lng_m.group(1))
141 + desc_m = _DESC_SECTION_RX.search(page_html)
142 + if desc_m:
143 + lines = [_clean(t) for t in
144 + _TAG_RX.sub("\n", desc_m.group(1)).split("\n")]
145 + lines = [t for t in lines if t
146 + and not t.lower().startswith(("à propos", "about"))]
147 + if lines:
148 + out["description"] = "\n\n".join(dict.fromkeys(lines))
149 + plans: list[dict] = []
150 + for key, body in _MODEL_WRAPPER_RX.findall(page_html):
151 + plan: dict = {"key": key}
152 + nm = _MODEL_NAME_RX.search(body)
153 + if nm:
154 + plan["name"] = _clean(nm.group(1))
155 + rl = _RENT_LABEL_RX.search(body)
156 + if rl:
157 + plan["rent"] = _clean(rl.group(1))
158 + dw = _DETAILS_WRAPPER_RX.search(body)
159 + if dw:
160 + plan["details"] = [_clean(s) for s in
161 + _SPAN_RX.findall(dw.group(1) + "</span>")]
162 + bb = _BEDS_BATHS_RX.search(body)
163 + if bb:
164 + plan["beds"] = _to_float(bb.group(1))
165 + plan["baths"] = _to_float(bb.group(2))
166 + av = _AVAIL_RX.search(body)
167 + if av:
168 + plan["availability"] = _clean(av.group(1))
169 + if plan.get("name") or plan.get("rent"):
170 + # la grille est présente deux fois dans la page (desktop/mobile)
171 + if not any(p.get("key") == plan.get("key")
172 + and p.get("name") == plan.get("name")
173 + and p.get("rent") == plan.get("rent") for p in plans):
174 + plans.append(plan)
175 + if plans:
176 + out["plans"] = plans
177 + amenities = [_clean(s) for s in _SPEC_INFO_RX.findall(page_html)]
178 + if amenities:
179 + out["amenities"] = list(dict.fromkeys(a for a in amenities if a))
180 + images = list(dict.fromkeys(
181 + u for u in _IMG_RX.findall(page_html) if "-logo" not in u))
182 + if images:
183 + out["images"] = images
184 + return out
185 +
186 +
187 +def fr_url(url: str) -> str:
188 + if "apartments.com/fr/" in url:
189 + return url
190 + return url.replace("www.apartments.com/", "www.apartments.com/fr/", 1)
191 +
192 +
193 +# --- orchestration --------------------------------------------------------------
194 +
195 +async def _crawl_search(bd: BrightData, base: str, max_pages: int,
196 + props: dict[str, dict]) -> None:
197 + base = base if base.endswith("/") else base + "/"
198 + for page in range(1, max_pages + 1):
199 + url = base if page == 1 else f"{base}{page}/"
200 + page_html = await bd.get(url)
201 + if page_html is None:
202 + Actor.log.warning(f"recherche : échec fetch {url}, arrêt")
203 + break
204 + if page > 1 and f"- Page {page}" not in page_title(page_html):
205 + Actor.log.info(f"recherche : fin de pagination à la page {page} "
206 + f"({base})")
207 + break
208 + placards = parse_search(page_html)
209 + if not placards:
210 + Actor.log.info(f"recherche : page {page} vide ({base})")
211 + break
212 + fresh = [p for p in placards if p["id"] not in props]
213 + for p in fresh:
214 + props[p["id"]] = p
215 + await Actor.push_data({"kind": "listing", **p})
216 + Actor.log.info(f"recherche p.{page} : {len(placards)} placards, "
217 + f"{len(fresh)} nouveaux ({base})")
218 +
219 +
220 +async def _fetch_detail(bd: BrightData, pid: str, url: str) -> None:
221 + page_html = await bd.get(fr_url(url))
222 + if page_html is None:
223 + Actor.log.warning(f"détail {pid} : échec fetch")
224 + return
225 + detail = parse_detail(page_html)
226 + if not detail:
227 + Actor.log.warning(f"détail {pid} : page sans données")
228 + return
229 + await Actor.push_data({"kind": "detail", "id": pid, "url": url, **detail})
230 +
231 +
232 +async def main() -> None:
233 + async with Actor:
234 + inp = await Actor.get_input() or {}
235 + token = (inp.get("brightdataToken") or "").strip()
236 + if not token:
237 + raise ValueError("brightdataToken requis (Web Unlocker)")
238 + bd = BrightData(
239 + token=token,
240 + zone=(inp.get("brightdataZone") or "web_unlocker1").strip(),
241 + concurrency=int(inp.get("concurrency") or 4),
242 + delay=float(inp.get("requestDelay") or 0))
243 + try:
244 + search_urls = [u.strip() for u in
245 + (inp.get("searchUrls")
246 + or ["https://www.apartments.com/qc/"])
247 + if u and u.strip()]
248 + max_pages = int(inp.get("maxPages") or 25)
249 + props: dict[str, dict] = {}
250 + for base in search_urls:
251 + await _crawl_search(bd, base, max_pages, props)
252 + Actor.log.info(f"recherche terminée : {len(props)} propriétés")
253 +
254 + if not inp.get("getDetails", True):
255 + return
256 + skip = set(inp.get("skipDetailIds") or [])
257 + targets: list[tuple[str, str]] = [
258 + (pid, p["url"]) for pid, p in props.items() if pid not in skip]
259 + for enc in inp.get("extraDetailIds") or []:
260 + pid, _, url = enc.partition("|")
261 + if pid and url and pid not in props and pid not in skip:
262 + targets.append((pid, url))
263 + max_details = int(inp.get("maxDetails") or 0)
264 + if max_details >= 0:
265 + targets = targets[:max_details]
266 + Actor.log.info(f"détails : {len(targets)} fiches à visiter")
267 + await asyncio.gather(
268 + *(_fetch_detail(bd, pid, url) for pid, url in targets))
269 + finally:
270 + await bd.close()
added actors/ka-apartments-com/src/net.py +63 −0
@@ -0,0 +1,63 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: src/net.py (ka-apartments-com)
4 +# Desc: Fetcher Bright Data Web Unlocker — apartments.com est verrouillé par
5 +# Akamai (curl_cffi/Scrapfly/Oxylabs → 403) ; seul le Web Unlocker
6 +# passe. Simple POST https://api.brightdata.com/request qui renvoie le
7 +# HTML brut de la page débloquée. httpx async + sémaphore + retries à
8 +# backoff exponentiel.
9 +# ==============================================================================
10 +from __future__ import annotations
11 +
12 +import asyncio
13 +import random
14 +
15 +import httpx
16 +
17 +from apify import Actor
18 +
19 +API = "https://api.brightdata.com/request"
20 +
21 +
22 +def _backoff(attempt: int) -> float:
23 + return min(1.5 * (2 ** attempt), 15.0) + random.uniform(0.0, 1.0)
24 +
25 +
26 +class BrightData:
27 + """GET d'une page via le Web Unlocker Bright Data (déblocage géré)."""
28 +
29 + def __init__(self, token: str, zone: str, concurrency: int = 4,
30 + delay: float = 0.0) -> None:
31 + self._headers = {"Authorization": f"Bearer {token}",
32 + "Content-Type": "application/json"}
33 + self._zone = zone
34 + self._sem = asyncio.Semaphore(max(1, concurrency))
35 + self._delay = max(0.0, delay)
36 + self._client = httpx.AsyncClient(timeout=120.0)
37 +
38 + async def close(self) -> None:
39 + await self._client.aclose()
40 +
41 + async def get(self, url: str, retries: int = 3) -> str | None:
42 + """HTML de la page, ou None si le déblocage échoue après retries."""
43 + body = {"zone": self._zone, "url": url,
44 + "format": "raw", "country": "ca"}
45 + async with self._sem:
46 + for attempt in range(retries):
47 + try:
48 + resp = await self._client.post(
49 + API, headers=self._headers, json=body)
50 + if resp.status_code == 200 and resp.text.strip():
51 + if self._delay:
52 + await asyncio.sleep(self._delay)
53 + return resp.text
54 + Actor.log.warning(
55 + f"Bright Data {resp.status_code} {url} — "
56 + f"{resp.text[:120]!r} (tentative {attempt + 1})")
57 + except httpx.HTTPError as exc:
58 + Actor.log.warning(
59 + f"Bright Data réseau {url} : {exc} "
60 + f"(tentative {attempt + 1})")
61 + if attempt < retries - 1:
62 + await asyncio.sleep(_backoff(attempt))
63 + return None
added actors/ka-fb-marketplace/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: Dockerfile (ka-fb-marketplace)
4 +# Desc: Acteur Apify KA — Dockerfile
5 +# ==============================================================================
6 +FROM apify/actor-python:3.12
7 +COPY requirements.txt ./
8 +RUN pip install --no-cache-dir -r requirements.txt
9 +COPY . ./
10 +CMD ["python3", "-m", "src"]
added actors/ka-fb-marketplace/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1 +{
2 + "actorSpecification": 1,
3 + "name": "ka-fb-marketplace",
4 + "title": "KA Facebook Marketplace — locations QC",
5 + "description": "Logements à louer (propertyrentals) sur Facebook Marketplace, Québec. Scraping public déconnecté sans rendu JS : recherche + fiches détail (GPS, description, statut, galerie via MediaViewer préchargé) depuis le JSON du HTML brut. Proxy résidentiel CA obligatoire.",
6 + "version": "0.1",
7 + "buildTag": "latest",
8 + "input": "./input_schema.json",
9 + "dockerfile": "./Dockerfile"
10 +}
added actors/ka-fb-marketplace/.actor/input_schema.json +74 −0
@@ -0,0 +1,74 @@
1 +{
2 + "title": "KA Facebook Marketplace input",
3 + "type": "object",
4 + "schemaVersion": 1,
5 + "properties": {
6 + "searchUrls": {
7 + "title": "URLs de recherche",
8 + "type": "array",
9 + "editor": "stringList",
10 + "description": "URLs de recherche Marketplace (ville × tranche de prix). Vide = couverture Québec par défaut (33 villes intégrées).",
11 + "prefill": []
12 + },
13 + "getDetails": {
14 + "title": "Visiter les fiches détail",
15 + "type": "boolean",
16 + "default": true,
17 + "description": "Visite /marketplace/item/<id> pour GPS, ville, description, statut et galerie complète."
18 + },
19 + "maxDetails": {
20 + "title": "Budget fiches détail",
21 + "type": "integer",
22 + "default": 200,
23 + "minimum": 0,
24 + "description": "Nombre maximal de fiches détail visitées par run."
25 + },
26 + "skipDetailIds": {
27 + "title": "IDs à NE PAS détailler",
28 + "type": "array",
29 + "editor": "stringList",
30 + "description": "IDs d'annonces dont la fiche détail est déjà connue de l'appelant (cache) : la recherche les liste quand même, mais aucune requête détail.",
31 + "prefill": []
32 + },
33 + "extraDetailIds": {
34 + "title": "IDs détail additionnels (rattrapage)",
35 + "type": "array",
36 + "editor": "stringList",
37 + "description": "Fiches à visiter MÊME si absentes des résultats de recherche (rattrapage d'annonces actives pauvres).",
38 + "prefill": []
39 + },
40 + "requestDelay": {
41 + "title": "Délai entre requêtes (s)",
42 + "type": "integer",
43 + "default": 1,
44 + "minimum": 0,
45 + "description": "Pause minimale entre deux requêtes sortantes, en secondes."
46 + },
47 + "concurrency": {
48 + "title": "Requêtes simultanées",
49 + "type": "integer",
50 + "default": 8,
51 + "minimum": 1,
52 + "maximum": 16,
53 + "description": "Nombre de requêtes menées en parallèle."
54 + },
55 + "proxyUrlTemplate": {
56 + "title": "Proxy externe (gabarit d'URL)",
57 + "type": "string",
58 + "editor": "textfield",
59 + "isSecret": true,
60 + "description": "Optionnel : URL de proxy externe avec placeholder {session} (ex. Oxylabs résidentiel http://user-cc-CA-sessid-{session}:pass@pr.oxylabs.io:7777). Si présent, remplace le proxy Apify — utile pour consommer un abonnement résidentiel existant."
61 + },
62 + "proxyConfiguration": {
63 + "title": "Proxy Apify",
64 + "type": "object",
65 + "editor": "proxy",
66 + "description": "Résidentiel obligatoire (Facebook rejette les IP datacenter). Ignoré si un gabarit de proxy externe est fourni.",
67 + "prefill": {
68 + "useApifyProxy": true,
69 + "apifyProxyGroups": ["RESIDENTIAL"],
70 + "apifyProxyCountry": "CA"
71 + }
72 + }
73 + }
74 +}
added actors/ka-fb-marketplace/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: requirements.txt (ka-fb-marketplace)
4 +# Desc: Acteur Apify KA — requirements.txt
5 +# ==============================================================================
6 +apify
7 +curl_cffi>=0.9
added actors/ka-fb-marketplace/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: src/__main__.py (ka-fb-marketplace)
4 +# Desc: Point d'entrée de l'acteur (asyncio.run).
5 +# ==============================================================================
6 +import asyncio
7 +
8 +from .main import main
9 +
10 +asyncio.run(main())
added actors/ka-fb-marketplace/src/main.py +352 −0
@@ -0,0 +1,352 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: src/main.py (ka-fb-marketplace)
4 +# Desc: Facebook Marketplace — logements à louer (propertyrentals), Québec.
5 +# Scraping public DÉCONNECTÉ, AUCUN rendu JS : les données vivent dans
6 +# les <script type="application/json"> du HTML brut.
7 +# - Recherche : objets marketplace_listing_title (~25/URL, on multiplie
8 +# les URLs ville × tranche de prix pour élargir).
9 +# - Fiche /marketplace/item/<id> : le target GraphQL préchargé
10 +# (marketplace_product_details_page) expose GPS, ville
11 +# (reverse_geocode_detailed), description, statut is_live/is_sold ;
12 +# la GALERIE complète vient du target MediaViewer préchargé
13 +# (listing_photos du même id) — validé live, aucun rendu requis.
14 +# Sortie dataset : {kind:"listing"} (recherche) et {kind:"detail"}.
15 +# ⚠️ Données personnelles (Loi 25) : ne jamais republier nom/téléphone
16 +# du vendeur ; le champ seller n'est PAS extrait.
17 +# ==============================================================================
18 +from __future__ import annotations
19 +
20 +import asyncio
21 +import html as _html
22 +import json
23 +import re
24 +
25 +from apify import Actor
26 +
27 +from .net import Fetcher
28 +
29 +BASE = "https://www.facebook.com/marketplace"
30 +
31 +# Couverture Québec par défaut (mode autonome, sans searchUrls) :
32 +# slug vanity OU id numérique Marketplace (validés live 2026-08-22 — les slugs
33 +# inventés retombent sur des villes aléatoires, seuls ceux-ci sont sûrs).
34 +# Chaque recherche couvre un rayon d'environ 65 km.
35 +BIG_CITIES = { # gros parcs : tranches de prix fines (flux ~25 annonces/URL)
36 + "montreal": "Montréal", "laval": "Laval", "longueuil": "Longueuil",
37 + "quebec": "Québec", "gatineau": "Gatineau", "sherbrooke": "Sherbrooke",
38 + "trois-rivieres": "Trois-Rivières", "levis": "Lévis",
39 + "saguenay": "Saguenay", "drummondville": "Drummondville",
40 +}
41 +REGIONAL_CITIES = { # couverture régions : ids numériques (Serper + validation)
42 + "105790762794728": "Rimouski", "110925022261358": "Rivière-du-Loup",
43 + "106528256049537": "Matane", "109502305742889": "Gaspé",
44 + "100246746683723": "Sept-Îles", "105615529471535": "Baie-Comeau",
45 + "106016152771864": "Rouyn-Noranda", "110594102301488": "Val-d'Or",
46 + "105598479473872": "Amos", "109699385723610": "Saint-Georges",
47 + "110304502331480": "Thetford Mines", "111938355489908": "Granby",
48 + "108126699220609": "Victoriaville", "109753249050671": "Joliette",
49 + "109429165750459": "Saint-Hyacinthe",
50 + "108645709159963": "Saint-Jean-sur-Richelieu",
51 + "112946182053396": "Saint-Jérôme", "113011352052118": "Salaberry-de-Valleyfield",
52 + "107992819229760": "Magog", "109514615734434": "Alma",
53 + "109664672393669": "Mont-Laurier", "108557812502323": "La Tuque",
54 + "108296932538245": "Dolbeau-Mistassini",
55 +}
56 +PRICE_BANDS = [(0, 800), (800, 1100), (1100, 1400), (1400, 1700),
57 + (1700, 2100), (2100, 2800), (2800, 6000)]
58 +REGIONAL_BANDS = [(0, 1200), (1200, 6000)] # parcs plus petits : 2 tranches
59 +
60 +_SCRIPT_RE = re.compile(
61 + r'<script type="application/json"[^>]*>(.*?)</script>', re.S)
62 +
63 +
64 +def default_search_urls() -> list[str]:
65 + urls = []
66 + for slug in BIG_CITIES:
67 + for lo, hi in PRICE_BANDS:
68 + urls.append(f"{BASE}/{slug}/propertyrentals"
69 + f"?minPrice={lo}&maxPrice={hi}"
70 + f"&sortBy=creation_time_descend")
71 + for cid in REGIONAL_CITIES:
72 + for lo, hi in REGIONAL_BANDS:
73 + urls.append(f"{BASE}/{cid}/propertyrentals"
74 + f"?minPrice={lo}&maxPrice={hi}"
75 + f"&sortBy=creation_time_descend")
76 + return urls
77 +
78 +
79 +def _iter_json_scripts(html: str):
80 + for m in _SCRIPT_RE.finditer(html):
81 + try:
82 + yield json.loads(m.group(1))
83 + except ValueError:
84 + continue
85 +
86 +
87 +def walk_listings(html: str) -> dict:
88 + """Objets d'annonce (marketplace_listing_title) des <script> JSON."""
89 + out: dict = {}
90 +
91 + def walk(o):
92 + if isinstance(o, dict):
93 + if "marketplace_listing_title" in o and o.get("id"):
94 + out.setdefault(str(o["id"]), o)
95 + for v in o.values():
96 + walk(v)
97 + elif isinstance(o, list):
98 + for v in o:
99 + walk(v)
100 +
101 + for data in _iter_json_scripts(html):
102 + walk(data)
103 + return out
104 +
105 +
106 +def price_of(obj: dict) -> float | None:
107 + """Montant du loyer depuis listing_price (offset en cents géré)."""
108 + pr = obj.get("listing_price") or {}
109 + for k in ("amount", "amount_with_offset_in_currency"):
110 + v = pr.get(k)
111 + if v is None:
112 + continue
113 + try:
114 + val = float(v)
115 + except (TypeError, ValueError):
116 + continue
117 + if k == "amount_with_offset_in_currency" and val > 100000:
118 + val /= 100.0
119 + if val > 0:
120 + return round(val, 0)
121 + return None
122 +
123 +
124 +def _pdp_target(html: str) -> dict | None:
125 + """Nœud cible le plus RICHE (description/GPS/statut) parmi les targets
126 + marketplace_product_details_page préchargés (stubs inclus)."""
127 + found: list[dict] = []
128 +
129 + def walk(o):
130 + if isinstance(o, dict):
131 + pdp = o.get("marketplace_product_details_page")
132 + if isinstance(pdp, dict) and isinstance(pdp.get("target"), dict):
133 + found.append(pdp["target"])
134 + for v in o.values():
135 + walk(v)
136 + elif isinstance(o, list):
137 + for v in o:
138 + walk(v)
139 +
140 + for data in _iter_json_scripts(html):
141 + walk(data)
142 +
143 + def score(t: dict) -> tuple:
144 + desc = (t.get("redacted_description") or {}).get("text") or ""
145 + loc = t.get("location") or {}
146 + return (1 if desc else 0) + (1 if loc.get("latitude") else 0), len(t)
147 + return max(found, key=score) if found else None
148 +
149 +
150 +def _gallery(html: str, lid: str) -> list[str]:
151 + """Galerie complète : plus grand tableau listing_photos porté par un objet
152 + du MÊME id (le target MediaViewer préchargé la contient hors connexion)."""
153 + best: list[str] = []
154 +
155 + def walk(o):
156 + nonlocal best
157 + if isinstance(o, dict):
158 + lp = o.get("listing_photos")
159 + if (isinstance(lp, list) and lp
160 + and str(o.get("id") or "") == lid):
161 + uris = []
162 + for p in lp:
163 + uri = ((p.get("image") or {}).get("uri")
164 + if isinstance(p, dict) else "") or ""
165 + if uri and uri not in uris:
166 + uris.append(uri)
167 + if len(uris) > len(best):
168 + best = uris
169 + for v in o.values():
170 + walk(v)
171 + elif isinstance(o, list):
172 + for v in o:
173 + walk(v)
174 +
175 + for data in _iter_json_scripts(html):
176 + walk(data)
177 + return best
178 +
179 +
180 +def _clean_desc(text: str) -> str:
181 + t = text.replace("\r", "\n")
182 + t = re.sub(r"[ \t ]+", " ", t)
183 + t = re.sub(r" ?\n ?", "\n", t)
184 + t = re.sub(r"\n{3,}", "\n\n", t)
185 + return t.strip()
186 +
187 +
188 +def parse_detail(html: str, lid: str) -> dict:
189 + """Champs riches d'une fiche /marketplace/item/<id> hors connexion.
190 +
191 + {} si la page n'a pas pu être rendue (échec réseau : l'appelant ne doit
192 + PAS mettre en cache), {"nopdp": True} si rendue mais sans objet cible
193 + (fiche supprimée / mur de login : cacher pour ne pas boucler)."""
194 + if not html:
195 + return {}
196 + o = _pdp_target(html)
197 + if not o:
198 + return {"nopdp": True}
199 + out: dict = {}
200 + if o.get("is_sold") or o.get("is_pending") or o.get("is_live") is False:
201 + out["gone"] = True # loué / en attente / retiré
202 + loc = o.get("location") or {}
203 + if loc.get("latitude") and loc.get("longitude"):
204 + out["lat"] = loc["latitude"]
205 + out["lng"] = loc["longitude"]
206 + geo = loc.get("reverse_geocode_detailed") or {}
207 + if geo.get("city"):
208 + out["city"] = geo["city"]
209 + if geo.get("state"):
210 + out["state"] = geo["state"]
211 + lt = (o.get("location_text") or {}).get("text") or ""
212 + if lt: # ex. « Québec, QC » / « Norfolk, ON »
213 + out["location_text"] = lt
214 + parts = [p.strip() for p in lt.split(",") if p.strip()]
215 + if parts and not out.get("city"):
216 + out["city"] = parts[0]
217 + if len(parts) > 1 and not out.get("state"):
218 + out["state"] = parts[1]
219 + desc = (o.get("redacted_description") or {}).get("text") or ""
220 + if desc:
221 + out["description"] = _clean_desc(desc)[:6000]
222 + addr = ((o.get("home_address") or {}).get("street") or "").strip()
223 + unit_fields: list[str] = []
224 + for sec in (o.get("pdp_display_sections") or []):
225 + stype = sec.get("section_type") or ""
226 + for f in (sec.get("pdp_fields") or []):
227 + lbl = (f.get("display_label") or "").strip()
228 + if not lbl:
229 + continue
230 + if stype == "UNIT_INCLUDES":
231 + unit_fields.append(lbl)
232 + elif stype == "UNIT_SUBTITLE":
233 + if (f.get("icon_name") or "") == "pin" and not addr:
234 + addr = lbl.split(",")[0].strip()
235 + elif (f.get("icon_name") or "") == "clock":
236 + out["listed_text"] = lbl
237 + if addr:
238 + out["address"] = addr
239 + if unit_fields:
240 + out["unit_fields"] = unit_fields
241 + for src_k, dst_k in (("walk_score_info", "walk_score"),
242 + ("transit_score_info", "transit_score"),
243 + ("bike_score_info", "bike_score")):
244 + sc = (o.get(src_k) or {}).get("score")
245 + if sc is not None:
246 + out[dst_k] = sc
247 + if o.get("virtual_tour_url"):
248 + out["virtual_tour_url"] = o["virtual_tour_url"]
249 + photos = _gallery(html, lid)
250 + if photos:
251 + out["images"] = photos
252 + return out
253 +
254 +
255 +async def main() -> None:
256 + async with Actor:
257 + inp = await Actor.get_input() or {}
258 + search_urls = [u for u in (inp.get("searchUrls") or []) if u]
259 + if not search_urls:
260 + search_urls = default_search_urls()
261 + get_details = inp.get("getDetails", True)
262 + max_details = int(inp.get("maxDetails") or 200)
263 + skip_ids = {str(i) for i in (inp.get("skipDetailIds") or [])}
264 + extra_ids = [str(i) for i in (inp.get("extraDetailIds") or [])]
265 + template = (inp.get("proxyUrlTemplate") or "").strip() or None
266 + proxy = None
267 + if not template:
268 + proxy = await Actor.create_proxy_configuration(
269 + actor_proxy_input=inp.get("proxyConfiguration"))
270 + fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1),
271 + proxy_template=template)
272 + sem = asyncio.Semaphore(int(inp.get("concurrency") or 8))
273 +
274 + # -- phase recherche ---------------------------------------------------
275 + found: dict[str, tuple[dict, str]] = {} # id -> (objet, source_url)
276 + fails = 0
277 +
278 + async def search(url: str) -> None:
279 + nonlocal fails
280 + async with sem:
281 + try:
282 + resp = await fetcher.get(url, session_id=f"q{hash(url)}")
283 + except Exception as exc:
284 + fails += 1
285 + Actor.log.warning(f"recherche KO {url} : {exc}")
286 + return
287 + if resp.status_code != 200:
288 + fails += 1
289 + return
290 + for lid, obj in walk_listings(resp.text).items():
291 + found.setdefault(lid, (obj, url))
292 +
293 + await asyncio.gather(*[search(u) for u in search_urls])
294 + Actor.log.info(f"recherche : {len(found)} annonces uniques "
295 + f"({len(search_urls)} URLs, {fails} échecs)")
296 +
297 + for lid, (obj, src) in found.items():
298 + photo = ((obj.get("primary_listing_photo") or {})
299 + .get("image") or {}).get("uri") or ""
300 + loc = obj.get("location") or {}
301 + geo = loc.get("reverse_geocode") or {}
302 + await Actor.push_data({
303 + "kind": "listing",
304 + "id": lid,
305 + "title": (obj.get("marketplace_listing_title") or "")[:300],
306 + "price": price_of(obj),
307 + "city": (geo.get("city")
308 + or (geo.get("city_page") or {}).get("display_name")
309 + or ""),
310 + "state": geo.get("state") or "",
311 + "primary_photo": photo,
312 + "is_sold": bool(obj.get("is_sold")),
313 + "is_pending": bool(obj.get("is_pending")),
314 + "source_url": src,
315 + "url": f"{BASE}/item/{lid}/",
316 + })
317 +
318 + # -- phase détail ------------------------------------------------------
319 + if not get_details or max_details <= 0:
320 + return
321 + todo: list[str] = []
322 + for lid, (obj, _src) in found.items():
323 + if lid in skip_ids or obj.get("is_sold") or obj.get("is_pending"):
324 + continue
325 + if price_of(obj) is None:
326 + continue # sans prix : jamais publiable, épargner
327 + # le flux public glisse des annonces d'autres provinces (suggestions)
328 + geo = (obj.get("location") or {}).get("reverse_geocode") or {}
329 + st = (geo.get("state") or "").strip().lower()
330 + if st and st not in ("qc", "quebec", "québec"):
331 + continue
332 + todo.append(lid)
333 + for lid in extra_ids: # rattrapage : après les nouveautés
334 + if lid not in todo and lid not in skip_ids:
335 + todo.append(lid)
336 + todo = todo[:max_details]
337 +
338 + async def detail(lid: str) -> None:
339 + async with sem:
340 + try:
341 + resp = await fetcher.get(f"{BASE}/item/{lid}/",
342 + session_id=f"d{lid}")
343 + payload = parse_detail(
344 + resp.text if resp.status_code == 200 else "", lid)
345 + except Exception as exc:
346 + Actor.log.warning(f"détail KO {lid} : {exc}")
347 + payload = {}
348 + await Actor.push_data({"kind": "detail", "id": lid,
349 + "ok": bool(payload), **payload})
350 +
351 + await asyncio.gather(*[detail(lid) for lid in todo])
352 + Actor.log.info(f"détail : {len(todo)} fiches visitées")
added actors/ka-fb-marketplace/src/net.py +97 −0
@@ -0,0 +1,97 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: src/net.py (ka-fb-marketplace)
4 +# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5 +# proxy Apify (résidentiel par défaut) OU gabarit de proxy externe
6 +# ({session} → id de session, nouvelle IP par tentative), throttling
7 +# poli, retries à backoff exponentiel + jitter sur 403/407/408/425/
8 +# 429/500/502/503/504.
9 +# ==============================================================================
10 +from __future__ import annotations
11 +
12 +import asyncio
13 +import random
14 +import re
15 +
16 +from apify import Actor
17 +from curl_cffi import requests as cffi
18 +
19 +UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
20 + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
21 +
22 +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur
23 +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504})
24 +
25 +
26 +def _backoff(attempt: int) -> float:
27 + """Backoff exponentiel plafonné + jitter (anti-troupeau)."""
28 + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8)
29 +
30 +
31 +class Fetcher:
32 + """HTTP poli : proxy Apify ou externe + impersonation Chrome + retries."""
33 +
34 + def __init__(self, proxy_configuration, delay: float = 1.0,
35 + proxy_template: str | None = None) -> None:
36 + self.proxy_configuration = proxy_configuration
37 + self.proxy_template = proxy_template or None
38 + self.delay = delay
39 + self._lock = asyncio.Lock()
40 + self._last = 0.0
41 +
42 + async def _throttle(self) -> None:
43 + async with self._lock:
44 + now = asyncio.get_event_loop().time()
45 + wait = self.delay - (now - self._last)
46 + if wait > 0:
47 + await asyncio.sleep(wait)
48 + self._last = asyncio.get_event_loop().time()
49 +
50 + async def _proxy_url(self, session_id: str | None, attempt: int) -> str | None:
51 + sid = session_id or f"s{random.randint(1, 999_999)}"
52 + sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
53 + # nouvelle session proxy (nouvelle IP) à chaque tentative
54 + sid = f"{sid}r{attempt}"
55 + if self.proxy_template:
56 + return self.proxy_template.replace("{session}", sid)
57 + if self.proxy_configuration:
58 + return await self.proxy_configuration.new_url(session_id=sid)
59 + return None
60 +
61 + async def request(self, method: str, url: str,
62 + headers: dict | None = None, data=None,
63 + retries: int = 4, session_id: str | None = None,
64 + impersonate: str | None = "chrome"):
65 + hdrs = {"User-Agent": UA,
66 + "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
67 + if headers:
68 + hdrs.update(headers)
69 + last_exc: Exception | None = None
70 + for attempt in range(retries):
71 + await self._throttle()
72 + proxy = await self._proxy_url(session_id, attempt)
73 + try:
74 + resp = await asyncio.to_thread(
75 + cffi.request, method, url, headers=hdrs, data=data,
76 + impersonate=impersonate, timeout=60, allow_redirects=True,
77 + proxies={"http": proxy, "https": proxy} if proxy else None)
78 + if resp.status_code in RETRY_STATUSES \
79 + and attempt < retries - 1:
80 + Actor.log.warning(
81 + f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
82 + await asyncio.sleep(_backoff(attempt))
83 + continue
84 + return resp
85 + except Exception as exc: # réseau/proxy : on retente
86 + last_exc = exc
87 + await asyncio.sleep(_backoff(attempt))
88 + if last_exc:
89 + raise last_exc
90 + raise RuntimeError(f"échec après {retries} tentatives : {url}")
91 +
92 + async def get(self, url: str, headers: dict | None = None,
93 + retries: int = 4, session_id: str | None = None,
94 + impersonate: str | None = "chrome"):
95 + return await self.request("GET", url, headers=headers,
96 + retries=retries, session_id=session_id,
97 + impersonate=impersonate)
added data/appfolio_clients.json +16 −0
@@ -0,0 +1,16 @@
1 +{
2 + "_comment": "Registre des clients AppFolio (<sous-domaine>.appfolio.com/listings — HTML rendu serveur, cartes .js-listing-item, page unique « Showing all available listings ») — longue traîne Ontario 2026-08. Consommé par rentka/connectors/appfolio.py qui génère un connecteur apf_<id> par entrée 'valide'. CONSTAT de la recherche du 2026-08-27 (Serper site:appfolio.com + scan des 273 sites de gestion-immobiliere-ontario.md) : AppFolio est RARE en Ontario — la longue traîne ON est massivement sur Buildium (voir buildium_clients.json). REJETS vérifiés (ne pas réessayer) : AG Secure (agsecure.ca) et Strano (stranoproperties.ca) ont migré sur Wix, plus aucun lien AppFolio même en rendu JS Scrapfly ; rpmcan07 (RPM Barrie, cité par un rapport simcoe.ca 2021) éteint, rpmcan01-15 balayés = 0 client ON ; homonymes US écartés (wpd=Chicago, skylineprops=ND, stonebrown=VT, northbay=Californie, marda* sans portail public). Les adresses AppFolio étant en texte libre, le connecteur filtre par code postal canadien (K/L/M/N/P = ON, G/H/J = QC).",
3 + "updated": "2026-08-27",
4 + "clients": [
5 + {
6 + "id": "fusepm",
7 + "name": "Fuse Property Management",
8 + "site": "https://fusepm.ca",
9 + "subdomain": "fusepm",
10 + "regions": ["Barrie", "Orillia", "Oro-Medonte", "Simcoe"],
11 + "status": "valide",
12 + "listings_on": 25,
13 + "notes": "25 annonces ON le 2026-08-27 (Barrie/Orillia/Oro-Medonte), page unique sans pagination ; sous-domaine trouvé dans fusepm.ca"
14 + }
15 + ]
16 +}
added data/buildium_clients.json +116 −0
@@ -0,0 +1,116 @@
1 +{
2 + "_comment": "Registre des clients Buildium (<sous-domaine>.managebuilding.com/Resident/public/rentals) — longue traîne Ontario 2026-08. Consommé par rentka/connectors/buildium.py qui génère un connecteur bld_<id> par entrée 'valide'. Sous-domaines découverts via Serper (site:managebuilding.com <nom>) ou le site vitrine du gestionnaire, puis VALIDÉS EN LIVE le 2026-08-27 (compte d'annonces ON résidentielles ; seuil ≥ 3). Deux mises en page servies par Buildium : « moderne » (/Resident/public/rentals, cartes a.featured-listing rendues serveur) et « héritée » (redirection 302 vers PublicPages/ApartmentSearch.aspx, annonces embarquées dans le viewmodel DotVVM __dot_viewmodel_root — AUCUN rendu JS requis dans les deux cas). Le connecteur détecte la mise en page automatiquement. REJETS du 2026-08-27 (ne pas re-tester avant un moment) : stsproperties (Shore to Slope, Collingwood) et lakeviewrealtyinc (Lakeview, Orillia) = 0 annonce en ligne (« check back ») ; keiltyresidential (Keilty, Cobourg), legacylogin (Legacy/LPRM, Thunder Bay), mgpropmgt (MG, Timmins), davispropertiesinc (Davis, Kirkland Lake) = pages publiques désactivées (302 vers /Resident/apps/portal/login) ; douglasfirpm (lié depuis prestigiousplacesudbury.com) = 302 vers /Resident/Public sans annonces.",
3 + "updated": "2026-08-27",
4 + "clients": [
5 + {
6 + "id": "richmond",
7 + "name": "Richmond Property Management",
8 + "site": "https://richmondpm.ca",
9 + "subdomain": "richmondmanagement",
10 + "regions": ["Windsor"],
11 + "status": "valide",
12 + "listings_on": 52,
13 + "notes": "52 annonces ON le 2026-08-27, toutes Windsor (MultiFamily 47, SingleFamily 3, CondoTownhome 2) ; mise en page moderne"
14 + },
15 + {
16 + "id": "dreampm",
17 + "name": "Dream Property Management",
18 + "site": "https://rentalsdreampm.com",
19 + "subdomain": "dreampm",
20 + "regions": ["Sarnia"],
21 + "status": "valide",
22 + "listings_on": 9,
23 + "notes": "9 annonces ON (Sarnia) le 2026-08-27 ; mise en page moderne"
24 + },
25 + {
26 + "id": "grem",
27 + "name": "Guardian Real Estate Management (GREM)",
28 + "site": "https://grem.ca",
29 + "subdomain": "guardianrealestatemanagement",
30 + "regions": ["Sarnia", "West Nipissing"],
31 + "status": "valide",
32 + "listings_on": 44,
33 + "notes": "44 annonces ON le 2026-08-27 (Sarnia surtout) ; MISE EN PAGE HÉRITÉE (302 vers ApartmentSearch.aspx, viewmodel DotVVM embarqué avec city/state/postalcode/description complète)"
34 + },
35 + {
36 + "id": "elite",
37 + "name": "Elite Property Group",
38 + "site": "https://elitepropertygroup.org",
39 + "subdomain": "elitepropertygroup",
40 + "regions": ["Sarnia"],
41 + "status": "valide",
42 + "listings_on": 34,
43 + "notes": "34 annonces ON (Sarnia) le 2026-08-27 ; mise en page moderne"
44 + },
45 + {
46 + "id": "paragon",
47 + "name": "Paragon Property Management & Maintenance",
48 + "site": "https://paragonproperty.ca",
49 + "subdomain": "paragonpropertymgt",
50 + "regions": ["Chatham-Kent"],
51 + "status": "valide",
52 + "listings_on": 34,
53 + "notes": "36 cartes ON le 2026-08-27 dont 2 Storage (filtrées par le connecteur) = 34 résidentielles ; attention : ne pas confondre avec paragonproperty.managebuilding.com (Brookings, Dakota du Sud)"
54 + },
55 + {
56 + "id": "panadew",
57 + "name": "Panadew Property Management",
58 + "site": "https://panadew.ca",
59 + "subdomain": "panadew",
60 + "regions": ["Kingston"],
61 + "status": "valide",
62 + "listings_on": 7,
63 + "notes": "8 cartes ON le 2026-08-27 dont 1 ParkingSpace (filtrée) = 7 résidentielles (Kingston) ; mise en page moderne"
64 + },
65 + {
66 + "id": "tag",
67 + "name": "TAG Property Management",
68 + "site": "https://tagpropertymanagement1.managebuilding.com/Resident/public/home",
69 + "subdomain": "tagpropertymanagement1",
70 + "regions": ["Cobourg", "Port Hope"],
71 + "status": "valide",
72 + "listings_on": 10,
73 + "notes": "10 annonces ON (Cobourg/Port Hope) le 2026-08-27 ; portail Buildium = seul site public ; mise en page moderne"
74 + },
75 + {
76 + "id": "genedco",
77 + "name": "Genedco Property Management (GRANITE)",
78 + "site": "https://genedco.ca",
79 + "subdomain": "genedco",
80 + "regions": ["Cobourg", "Port Hope", "Peterborough"],
81 + "status": "valide",
82 + "listings_on": 9,
83 + "notes": "9 annonces ON le 2026-08-27 ; mise en page moderne"
84 + },
85 + {
86 + "id": "rentcorp",
87 + "name": "RentCorp Property Management",
88 + "site": "https://www.rentcorp.ca",
89 + "subdomain": "rentcorppropertymanagement",
90 + "regions": ["Waterloo", "North Bay", "London"],
91 + "status": "valide",
92 + "listings_on": 26,
93 + "notes": "26 annonces ON le 2026-08-27 (Waterloo surtout, incl. chambres étudiantes 575-700 $ = longue durée, on garde) ; mise en page moderne"
94 + },
95 + {
96 + "id": "sleepwell",
97 + "name": "Sleepwell Property Management",
98 + "site": "https://www.sleepwellmanagement.com",
99 + "subdomain": "sleepwellpropertymanagement",
100 + "regions": ["Ottawa"],
101 + "status": "valide",
102 + "listings_on": 130,
103 + "notes": "130 annonces ON le 2026-08-27 (Ottawa — MultiFamily 121, SingleFamily 6, CondoTownhome 3) : plus gros client Buildium du registre, trouvé via Serper pendant la validation ; mise en page moderne"
104 + },
105 + {
106 + "id": "athena",
107 + "name": "Athena Property Management",
108 + "site": "https://athenapropertymanagementinc1.managebuilding.com/Resident/public/home",
109 + "subdomain": "athenapropertymanagementinc1",
110 + "regions": ["Hamilton", "St. Catharines"],
111 + "status": "valide",
112 + "listings_on": 29,
113 + "notes": "29 annonces ON le 2026-08-27 (Hamilton surtout) ; découvert via Serper pendant la validation ; mise en page moderne"
114 + }
115 + ]
116 +}
added data/liftsystem_clients.json +529 −0
@@ -0,0 +1,529 @@
1 +{
2 + "_comment": "Registre des clients Rentsync/LiftSystem (api.theliftsystem.com/v2/search) — expansion Ontario 2026-08. Consommé par rentka/connectors/liftsystem.py qui génère un connecteur lift_<id> par entrée validée. client_id découverts en live (div.search-data data-client-id, EnhancedSearch.config ou /scripts/main.js de chaque site) puis validés contre l'API le 2026-08-26 (token universel sswpREkUtyeYjeoahA2i). status: 'valide' = API confirmée ; 'a_verifier' = site sur la NOUVELLE plateforme Rentsync (website-gateway.rentsync.com, Vue) — pas de flux LiftSystem v2, connecteur à part à écrire. EXCLUS (connecteurs dédiés existants) : Minto, Hazelview, Centurion/CP Living, Summit, MetCap, InterRent. NB : properties_on = propriétés ON brutes du flux ; le connecteur filtre ensuite les types non résidentiels (Office/Retail/Industrial/Construction…) — résidentiel ON/QC réel au 2026-08-26 : greenwin 116, park 64, williamsmcdaniel 167, briarlane 116, dms 84, signet 108, shelter 25, york 76, terracorp 33, rentsarnia 69, firstnorth 26, tvm 30, urbandale 8 = 922 propriétés.",
3 + "updated": "2026-08-27",
4 + "clients": [
5 + {
6 + "id": "greenwin",
7 + "name": "Greenwin Corp",
8 + "site": "https://www.greenwin.ca",
9 + "client_id": 2,
10 + "listing_url": "https://www.greenwin.ca/apartments-for-rent/cities/toronto",
11 + "regions": [
12 + "Toronto/GTA",
13 + "Kitchener",
14 + "Hamilton",
15 + "Brampton",
16 + "Guelph"
17 + ],
18 + "status": "valide",
19 + "properties_on": 164,
20 + "notes": "~20 000 unités gérées ; 164 propriétés ON au flux (Toronto 76, Kitchener 16, North York 14, Hamilton 13)"
21 + },
22 + {
23 + "id": "park",
24 + "name": "Park Property Management",
25 + "site": "https://www.parkproperty.ca",
26 + "client_id": 65,
27 + "listing_url": "https://www.parkproperty.ca/apartments-for-rent",
28 + "regions": [
29 + "Toronto/GTA",
30 + "Stratford",
31 + "Waterloo",
32 + "Cambridge",
33 + "Burlington",
34 + "London"
35 + ],
36 + "status": "valide",
37 + "properties_on": 64,
38 + "notes": "64 propriétés ON (Stratford 13, Toronto 13, Scarborough 9, Waterloo 7)"
39 + },
40 + {
41 + "id": "williamsmcdaniel",
42 + "name": "Williams & McDaniel",
43 + "site": "https://www.williamsandmcdaniel.com",
44 + "client_id": 12,
45 + "listing_url": "https://www.williamsandmcdaniel.com/apartments",
46 + "regions": [
47 + "Guelph",
48 + "Sault Ste. Marie",
49 + "Fergus",
50 + "Midland",
51 + "Kitchener",
52 + "Hamilton",
53 + "Stratford"
54 + ],
55 + "status": "valide",
56 + "properties_on": 172,
57 + "notes": "177 propriétés au flux dont 172 ON + 5 AB (filtrées) ; Guelph 54, Sault Ste. Marie 30, Fergus 20"
58 + },
59 + {
60 + "id": "briarlane",
61 + "name": "Briarlane Rental Property Management",
62 + "site": "https://www.briarlane.ca",
63 + "client_id": 470,
64 + "listing_url": "https://www.briarlane.ca/apartments-for-rent",
65 + "regions": [
66 + "London",
67 + "Toronto/GTA",
68 + "Markham/York",
69 + "Brampton",
70 + "Mississauga"
71 + ],
72 + "status": "valide",
73 + "properties_on": 203,
74 + "notes": "204 propriétés dont 203 ON + 1 SK (filtrée) ; London 52, Toronto 43, North York 34 — rare couverture Vaughan/York"
75 + },
76 + {
77 + "id": "dms",
78 + "name": "DMS Property Management",
79 + "site": "https://www.dmsproperty.com",
80 + "client_id": 122,
81 + "listing_url": "https://www.dmsproperty.com/apartments-for-rent",
82 + "regions": [
83 + "Toronto/GTA",
84 + "Hamilton",
85 + "Kingston",
86 + "Barrie",
87 + "Richmond Hill",
88 + "Welland"
89 + ],
90 + "status": "valide",
91 + "properties_on": 97,
92 + "notes": "97 propriétés ON (Toronto 30, Hamilton 15, North York 14, Kingston 7)"
93 + },
94 + {
95 + "id": "signet",
96 + "name": "Signet Group",
97 + "site": "https://www.signetgroup.ca",
98 + "client_id": 84,
99 + "listing_url": "https://www.signetgroup.ca/apartments",
100 + "regions": [
101 + "Toronto",
102 + "Mississauga",
103 + "Kitchener",
104 + "Cambridge",
105 + "Stratford"
106 + ],
107 + "status": "valide",
108 + "properties_on": 131,
109 + "notes": "131 propriétés ON (Toronto 71, Mississauga 12, Kitchener 7, Cambridge 7)"
110 + },
111 + {
112 + "id": "shelter",
113 + "name": "Shelter Canadian Properties",
114 + "site": "https://www.scpl.com",
115 + "client_id": 407,
116 + "listing_url": "https://www.scpl.com/residential-rental",
117 + "regions": [
118 + "Toronto",
119 + "Ottawa",
120 + "Belleville",
121 + "Thunder Bay",
122 + "Orillia"
123 + ],
124 + "status": "valide",
125 + "properties_on": 26,
126 + "permalink_sub": [
127 + "/images/",
128 + "/residential-rental/"
129 + ],
130 + "notes": "87 propriétés au flux, pancanadien : 26 ON seulement (le connecteur filtre MB 37 / AB 22 / SK / ND) ; client_id trouvé sur les fiches propriété (pas la page villes) ; permaliens API cassés (/images/<slug> → 404), corrigés via permalink_sub"
131 + },
132 + {
133 + "id": "york",
134 + "name": "York Property Management",
135 + "site": "https://www.yorkproperty.ca",
136 + "client_id": 71,
137 + "listing_url": "https://www.yorkproperty.ca/apartments-for-rent/london",
138 + "regions": [
139 + "Windsor",
140 + "London",
141 + "Sarnia",
142 + "Brantford",
143 + "Woodstock",
144 + "sud-ouest ON"
145 + ],
146 + "status": "valide",
147 + "properties_on": 77,
148 + "notes": "77 propriétés ON (Windsor 32, London 23, Sarnia 7) ; client_id via EnhancedSearch.config.client.id = 71 sur les pages villes"
149 + },
150 + {
151 + "id": "terracorp",
152 + "name": "Terracorp Management (Auburn Developments)",
153 + "site": "https://terracorp.ca",
154 + "client_id": 684,
155 + "listing_url": "https://terracorp.ca/apartments-for-rent",
156 + "regions": [
157 + "London",
158 + "Windsor",
159 + "Kitchener",
160 + "Owen Sound"
161 + ],
162 + "status": "valide",
163 + "properties_on": 33,
164 + "notes": "33 propriétés ON (London 19, Windsor 3, Kitchener 3)"
165 + },
166 + {
167 + "id": "rentsarnia",
168 + "name": "Mutual Property Management (RentSarnia)",
169 + "site": "https://rentsarnia.com",
170 + "client_id": 115,
171 + "listing_url": "https://rentsarnia.com/",
172 + "regions": [
173 + "Sarnia",
174 + "Corunna"
175 + ],
176 + "status": "valide",
177 + "properties_on": 70,
178 + "notes": "70 propriétés ON (Sarnia 67, Corunna 3) ; portail dédié de Mutual PM"
179 + },
180 + {
181 + "id": "firstnorth",
182 + "name": "First North Property Management",
183 + "site": "https://firstnorthpm.com",
184 + "client_id": 558,
185 + "listing_url": "https://firstnorthpm.com/apartments-for-rent",
186 + "regions": [
187 + "Sudbury",
188 + "North Bay",
189 + "Timmins",
190 + "Nord de l'Ontario"
191 + ],
192 + "status": "valide",
193 + "properties_on": 27,
194 + "notes": "28 propriétés dont 27 ON (Sudbury 16, North Bay 5, Timmins 4) + 1 sans province (filtrée)"
195 + },
196 + {
197 + "id": "tvm",
198 + "name": "TVM Group",
199 + "site": "https://www.tvm.ca",
200 + "client_id": 359,
201 + "listing_url": "https://www.tvm.ca/apartments-for-rent",
202 + "regions": [
203 + "Peterborough",
204 + "Cobourg",
205 + "Toronto",
206 + "Port Hope"
207 + ],
208 + "status": "valide",
209 + "properties_on": 47,
210 + "notes": "47 propriétés ON (Peterborough 33, Cobourg 6, Toronto 5)"
211 + },
212 + {
213 + "id": "urbandale",
214 + "name": "Urbandale Corporation",
215 + "site": "https://urbandalecorporation.com",
216 + "client_id": 307,
217 + "listing_url": "https://urbandalecorporation.com/residential-rentals",
218 + "regions": [
219 + "Ottawa",
220 + "Gloucester",
221 + "Riverside South"
222 + ],
223 + "status": "valide",
224 + "properties_on": 24,
225 + "notes": "24 propriétés ON (Ottawa 23, Gloucester 1) ; urbandale.com = volet construction, le locatif est sur urbandalecorporation.com"
226 + },
227 + {
228 + "id": "sterlingkaramar",
229 + "name": "Sterling Karamar Property Management",
230 + "site": "https://www.sterlingkaramar.com",
231 + "client_id": null,
232 + "listing_url": "https://www.sterlingkaramar.com/apartments-for-rent",
233 + "regions": [
234 + "Toronto/GTA",
235 + "Mississauga",
236 + "Richmond Hill",
237 + "Oakville",
238 + "Oshawa"
239 + ],
240 + "status": "migre_rentsyncgw",
241 + "notes": "NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/sterlingkaramar, bundles Vue + website-gateway.rentsync.com) — aucun flux LiftSystem v2 ; ~15 000 unités, nécessite un connecteur website-gateway dédié ; FAIT 2026-08-27 → couvert par rsgw_sterlingkaramar (rentsyncgw_clients.json)"
242 + },
243 + {
244 + "id": "panoramic",
245 + "name": "Panoramic Properties",
246 + "site": "https://www.panoramicproperties.ca",
247 + "client_id": null,
248 + "listing_url": "https://www.panoramicproperties.ca/apartments-for-rent",
249 + "regions": [
250 + "Niagara",
251 + "Oshawa",
252 + "Sudbury"
253 + ],
254 + "status": "migre_rentsyncgw",
255 + "notes": "NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/panoramic_properties, Vue) — aucun flux LiftSystem v2 exposé ; FAIT 2026-08-27 → couvert par rsgw_panoramic (rentsyncgw_clients.json)"
256 + },
257 + {
258 + "id": "drewlo",
259 + "name": "Drewlo Holdings",
260 + "site": "https://www.drewloholdings.com",
261 + "client_id": null,
262 + "listing_url": "https://www.drewloholdings.com/apartments-for-rent",
263 + "regions": [
264 + "London",
265 + "Kitchener",
266 + "Burlington",
267 + "Sarnia",
268 + "Woodstock"
269 + ],
270 + "status": "migre_rentsyncgw",
271 + "notes": "NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/drewlo_rebuild, Vue) — aucun flux LiftSystem v2 exposé ; ~10 000 unités, gros morceau à récupérer via website-gateway ; FAIT 2026-08-27 → couvert par rsgw_drewlo (rentsyncgw_clients.json)"
272 + },
273 + {
274 + "id": "browngroup",
275 + "name": "The Brown Group of Companies",
276 + "site": "https://thebrowngroup.net",
277 + "client_id": 560,
278 + "listing_url": "https://thebrowngroup.net/properties",
279 + "regions": [
280 + "Toronto/GTA",
281 + "North York",
282 + "Etobicoke",
283 + "Brampton",
284 + "St. Catharines",
285 + "Cambridge"
286 + ],
287 + "status": "valide",
288 + "properties_on": 28,
289 + "notes": "Vague 2 (validé 2026-08-27) : 29 propriétés au flux dont 28 ON + 1 FL (filtrée) ; 21 résidentielles ON après filtre types (Toronto 7, North York 6, Etobicoke 2, Brampton 2)"
290 + },
291 + {
292 + "id": "ppml",
293 + "name": "Prudential Property Management (PPML)",
294 + "site": "https://ppml.ca",
295 + "client_id": 173,
296 + "listing_url": "https://ppml.ca/properties",
297 + "regions": [
298 + "Toronto",
299 + "Mississauga",
300 + "Waterloo",
301 + "Richmond Hill",
302 + "Brampton",
303 + "Scarborough"
304 + ],
305 + "status": "valide",
306 + "properties_on": 128,
307 + "notes": "Vague 2 (validé 2026-08-27) : 128 propriétés ON, toutes résidentielles (Toronto 91, Mississauga 14, Waterloo 7, Richmond Hill 3)"
308 + },
309 + {
310 + "id": "equiton",
311 + "name": "Equiton Living",
312 + "site": "https://equitonliving.com",
313 + "client_id": 885,
314 + "listing_url": "https://equitonliving.com/properties",
315 + "regions": [
316 + "Toronto",
317 + "Kingston",
318 + "London",
319 + "Guelph",
320 + "Chatham",
321 + "Brantford",
322 + "Kitchener"
323 + ],
324 + "status": "valide",
325 + "properties_on": 35,
326 + "notes": "Vague 2 (validé 2026-08-27) : 40 propriétés au flux dont 35 ON + 4 AB + 1 BC (filtrées) ; 32 résidentielles ON (Toronto 7, Kingston 3, London 3, Guelph 3)"
327 + },
328 + {
329 + "id": "regional",
330 + "name": "Regional Group (availabilities.regionalgroup.com)",
331 + "site": "https://regionalgroup.com",
332 + "client_id": 1432,
333 + "listing_url": "https://regionalgroup.com/availabilities/residential/",
334 + "regions": [
335 + "Ottawa",
336 + "Gloucester",
337 + "Nepean",
338 + "Gatineau (QC)"
339 + ],
340 + "status": "valide",
341 + "properties_on": 12,
342 + "notes": "Vague 2 (validé 2026-08-27) : 13 propriétés dont 12 ON + 1 QC (Gatineau, gardée par le connecteur) ; Ottawa 10 ; permaliens sur availabilities.regionalgroup.com (certains redirigent vers des sites de marque, ex. bravoliving.ca)"
343 + },
344 + {
345 + "id": "ranee",
346 + "name": "Ranee Management",
347 + "site": "https://www.ranee.ca",
348 + "client_id": 223,
349 + "listing_url": "https://www.ranee.ca/apartments-for-rent",
350 + "regions": [
351 + "Toronto",
352 + "Mississauga",
353 + "Brampton",
354 + "East York",
355 + "North York",
356 + "Scarborough"
357 + ],
358 + "status": "valide",
359 + "properties_on": 38,
360 + "notes": "Vague 2 (validé 2026-08-27) : 38 propriétés ON, toutes résidentielles (Toronto 21, Mississauga 5, Brampton 4, East York 3)"
361 + },
362 + {
363 + "id": "stellen",
364 + "name": "Stellen Property Management",
365 + "site": "https://www.stellen.ca",
366 + "client_id": 238,
367 + "listing_url": "https://www.stellen.ca/apartments-for-rent",
368 + "regions": [
369 + "Burlington",
370 + "North York",
371 + "Toronto"
372 + ],
373 + "status": "valide",
374 + "properties_on": 5,
375 + "notes": "Vague 2 (validé 2026-08-27) : 5 propriétés ON (Burlington 2, North York 2, Toronto 1) ; client_id via data-client-id des pages villes"
376 + },
377 + {
378 + "id": "fpm",
379 + "name": "Fleming Property Management",
380 + "site": "https://www.fpm.ca",
381 + "client_id": 963,
382 + "listing_url": "https://www.fpm.ca/residential",
383 + "regions": [
384 + "Ottawa"
385 + ],
386 + "status": "valide",
387 + "properties_on": 72,
388 + "permalink_sub": [
389 + "/listings/",
390 + "/residential/"
391 + ],
392 + "notes": "Vague 2 (validé 2026-08-27) : 72 propriétés ON, toutes Ottawa ; permaliens API cassés (fpm.ca/listings/<slug> → 404), corrigés via permalink_sub → /residential/ (testé sur 4 slugs) ; quelques permaliens de quartier (ex. /byward-market/…) intacts car sans /listings/"
393 + },
394 + {
395 + "id": "ottawapm",
396 + "name": "Ottawa Property Managers",
397 + "site": "https://www.ottawapropertymanagers.com",
398 + "client_id": 930,
399 + "listing_url": "https://www.ottawapropertymanagers.com/apartments",
400 + "regions": [
401 + "Ottawa",
402 + "Richmond",
403 + "Cumberland"
404 + ],
405 + "status": "valide",
406 + "properties_on": 175,
407 + "permalink_sub": [
408 + "/apartments/",
409 + "/for-rent/"
410 + ],
411 + "notes": "Vague 2 (validé 2026-08-27) : 175 propriétés ON (Ottawa 172) ; ~la moitié des permaliens API /apartments/<slug> → 404, mais /for-rent/<slug> rend la fiche (titre + galerie rentsync) pour TOUS les slugs testés → permalink_sub"
412 + },
413 + {
414 + "id": "tribond",
415 + "name": "Tribond Property Management",
416 + "site": "https://www.tribondpm.com",
417 + "client_id": 701,
418 + "listing_url": "https://www.tribondpm.com/residential",
419 + "regions": [
420 + "Kingston",
421 + "Napanee"
422 + ],
423 + "status": "valide",
424 + "properties_on": 13,
425 + "notes": "Vague 2 (validé 2026-08-27) : 13 propriétés ON (Kingston 12, Napanee 1)"
426 + },
427 + {
428 + "id": "ekort",
429 + "name": "Ekort Property Management",
430 + "site": "https://www.ekortpm.com",
431 + "client_id": 242,
432 + "listing_url": "https://www.ekortpm.com/properties",
433 + "regions": [
434 + "Belleville",
435 + "Quinte West",
436 + "Trenton",
437 + "Picton",
438 + "Bay of Quinte"
439 + ],
440 + "status": "valide",
441 + "properties_on": 59,
442 + "permalink_sub": [
443 + "/luxury-apartments/",
444 + "/residential/"
445 + ],
446 + "notes": "Vague 2 (validé 2026-08-27) : 59 propriétés ON dont 47 résidentielles (Belleville 32, Quinte West 6, Trenton 5) ; permaliens API cassés (/luxury-apartments/<slug> → 404), corrigés via permalink_sub → /residential/ (testé sur 6 slugs)"
447 + },
448 + {
449 + "id": "marandola",
450 + "name": "Team Marandola Property Management",
451 + "site": "https://www.teammarandola.com",
452 + "client_id": 554,
453 + "listing_url": "https://www.teammarandola.com/residential",
454 + "regions": [
455 + "Thorold",
456 + "St. Catharines",
457 + "Niagara Falls",
458 + "Niagara-on-the-Lake",
459 + "Welland"
460 + ],
461 + "status": "valide",
462 + "properties_on": 83,
463 + "notes": "Vague 2 (validé 2026-08-27) : 83 propriétés ON, rare couverture Niagara (Thorold 60, St. Catharines 16) ; beaucoup d'unités individuelles (maisons/upper-lower)"
464 + },
465 + {
466 + "id": "ceming",
467 + "name": "Ceming Apartments",
468 + "site": "https://www.cemingapartments.com",
469 + "client_id": 196,
470 + "listing_url": "https://www.cemingapartments.com/apartments",
471 + "regions": [
472 + "Sudbury"
473 + ],
474 + "status": "valide",
475 + "properties_on": 8,
476 + "notes": "Vague 2 (validé 2026-08-27) : 8 propriétés ON, toutes Sudbury"
477 + },
478 + {
479 + "id": "fitzrovia",
480 + "name": "Fitzrovia Real Estate",
481 + "site": "https://fitzrovia.ca",
482 + "client_id": null,
483 + "listing_url": "https://fitzrovia.ca/residences",
484 + "regions": [
485 + "Toronto/GTA"
486 + ],
487 + "status": "migre_rentsyncgw",
488 + "notes": "Vague 2 (2026-08-27) : NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/fitzrovia, Vue) — aucun flux LiftSystem v2 ; gros portefeuille locatif Toronto, à récupérer via website-gateway ; FAIT 2026-08-27 → couvert par rsgw_fitzrovia (rentsyncgw_clients.json)"
489 + },
490 + {
491 + "id": "drakepm",
492 + "name": "Drake Property Management",
493 + "site": "https://www.drakepm.com",
494 + "client_id": null,
495 + "listing_url": "https://www.drakepm.com/residential",
496 + "regions": [
497 + "Hamilton",
498 + "sud de l'Ontario"
499 + ],
500 + "status": "migre_rentsyncgw",
501 + "notes": "Vague 2 (2026-08-27) : NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/drakepm, Vue) — aucun flux LiftSystem v2 ; connecteur website-gateway à écrire ; FAIT 2026-08-27 → couvert par rsgw_drakepm (rentsyncgw_clients.json)"
502 + },
503 + {
504 + "id": "arcanos",
505 + "name": "Arcanos Property Management",
506 + "site": "https://arcanos.ca",
507 + "client_id": null,
508 + "listing_url": "https://arcanos.ca/apartments-for-rent",
509 + "regions": [
510 + "Ontario"
511 + ],
512 + "status": "migre_rentsyncgw",
513 + "notes": "Vague 2 (2026-08-27) : NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/arcanos_rebuild, Vue) — aucun flux LiftSystem v2 ; FAIT 2026-08-27 → couvert par rsgw_arcanos (rentsyncgw_clients.json)"
514 + },
515 + {
516 + "id": "bgo",
517 + "name": "BGO Living (BentallGreenOak)",
518 + "site": "https://bgoliving.com",
519 + "client_id": null,
520 + "listing_url": "https://bgoliving.com/apartments-for-rent",
521 + "regions": [
522 + "Toronto/GTA",
523 + "pancanadien"
524 + ],
525 + "status": "migre_rentsyncgw",
526 + "notes": "Vague 2 (2026-08-27) : NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/bgo_rebuild, Vue) — aucun flux LiftSystem v2 ; gros gestionnaire institutionnel, morceau important pour website-gateway ; FAIT 2026-08-27 → couvert par rsgw_bgo (rentsyncgw_clients.json)"
527 + }
528 + ]
529 +}
\ No newline at end of file
added data/liftsystem_national.txt +1143 −0
@@ -0,0 +1,1143 @@
1 +5|"client":{"id":5,"name":"Osgoode Properties","website":"osgoodesuites.com
2 +1|"client":{"id":1,"name":"Landlord Web Solutions","website":"landlordwebsolutions.com
3 +6|"client":{"id":6,"name":"Homestead Land Holdings","website":"homestead.ca
4 +3|"client":{"id":3,"name":"Kelson Group","website":"kelsongroup.com
5 +4|"client":{"id":4,"name":"Drewlo Holdings","website":"www.drewloholdings.com
6 +2|"client":{"id":2,"name":"Greenwin","website":"greenwin.ca
7 +8|"client":{"id":8,"name":"Panoramic Properties Inc.","website":"panoramicproperties.ca
8 +9|"client":{"id":9,"name":"Pine Allard \u0026 Willow Properties","website":"pineallard.ca
9 +11|"client":{"id":11,"name":"The Myriad Group","website":"myriadrentals.com
10 +15|"client":{"id":15,"name":"Berkley Property Management Inc.","website":"berkleypm.ca
11 +16|"client":{"id":16,"name":"Enderby Developments Limited | Connaught Yonge Square Limited | Oakleaf Building \u0026 Investment Limited","website":"enderbymanagement.com
12 +13|"client":{"id":13,"name":"Crestview Investment Corporation","website":"
13 +10|"client":{"id":10,"name":"KG Group","website":"myrental.ca
14 +12|"client":{"id":12,"name":"Williams \u0026 McDaniel Property Management","website":"williamsandmcdaniel.com
15 +17|"client":{"id":17,"name":"The Effort Trust Company Ltd.","website":"effortrentals.com
16 +19|"client":{"id":19,"name":"Rycal Properties","website":"rycal.ca
17 +22|"client":{"id":22,"name":"Parkway Village/L. Viger Construction Ltd.","website":"
18 +20|"client":{"id":20,"name":"SKA Apartment Rentals","website":"ska-apartmentrentals.com
19 +23|"client":{"id":23,"name":"Canahahns Company Limited","website":"canahahns.com
20 +7|"client":{"id":7,"name":"Avenue Living","website":"avenueliving.ca
21 +21|"client":{"id":21,"name":"Centurion Property Associates Inc.","website":"cpliving.com
22 +24|"client":{"id":24,"name":"Varsity Properties Inc.","website":"varsityoshawa.com
23 +27|"client":{"id":27,"name":"Skyview Living","website":"skyviewliving.ca
24 +38|"client":{"id":38,"name":"Littlewood Properties ","website":"lpliving.ca
25 +39|"client":{"id":39,"name":"Schickedanz Properties","website":"theplacetorent.ca
26 +36|"client":{"id":36,"name":"Rent In Ottawa Property Management","website":"rentinottawa.com
27 +46|"client":{"id":46,"name":"Remai","website":"
28 +44|"client":{"id":44,"name":"Compten Management Inc.","website":"compten.ca
29 +49|"client":{"id":49,"name":"Devonshire Properties","website":"devonshire-inc.com
30 +52|"client":{"id":52,"name":"Macro Properties","website":"macroproperties.com
31 +48|"client":{"id":48,"name":"DH Westview","website":"dhwestview.com
32 +56|"client":{"id":56,"name":"SMAR Holdings Ltd.","website":"smar.ca
33 +55|"client":{"id":55,"name":"Cromwell Management Inc. ","website":"cromwellmgt.ca
34 +58|"client":{"id":58,"name":"Better Living Homes","website":"betterlivinghomes.ca
35 +57|"client":{"id":57,"name":"Village Millcraft Apartments","website":"millcraftinc.ca
36 +59|"client":{"id":59,"name":"Bedford Living","website":"bedfordliving.ca
37 +60|"client":{"id":60,"name":"Carlisle Management Inc.","website":"
38 +61|"client":{"id":61,"name":"Sireg Management Inc.","website":"siregmanagement.com
39 +62|"client":{"id":62,"name":"Stewart Properties","website":"stewartproperties.ca
40 +65|"client":{"id":65,"name":"Park Property Management","website":"parkproperty.ca
41 +68|"client":{"id":68,"name":"Burnaby Centre","website":"burnabycentre.com
42 +69|"client":{"id":69,"name":"Centurion Students","website":"themarq.ca
43 +70|"client":{"id":70,"name":"Sifton Properties","website":"sifton.com
44 +67|"client":{"id":67,"name":"Cascadia Apartment Rentals Ltd.","website":"aptrentals.net
45 +73|"client":{"id":73,"name":"Rockport Holdings","website":"rockportgroup.ca
46 +71|"client":{"id":71,"name":"York Property Management","website":"yorkproperty.ca
47 +78|"client":{"id":78,"name":"Glen Corporation","website":"glencorp.com
48 +80|"client":{"id":80,"name":"Trivest Developments Corporation","website":"miramarpm.com
49 +81|"client":{"id":81,"name":"CLV Group","website":"clvgroup.com
50 +79|"client":{"id":79,"name":"WJ Properties","website":"wjproperties.ca
51 +74|"client":{"id":74,"name":"Medallion Corporation","website":"medallioncorp.com
52 +76|"client":{"id":76,"name":"Midwest Property Management","website":"rentmidwest.com
53 +82|"client":{"id":82,"name":"Westwood Management International","website":"academywestwood.com
54 +83|"client":{"id":83,"name":"DBS Developments ","website":"dbsdevelopments.ca
55 +86|"client":{"id":86,"name":"Pinedale Property Management","website":"pinedaleprop.com
56 +89|"client":{"id":89,"name":"Niagara on the Lake Furnished Rentals","website":"niagaraonthelakefurnishedrentals.com
57 +85|"client":{"id":85,"name":"Arcanos Property Management Corporation","website":"arcanos.ca
58 +88|"client":{"id":88,"name":"223 Woodbine Ave Apartments","website":"
59 +90|"client":{"id":90,"name":"Kaneff Corporation","website":"kaneff.com
60 +92|"client":{"id":92,"name":"Westwood West","website":"academywestwood.com
61 +94|"client":{"id":94,"name":"Maple Leaf Property Management","website":"
62 +100|"client":{"id":100,"name":"The Steeves \u0026 Rozema Group","website":"srcommunities.ca
63 +106|"client":{"id":106,"name":"Knightstone Student Communities","website":"
64 +99|"client":{"id":99,"name":"Killam Apartment REIT","website":"killamreit.com
65 +107|"client":{"id":107,"name":"District Realty","website":"districtrealty.com
66 +110|"client":{"id":110,"name":"North44 Property Management","website":"north44pm.com
67 +109|"client":{"id":109,"name":"Well Grounded Real Estate","website":"wellgroundedrealestate.com
68 +114|"client":{"id":114,"name":"Itziar Management","website":"itziar.ca
69 +115|"client":{"id":115,"name":"Mutual Property","website":"rentsarnia.com
70 +116|"client":{"id":116,"name":"Hume Investments Ltd","website":"humeinvestments.com
71 +117|"client":{"id":117,"name":"Clermont Properties","website":"clermontgroup.com
72 +53|"client":{"id":53,"name":"Sterling Karamar Property Management","website":"www.sterlingkaramar.com
73 +118|"client":{"id":118,"name":"Tribe Rentals","website":"triberentals.com
74 +123|"client":{"id":123,"name":"Kay Four Properties Inc.","website":"kayfour.ca
75 +125|"client":{"id":125,"name":"Davies Property Management","website":"daviesmanagement.com
76 +126|"client":{"id":126,"name":"Majesty Properties","website":"majestyproperties.com
77 +122|"client":{"id":122,"name":"DMS Property Management","website":"dmsproperty.com
78 +129|"client":{"id":129,"name":"Creccal Investments Ltd","website":"
79 +128|"client":{"id":128,"name":"AML Properties","website":"aml.properties
80 +97|"client":{"id":97,"name":"BGO Living","website":"bgoliving.com
81 +132|"client":{"id":132,"name":"Active Management Ltd.","website":"activemgmt.ca
82 +135|"client":{"id":135,"name":"Property Link Management Services Inc.","website":"propertylinkms.com
83 +137|"client":{"id":137,"name":"Avail Properties Inc.","website":"availproperties.ca
84 +136|"client":{"id":136,"name":"Towers Realty","website":"www.towersrealty.ca
85 +133|"client":{"id":133,"name":"Edison Properties","website":"edisonproperties.ca
86 +141|"client":{"id":141,"name":"Maison Hamilton House Apartments","website":"
87 +142|"client":{"id":142,"name":"Forest Ridge","website":"
88 +140|"client":{"id":140,"name":"GWL Realty Advisors Residential","website":"gwlraresidential.com
89 +144|"client":{"id":144,"name":"Will Pereira Real Estate","website":"
90 +146|"client":{"id":146,"name":"Britannia Parkside","website":"
91 +145|"client":{"id":145,"name":"Halstead Gardens","website":"
92 +149|"client":{"id":149,"name":"Lionheart Property Management Inc.","website":"lionheartpm.ca
93 +151|"client":{"id":151,"name":"PMA Realty Consulting Ltd.","website":"
94 +152|"client":{"id":152,"name":"Pacific Cove Properties","website":"pacificcoveproperties.com
95 +148|"client":{"id":148,"name":"Akelius Canada Ltd.","website":"akelius.ca
96 +154|"client":{"id":154,"name":"Rest Easy Property Management","website":"resteasyproperty.com
97 +155|"client":{"id":155,"name":"Dore PM","website":"dorerentals.com
98 +84|"client":{"id":84,"name":"Signet Group Inc.","website":"signetgroup.ca
99 +159|"client":{"id":159,"name":"Britannia Beach","website":"
100 +157|"client":{"id":157,"name":"Keystone Properties Management Inc","website":"keyprop.com
101 +153|"client":{"id":153,"name":"Minto Properties Inc.","website":"mintoapartments.com
102 +164|"client":{"id":164,"name":"The Effort Trust","website":"effortcommercial.com
103 +162|"client":{"id":162,"name":"Summit PM","website":"summitmanagement.ca
104 +167|"client":{"id":167,"name":"Clear View Property Management","website":"clearviewpropertymanagement.ca
105 +170|"client":{"id":170,"name":"Lumen Winnipeg","website":"lumenwinnipeg.com
106 +169|"client":{"id":169,"name":"Redwood Properties","website":"redwoodliving.ca
107 +172|"client":{"id":172,"name":"rentalaccommodations.net","website":"rentalaccommodations.net
108 +174|"client":{"id":174,"name":"Blue Basin Group","website":"bluebasingroup.ca
109 +173|"client":{"id":173,"name":"Prudential Property Management Ltd.","website":"ppml.ca
110 +175|"client":{"id":175,"name":"Veyron Properties Group Ltd.","website":"veyronproperties.com
111 +179|"client":{"id":179,"name":"Abode Property Management","website":"abodepropertymgmt.com
112 +177|"client":{"id":177,"name":"Melchior Management","website":"mmcorp.ca
113 +178|"client":{"id":178,"name":"Parliament","website":"parliamentrentals.ca
114 +176|"client":{"id":176,"name":"Northview Residential REIT","website":"rentnorthview.com
115 +180|"client":{"id":180,"name":"Westcorp Property Management Inc","website":"westcorp.net
116 +186|"client":{"id":186,"name":"Canci Rentals","website":"cancirentals.com
117 +189|"client":{"id":189,"name":"Canadian Urban","website":"culrents.com
118 +188|"client":{"id":188,"name":"Ground Floor Property Management","website":"groundfloorpm.com
119 +194|"client":{"id":194,"name":"Emerald Management","website":"emeraldmanagement.com
120 +192|"client":{"id":192,"name":"Green Valley Management","website":"greenvalleymgmt.com
121 +196|"client":{"id":196,"name":"Ceming/Banyan Apartments","website":"cemingapartments.com
122 +198|"client":{"id":198,"name":"Malmac Properties","website":"www.malmacproperties.com
123 +200|"client":{"id":200,"name":"Qualico Properties","website":"
124 +201|"client":{"id":201,"name":"Greenwin - 181 John","website":"181john.ca
125 +204|"client":{"id":204,"name":"Royal York Apartments","website":"royalyorkapartments.com
126 +205|"client":{"id":205,"name":"Quality Management","website":"qualitymanagement.net
127 +208|"client":{"id":208,"name":"Fern Property Management","website":"fernpropertymanagement.com
128 +209|"client":{"id":209,"name":"PMB","website":"pmbrokers.ca
129 +211|"client":{"id":211,"name":"Century 21 Elevate Real Estate","website":"century21.ca
130 +203|"client":{"id":203,"name":"Boardwalk","website":"bwalk.com
131 +212|"client":{"id":212,"name":"AB \u0026 I Holdings","website":"officespaceburlington.com
132 +207|"client":{"id":207,"name":"Redline Real Estate","website":"redlinerealestate.ca
133 +213|"client":{"id":213,"name":"HNR Dundas Square Tower Limited","website":"velocityatthesquare.com
134 +215|"client":{"id":215,"name":"Concorp Inc.","website":"concorp.ca
135 +222|"client":{"id":222,"name":"Ayre \u0026 Oxford Inc.","website":"ayreoxford.com
136 +223|"client":{"id":223,"name":"Ranee Management","website":"ranee.ca
137 +225|"client":{"id":225,"name":"Q Residential","website":"
138 +224|"client":{"id":224,"name":"Safri Management Corp.","website":"
139 +226|"client":{"id":226,"name":"O'Shanter Development Company Ltd.","website":"oshanter.com
140 +96|"client":{"id":96,"name":"Skyline","website":"www.skylineliving.ca
141 +219|"client":{"id":219,"name":"Deveraux Apartment Communities","website":"deverauxrentals.ca
142 +229|"client":{"id":229,"name":"Capilano Property Management Services","website":"capilanopropertymanagement.com
143 +233|"client":{"id":233,"name":"Transcendent Properties","website":"
144 +232|"client":{"id":232,"name":"Drummer Realty \u0026 Property Management","website":"https://www.drummerrealty.com/
145 +238|"client":{"id":238,"name":"Stellen Property Management ","website":"stellen.ca
146 +235|"client":{"id":235,"name":"Eldebron Holdings","website":"
147 +228|"client":{"id":228,"name":"CAPREIT","website":"capreit.ca
148 +236|"client":{"id":236,"name":"Homestead: Rosecorp","website":"212davis.ca
149 +240|"client":{"id":240,"name":"Tricar","website":"tricar.com
150 +239|"client":{"id":239,"name":"Nubury Properties Limited","website":"roseburysquare.com
151 +242|"client":{"id":242,"name":"Ekort PM","website":"ekortpm.com
152 +243|"client":{"id":243,"name":"Beacon Hill Properties Ltd. ","website":"beaconhillproperties.ca
153 +247|"client":{"id":247,"name":"Property Care \u0026 Management Inc","website":"
154 +241|"client":{"id":241,"name":"Lineage Property Management Services","website":"lineagepm.com
155 +248|"client":{"id":248,"name":"Westmount Manor","website":"westmountmanor.ca
156 +250|"client":{"id":250,"name":"Rhapsody Property Management Services","website":"rhapsodyliving.ca
157 +254|"client":{"id":254,"name":"Sleepwell Property Management","website":"sleepwellmanagement.com
158 +253|"client":{"id":253,"name":"Universal Realty Group","website":"universalgroup.ca
159 +260|"client":{"id":260,"name":"Megacity Management Inc.","website":"megacity.rentals
160 +263|"client":{"id":263,"name":"Coastline Equities","website":"shediacapartments.ca
161 +264|"client":{"id":264,"name":"BSR Group","website":"bsrgroup.ca
162 +261|"client":{"id":261,"name":"Summerhill Properties","website":"summerhillproperties.com
163 +266|"client":{"id":266,"name":"Canal Property Group","website":"canalpm.ca
164 +267|"client":{"id":267,"name":"Atlas Property Group Inc.","website":"atlasproperty.com
165 +268|"client":{"id":268,"name":"Prospera Property Services","website":"
166 +269|"client":{"id":269,"name":"HomesForTenants.com","website":"homesfortenants.com
167 +271|"client":{"id":271,"name":"Westbury Rental Residences","website":"westburyrentals.com
168 +270|"client":{"id":270,"name":"IMR Property/ Capital Management ","website":"imr.lws1.com
169 +274|"client":{"id":274,"name":"Zen Residential Ltd.","website":"zenrentals.ca
170 +282|"client":{"id":282,"name":"Parkway Village","website":"wellandapartments.ca
171 +281|"client":{"id":281,"name":"Braebury Properties","website":"braeburyproperties.com
172 +283|"client":{"id":283,"name":"Morguard","website":"morguardapartments.com
173 +285|"client":{"id":285,"name":"Apartments For Rent Ottawa","website":"apartmentsforrentottawa.com
174 +284|"client":{"id":284,"name":"RE/MAX Rental Advisors","website":"rentaladvisors.ca
175 +288|"client":{"id":288,"name":"Laidley Management Ltd","website":"
176 +286|"client":{"id":286,"name":"Family Properties","website":"familyproperties.ca
177 +290|"client":{"id":290,"name":"Elite Property Management Ltd.","website":"elitesuite.ca
178 +291|"client":{"id":291,"name":"Saickley Enterprises LTD.","website":"saickleyproperties.ca
179 +292|"client":{"id":292,"name":"McCowan \u0026 Associates Ltd.","website":"
180 +301|"client":{"id":301,"name":"A.J. Freiman Ltd.","website":"456mcarthuravenue.ca
181 +302|"client":{"id":302,"name":"Biddwell","website":"biddwell.com
182 +303|"client":{"id":303,"name":"Apollo Property Management","website":"apollomgt.com
183 +307|"client":{"id":307,"name":"Urbandale Corporation","website":"urbandalecorporation.com
184 +312|"client":{"id":312,"name":"Toronto Community Housing Corporation","website":"torontohousing.ca
185 +305|"client":{"id":305,"name":"Broadstreet Properties","website":"www.broadstreet.ca
186 +311|"client":{"id":311,"name":"Centa Real Estate Ltd. (Brokerage) ","website":"centarealestateltd.com
187 +309|"client":{"id":309,"name":"Zulich Enterprises Limited","website":"www.zulich.com
188 +314|"client":{"id":314,"name":"Rockwood Property Management","website":"rockwoodmanagement.ca
189 +315|"client":{"id":315,"name":"Mcd Independent Property Management","website":"taylorwoodoshawa.ca
190 +316|"client":{"id":316,"name":"Extreme Property Management","website":"extremepropertymanagement.com
191 +313|"client":{"id":313,"name":"Globe Property Management","website":"globepm.ca
192 +319|"client":{"id":319,"name":"Magnum York","website":"magnumyork.com
193 +318|"client":{"id":318,"name":"Today Living Group","website":"todaylivinggroup.com
194 +322|"client":{"id":322,"name":"Bond Apartments","website":"apartmentsbybond.com
195 +323|"client":{"id":323,"name":"Carling Realty Company Ltd.","website":"carlingrealty.com
196 +326|"client":{"id":326,"name":"Fireside Property Group","website":"firesidepropertygroup.com
197 +328|"client":{"id":328,"name":"Nuspor Investments","website":"25leithhill.com
198 +329|"client":{"id":329,"name":"Numert Investments","website":"345mertonapartments.com
199 +327|"client":{"id":327,"name":"Red Key Realty","website":"redkeyproperties.ca
200 +330|"client":{"id":330,"name":"MHA Properties","website":"greatapartments.ca
201 +325|"client":{"id":325,"name":"Royal York Property Management","website":"royalyorkpropertymanagement.com
202 +337|"client":{"id":337,"name":"Immeubles Universel","website":"
203 +338|"client":{"id":338,"name":"Hope Street","website":"hopestreet.ca
204 +344|"client":{"id":344,"name":"Remax Elk Valley Realty","website":"
205 +339|"client":{"id":339,"name":"Sunrex Management Ltd.","website":"sunrex.ca
206 +341|"client":{"id":341,"name":"McLeod Realty \u0026 Management ","website":"mcleodrealty.com
207 +342|"client":{"id":342,"name":"Fahel \u0026 Co","website":"listings.fcorentals.com
208 +347|"client":{"id":347,"name":"Walkley Square Apartments Inc.","website":"walkleysquareapartments.com
209 +348|"client":{"id":348,"name":"Picton Apartments","website":"
210 +349|"client":{"id":349,"name":"Campbellford Apartments","website":"
211 +353|"client":{"id":353,"name":"HHP Properties","website":"hhpproperties.com
212 +357|"client":{"id":357,"name":"Shediac Equities Limited","website":"shediacseniors.com
213 +355|"client":{"id":355,"name":"Libertas Property Management Inc","website":"lpmimgmt.com
214 +359|"client":{"id":359,"name":"TVM","website":"tvm.ca
215 +367|"client":{"id":367,"name":"Wilmington Pointe LLC","website":"
216 +366|"client":{"id":366,"name":"Braden Equities Inc.","website":"bradenequitiesinc.com
217 +370|"client":{"id":370,"name":"Wilmington Pointe LLC: Wilmington_Pointe","website":"wilmingtonpointe.com
218 +374|"client":{"id":374,"name":"Clover Living","website":"cloverliving.ca
219 +377|"client":{"id":377,"name":"Prime Property Management","website":"gpprime.net
220 +364|"client":{"id":364,"name":"Mainstreet Equity Corp.","website":"mainst.biz
221 +375|"client":{"id":375,"name":"Terrace Apartments","website":"edmontonapartments.rent
222 +379|"client":{"id":379,"name":"Gill Apartments LTD","website":"gskproperties.ca
223 +380|"client":{"id":380,"name":"Boswell Krieger Management \u0026 Realty Ltd.","website":"boswellkrieger.com
224 +381|"client":{"id":381,"name":"NP Rentals","website":"rentnp.ca
225 +386|"client":{"id":386,"name":"Cornerstone Management","website":"csmanagement.ca
226 +384|"client":{"id":384,"name":"Tandem Property Management","website":"sheppardcentreapartments.com
227 +389|"client":{"id":389,"name":"Wildwood Oaks","website":"wildwoodoaks.com
228 +391|"client":{"id":391,"name":"Dragon Villa","website":"dragonvillas.ca
229 +394|"client":{"id":394,"name":"Concorde Properties","website":"concorde-properties.ca
230 +393|"client":{"id":393,"name":"Fahel \u0026 Co Inc","website":"fahelandco.com
231 +395|"client":{"id":395,"name":"Frastell Property Management Inc.","website":"frastell.com
232 +396|"client":{"id":396,"name":"Blackmud Properties","website":"blackmudproperties.ca
233 +399|"client":{"id":399,"name":"C \u0026 D Property Management Inc","website":"cdpm-inc.com
234 +401|"client":{"id":401,"name":"Red Deer Property Rentals","website":"reddeerpropertyrentals.com
235 +397|"client":{"id":397,"name":"Premier Immobilier","website":"premiermontreal.com
236 +400|"client":{"id":400,"name":"W.P.A.","website":"
237 +403|"client":{"id":403,"name":"Denizen Management","website":"
238 +407|"client":{"id":407,"name":"Shelter Canadian Properties Limited","website":"scpl.com
239 +406|"client":{"id":406,"name":"Trifilio Ventures Ltd.","website":"trifiliorentalproperties.com
240 +420|"client":{"id":420,"name":"Platinum Realty \u0026 Management","website":"platinumrealtyandmgmt.com
241 +424|"client":{"id":424,"name":"Susan Bescec","website":"
242 +428|"client":{"id":428,"name":"TMC Property Management","website":"tmcpropertymanagement.com
243 +432|"client":{"id":432,"name":"Drake Property Management","website":"drakepm.com
244 +434|"client":{"id":434,"name":"Saga Realties","website":"sagarealties.com
245 +438|"client":{"id":438,"name":"MMI Group Inc","website":"180villageliving.com
246 +437|"client":{"id":437,"name":"ICR","website":"icrcommercial.com
247 +435|"client":{"id":435,"name":"Del Condominium Rentals Inc","website":"delrentals.com
248 +443|"client":{"id":443,"name":"PacRim Properties, LLC","website":"prpalaska.com
249 +439|"client":{"id":439,"name":"QuadReal","website":"quadrealresidential.com
250 +448|"client":{"id":448,"name":"New Leaf Properties","website":"newleafproperties.ca
251 +450|"client":{"id":450,"name":"The Residences at Ag47","website":"residencesatag47.com
252 +449|"client":{"id":449,"name":"BlueStone Properties Inc.","website":"bluestoneproperties.com
253 +447|"client":{"id":447,"name":"Pivotal","website":"pivotal-communities.com
254 +436|"client":{"id":436,"name":"Realstar Corp.","website":"realstar.ca
255 +459|"client":{"id":459,"name":"Wycliffe PM","website":"wycliffeproperties.com
256 +460|"client":{"id":460,"name":"Leston Holdings","website":"lestonholdings.com
257 +461|"client":{"id":461,"name":"Celtic Group of Companies","website":"celticmanagement.ca
258 +458|"client":{"id":458,"name":"Saskatoon RPM","website":"saskpropertymanagement.com
259 +465|"client":{"id":465,"name":"Found Spaces Inc","website":"rent.foundspaces.ca
260 +464|"client":{"id":464,"name":"Hanley Property Management Inc.","website":"
261 +463|"client":{"id":463,"name":"MaxWell Horizon","website":"maxwellhorizon.ca
262 +471|"client":{"id":471,"name":"Yongelife","website":"
263 +472|"client":{"id":472,"name":"Oz Property Management","website":"ozpropertymanagement.ca
264 +474|"client":{"id":474,"name":"TMDL Property Management Inc","website":"tmdlpm.ca
265 +473|"client":{"id":473,"name":"MU Property Management","website":"mupm.ca
266 +475|"client":{"id":475,"name":"(RMS) Royal Management Services Inc.","website":"royalmanagement.ca
267 +477|"client":{"id":477,"name":"Birch Housing","website":"birchhousing.ca
268 +476|"client":{"id":476,"name":"Property Manager","website":"
269 +478|"client":{"id":478,"name":"Highstreet Communities","website":"renthighstreet.ca
270 +485|"client":{"id":485,"name":"Birchwood Equities Inc.","website":"bestedmontonapartments.ca
271 +488|"client":{"id":488,"name":"Burk Management and Realty Inc.","website":"burkmanagement.com
272 +487|"client":{"id":487,"name":"Amelin Property Management","website":"amelinproperties.com
273 +486|"client":{"id":486,"name":"Shelter Asset Management and SAM Development Group","website":"shelterasset.com
274 +489|"client":{"id":489,"name":"Menke Holdings Limited","website":"menkesuites.ca
275 +490|"client":{"id":490,"name":"Forum","website":"forumproperties.com
276 +484|"client":{"id":484,"name":"Rakotta Real Estate","website":"rakotta.com
277 +491|"client":{"id":491,"name":"CIR Commercial Realty Inc.","website":"colliersrentals.com
278 +499|"client":{"id":499,"name":"Thorwin Properties","website":"thorwin.ca
279 +500|"client":{"id":500,"name":"Northern Properties","website":"northernprop.ca
280 +501|"client":{"id":501,"name":"High Value Consultants Ltd.","website":"hvconsultants.com
281 +470|"client":{"id":470,"name":"Briarlane Rental Property Management Inc","website":"briarlane.ca
282 +497|"client":{"id":497,"name":"Hazelview Properties","website":"hazelviewproperties.com
283 +516|"client":{"id":516,"name":"Vanak International Properties","website":"vanakrealty.com
284 +520|"client":{"id":520,"name":"Simms Management","website":"simmsmanagement.com
285 +518|"client":{"id":518,"name":"InterRent","website":"irent.com
286 +525|"client":{"id":525,"name":"Cres Property Management, LLC","website":"cresproperties.com
287 +526|"client":{"id":526,"name":"Laurence Management Group","website":"lmgproperties.com
288 +532|"client":{"id":532,"name":"ACSO PM Inc","website":"thehome4u.ca
289 +531|"client":{"id":531,"name":"Hazelview 2360 Weston Road","website":"2360weston.com
290 +534|"client":{"id":534,"name":"Stone Mill Inn","website":"
291 +537|"client":{"id":537,"name":"BIN Management Inc.","website":"binmanagementinc.com
292 +539|"client":{"id":539,"name":"Rhapsody:West22","website":"westonrentals.ca
293 +546|"client":{"id":546,"name":"P3 Realty","website":"
294 +550|"client":{"id":550,"name":"Dock Equities Ltd: Commerce House","website":"
295 +552|"client":{"id":552,"name":"Marwest Management Canada Ltd.","website":"marwestrentals.ca
296 +553|"client":{"id":553,"name":"Village Creek Property Management","website":"villagecreek.ca
297 +554|"client":{"id":554,"name":"Team Marandola Property Management","website":"teammarandola.com
298 +556|"client":{"id":556,"name":"Riverwood Senior Living","website":"riverwoodseniorliving.ca
299 +557|"client":{"id":557,"name":"Northland Asset Management","website":"northlandmanagement.ca
300 +558|"client":{"id":558,"name":"First North Property Management","website":"firstnorthpm.com
301 +560|"client":{"id":560,"name":"Brown Management Services","website":"thebrowngroup.net
302 +561|"client":{"id":561,"name":"The Stack At Bayview","website":"thestack.ca
303 +563|"client":{"id":563,"name":"Evergreen Lands Ltd","website":"rentcentrepoint.com
304 +564|"client":{"id":564,"name":"KW4Rent","website":"kw4rent.com
305 +570|"client":{"id":570,"name":"Premium Rentals","website":"premiumrentals.ca
306 +571|"client":{"id":571,"name":"Imperra General Management","website":"imperra.ca
307 +575|"client":{"id":575,"name":"Colonial Investment \u0026 Management Company","website":"colonialinvestment.com
308 +580|"client":{"id":580,"name":"Urbanlife Management Ltd ","website":"rentulm.ca
309 +582|"client":{"id":582,"name":"Fidelity Property Management Ltd","website":"fidelitypm.com
310 +581|"client":{"id":581,"name":"Power Properties Ltd.","website":"powerproperties.rentals
311 +586|"client":{"id":586,"name":"Remax Saskatoon Property Management","website":"rentalsaskatoon.ca
312 +587|"client":{"id":587,"name":"Uptimo Gestion Immobilière","website":"uptimo.ca
313 +588|"client":{"id":588,"name":"On-Site Solutions Inc","website":"riverparkglenrentals.ca
314 +584|"client":{"id":584,"name":"Templeton Properties","website":"templetonproperties.ca
315 +589|"client":{"id":589,"name":"Skyward Living Properties","website":"skywardlivingproperties.ca
316 +596|"client":{"id":596,"name":"Jamison Management Company","website":"jamico.com
317 +597|"client":{"id":597,"name":"Anagram RENTALS Inc.","website":"anagramrentals.com
318 +600|"client":{"id":600,"name":"The Luxury Collective Group","website":"
319 +598|"client":{"id":598,"name":"Zahra Properties","website":"zahraproperties.com
320 +612|"client":{"id":612,"name":"OBASA / Six Three Suites","website":"obasasuites.com
321 +611|"client":{"id":611,"name":"Boncore Management Ltd (Corp)","website":"boncore.ca
322 +614|"client":{"id":614,"name":"Prospero International Realty Inc","website":"prospero.ca
323 +616|"client":{"id":616,"name":"Lloydminster Apartment Rentals","website":"
324 +615|"client":{"id":615,"name":"Pillar Property Management Inc","website":"pillarmanagement.ca
325 +619|"client":{"id":619,"name":"Greater Vancouver Tenant \u0026 Property Management Ltd","website":"gvantpm.com
326 +624|"client":{"id":624,"name":"Clearwater Investments Inc.","website":"armourmanagement.ca
327 +623|"client":{"id":623,"name":"Centre 100 Holdings Ltd","website":"centre100.ca
328 +621|"client":{"id":621,"name":"Rockport Investment Group Inc","website":"rockportgroup.ca
329 +628|"client":{"id":628,"name":"WRE Development Ltd","website":"wredevelopment.ca
330 +627|"client":{"id":627,"name":"Crystal Properties","website":"crystalproperties.ca
331 +629|"client":{"id":629,"name":"Residential Leasing Group Inc.","website":"rlg.ca
332 +635|"client":{"id":635,"name":"Lakewood Agencies","website":"lakewoodagencies.ca
333 +634|"client":{"id":634,"name":"Karma Property Management","website":"karmaproperties.ca
334 +638|"client":{"id":638,"name":"Rivard Apartments","website":"
335 +636|"client":{"id":636,"name":"J.C. Property Management","website":"jcproperties.ca
336 +637|"client":{"id":637,"name":"A.S.H. Management Group Inc.","website":"ashmanagementgroup.com
337 +642|"client":{"id":642,"name":"Southwest One","website":"southwestone.ca
338 +641|"client":{"id":641,"name":"Ironclad Properties Inc.","website":"ironcladproperties.ca
339 +644|"client":{"id":644,"name":"Severn Court Student Residence","website":"studentrez.com
340 +645|"client":{"id":645,"name":"Manor Park Management","website":"
341 +647|"client":{"id":647,"name":"Cityscape Residence Corporation","website":"placepromenade.ca
342 +649|"client":{"id":649,"name":"Bronson Place Realties Ltd.","website":"
343 +648|"client":{"id":648,"name":"Alderman Capital","website":"
344 +650|"client":{"id":650,"name":"McCor Management (MB) Inc. (RC only)","website":"mccor.ca
345 +658|"client":{"id":658,"name":"Citysearch Rental Network","website":"citysearchcalgary.com
346 +656|"client":{"id":656,"name":"Ace Agencies","website":"abbotsfordpropertymgmt.ca
347 +660|"client":{"id":660,"name":"AG47 Residential LLC","website":"residencesatag47.com
348 +664|"client":{"id":664,"name":"Elk Property Management Ltd.","website":"elkproperty.com
349 +665|"client":{"id":665,"name":"B \u0026 J Enterprises LLC","website":"mcgurnenterprises.com
350 +663|"client":{"id":663,"name":"Parabelle Properties Ltd","website":"parabelle.com
351 +666|"client":{"id":666,"name":"Bartlett Realty Ltd.","website":"bartlettrealestate.ca
352 +667|"client":{"id":667,"name":"Black Mountain Property Management","website":"blackmountainpropertymgmt.com
353 +668|"client":{"id":668,"name":"Karma GP Ltd.","website":"liveatthemet.com
354 +669|"client":{"id":669,"name":"Cressman Realty \u0026 Property Management","website":"cressmanrpm.ca
355 +672|"client":{"id":672,"name":"Rancho: Edmonton","website":"ranchoedmonton.com
356 +671|"client":{"id":671,"name":"Residence \u0026 Conference Centre","website":"stayrcc.com
357 +654|"client":{"id":654,"name":"Groupe Denux","website":"groupedenux.com
358 +673|"client":{"id":673,"name":"City Vibe Developments Inc","website":"rentcityvibe.ca
359 +674|"client":{"id":674,"name":"Halfred","website":"halfred.ca
360 +670|"client":{"id":670,"name":"DelSuites","website":"delsuites.com
361 +676|"client":{"id":676,"name":"Holand Real Estate Group","website":"holandrealestategroup.com
362 +680|"client":{"id":680,"name":"LiveTreo","website":"livetreo.ca
363 +679|"client":{"id":679,"name":"Strategic Group","website":"strategicgroup.ca
364 +677|"client":{"id":677,"name":"Barney River","website":"barneyriver.com
365 +678|"client":{"id":678,"name":"Weidner Apartments","website":"weidner.com
366 +682|"client":{"id":682,"name":"Stevenson Management","website":"
367 +686|"client":{"id":686,"name":"Pinnacle Pointe North","website":"pinnaclepointenorth.com
368 +684|"client":{"id":684,"name":"TerraCorp Management Inc.","website":"terracorp.ca
369 +687|"client":{"id":687,"name":"Realty Canada Inc","website":"realtycan.com
370 +689|"client":{"id":689,"name":"Carrera Management Corporation","website":"carreramc.com
371 +692|"client":{"id":692,"name":"Strano Property Management","website":"stranoproperties.ca
372 +695|"client":{"id":695,"name":"Pacific Edge Properties Ltd","website":"pacificedgeproperties.ca
373 +693|"client":{"id":693,"name":"Onni Group","website":"onni.com
374 +688|"client":{"id":688,"name":"Shiplake Properties","website":"shiplake.com
375 +697|"client":{"id":697,"name":"Metro Property Management","website":"metropropertymanagement.ca
376 +694|"client":{"id":694,"name":"Plan A","website":"plan-a.ca
377 +699|"client":{"id":699,"name":"Yorkton Group","website":"yorktonmanagement.com
378 +700|"client":{"id":700,"name":"3J Holdings Corp","website":"reginaapartment.ca
379 +706|"client":{"id":706,"name":"Re/Nat","website":"renathomerenters.ca
380 +703|"client":{"id":703,"name":"Cons Properties Inc","website":"
381 +701|"client":{"id":701,"name":"Tribond Property Management Inc","website":"tribondpm.com
382 +704|"client":{"id":704,"name":"Harrison Carter Group","website":"harrisoncartergroup.com
383 +705|"client":{"id":705,"name":"Halifax Quality Homes Ltd.","website":"halifaxqualityhomes.com
384 +711|"client":{"id":711,"name":"Phoenix Properties Inc ","website":"phoenixpropertiesinc.ca
385 +714|"client":{"id":714,"name":"Redbrick Properties Inc","website":"redbrickproperties.ca
386 +719|"client":{"id":719,"name":"Schinkel Properties","website":"schinkelproperties.com
387 +721|"client":{"id":721,"name":"Charan Property Management","website":"charan.co
388 +720|"client":{"id":720,"name":"Bic Hayer Rental Properties","website":"bichayerproperties.com
389 +718|"client":{"id":718,"name":"Don Mintz","website":"
390 +724|"client":{"id":724,"name":"MSI Gestion Immobilière","website":"le2320cezanne.ca
391 +725|"client":{"id":725,"name":"Hometime Property Services Ltd.","website":"hometimecanada.com
392 +726|"client":{"id":726,"name":"Westwood Ridge Development Corporation","website":"westwoodridge.ca
393 +730|"client":{"id":730,"name":"Louis Paquette","website":"
394 +737|"client":{"id":737,"name":"ExecSuite","website":"execsuite.ca
395 +739|"client":{"id":739,"name":"Capital Region Housing Corporation (CRD)","website":"crd.bc.ca
396 +738|"client":{"id":738,"name":"Western Management, LLC","website":"westernm.com
397 +741|"client":{"id":741,"name":"Northpoint Properties","website":"rentinhalifax.com
398 +740|"client":{"id":740,"name":"Olive Bark LLC","website":"olivebark.com
399 +745|"client":{"id":745,"name":"Cogir Real Estate (Quebec)","website":"cogir.net
400 +751|"client":{"id":751,"name":"Keller Williams Referred Urban Connect Realty \u0026 Marco Toronto Management","website":"marcotoronto.com
401 +753|"client":{"id":753,"name":"VRENT.com","website":"vrent.com
402 +755|"client":{"id":755,"name":"Lifestyle Rentals Communities","website":"
403 +742|"client":{"id":742,"name":"Cogir Management GP","website":"cogir.net
404 +756|"client":{"id":756,"name":"Kodiak Property Management","website":"kodiakpropertymanagement.ca
405 +758|"client":{"id":758,"name":"Novi Properties","website":"noviproperties.ca
406 +760|"client":{"id":760,"name":"Prompton Real Estate Services Inc.","website":"promptonrentals.ca
407 +761|"client":{"id":761,"name":"Superior Property Rental","website":"superiorpropertyrental.com
408 +762|"client":{"id":762,"name":"Bosa Properties","website":"bosaproperties.com
409 +764|"client":{"id":764,"name":"Ready4Rent.com","website":"ready4rent.com
410 +768|"client":{"id":768,"name":"Wellesley Parliament Square","website":"wpsq.ca
411 +767|"client":{"id":767,"name":"Rosemont Residences","website":"rosemontresidences.com
412 +727|"client":{"id":727,"name":"Devon Properties","website":"devonproperties.com
413 +757|"client":{"id":757,"name":"Forthright Properties","website":"forthrightproperties.ca
414 +772|"client":{"id":772,"name":"Valour Management Inc","website":"valourgroup.ca
415 +774|"client":{"id":774,"name":"Flying Stone Canada Ltd","website":"flyingstonecanada.com
416 +773|"client":{"id":773,"name":"Lofts MTL","website":"loftsmtl.com
417 +780|"client":{"id":780,"name":"Har-Par Investments","website":"www.har-par.com
418 +784|"client":{"id":784,"name":"TLR Property Management LP ","website":"triconliving.com
419 +783|"client":{"id":783,"name":"Castle Property Management Inc. ","website":"lebijoundgcondos.com
420 +786|"client":{"id":786,"name":"ACD Realty Corporation","website":"acdrealty.com
421 +788|"client":{"id":788,"name":"Sutton Select Property Management \u0026 Realty","website":"mysuttonpm.com
422 +790|"client":{"id":790,"name":"Alltrade Property Management","website":"alltradeproperties.com
423 +805|"client":{"id":805,"name":"Corev Immobilier Inc.","website":"appartement.ca
424 +804|"client":{"id":804,"name":"RPM Property Management","website":"
425 +806|"client":{"id":806,"name":"Bodewell Real Estate Management","website":"bodewell.ca
426 +808|"client":{"id":808,"name":"Hollyburn Properties Limited","website":"hollyburn.com
427 +813|"client":{"id":813,"name":"Twin City Management Limited","website":"twincity.ca
428 +814|"client":{"id":814,"name":"Live Furnished","website":"livefurnished.ca
429 +818|"client":{"id":818,"name":"Strand Development","website":"stranddev.com
430 +824|"client":{"id":824,"name":"Groupe Dacoh","website":"
431 +820|"client":{"id":820,"name":"OHM Property","website":"ohmproperty.com
432 +821|"client":{"id":821,"name":"Peter Fang Personal Real Estate Corporation","website":"elitehometeam.ca
433 +825|"client":{"id":825,"name":"Westbank Living","website":"living.westbankcorp.com
434 +819|"client":{"id":819,"name":"Clyde Property Management Services Ltd. ","website":"clydemanagement.com
435 +826|"client":{"id":826,"name":"Mondev Construction","website":"mondev.ca
436 +827|"client":{"id":827,"name":"Société immobilière Bélanger ","website":"sibelanger.com
437 +835|"client":{"id":835,"name":"Flex Property Developments","website":"flexproperty.ca
438 +837|"client":{"id":837,"name":"CJM Properties","website":"
439 +838|"client":{"id":838,"name":"Top Vision Realty Inc","website":"topvisionrealty.com/en/index.htm
440 +840|"client":{"id":840,"name":"Louer Mon Appart","website":"listing.louermonappart.com
441 +842|"client":{"id":842,"name":"Jay Patry Enterprises LLC","website":"patryinc.com
442 +841|"client":{"id":841,"name":"OrangeList","website":"orangelist.com
443 +844|"client":{"id":844,"name":"Toronto Leasing Agents","website":"
444 +845|"client":{"id":845,"name":"Immomarketing Inc","website":"immomarketing.ca
445 +846|"client":{"id":846,"name":"Kensington Real Estate Studio","website":"kensingtonre.ca
446 +848|"client":{"id":848,"name":"Group II Investments Inc","website":"group2.mb.ca
447 +850|"client":{"id":850,"name":"Toronto Luxury Rentals","website":"
448 +851|"client":{"id":851,"name":"Oscar Barrera Real Estate","website":"obsold.com
449 +853|"client":{"id":853,"name":"Capital Management Ltd. ","website":"capmanagement.ca
450 +852|"client":{"id":852,"name":"Sunstone: VUE Canmore","website":"vuecanmore.com
451 +860|"client":{"id":860,"name":"Zeen Zele","website":"
452 +859|"client":{"id":859,"name":"Riverview Property Management","website":"riverviewmgt.ca
453 +861|"client":{"id":861,"name":"Niot Investment Holdings Limited","website":"rentindonmills.com
454 +843|"client":{"id":843,"name":"Akelius Montreal","website":"akeliusmontreal.com
455 +858|"client":{"id":858,"name":"The Prime Residence Group","website":"primeresidencegroup.com
456 +862|"client":{"id":862,"name":"Spotted Properties Management Solutions","website":"
457 +868|"client":{"id":868,"name":"Benvenuto Group","website":"benvenutogroup.com
458 +866|"client":{"id":866,"name":"TCU Development Corporation","website":"tcudevcorp.com
459 +865|"client":{"id":865,"name":"Maxwell Direct Realty ","website":"maxwelldirectrealty.ca
460 +869|"client":{"id":869,"name":"Westsea Construction Ltd","website":"
461 +870|"client":{"id":870,"name":"Homestead: Lincoln Apartments","website":"theduchessapartments.ca
462 +871|"client":{"id":871,"name":"Kenlar Properties","website":"
463 +875|"client":{"id":875,"name":"Lasfu.com","website":"lasfu.com
464 +872|"client":{"id":872,"name":"Gestion Rochefort et Tessier Inc","website":"grtinc.ca
465 +874|"client":{"id":874,"name":"Gulburg Management","website":"
466 +876|"client":{"id":876,"name":"Harron Investments Inc","website":"hrent.ca
467 +880|"client":{"id":880,"name":"RKH INVESTMENTS LTD ","website":"rkh-investments.lws1.com
468 +877|"client":{"id":877,"name":"PPG","website":"
469 +879|"client":{"id":879,"name":"Tilt Property Group","website":"tiltgroup.ca
470 +752|"client":{"id":752,"name":"MetCap Living Management Inc.","website":"metcap.com
471 +884|"client":{"id":884,"name":"Grassy Valley Homes","website":"grassyvalleyhomes.com
472 +885|"client":{"id":885,"name":"Equiton Living ","website":"equitonliving.com
473 +883|"client":{"id":883,"name":"Property Management Toronto","website":"propertymanagementto.com
474 +888|"client":{"id":888,"name":"Grassroots Property Management","website":"grassrootspropertymanagement.managebuilding.com
475 +882|"client":{"id":882,"name":"Guiker","website":"guiker.com
476 +892|"client":{"id":892,"name":"Vancouver Extended Stay","website":"vancouverextendedstay.com
477 +889|"client":{"id":889,"name":"Astro Hill","website":"astrohill.ca
478 +890|"client":{"id":890,"name":"Sakto Corporation","website":"sakto.com
479 +891|"client":{"id":891,"name":"Town Inn Suites","website":"towninn.com
480 +894|"client":{"id":894,"name":"Tolobi Rental Team","website":"tolobi.com
481 +896|"client":{"id":896,"name":"Tricorp Realty","website":"tricorprealty.com
482 +897|"client":{"id":897,"name":"Hanlyn Property Management Ltd.","website":"hanlyn.ca
483 +899|"client":{"id":899,"name":"Ottawa VIP Property Management","website":"ovippm.com
484 +900|"client":{"id":900,"name":"Performance Property Management","website":"performancepm.com
485 +903|"client":{"id":903,"name":"Atlantis Realty Services","website":"atlantisrealtyservices.com
486 +905|"client":{"id":905,"name":"Avana","website":"avanabuilds.com
487 +902|"client":{"id":902,"name":"Park Village","website":"parkvillagerentals.ca
488 +907|"client":{"id":907,"name":"Shield Investment Property Management","website":"shieldipm.com
489 +908|"client":{"id":908,"name":"University of Winnipeg Community Renewal Corporation 2.0","website":"uwcrc.ca
490 +911|"client":{"id":911,"name":"GoodDoors Property Management","website":"gooddoors.com
491 +912|"client":{"id":912,"name":"GH Capital Corporation","website":"graydonhallapartments.ca
492 +913|"client":{"id":913,"name":"Pacific Reach","website":"pacificreach.com
493 +914|"client":{"id":914,"name":"SH","website":"shorelinewestend.ca
494 +919|"client":{"id":919,"name":"Engel \u0026 Volkers Montreal","website":"engelvoelkers.com
495 +916|"client":{"id":916,"name":"McCOR Management (MB) Inc.","website":"mccor.ca
496 +920|"client":{"id":920,"name":"Siegle Properties","website":"siegleproperties.com
497 +923|"client":{"id":923,"name":"Coronet Realty Ltd","website":"coronetrealtyltd.com
498 +921|"client":{"id":921,"name":"Kingsley Management","website":"kingsleyca.com
499 +925|"client":{"id":925,"name":"Audom Property Management","website":"www.audom.ca
500 +929|"client":{"id":929,"name":"Berlin Communities","website":"berlincommunities.com
501 +930|"client":{"id":930,"name":"Ottawa Property Managers","website":"ottawapropertymanagers.com
502 +931|"client":{"id":931,"name":"Tourbee Rentals","website":"tourbee.ca
503 +935|"client":{"id":935,"name":"Profile Properties","website":"www.profile-properties.com
504 +933|"client":{"id":933,"name":"Orca Realty Inc","website":"orcarealty.ca
505 +938|"client":{"id":938,"name":"Associa British Columbia, Inc.-Rhome Property Management S","website":"
506 +939|"client":{"id":939,"name":"Groupe Dayan","website":"groupedayan.com
507 +937|"client":{"id":937,"name":"Associa British Columbia, Inc.-Rhome Property Management MF","website":"
508 +940|"client":{"id":940,"name":"Concert Properties ","website":"concertproperties.com
509 +950|"client":{"id":950,"name":"Azuria Group","website":"azuriagroup.com
510 +952|"client":{"id":952,"name":"Douek Apartments - Douek.ca","website":"douek.ca
511 +954|"client":{"id":954,"name":"WestUrban Properties Management","website":"westurbanproperties.ca
512 +953|"client":{"id":953,"name":"Grafton Developments","website":"
513 +956|"client":{"id":956,"name":"Fairway Group","website":"thefairwaygroup.ca
514 +955|"client":{"id":955,"name":"Reside Management","website":"residemanage.com
515 +960|"client":{"id":960,"name":"Ferguslea Properties","website":"accoravillage.com
516 +961|"client":{"id":961,"name":"Accommod8u","website":"accommod8u.com
517 +963|"client":{"id":963,"name":"Fleming Property Management","website":"fpm.ca
518 +962|"client":{"id":962,"name":"Austeville Properties Ltd","website":"austeville.com
519 +966|"client":{"id":966,"name":"Riverview Living","website":"riverviewliving.ca
520 +969|"client":{"id":969,"name":"KSDM Holdings Inc","website":"
521 +971|"client":{"id":971,"name":"Cityscape","website":"cityscapeone.com
522 +977|"client":{"id":977,"name":"TajCorp","website":"tajcorp.com
523 +978|"client":{"id":978,"name":"Alston Properties","website":"alstonproperties.com
524 +979|"client":{"id":979,"name":"Opus Corporation","website":"opuscorp.ca
525 +982|"client":{"id":982,"name":"Gowan Properties","website":"
526 +980|"client":{"id":980,"name":"Smallwood Pacific Properties","website":"
527 +981|"client":{"id":981,"name":"North American Leaseholds","website":"nalgp.com
528 +984|"client":{"id":984,"name":"13171558 Canada Inc.","website":"
529 +986|"client":{"id":986,"name":"Sunstone: Rathgar Equities","website":"mystationside.com
530 +991|"client":{"id":991,"name":"Pemberton Holmes ","website":"thepropertymanagers.ca
531 +990|"client":{"id":990,"name":"Pemberton Holmes Property Management","website":"thepropertymanagers.ca
532 +993|"client":{"id":993,"name":"Oak \u0026 Apple Property Management","website":"oakandapple.ca
533 +994|"client":{"id":994,"name":"Beaux Properties","website":"beauxproperties.ca
534 +996|"client":{"id":996,"name":"Yonge Pleasant Holdings Ltd","website":"commonwealthtowers.ca
535 +995|"client":{"id":995,"name":"Patty Ho PREC - Vancouver Property Manager at Easyrent Real Estate Services","website":"vanpm.com
536 +999|"client":{"id":999,"name":"Fresh Coast Investments","website":"freshcoast.managebuilding.com
537 +1000|"client":{"id":1000,"name":"Mac Management Properties Inc","website":"macmanagement.ca
538 +998|"client":{"id":998,"name":"Trico Communities","website":"tricocommunities.com
539 +1002|"client":{"id":1002,"name":"DSM Investment Group Limited","website":"dsminvestments.ca
540 +1004|"client":{"id":1004,"name":"Macdonald Commercial Real Estate Services Ltd (SF)","website":"macdonaldpm.com
541 +1003|"client":{"id":1003,"name":"Trustcan Gestion Immobiliere","website":"rustcan.com
542 +1008|"client":{"id":1008,"name":"Marquee on 16th","website":"marquee16.com
543 +1009|"client":{"id":1009,"name":"West Fraser Development","website":"westfraserdev.ca
544 +1007|"client":{"id":1007,"name":"Headwater Projects","website":"headwaterprojects.com
545 +1010|"client":{"id":1010,"name":"Rental Lifestyle Group Inc","website":"rentallifestyle.com
546 +1011|"client":{"id":1011,"name":"Cricket Property Management","website":"cricketmgt.com
547 +1014|"client":{"id":1014,"name":"Northtowne Properties","website":"northtowne.ca
548 +1015|"client":{"id":1015,"name":"G\u0026S Group of Companies","website":"gsrentals.ca
549 +1017|"client":{"id":1017,"name":"Hazelview The Arts","website":"theartsres.com
550 +1018|"client":{"id":1018,"name":"Wesgroup Properties","website":"wesgroup.ca
551 +1020|"client":{"id":1020,"name":"Montik Planning \u0026 Development","website":"montik.ca
552 +1019|"client":{"id":1019,"name":"653858 Ontario Inc.","website":"
553 +1021|"client":{"id":1021,"name":"Harwood Group","website":"
554 +1022|"client":{"id":1022,"name":"FirstService Residential Alberta (MF)","website":"fsresidentialrentals.com
555 +1026|"client":{"id":1026,"name":"Qualex-Landmark Residences Inc.","website":"qualex.ca
556 +1024|"client":{"id":1024,"name":"Brigil","website":"brigil.com
557 +1031|"client":{"id":1031,"name":"Bedouin Management Corp","website":"bedouinsuites.com
558 +1036|"client":{"id":1036,"name":"Hines","website":"oneparkcentral.com
559 +1016|"client":{"id":1016,"name":"Kipling Residential Management Inc","website":"fhkipling.com
560 +1038|"client":{"id":1038,"name":"Orchard Apartments","website":"orchardapartments.ca
561 +1044|"client":{"id":1044,"name":"Hollyburn Properties (Furnished)","website":"hollyburn.com
562 +1042|"client":{"id":1042,"name":"Colliers International","website":"colliersrentals.com
563 +1043|"client":{"id":1043,"name":"Campbell \u0026 Haliburton Group of Companies","website":"https://www.chliving.ca/
564 +1046|"client":{"id":1046,"name":"Elite Immobilier","website":"eliteimmobilier.ca
565 +1050|"client":{"id":1050,"name":"Brix","website":"brixmtl.com
566 +1048|"client":{"id":1048,"name":"CLV Group - Furnished Units","website":"clvgroup.com
567 +1058|"client":{"id":1058,"name":"Westwood Developments","website":"westwoodgroup.ca
568 +1070|"client":{"id":1070,"name":"Gulf Pacific Property Management Ltd","website":"hollysquareapartments.ca
569 +1067|"client":{"id":1067,"name":"Elite Assets","website":"eliteassets.ca
570 +1066|"client":{"id":1066,"name":"Chard Property Group Ltd. ","website":"charddevelopment.com
571 +1069|"client":{"id":1069,"name":"Orr Development","website":"orrdevelopment.com
572 +1071|"client":{"id":1071,"name":"The Howard Property Company Inc","website":"thehoward.ca
573 +1072|"client":{"id":1072,"name":"Oneka Land Company","website":"olcl.ca
574 +1077|"client":{"id":1077,"name":"Hi-Lo Investments Inc","website":"
575 +1074|"client":{"id":1074,"name":"Cando Apartments","website":"candoapartments.com
576 +1076|"client":{"id":1076,"name":"AMR Group","website":"amrpropertymanagement.com
577 +1080|"client":{"id":1080,"name":"LMA Properties / 9403-3420 Québec Inc ","website":"
578 +1079|"client":{"id":1079,"name":"Lazar Equities","website":"lazarequities.com
579 +1083|"client":{"id":1083,"name":"Nuspor Investments","website":"1251king.ca
580 +1081|"client":{"id":1081,"name":"Rehabitat Inc","website":"rehabitat.ca
581 +1084|"client":{"id":1084,"name":"J\u0026D Properties","website":"
582 +1087|"client":{"id":1087,"name":"Key Property Management","website":"keypropertymanagement.ca
583 +1090|"client":{"id":1090,"name":"Jeremy Ezerzer Realties Inc.","website":"ezerzerrealties.com
584 +1088|"client":{"id":1088,"name":"Trylon Appartment Hôtel","website":"trylon.ca
585 +1089|"client":{"id":1089,"name":"Mainline Living","website":"mainlineliving.ca
586 +1092|"client":{"id":1092,"name":"Administrateur de Biens Theresa Schauer Inc.","website":"our.immo
587 +1095|"client":{"id":1095,"name":"Macdonald Commercial Real Estate Services Ltd (MF)","website":"macdonaldpm-rentals.com
588 +1094|"client":{"id":1094,"name":"Welcome Home Management","website":"
589 +1098|"client":{"id":1098,"name":"Gestion Lameer Inc","website":"lameer.ca
590 +1101|"client":{"id":1101,"name":"H \u0026 R Property Management Limited","website":"hrpm.ca
591 +1102|"client":{"id":1102,"name":"Wedgewood Properties","website":"wprents.com
592 +1103|"client":{"id":1103,"name":"Dina Mulic","website":"
593 +1104|"client":{"id":1104,"name":"City of Edmonton Non-Profit Housing Corporation","website":"myhomeed.ca
594 +1106|"client":{"id":1106,"name":" Junic","website":"junic.ca
595 +1108|"client":{"id":1108,"name":"Oberon Development ","website":"
596 +1113|"client":{"id":1113,"name":"Residential Property Management Services (RPMS)","website":"
597 +1109|"client":{"id":1109,"name":"Larocque Property Management","website":"larocquepm.com
598 +1110|"client":{"id":1110,"name":"2136615 Ontario Ltd.","website":"everlastgroup.com
599 +1111|"client":{"id":1111,"name":"Camélia Fathi","website":"realtyinmontreal.com
600 +1115|"client":{"id":1115,"name":"Case Mansion Flats","website":"5westgroup.com
601 +1114|"client":{"id":1114,"name":"Stewart Property Management","website":"stewartpm.ca
602 +1116|"client":{"id":1116,"name":"DMC Apartments Ltd","website":"
603 +1117|"client":{"id":1117,"name":"Devonshire Properties Inc.","website":"devonshire-inc.com
604 +1118|"client":{"id":1118,"name":"Nelms Group Ltd (CCA Apartments - London)","website":"ccalondon.ca
605 +1125|"client":{"id":1125,"name":"Advent Properties Inc","website":"adventpropertiesinc.com
606 +1128|"client":{"id":1128,"name":"1300 Boulevard Alexis-Nihon","website":"toursboisfranc.com
607 +1129|"client":{"id":1129,"name":"Campanale","website":"campanale.com
608 +1132|"client":{"id":1132,"name":"Mazzone Group","website":"mazzone.ca
609 +1131|"client":{"id":1131,"name":"Homease","website":"homease.ca
610 +1137|"client":{"id":1137,"name":"Taggart Realty Management","website":"taggart.ca
611 +1136|"client":{"id":1136,"name":"United Properties","website":"rentupm.com
612 +1139|"client":{"id":1139,"name":"Peace of Mind Management","website":"pomm.ca
613 +1141|"client":{"id":1141,"name":"Myvic Property Management","website":"myvicpropertymanagement.com
614 +1144|"client":{"id":1144,"name":"Montlyn Group","website":"
615 +1145|"client":{"id":1145,"name":"K and R Properties","website":"kandrproperties.com
616 +1142|"client":{"id":1142,"name":"Saroukian Group","website":"
617 +1147|"client":{"id":1147,"name":"NomadHomes Inc","website":"nomadhomes.com
618 +1150|"client":{"id":1150,"name":"YorkWest Developments","website":"yorkwestdevelopments.ca
619 +1151|"client":{"id":1151,"name":"Kline Properties Inc","website":"klineproperties.com
620 +1153|"client":{"id":1153,"name":"Rancho Management Services (Vancouver)","website":"ranchovan.com
621 +1152|"client":{"id":1152,"name":"Adaptive Realty","website":"adaptiverealty.com
622 +1154|"client":{"id":1154,"name":"Real Life Rentals","website":"realliferentals.ca
623 +1156|"client":{"id":1156,"name":"Rental Real Estate (A Dennis Management Co.)","website":"rentalre.com
624 +1157|"client":{"id":1157,"name":"Noah Properties","website":"rentnoah.com
625 +1155|"client":{"id":1155,"name":"Prime Student Housing","website":"primesh.sitemanager.rentmanager.com
626 +1163|"client":{"id":1163,"name":"Downtown Apartments","website":"
627 +1160|"client":{"id":1160,"name":"Whispering Hills Apartments","website":"whisperinghillsks.com
628 +1162|"client":{"id":1162,"name":"Alfid Services Immobiliers Ltée (Alfid Group)","website":"alfidlouer.ca
629 +1161|"client":{"id":1161,"name":"Mid Continent Management Corporation","website":"midcontinentmgmt.com
630 +1165|"client":{"id":1165,"name":"Garden State Realty Management","website":"gs-rm.com
631 +1164|"client":{"id":1164,"name":"Groupe Inspire","website":"groupeinspire.ca
632 +1166|"client":{"id":1166,"name":"Cominar","website":"
633 +1169|"client":{"id":1169,"name":"Dobrin Property Management","website":"dobrinpropertymanagement.com
634 +1170|"client":{"id":1170,"name":"Welwyn Interests","website":"welwyninterests.com
635 +1173|"client":{"id":1173,"name":"Tektonik","website":"
636 +1172|"client":{"id":1172,"name":"United Apartments","website":"livewithunited.com
637 +1171|"client":{"id":1171,"name":"Hillpark Capital","website":"hillpark.ca
638 +1174|"client":{"id":1174,"name":"Blue Panda Realty","website":"bluepandarealty.com
639 +1177|"client":{"id":1177,"name":"Bluestone \u0026 Hockley","website":"bluestonehockley.com
640 +1179|"client":{"id":1179,"name":"COSTANZA ENTERPRISES, INC.","website":"costanzaenterprises.com
641 +1135|"client":{"id":1135,"name":"RPG Management","website":"rpgmanage.com
642 +1178|"client":{"id":1178,"name":"Claridge Homes","website":"claridgehomes.com
643 +1181|"client":{"id":1181,"name":"Cabrio Properties","website":"cabrioproperties.com
644 +1185|"client":{"id":1185,"name":"TAB Property Management","website":"tabproperties.com
645 +1182|"client":{"id":1182,"name":"Becovic Management Group","website":"becovicmgmt.com
646 +1188|"client":{"id":1188,"name":"Eagle Rock Properties","website":"eaglerockproperties.com
647 +1189|"client":{"id":1189,"name":"I360 Rentals","website":"i360rentals.com
648 +1192|"client":{"id":1192,"name":"Hickory Management Services","website":"hmsys.com
649 +1193|"client":{"id":1193,"name":"GTA MFP","website":"
650 +1191|"client":{"id":1191,"name":"Adcock Properties","website":"adcockproperties.com
651 +1194|"client":{"id":1194,"name":"Canadian Student Living Group LP","website":"
652 +1195|"client":{"id":1195,"name":"InMotion Property Management","website":"inmotionproperties.com
653 +1196|"client":{"id":1196,"name":"Fairview Realty Group","website":"
654 +1200|"client":{"id":1200,"name":"Gates Hudson","website":"gateshudson.com
655 +1204|"client":{"id":1204,"name":"Brivia Group","website":"
656 +1205|"client":{"id":1205,"name":"Anchor Group Atlantic Ltd.","website":"anchorminot.com
657 +1187|"client":{"id":1187,"name":"Old Oak Properties","website":"oldoakproperties.com
658 +1207|"client":{"id":1207,"name":"Foxboro Property Management","website":"foxboroproperties.com
659 +1209|"client":{"id":1209,"name":"Tidan Inc.","website":"tidan.com
660 +1206|"client":{"id":1206,"name":"Excel Real Estate Management","website":"excelrem.com
661 +1210|"client":{"id":1210,"name":"The Barrel Yards","website":"barrelyards.com
662 +1215|"client":{"id":1215,"name":"Aegis Real Estate Services","website":"aegisrealestateservices.com
663 +1214|"client":{"id":1214,"name":"Les Placements Rivière Gatineau Inc c/o Groupe Heafey","website":"groupeheafey.com
664 +1213|"client":{"id":1213,"name":"Union Park Management","website":"unionparkmgmt.com
665 +1216|"client":{"id":1216,"name":"Kieran Kelleher","website":"
666 +1218|"client":{"id":1218,"name":"Puppy \u0026 Kitty Corporation","website":"
667 +1221|"client":{"id":1221,"name":"Kathy Lu","website":"communitylivingyorksouth.ca
668 +1223|"client":{"id":1223,"name":"URent Ottawa c/o Jennifer Sarumi","website":"urentottawa.com
669 +1225|"client":{"id":1225,"name":"Pacific Crown Management","website":"
670 +1227|"client":{"id":1227,"name":"LPZ Management","website":"rentnjapts.com
671 +1228|"client":{"id":1228,"name":"EIP Property Management","website":"eiprops.com
672 +1230|"client":{"id":1230,"name":"Sud Group","website":"15walmer.ca
673 +1212|"client":{"id":1212,"name":"10th Floor Property Management","website":"10thfloorbrokers.com
674 +1232|"client":{"id":1232,"name":"MC Properties","website":"mcproperties.us
675 +1235|"client":{"id":1235,"name":"Midwest Expansion Companies","website":"midwestexpansion.com
676 +1236|"client":{"id":1236,"name":"Eagle Management Re LLc - Abbey apartments","website":"abbey4lakes.com
677 +1199|"client":{"id":1199,"name":"Wilson Property Management","website":"wpminc.net
678 +1239|"client":{"id":1239,"name":"Town \u0026 Country Developments","website":"
679 +1237|"client":{"id":1237,"name":"Hawthorne Arts Complex","website":"hawthorneartscomplex.com
680 +1240|"client":{"id":1240,"name":"Groupe Accueil","website":"
681 +1243|"client":{"id":1243,"name":"Prokey Living","website":"www.prokey.ca
682 +1241|"client":{"id":1241,"name":"Rio Residential","website":"rioresidential.com
683 +1244|"client":{"id":1244,"name":"L \u0026 L Investors Group LLC","website":"landlinvestors.com
684 +1249|"client":{"id":1249,"name":"Bass Management","website":"bassmanagement.com
685 +1252|"client":{"id":1252,"name":"K-W Property Management Corp.","website":"kwproperty.com
686 +1138|"client":{"id":1138,"name":"A.L. Burke Inc.","website":"alburkeinc.com
687 +1254|"client":{"id":1254,"name":"Cores Management Inc","website":"coresmgt.com
688 +1203|"client":{"id":1203,"name":"Meridian Group","website":"meridiangrouprem.com
689 +1253|"client":{"id":1253,"name":"The Realty Store","website":"therealtystore.ca
690 +1255|"client":{"id":1255,"name":"Envie ","website":"enviesuites.com
691 +1257|"client":{"id":1257,"name":"Daniels Gateway Rentals","website":"danielsgateway.com
692 +1260|"client":{"id":1260,"name":"Cedar Point Communities","website":"mycedarpoint.com
693 +1262|"client":{"id":1262,"name":"CGK Realties","website":"cgkrealties.com
694 +1263|"client":{"id":1263,"name":"RentBolt","website":"rentbolt.ca
695 +1256|"client":{"id":1256,"name":"9375-4364 Quebec Inc. (Vivre Outremont)","website":"vivremtl.ca
696 +1265|"client":{"id":1265,"name":"AMG Realty Group LLC","website":"
697 +1267|"client":{"id":1267,"name":"Propriété Bonavista S.E.C","website":"ropriete-bonavista-sec.business.site
698 +1272|"client":{"id":1272,"name":"Lesco Realty Inc.","website":"www.lesco.ca
699 +1270|"client":{"id":1270,"name":"Rentalys Solution Inc.","website":"rentalys.ca
700 +1273|"client":{"id":1273,"name":"Urban Leasing KW","website":"
701 +1274|"client":{"id":1274,"name":"Trantor Realty","website":"
702 +1268|"client":{"id":1268,"name":"Building Stack","website":"buildingstack.com
703 +1276|"client":{"id":1276,"name":"Colonnade BridgePort","website":"
704 +1282|"client":{"id":1282,"name":"Harbor Realty Management Inc.","website":"harborrealties.com
705 +1285|"client":{"id":1285,"name":"Immo Expo Inc.","website":"
706 +1284|"client":{"id":1284,"name":"Mayne Holdings","website":"
707 +1288|"client":{"id":1288,"name":"Bridgeway Property Management Inc","website":"bridgewaypm.com
708 +1290|"client":{"id":1290,"name":"Freedhome Developments","website":"
709 +1294|"client":{"id":1294,"name":"SLG Montreal","website":"www,slgmontreal.com
710 +1293|"client":{"id":1293,"name":"GVR Financial Corp.","website":"
711 +1297|"client":{"id":1297,"name":"Our Neighborhood Realty","website":"app.listatto.com
712 +1302|"client":{"id":1302,"name":"HostGenius Property Management","website":"hostgenius.ca
713 +1299|"client":{"id":1299,"name":"FirstService Residential Alberta","website":"fsresidentialrentals.com
714 +1301|"client":{"id":1301,"name":"Home 2 Home Properties Inc","website":"h2hpm.com
715 +1291|"client":{"id":1291,"name":"Fitzrovia","website":"https://fitzrovia.ca/
716 +1306|"client":{"id":1306,"name":"Murex Realty Inc. ","website":"murexrealty.ca
717 +1307|"client":{"id":1307,"name":"Elmy Vacation \u0026 Corporate Rental Management Ltd.","website":"elmystays.com
718 +1310|"client":{"id":1310,"name":"The Rent Shop","website":"
719 +1312|"client":{"id":1312,"name":"Rester Inc.","website":"serein.ca
720 +1314|"client":{"id":1314,"name":"VIDA Communities Inc","website":"vidaliving.ca
721 +1316|"client":{"id":1316,"name":"Peartree Group Property Management","website":"peartreegroup.ca
722 +1319|"client":{"id":1319,"name":"RPM ACE","website":"ace.realpm.ca
723 +1322|"client":{"id":1322,"name":"Laebon Rental Communities Ltd","website":"blubelvedere.ca
724 +1320|"client":{"id":1320,"name":"Envision Real Estate Services","website":"envisionyxe.com
725 +1321|"client":{"id":1321,"name":"Surecap Finance Inc","website":"montrealliving.info
726 +1326|"client":{"id":1326,"name":"unitiiPM","website":"unitiipm.com
727 +1328|"client":{"id":1328,"name":"Leaf Properties","website":"leafproperties.ca
728 +1329|"client":{"id":1329,"name":"RST Property Management, LLC ","website":"rstpropertymanagement.com
729 +1327|"client":{"id":1327,"name":"OM - Outremont","website":"om-outremont.com
730 +1332|"client":{"id":1332,"name":"First Elm Holdings Inc","website":"
731 +1335|"client":{"id":1335,"name":"REMAX Lifestyles \u0026 Compass Property Management Team","website":"mariamoudatsos.ca
732 +1334|"client":{"id":1334,"name":"Colliers International Regina","website":"colliersrentals.com
733 +1347|"client":{"id":1347,"name":"Century 21 Property Management Professionals","website":"professionalspm.c21.ca
734 +1353|"client":{"id":1353,"name":"Hazelview Properties (rnt)","website":"
735 +1351|"client":{"id":1351,"name":"Firstservice Residential Vancouver","website":"fsresidential.com
736 +1356|"client":{"id":1356,"name":"Sidewalk RED","website":"www.sidewalkred.ca
737 +1359|"client":{"id":1359,"name":"JB Holdings Inc","website":"jbholdingsinc.ca
738 +1355|"client":{"id":1355,"name":"Investors Choice Property Management","website":"icpmpros.com
739 +1364|"client":{"id":1364,"name":"Bucci Living Limited","website":"bucci.com
740 +1362|"client":{"id":1362,"name":"RFA Asset Management","website":"rfaassetmangement.ca
741 +1365|"client":{"id":1365,"name":"Canopy mgmt - Property Managers","website":"canopymgmt.ca
742 +1367|"client":{"id":1367,"name":"Royop Development Corporation","website":"
743 +1370|"client":{"id":1370,"name":"Petwin Properties","website":"petwin.com
744 +1371|"client":{"id":1371,"name":"Renaissance Property Management","website":"renaissancepropertymanagement.ca
745 +1376|"client":{"id":1376,"name":"Larlyn Property Management Ltd.","website":"larlyn.com
746 +1378|"client":{"id":1378,"name":"QMW Corp","website":"qmwcorp.com
747 +1381|"client":{"id":1381,"name":"Hyphen Group","website":"hyphengroup.ca
748 +1379|"client":{"id":1379,"name":"Berkshire Hathaway Home Services 2","website":"bhhstoronto.ca
749 +1390|"client":{"id":1390,"name":"Hazelview: CX","website":"rentcx.ca
750 +1389|"client":{"id":1389,"name":"Leven Homes","website":"leven.ca
751 +1393|"client":{"id":1393,"name":"Padly Apartments","website":"padly.ca
752 +1396|"client":{"id":1396,"name":"Willow Green Estates","website":"willowgreenestates.com
753 +1397|"client":{"id":1397,"name":"Fred Douglas Place Inc (Corp)","website":"freddouglasplace.ca
754 +1395|"client":{"id":1395,"name":"Rohit","website":"www.rohitgroup.com/contact
755 +1401|"client":{"id":1401,"name":"Intelligence House Ltd.","website":"intelligencehouse.ca
756 +1402|"client":{"id":1402,"name":"Centron Group","website":"centrongroup.com
757 +1404|"client":{"id":1404,"name":"Partners Development Group Ltd.","website":"partnersproperties.ca
758 +1405|"client":{"id":1405,"name":"Western Legacy Group Inc.","website":"westernlegacygroup.ca
759 +1406|"client":{"id":1406,"name":"Ming Wong","website":"
760 +1408|"client":{"id":1408,"name":"Cantiva Properties ULC","website":"
761 +1411|"client":{"id":1411,"name":"DMI PROPERTY MANAGEMENT INC.","website":"lnxrentals.com
762 +1415|"client":{"id":1415,"name":"Occidental Group / Groupe Occidental","website":"occidentallofts.com
763 +1419|"client":{"id":1419,"name":"BosCana Property Management Inc","website":"boscanapropertymgt.ca
764 +1420|"client":{"id":1420,"name":"Carangelo Management","website":"carangelomgmnt.com
765 +1422|"client":{"id":1422,"name":"S.M.T. Equities Ltd","website":"smtequities.com
766 +1423|"client":{"id":1423,"name":"Izzy Laz Homes","website":"
767 +1426|"client":{"id":1426,"name":"Westrich Management Ltd. ","website":"rentwestrich.com
768 +1431|"client":{"id":1431,"name":"McCrae Property Management LTD","website":"mccraeproperty.com
769 +1430|"client":{"id":1430,"name":"Shape Properties","website":"shapeproperties.com
770 +1428|"client":{"id":1428,"name":"Buttonwood Property Management","website":"
771 +1429|"client":{"id":1429,"name":"MillDon Enterprises Ltd. ","website":"milldonliving.com
772 +1434|"client":{"id":1434,"name":"KeyDay Inc.","website":"www.keyday.ca
773 +1435|"client":{"id":1435,"name":"Michelle Vickress","website":"
774 +1432|"client":{"id":1432,"name":"Regional Group","website":"availabilities.regionalgroup.com
775 +1436|"client":{"id":1436,"name":"Pier 4 Residential","website":"pier4residential.com
776 +1440|"client":{"id":1440,"name":"Richer Realty Ltd","website":"
777 +1448|"client":{"id":1448,"name":"Orchard Park Property Management","website":"
778 +1451|"client":{"id":1451,"name":"TransGlobe Equities Ltd","website":"
779 +1450|"client":{"id":1450,"name":"EC Living","website":"ecliving.ca
780 +1454|"client":{"id":1454,"name":"Equity Rentals Ltd","website":"equity.rentals
781 +1456|"client":{"id":1456,"name":"Armco","website":"
782 +1458|"client":{"id":1458,"name":"2100 Remembrance L.P.","website":"veritagelachine.com
783 +1462|"client":{"id":1462,"name":"Signature Properties Inc.","website":"signaturepropertiesinc.ca
784 +1465|"client":{"id":1465,"name":"Meridian Development","website":"meridiandevelopment.ca
785 +1466|"client":{"id":1466,"name":"Polo IV Properties Inc","website":"evoottawa.com
786 +1469|"client":{"id":1469,"name":"Ninco Developments Inc","website":"
787 +1472|"client":{"id":1472,"name":"Signature Suites","website":"signature-suites.ca
788 +1473|"client":{"id":1473,"name":"LIV MTL","website":"livmtl.com
789 +1468|"client":{"id":1468,"name":"Richcraft Rentals","website":"richcraftrentals.com
790 +1479|"client":{"id":1479,"name":"CAPREIT - Landlease","website":"
791 +1486|"client":{"id":1486,"name":"Losani Homes (1998) Ltd.","website":"
792 +1484|"client":{"id":1484,"name":"Les Immeubles Di Ciocco","website":"
793 +1488|"client":{"id":1488,"name":"Mike Witt","website":"
794 +1481|"client":{"id":1481,"name":"AG Property Services","website":"agpropertyservices.ca
795 +1491|"client":{"id":1491,"name":"Highliving Properties Ltd.","website":"highliving.ca
796 +1489|"client":{"id":1489,"name":"Jeff Poon","website":"jeff-poon.c21.ca
797 +1494|"client":{"id":1494,"name":"REMAX At Home, a division of REMAX Prairie Reality ","website":"remaxathome.managebuilding.com
798 +1497|"client":{"id":1497,"name":"ALTA Canada","website":"altacanada.com
799 +1498|"client":{"id":1498,"name":"The Properties at Wright Field ","website":"wpafbhomes.com
800 +1501|"client":{"id":1501,"name":"Mastercraft Starwood","website":"
801 +1503|"client":{"id":1503,"name":"Flex Developments ","website":"flexbuild.co
802 +1502|"client":{"id":1502,"name":"Fieldgate Properties","website":"fieldgateproperties.com
803 +1505|"client":{"id":1505,"name":"Equinoxe Rental Condos","website":"collectionequinoxe.com/en
804 +1510|"client":{"id":1510,"name":"Metro-Luminere","website":"
805 +1514|"client":{"id":1514,"name":"Maintenance Management Solutions Inc.","website":"
806 +1519|"client":{"id":1519,"name":"Rico Real Estate Inc.","website":"ricorealestate.ca
807 +1520|"client":{"id":1520,"name":"6100 CMI","website":"therooksofwestbedford.com
808 +1527|"client":{"id":1527,"name":"RISE PROPERTY SOLUTIONS INC. ","website":"https://www.risepropertymanagement.ca/
809 +1532|"client":{"id":1532,"name":"Tells","website":"Tells.com
810 +1528|"client":{"id":1528,"name":"Innovative Property Management","website":"innovativepropertymanagement.ca
811 +1531|"client":{"id":1531,"name":"Corporate Stays","website":"
812 +1530|"client":{"id":1530,"name":"CIR Realty-Property Management","website":"cirrealtypm.ca
813 +1534|"client":{"id":1534,"name":"JEMM Properties","website":"jemm.ca
814 +1533|"client":{"id":1533,"name":"Grandview Consulting","website":"
815 +1537|"client":{"id":1537,"name":"BLVD Investments","website":"www.blvdinvestments.ca
816 +1535|"client":{"id":1535,"name":"Uptown Residential","website":"rentuptown.com
817 +1540|"client":{"id":1540,"name":"Westhawk Property Management","website":"www.westhawkproperties.ca
818 +1541|"client":{"id":1541,"name":"Astoria Asset Management Ltd.","website":"www.astoriamanagement.ca
819 +1542|"client":{"id":1542,"name":"SERRA MONTREAL","website":"serramtl.com
820 +1548|"client":{"id":1548,"name":"Chenier Group","website":"www.cheniergroup.com
821 +1550|"client":{"id":1550,"name":"Carrington Construction Canada Ltd.","website":"carrington.ca
822 +1552|"client":{"id":1552,"name":"New Rock Developments Inc - Corp","website":"www.newrockproperties.com
823 +1549|"client":{"id":1549,"name":"Jefferson Commons APTs LLC","website":"jeffersoncommonsapts.com
824 +1559|"client":{"id":1559,"name":"R4 Management","website":"www.r4m.ca
825 +1560|"client":{"id":1560,"name":"OParc Management","website":"
826 +1567|"client":{"id":1567,"name":"Locarno Legacy","website":"www.locarno.ca
827 +1565|"client":{"id":1565,"name":"Hopestreet: Hope Street - RentFaster Direct Billing","website":"www.hopestreet.ca
828 +1563|"client":{"id":1563,"name":"Tilt Pm: Tilt Property Group - RentFaster Direct Billing","website":"www.tiltgroup.ca
829 +1564|"client":{"id":1564,"name":"Rental Advisors - RentFaster Direct Billing","website":"www.rentaladvisors.ca
830 +1576|"client":{"id":1576,"name":"Royal LePage Preferred Realty","website":"www.andersonavenue.com
831 +1581|"client":{"id":1581,"name":"Paragon Living","website":"https://rentals.paragonliving.com/
832 +1586|"client":{"id":1586,"name":"Premier Investments","website":"
833 +1588|"client":{"id":1588,"name":"Provider Homes Inc.","website":"www.providerhomes.com
834 +1590|"client":{"id":1590,"name":"Roxborough Communities","website":"roxcom.ca
835 +1591|"client":{"id":1591,"name":"MG Grand","website":"mggrandinc.com
836 +1606|"client":{"id":1606,"name":"Quarter Park Management","website":"quarterpark.com
837 +1608|"client":{"id":1608,"name":"Asset Living","website":"
838 +1617|"client":{"id":1617,"name":"WRE Development Ltd Calgary","website":"wredevelopment.ca
839 +1613|"client":{"id":1613,"name":"Qterra Property Management","website":"www.qterrapropertymanagement.com
840 +1627|"client":{"id":1627,"name":"Westfolio Properties Corp","website":"www.westfoliorentals.ca
841 +1628|"client":{"id":1628,"name":"Nineteen 88 Real Estate","website":"
842 +1632|"client":{"id":1632,"name":"Capital West","website":"capitalwest.ca
843 +1633|"client":{"id":1633,"name":"Diverse Property Management LTD","website":"diverserentals.com
844 +1635|"client":{"id":1635,"name":"OBEN PM","website":"https://www.thefairbank.ca/
845 +1640|"client":{"id":1640,"name":"Greystar Canada Property Management Services Ltd ","website":"
846 +1644|"client":{"id":1644,"name":"Westwood Ridge Development Corporation","website":"https://westwoodridge.ca/properties/
847 +1642|"client":{"id":1642,"name":"Hazelview: Augustana","website":"liveaugustana.ca
848 +1647|"client":{"id":1647,"name":"Paragon Property Management \u0026 Maintenance","website":"www.paragonproperty.ca
849 +1652|"client":{"id":1652,"name":"Rentals Ottawa","website":"
850 +1648|"client":{"id":1648,"name":"Domus Developments Inc.","website":"domusdev.com
851 +1656|"client":{"id":1656,"name":"Top Producers Realty Ltd.","website":"topproducersrealty.ca
852 +1658|"client":{"id":1658,"name":"SOCAN Property Management","website":"socanpm.com
853 +1660|"client":{"id":1660,"name":"Penguin Living","website":"penguinliving.com
854 +1661|"client":{"id":1661,"name":"Gateway Properties","website":"gatewayproperties.ca
855 +1666|"client":{"id":1666,"name":"Josh Felice","website":"
856 +1668|"client":{"id":1668,"name":"Split Rock Properties","website":"litrockproperties.ca
857 +1669|"client":{"id":1669,"name":"AWM-Alliance Real Estate Group Ltd.","website":"
858 +1674|"client":{"id":1674,"name":"Lokus","website":"
859 +1681|"client":{"id":1681,"name":"Sterling Karamar: The Britt","website":"brittluxuryresidences.com
860 +1689|"client":{"id":1689,"name":"Summit Properties","website":"https://www.summitproperties.ca/residential-lp/?utm_source=Rental.ca\u0026utm_medium=referral\u0026utm_campaign=residential2026\u0026utm_conten
861 +1695|"client":{"id":1695,"name":"Apartments LTD ","website":"
862 +1696|"client":{"id":1696,"name":"Fundy Harbour Group Inc","website":"fundyquay.com
863 +1694|"client":{"id":1694,"name":"GMR Real Estate","website":"http://www.gmrre.com/
864 +1699|"client":{"id":1699,"name":" Property Management Gateway","website":"pmgateway.ca
865 +1700|"client":{"id":1700,"name":"Premier Property Solutions","website":"1125pembina.ca
866 +1702|"client":{"id":1702,"name":"Dunpar Homes","website":"dunparhomes.com
867 +1690|"client":{"id":1690,"name":"Brick Properties Inc.","website":"brickproperties.ca
868 +1709|"client":{"id":1709,"name":"Fitzrovia Bloomsbury Inc. c/o Fitzrovia Real Estate Inc.","website":"bloomsbury.ca
869 +1706|"client":{"id":1706,"name":"JDC Property Management Ltd.","website":"https://www.jdcgroup.net/
870 +1707|"client":{"id":1707,"name":"Princess Towers","website":"www.princesstowers.ca
871 +1712|"client":{"id":1712,"name":"Lionsgate Communities","website":"lionsgc.com
872 +1714|"client":{"id":1714,"name":"STACK","website":"stackpm.ca
873 +1715|"client":{"id":1715,"name":"Refinery Town homes ","website":"refinerytownhomes.ca
874 +1722|"client":{"id":1722,"name":"Ivy Manor Apartments LLC","website":"ivymanorfairborn.com
875 +1725|"client":{"id":1725,"name":"Revolution Property Management Inc.","website":"www.revolutionpm.ca
876 +1717|"client":{"id":1717,"name":"Greystar Canada Property Management Services Limited","website":"
877 +1729|"client":{"id":1729,"name":"Carrington Real Estate","website":"
878 +1734|"client":{"id":1734,"name":"Uniform Living","website":"uniformliving.com
879 +1733|"client":{"id":1733,"name":"Smith Agency Ltd.","website":"smithagency.ca
880 +1740|"client":{"id":1740,"name":"Haven Towers","website":"
881 +1745|"client":{"id":1745,"name":"Property Manager","website":"
882 +1748|"client":{"id":1748,"name":"Millway Residences Inc.","website":"themillway.ca
883 +1747|"client":{"id":1747,"name":"Three Shores","website":"www.threeshores.ca/about-us
884 +1743|"client":{"id":1743,"name":"Cherishome Living","website":"cherishome.com
885 +1751|"client":{"id":1751,"name":"Glenora Homes Group","website":"live-emerald.com
886 +1752|"client":{"id":1752,"name":"Blanc et Noir Immobilier","website":"blancetnoircondosneufs.ca
887 +1763|"client":{"id":1763,"name":"Barbican Property Management","website":"https://www.barbicanpm.ca/
888 +1764|"client":{"id":1764,"name":"Kothari Group","website":"www.grandpacificrentals.ca
889 +1773|"client":{"id":1773,"name":"Destaron Property Management","website":"destaron.ca
890 +1770|"client":{"id":1770,"name":"Holt Meadow Group","website":"www.holtmeadow.com/about
891 +1772|"client":{"id":1772,"name":"Greystar Canada Property Management Services Ltd. ","website":"
892 +1771|"client":{"id":1771,"name":"HomesByElla Daniela Captariu BSc. ABR, Broker Real Estate with Keller Williams Referred Urban Realty Brokerage","website":"www.homesbyella.com
893 +1775|"client":{"id":1775,"name":"Bayview Group","website":"www.bayviewgroup.com
894 +1779|"client":{"id":1779,"name":"Andrew Higgs","website":"
895 +1783|"client":{"id":1783,"name":"2274 Princess Street Limited Partnership","website":"www.2274princess.com
896 +1785|"client":{"id":1785,"name":"TG BECO","website":"
897 +1786|"client":{"id":1786,"name":"Savic Homes Ltd","website":"
898 +1784|"client":{"id":1784,"name":"Common Living","website":"www.common.com
899 +1791|"client":{"id":1791,"name":"Interim Account - Hazelview Properties","website":"
900 +1792|"client":{"id":1792,"name":"Griesbach Rental - 49th Battalion","website":"www.rent49thbattalion.com
901 +1795|"client":{"id":1795,"name":"Advantage Property Management","website":"advantagepm.ca
902 +1800|"client":{"id":1800,"name":"RentXS","website":"https://www.rentxs.ca/
903 +1805|"client":{"id":1805,"name":"RENTHAMILTON","website":"blockmgmt.ca
904 +1804|"client":{"id":1804,"name":" 3340233 NS Ltd.","website":"rentbakerdrive.com
905 +1809|"client":{"id":1809,"name":"Molnar Group","website":"hendryplace.com
906 +1814|"client":{"id":1814,"name":"The Willow Apartments Limited Partnership by its General Partner The Willow Block (GP) Ltd.","website":"
907 +1812|"client":{"id":1812,"name":"Tannin Developments","website":"www.tannindev.com
908 +1817|"client":{"id":1817,"name":"Laurent Student Housing x UniLodge","website":"www.maclabdevelopment.com
909 +1815|"client":{"id":1815,"name":"Iron Door Property Rentals","website":"www.irondoorproperty.com
910 +1819|"client":{"id":1819,"name":"CPM Properties - SF","website":"www.cpmproperties.ca/contact
911 +1825|"client":{"id":1825,"name":"EGMK Properties","website":"www.egmkproperties.com
912 +1829|"client":{"id":1829,"name":"Peak Residences","website":"peakresidences.ca
913 +1828|"client":{"id":1828,"name":"Townline Property Management Corp.","website":"www.townline.com
914 +1847|"client":{"id":1847,"name":"Darcy Ranch Townhouse GP Inc","website":"http://darcytownhomes.com/
915 +1849|"client":{"id":1849,"name":"One Urban Developments Inc","website":"
916 +1859|"client":{"id":1859,"name":"JLL","website":"
917 +1858|"client":{"id":1858,"name":"Royal York - Test","website":"www.royalyorkpropertymanagement.ca
918 +1865|"client":{"id":1865,"name":"Homelife Property Management BC","website":"homelifepropertyrentals.ca
919 +1867|"client":{"id":1867,"name":"Luther College Student Village","website":"
920 +1869|"client":{"id":1869,"name":"Kindred Works Inc","website":"
921 +1878|"client":{"id":1878,"name":"Halifax 4 Rent","website":"
922 +1880|"client":{"id":1880,"name":"Albert Slater Properties LP","website":"
923 +1881|"client":{"id":1881,"name":"Meridiem","website":"
924 +1882|"client":{"id":1882,"name":"Villasola Residential","website":"villasola.ca
925 +1883|"client":{"id":1883,"name":"Trinity Group","website":"trinity-group.com
926 +1888|"client":{"id":1888,"name":"Domus Inc.","website":"www.domushousing.com
927 +1892|"client":{"id":1892,"name":"Valleywood Apartments","website":"
928 +1889|"client":{"id":1889,"name":"Immeubles Notre Dame","website":"
929 +1896|"client":{"id":1896,"name":"Anthem Properties Group Ltd.","website":"
930 +1897|"client":{"id":1897,"name":"York PM Bundle ","website":"
931 +1911|"client":{"id":1911,"name":"Astra Living","website":"astraliving.ca
932 +1912|"client":{"id":1912,"name":"Livwell Development Group Inc","website":"
933 +1919|"client":{"id":1919,"name":"Playfair \u0026 Norberry","website":"
934 +1918|"client":{"id":1918,"name":"Gor Fay Realty","website":"gorfayrealty.com
935 +1920|"client":{"id":1920,"name":"Drewlo bundle","website":"drewloholdings.com
936 +1921|"client":{"id":1921,"name":"Olympus Properties Group","website":"www.olympuspropertiesgroup.ca
937 +1923|"client":{"id":1923,"name":"GTA Home Rentals","website":"
938 +1926|"client":{"id":1926,"name":"SBI Management Limited","website":"
939 +1930|"client":{"id":1930,"name":"NAI Advent","website":"www.naiadvent.com
940 +1932|"client":{"id":1932,"name":"Ripple Property Management","website":"www.ripplecalgary.com
941 +1933|"client":{"id":1933,"name":"Icon Falcon Limited Partnership","website":"
942 +1940|"client":{"id":1940,"name":"Canci Rentals ","website":"
943 +1943|"client":{"id":1943,"name":"CLARIA","website":"https://gardenialachine.com/en/
944 +1947|"client":{"id":1947,"name":"Peyrow","website":"
945 +1954|"client":{"id":1954,"name":"The Diamond","website":"diamondrentals.ca
946 +1955|"client":{"id":1955,"name":"Low Tide Properties","website":"www.lowtideproperties.com
947 +1956|"client":{"id":1956,"name":"Mann Group","website":"
948 +1960|"client":{"id":1960,"name":"Fraser Park Realty Ltd.","website":"
949 +1961|"client":{"id":1961,"name":"Devmont","website":"devmont.ca
950 +1964|"client":{"id":1964,"name":"334154 Alberta Ltd. o/a Best Investment Properties","website":"
951 +1965|"client":{"id":1965,"name":"Simco Management (Calgary) Inc.","website":"
952 +1969|"client":{"id":1969,"name":"Prod Test Client","website":"website-test-prod.lws1.com
953 +1970|"client":{"id":1970,"name":"Excel Leasing","website":"
954 +1972|"client":{"id":1972,"name":"Nujour","website":"
955 +1976|"client":{"id":1976,"name":"Park Vista","website":"
956 +1978|"client":{"id":1978,"name":"McLeod Realty \u0026 Management Ltd.","website":"www.mcleodrealty.com
957 +1980|"client":{"id":1980,"name":"Brasswater","website":"https://www.brasswater.ca/
958 +1985|"client":{"id":1985,"name":"Redding-Rouleau","website":"https://www.redding-rouleau.com/
959 +1983|"client":{"id":1983,"name":"Peakmade Real Estate","website":"peakmade.com/contact
960 +1986|"client":{"id":1986,"name":"Base Spaces, Inc.","website":"www.basespaces.ca
961 +1987|"client":{"id":1987,"name":"Longboat","website":"1530warde.ca
962 +1995|"client":{"id":1995,"name":"Labo MTL","website":"
963 +1999|"client":{"id":1999,"name":"Interloq Capital Corp.","website":"
964 +2003|"client":{"id":2003,"name":"FSR Vancouver","website":"www.fsresidential.com/corporate
965 +2002|"client":{"id":2002,"name":"Aquaterra Management LTD","website":"https://www.raincityrents.com/
966 +2009|"client":{"id":2009,"name":"Pinemount Gorman Park Limited Partnership","website":"
967 +2013|"client":{"id":2013,"name":"Vertus Property Management Inc.","website":"
968 +2015|"client":{"id":2015,"name":"Wynnward Developments","website":"wynnward.ca
969 +2019|"client":{"id":2019,"name":"MaxWell Progressive Property Management","website":"www.yegrent.com
970 +2018|"client":{"id":2018,"name":"Go Smart Property Managers Inc.","website":"gosmartmanagers.com
971 +2021|"client":{"id":2021,"name":"Elias M'Butho","website":"atriarealty.c21.ca
972 +2012|"client":{"id":2012,"name":"Compass Communities","website":"https://www.compasscommunities.ca/
973 +2033|"client":{"id":2033,"name":"Vivenda Group","website":"vivendagroup.ca
974 +2030|"client":{"id":2030,"name":"Urban Avas Corp.","website":"www.urbanavas.com/contact
975 +2031|"client":{"id":2031,"name":"Troika Management Corp","website":"troikaliving.com
976 +2036|"client":{"id":2036,"name":"Brookfield Properties","website":"
977 +2035|"client":{"id":2035,"name":"Peterson Rentals","website":"www.petersonbc.com
978 +2041|"client":{"id":2041,"name":"Transpacific Realty Advisors","website":"www.transpacificrealty.com
979 +2048|"client":{"id":2048,"name":"Gill Villa","website":"rentgillvilla.ca
980 +2050|"client":{"id":2050,"name":"Elora MTL","website":"eloramtl.com
981 +2055|"client":{"id":2055,"name":"Blue Anchor Property Management","website":"www.blueanchorpm.rent
982 +2056|"client":{"id":2056,"name":"Theberge Homes","website":"www.thebergehomes.com
983 +2057|"client":{"id":2057,"name":"The Property Consulting Group Inc.","website":"
984 +2058|"client":{"id":2058,"name":"1000870452 Ontario Inc.","website":"
985 +2060|"client":{"id":2060,"name":"ML Westboro Inc. c/o ML Devco","website":"westmountliving.ca
986 +2064|"client":{"id":2064,"name":"Avante Properties, LLC","website":"https://www.avanteproperties.com
987 +2070|"client":{"id":2070,"name":"Intracorp","website":"
988 +2067|"client":{"id":2067,"name":"Girescom","website":"
989 +2073|"client":{"id":2073,"name":"Rentch","website":"rentch.ca
990 +2074|"client":{"id":2074,"name":"Valery Properties","website":"www.valeryproperties.com
991 +2076|"client":{"id":2076,"name":"MTL Student Living ","website":"www.foxre.co
992 +2068|"client":{"id":2068,"name":"Fengate","website":"
993 +2077|"client":{"id":2077,"name":"Woodsmere Holdings Corp.","website":"woodsmere.ca
994 +2079|"client":{"id":2079,"name":"Drake Property Management Infinity","website":"
995 +2083|"client":{"id":2083,"name":"Shelborne Property Management","website":"
996 +2078|"client":{"id":2078,"name":"Sixth Avenue Arbutus Management Corp.","website":"
997 +2085|"client":{"id":2085,"name":"The Woods","website":"https://www.180kanata.com/
998 +2088|"client":{"id":2088,"name":"Arek Property Management","website":"
999 +2089|"client":{"id":2089,"name":"Infinity Plan A Real Estate Services","website":"www.plana.pro
1000 +2086|"client":{"id":2086,"name":"Vrancor Property Management","website":"vrancor.com
1001 +2087|"client":{"id":2087,"name":"Aspen Towers / 2935 Richmond Rd Ltd. ","website":"Bedfordliving.ca
1002 +2092|"client":{"id":2092,"name":"Arlington Group","website":"arlingtonstreet.ca
1003 +2100|"client":{"id":2100,"name":"Athena Property Management","website":"athenapmi.com
1004 +2099|"client":{"id":2099,"name":"Third Space Properties","website":"third.space
1005 +2103|"client":{"id":2103,"name":"Nivii","website":"nivii.com
1006 +2106|"client":{"id":2106,"name":"Hyland Properties","website":"www.hylandproperties.ca
1007 +2111|"client":{"id":2111,"name":"RALA (Barrington Ave) LLC","website":"
1008 +2120|"client":{"id":2120,"name":"Lexington Park Real Estate Capital","website":"
1009 +2117|"client":{"id":2117,"name":"Zen Residential - SF","website":"www.zenrentals.ca
1010 +2128|"client":{"id":2128,"name":"CW Properties","website":"
1011 +2134|"client":{"id":2134,"name":"Warrington PCI Management","website":"www.warringtonpci.com
1012 +2130|"client":{"id":2130,"name":"Daniels Gateway SF","website":"www.danielsgateway.com
1013 +2141|"client":{"id":2141,"name":"Place Doree","website":"
1014 +2145|"client":{"id":2145,"name":"Wingspan Properties","website":"wingspanproperties.ca
1015 +2146|"client":{"id":2146,"name":"Blue Peak Property Solutions Ltd","website":"
1016 +2149|"client":{"id":2149,"name":"PG Brar Investments Inc.","website":"
1017 +2147|"client":{"id":2147,"name":"Triami Developments \u0026 Investments","website":"
1018 +2155|"client":{"id":2155,"name":"Digi Brokerage","website":"www.dashq.io
1019 +2157|"client":{"id":2157,"name":"CityPlex Property Management","website":"cityplex.ca
1020 +2156|"client":{"id":2156,"name":"Laketree Properties","website":"https://www.laketree.ca/
1021 +2159|"client":{"id":2159,"name":"The Corner on Broadway","website":"https://thecornerrentals.com/
1022 +2162|"client":{"id":2162,"name":" L\u0026Y Railway Corp c/o GF Management Corp","website":"
1023 +2160|"client":{"id":2160,"name":"Limestone Property Management","website":"www.limestonepropertymanagement.ca
1024 +2161|"client":{"id":2161,"name":"Holloway Lodging","website":"
1025 +2166|"client":{"id":2166,"name":"Keilty Residential Management Inc","website":"keilty.com/pages/contact
1026 +2171|"client":{"id":2171,"name":"Skyview Living Apartments - 2526135 Alberta Ltd.","website":"liveatskyview.ca
1027 +2172|"client":{"id":2172,"name":"Serco Realty Group","website":"www.sercorealty.com
1028 +2173|"client":{"id":2173,"name":"Tribe Management - Alma","website":"www.triberentals.com
1029 +2177|"client":{"id":2177,"name":"Toronto House","website":"torontohouse.alliedreit.com
1030 +2179|"client":{"id":2179,"name":"PC Urban","website":"www.pcurban.ca
1031 +2183|"client":{"id":2183,"name":"Apartments Inc.","website":"
1032 +2184|"client":{"id":2184,"name":"Liberty Homes (Claremont) Ltd.","website":"
1033 +2186|"client":{"id":2186,"name":"Urban Village LP","website":"https://east-village.ca/
1034 +2187|"client":{"id":2187,"name":"rennie","website":"rennie.com/contact
1035 +2189|"client":{"id":2189,"name":"Les Développements Pur Immobilia inc.","website":"
1036 +2190|"client":{"id":2190,"name":"R.A.M. Contracting Limited ","website":"
1037 +2193|"client":{"id":2193,"name":"VIP Realty \u0026 Management","website":"www.viprealtyandmanagement.com/contact.php
1038 +2201|"client":{"id":2201,"name":"Owl's Nest Property Management Inc.","website":"owlsnestpm.com
1039 +2203|"client":{"id":2203,"name":"1267629 BC Ltd","website":"
1040 +2208|"client":{"id":2208,"name":"Reliable Pro Management Inc.","website":"www.reliablepromng.com
1041 +2205|"client":{"id":2205,"name":"Eagle Builders","website":"
1042 +2210|"client":{"id":2210,"name":"AspirePeak Properties Ltd.","website":"www.aspirepeakproperties.com
1043 +2212|"client":{"id":2212,"name":"Times Group","website":"
1044 +2216|"client":{"id":2216,"name":"One Story Developments Inc.","website":"
1045 +2223|"client":{"id":2223,"name":"Rozenblat Realty Group","website":"
1046 +2219|"client":{"id":2219,"name":"Glamorgan Landing Estates","website":"mygle.ca
1047 +2233|"client":{"id":2233,"name":"Menkes Condominium Rentals ","website":"www.menkescondorentals.com
1048 +2235|"client":{"id":2235,"name":"Areva Living","website":"
1049 +2238|"client":{"id":2238,"name":"Upliv","website":"
1050 +2247|"client":{"id":2247,"name":"Diamond Property Management Group","website":"www.dpmg.ca
1051 +2242|"client":{"id":2242,"name":"Gestion Immobilière LR","website":"
1052 +2250|"client":{"id":2250,"name":"JSB Consulting","website":"
1053 +2258|"client":{"id":2258,"name":"MMI Group Inc","website":" mmigroupinc.ca
1054 +2261|"client":{"id":2261,"name":"Dove Preter","website":"
1055 +2264|"client":{"id":2264,"name":"Palay MTL","website":"www.palaymtl.ca
1056 +2265|"client":{"id":2265,"name":"Haven Bridge Property Management","website":"https://www.havenbridge.ca/
1057 +2266|"client":{"id":2266,"name":"Westfolio Properties","website":"
1058 +2267|"client":{"id":2267,"name":"Century 21 Property Management Bravo","website":"
1059 +2269|"client":{"id":2269,"name":"Porte Communities","website":"www.porte.ca
1060 +2276|"client":{"id":2276,"name":"Axon Property Management","website":"axonproperties.ca
1061 +2278|"client":{"id":2278,"name":"Yoke Group","website":"www.yokegroup.ca
1062 +2277|"client":{"id":2277,"name":"Kipling Group","website":"www.kiplinggroupinc.com
1063 +2285|"client":{"id":2285,"name":"Orin Management Group Inc.","website":"https://oringroup.ca/
1064 +2284|"client":{"id":2284,"name":"Hudson Projects","website":"
1065 +2287|"client":{"id":2287,"name":"Delbec Real Estate Developments Ltd","website":"
1066 +2282|"client":{"id":2282,"name":"Devon Properties Infinity","website":"devonproperties.com
1067 +2296|"client":{"id":2296,"name":"Corley Developments","website":"www.corleydev.com
1068 +2294|"client":{"id":2294,"name":"Vada Property Management","website":"vadapm.com
1069 +2295|"client":{"id":2295,"name":"Josan Properties","website":"www.josanproperties.com
1070 +2292|"client":{"id":2292,"name":"ROI Property Management","website":"roipm.ca/about-us
1071 +2297|"client":{"id":2297,"name":"Cidex Group","website":"
1072 +2299|"client":{"id":2299,"name":"Orchard West Real Estate Group","website":"
1073 +2300|"client":{"id":2300,"name":"Phoenix Rising Property Management","website":"www.prpminc.ca
1074 +2304|"client":{"id":2304,"name":"Baum Real Estate","website":"
1075 +2301|"client":{"id":2301,"name":"Forterra Property Management","website":"www.themadera.ca
1076 +2307|"client":{"id":2307,"name":"Marcon","website":"mera.ca/rental/west-42
1077 +2308|"client":{"id":2308,"name":"Wildgust Elite Property Management","website":"
1078 +2313|"client":{"id":2313,"name":"Burk Management \u0026 Realty Inc. ","website":"https://www.seerproperties.com/
1079 +2314|"client":{"id":2314,"name":"IG Mackenzie Real Property Fund","website":"
1080 +2312|"client":{"id":2312,"name":"Metro GP","website":"
1081 +2311|"client":{"id":2311,"name":"Westdell Development Corp","website":"https://arianorth.ca/
1082 +2310|"client":{"id":2310,"name":"Bright Star Management Corporation","website":"https://www.brightstarproperties.ca/
1083 +2315|"client":{"id":2315,"name":"Quality Assured Property Professional Ltd","website":"
1084 +2320|"client":{"id":2320,"name":"Evospace Developments","website":"evospace.dev
1085 +2323|"client":{"id":2323,"name":"Norquay Property Management Ltd","website":"ndev.ca
1086 +2324|"client":{"id":2324,"name":"Integro Management Services Inc.","website":"
1087 +2327|"client":{"id":2327,"name":"Blanc\u0026Noir Immobilier ","website":"
1088 +2325|"client":{"id":2325,"name":"Welcome Co ","website":"
1089 +2328|"client":{"id":2328,"name":"2Beat Management","website":"
1090 +2337|"client":{"id":2337,"name":"Korecki Real Estate Services","website":"www.korecki.ca
1091 +2346|"client":{"id":2346,"name":"D'Arcy Heights","website":"www.DarcyHeights.ca
1092 +2349|"client":{"id":2349,"name":"Stober Group","website":"www.stobergroup.com
1093 +2353|"client":{"id":2353,"name":"Realrent","website":"realrent.ca
1094 +2355|"client":{"id":2355,"name":"Sightline Properties","website":"
1095 +2359|"client":{"id":2359,"name":"Zigma Development Ltd","website":"
1096 +2362|"client":{"id":2362,"name":"Truman Rentals","website":"renttruman.com
1097 +2363|"client":{"id":2363,"name":"Pivot Turn Property Management","website":"ptpropertymanagement.ca
1098 +2366|"client":{"id":2366,"name":"Times 5565 Inc. - c/o 65 Broadway Leasing","website":"https://t2rentals.com/sixtyfivebroadway/
1099 +2368|"client":{"id":2368,"name":"Broccolini - Le Sherbrooke","website":"
1100 +2369|"client":{"id":2369,"name":"Montreal4Rent.ca","website":"
1101 +2370|"client":{"id":2370,"name":"Helisis Management","website":"helisis.ca
1102 +2373|"client":{"id":2373,"name":"Mantler, Bowman \u0026 Associates Inc.","website":"mbaa.ca
1103 +2380|"client":{"id":2380,"name":"Sanpra Real Estate Inc.","website":"https://sanpra.ca/
1104 +2378|"client":{"id":2378,"name":"SiteLine Residential","website":"
1105 +2387|"client":{"id":2387,"name":"Deva Rentals","website":"devarentals.com/
1106 +2390|"client":{"id":2390,"name":"Bosa Development","website":"https://bosadevelopment.com/project/pacific-village-apartments/
1107 +2396|"client":{"id":2396,"name":"i2 Developments (Brampton) Inc.","website":"
1108 +2400|"client":{"id":2400,"name":"Prism Property Management","website":"
1109 +2399|"client":{"id":2399,"name":"Meta West Realty","website":"
1110 +2408|"client":{"id":2408,"name":"Christel Asset Management","website":"www.christelrealty.ca
1111 +2412|"client":{"id":2412,"name":"Hazelview Properties - 186 King Street","website":"
1112 +2413|"client":{"id":2413,"name":"Paramount Management","website":"paramountmanagement.ca
1113 +2409|"client":{"id":2409,"name":"Hama Canada","website":"amacanada.ca
1114 +2416|"client":{"id":2416,"name":"Oktodev","website":"
1115 +2421|"client":{"id":2421,"name":"Prince Property Inc.","website":"www.princeproperty.ca
1116 +2423|"client":{"id":2423,"name":"NuEra Properties","website":"
1117 +2424|"client":{"id":2424,"name":"Square One Developments Inc.","website":"
1118 +2426|"client":{"id":2426,"name":"Highland Park Place","website":"
1119 +2427|"client":{"id":2427,"name":"Virtus Asset Management","website":"vreit.ca
1120 +2441|"client":{"id":2441,"name":"Premier Strategy Corp.","website":"
1121 +2447|"client":{"id":2447,"name":"New Horizon Development Group","website":"nhdg.ca
1122 +2458|"client":{"id":2458,"name":"Twelvestone Realty Advisors Inc.","website":"
1123 +2457|"client":{"id":2457,"name":"First Service Residential","website":"
1124 +2465|"client":{"id":2465,"name":"West Point Projects","website":"www.westpointprojects.com
1125 +2464|"client":{"id":2464,"name":"Anchor Group Atlantic Limited","website":"
1126 +2466|"client":{"id":2466,"name":"Shannondale Developments","website":"https://www.kiplinggroupinc.com
1127 +2463|"client":{"id":2463,"name":"Wesbrook properties","website":"https://www.wesbrookproperties.com/
1128 +2467|"client":{"id":2467,"name":"Sante Apartments","website":"https://rentals.ca/kingston/sante-apartments
1129 +2469|"client":{"id":2469,"name":"Cromwell Management Inc - Ontario","website":"cromwellmgt.ca
1130 +2473|"client":{"id":2473,"name":"Campus Living Centres","website":"
1131 +2471|"client":{"id":2471,"name":"Morguard Investments Limited","website":"www.morguardapartments.com
1132 +2472|"client":{"id":2472,"name":"Brad J. Lamb Realty","website":"bradjlambrealty.com
1133 +2477|"client":{"id":2477,"name":"1147 St. Anne’s Inc. c/o Genesis Property Management Group Inc.","website":"
1134 +2478|"client":{"id":2478,"name":"Longwood Building Corporation","website":"
1135 +2481|"client":{"id":2481,"name":"Western Canadian Properties Group","website":"www.wcpg.ca
1136 +2489|"client":{"id":2489,"name":"Rent Assist/","website":"www.rent-assist.ca
1137 +2488|"client":{"id":2488,"name":"Wiebe Properties","website":"abbotsfordapartments.com
1138 +2493|"client":{"id":2493,"name":"Alabaster Rental","website":"alabasterrental.ca
1139 +2492|"client":{"id":2492,"name":"Axia Apartments","website":"
1140 +2494|"client":{"id":2494,"name":"Bosco (Pur Immobilia)","website":"
1141 +2495|"client":{"id":2495,"name":"Les Immeubles Logis Belanger","website":"
1142 +2497|"client":{"id":2497,"name":"Spire Development","website":"www.spiredevelopment.ca
1143 +2500|"client":{"id":2500,"name":"Coletara Developments","website":"
added data/rentcafe_clients.json +225 −0
@@ -0,0 +1,225 @@
1 +{
2 + "_comment": "Rent-Ka — Registre des clients Yardi RentCafe/SecureCafe (expansion Ontario 2026-08). Auteur : Simon-Pierre Boucher — contact@spboucher.ai. Consommé par rentka/connectors/rentcafe.py : une sous-classe de connecteur est générée par client au statut « ok » (source_id = rc_<id>). Les entrées « echec » documentent les impasses vérifiées (ne pas réessayer sans nouvelle info). Enquêtes : vague 1 le 2026-08-26, vague 2 le 2026-08-27 ; tout passe par Scrapfly (Cloudflare 403 en direct).",
3 + "clients": [
4 + {
5 + "id": "effort",
6 + "name": "Effort Trust",
7 + "site": "https://www.effortrentals.com",
8 + "search_url": "https://www.effortrentals.com/searchlisting.aspx",
9 + "pattern": "searchlisting",
10 + "regions": "Hamilton, Kitchener-Waterloo, Cambridge, Guelph, Brantford, Woodstock, St. Catharines, Burlington",
11 + "status": "ok",
12 + "note": "153 propriétés ON au 2026-08-26 (toutes les cartes sont ON). Cartes li.property-box-hidden + carte available_prop_map ; plans fp-container embarqués dans la fiche default.aspx (PAS de page /floorplans, 404)."
13 + },
14 + {
15 + "id": "osgoode",
16 + "name": "Osgoode Properties (Ontario)",
17 + "site": "https://www.osgoodeproperties.com",
18 + "search_url": "https://www.osgoodeproperties.com/searchlisting.aspx",
19 + "pattern": "searchlisting",
20 + "regions": "Ottawa, Kingston",
21 + "status": "ok",
22 + "note": "17 cartes ON sur 23 (AB exclues par le filtre province, Gatineau QC couvert par le connecteur dédié osgoode.py). Plans sur <fiche>/floorplans (spans data-selenium-id)."
23 + },
24 + {
25 + "id": "gwlra",
26 + "name": "GWLRA Residential",
27 + "site": "https://www.gwlraresidential.com",
28 + "search_url": "https://www.gwlraresidential.com/searchlisting.aspx",
29 + "pattern": "searchlisting",
30 + "regions": "Toronto, Mississauga, Burlington, Ottawa",
31 + "status": "ok",
32 + "note": "16 cartes ON sur 26 (BC/AB exclues par le filtre province). Plans sur <fiche>/floorplans (gabarit cartes h2.card-title + nu-bed/nu-bathroom/nu-area)."
33 + },
34 + {
35 + "id": "oshanter",
36 + "name": "O'Shanter Development",
37 + "site": "https://www.oshanter.com",
38 + "search_url": "https://www.oshanter.com/searchlisting.aspx",
39 + "pattern": "searchlisting",
40 + "regions": "Toronto, East York, Mississauga",
41 + "status": "ok",
42 + "note": "17 cartes ON. Les fiches pointent vers des microsites RentCafe par immeuble (ex. 2150bromsgrove.com?rcstdid=…) — détail plus coûteux, budget serré."
43 + },
44 + {
45 + "id": "oldoak",
46 + "name": "Old Oak Properties",
47 + "site": "https://oldoak.com",
48 + "search_url": "https://oldoakproperties.securecafe.com/residentservices/apartmentsforrent/userlogin.aspx",
49 + "pattern": "securecafe",
50 + "regions": "London",
51 + "status": "echec",
52 + "note": "Vérifié 2026-08-26 : oldoak.com est un WordPress/JetEngine (pas de searchlisting.aspx, 404) et le portail SecureCafe « apartmentsforrent » exige un compte (userlogin/register seulement, même via Scrapfly). Piste restante : connecteur WP dédié (grille JetEngine /apartment-rentals/, ~10 immeubles/page)."
53 + },
54 + {
55 + "id": "paramount",
56 + "name": "Paramount Properties",
57 + "site": "https://paramountapts.com",
58 + "search_url": "https://paramountapts.securecafe.com/residentservices/apartmentsforrent/userlogin.aspx",
59 + "pattern": "securecafe",
60 + "regions": "Ottawa",
61 + "status": "echec",
62 + "note": "Vérifié 2026-08-26 : paramountapts.com est un WordPress (pages /location/<immeuble>/, pas de searchlisting.aspx) ; le portail SecureCafe est fermé derrière login. Piste restante : connecteur WP dédié (13 immeubles listés dans le menu)."
63 + },
64 + {
65 + "id": "tricar",
66 + "name": "The Tricar Group",
67 + "site": "https://www.tricar.com",
68 + "search_url": "https://tricar.securecafe.com/residentservices/apartmentsforrent/propertyselect.aspx",
69 + "pattern": "securecafe",
70 + "regions": "London, Sarnia, Guelph, Woodstock",
71 + "status": "echec",
72 + "note": "Vérifié 2026-08-26 : tricar.com est un WordPress (pas de searchlisting.aspx, 404) ; propertyselect.aspx du portail SecureCafe = enregistrement des résidents, aucune liste publique. Piste restante : connecteur WP dédié (/rent-an-apartment/<ville>-apartments/)."
73 + },
74 + {
75 + "id": "claridge",
76 + "name": "Claridge Homes",
77 + "site": "https://rent.claridgehomes.com",
78 + "search_url": "https://rent.claridgehomes.com/searchlisting.aspx",
79 + "pattern": "searchlisting",
80 + "regions": "Ottawa (centre, Gloucester, Riverside Sud)",
81 + "status": "ok",
82 + "note": "14 cartes ON au 2026-08-27 (portail locatif rent.claridgehomes.com — le site corporatif claridgehomes.com n'a pas de searchlisting, 404). Fiches = microsites RentCafe par immeuble (ex. stirling.claridgehomes.com, magnolia.claridgehomes.com) ; plans fp-container sur <microsite>/floorplans (validé : Stirling Park 1 ch. 710 pi², à partir de 2 225 $)."
83 + },
84 + {
85 + "id": "richcraft",
86 + "name": "Richcraft Rentals",
87 + "site": "https://www.richcraftrentals.com",
88 + "search_url": "https://www.richcraftrentals.com/searchlisting.aspx",
89 + "pattern": "searchlisting",
90 + "regions": "Ottawa (Stittsville, Kanata, Barrhaven, Orléans, Riverside Sud)",
91 + "status": "ok",
92 + "note": "13 cartes ON au 2026-08-27. ⚠ Utiliser www.richcraftrentals.com (l'apex sans www ne répond pas ; richcraft.com corporatif = 404). Fiches = microsites par immeuble (grantcourtrentals.com, mapletonrentals.com…) : description/galerie OK mais certains microsites ne publient pas de plans fp-container (Grant Court) — les prix des cartes liste suffisent."
93 + },
94 + {
95 + "id": "arnon",
96 + "name": "Arnon Development",
97 + "site": "https://www.arnon.ca",
98 + "search_url": "https://www.arnon.ca/searchlisting.aspx",
99 + "pattern": "searchlisting",
100 + "regions": "Ottawa",
101 + "status": "ok",
102 + "note": "3 cartes ON au 2026-08-27 (petit parc). Aucun prix sur les cartes NI les épingles : le prix vient uniquement des plans fp-container des microsites par immeuble (ex. tendriveway.arnon.ca : à partir de 1 500 $) — garder max_details ≥ 2 pour ne pas publier sans prix."
103 + },
104 + {
105 + "id": "concert",
106 + "name": "Concert Properties",
107 + "site": "https://rent.concertproperties.com",
108 + "search_url": "https://rent.concertproperties.com/searchlisting.aspx",
109 + "pattern": "searchlisting",
110 + "regions": "Toronto, Etobicoke",
111 + "status": "ok",
112 + "note": "7 cartes ON au 2026-08-27. Fiches sur le même domaine (/etobicoke/20-samuel-wood, /toronto/jazz…) ; plans fp-container via <fiche>/floorplans (validé : 20 Samuel Wood, studio 360 pi² à partir de 1 810 $, 20 photos)."
113 + },
114 + {
115 + "id": "caraco",
116 + "name": "CaraCo Property Management",
117 + "site": "https://www.caracoproperties.ca",
118 + "search_url": "https://www.caracoproperties.ca/searchlisting.aspx",
119 + "pattern": "searchlisting",
120 + "regions": "Kingston",
121 + "status": "ok",
122 + "note": "9 cartes ON au 2026-08-27. ⚠ Domaine = caracoproperties.ca AVEC www (l'apex renvoie une erreur SSL 525 ; caracoliving.com = 404). Fiches même domaine ; plans fp-container (validé : L'Hermitage East 1 ch. 770 pi², à partir de 1 900 $)."
123 + },
124 + {
125 + "id": "colonnade",
126 + "name": "Colonnade BridgePort",
127 + "site": "https://colonnadebridgeport.ca",
128 + "search_url": "https://colonnadebridgeport.ca/searchlisting.aspx",
129 + "pattern": "microsites",
130 + "regions": "Ottawa (Westboro, Hintonburg)",
131 + "status": "echec",
132 + "note": "Vérifié 2026-08-27 : pas de searchlisting central — searchlisting.aspx renvoie un 403 origine même via Scrapfly ASP. Portefeuille éclaté en microsites RentCafe par immeuble (westboroconnection.com, hintonburgconnection.com…). Piste restante : registre de microsites dédié."
133 + },
134 + {
135 + "id": "destaron",
136 + "name": "Destaron Property Management",
137 + "site": "https://destaron.ca",
138 + "search_url": "https://destaron.ca/searchlisting.aspx",
139 + "pattern": "wordpress",
140 + "regions": "Oakville, Burlington, Mississauga, Milton, Brantford, Barrie, Orillia",
141 + "status": "echec",
142 + "note": "Vérifié 2026-08-27 : destaron.ca est un WordPress — searchlisting.aspx renvoie un 403 origine même via Scrapfly ASP ; le Yardi est intégré par immeuble (SecureCafe). Piste restante : connecteur WP dédié (pages villes /oakville/, /brantford/, /orillia/…)."
143 + },
144 + {
145 + "id": "hillside",
146 + "name": "Hillside Apartments",
147 + "site": "https://hillsideapartments.com",
148 + "search_url": "https://hillsideapartments.com/searchlisting.aspx",
149 + "pattern": "entrata",
150 + "regions": "Ottawa (étudiant)",
151 + "status": "echec",
152 + "note": "Vérifié 2026-08-27 : PAS un Yardi — plateforme Entrata/ProspectPortal (hillsideottawa.prospectportal.com, assets rcommoncf.entrata.com). searchlisting.aspx renvoie 200 mais c'est la page d'accueil (0 carte property-box). Hors périmètre rentcafe.py ; piste : connecteur Entrata."
153 + },
154 + {
155 + "id": "wellington",
156 + "name": "Wellington Towers",
157 + "site": "https://147wellington.com",
158 + "search_url": "https://147wellington.com/searchlisting.aspx",
159 + "pattern": "searchlisting",
160 + "regions": "Aurora",
161 + "status": "echec",
162 + "note": "Vérifié 2026-08-27 : 147wellingtonstreet.com ne répond pas en HTTPS (timeout, DNS pourtant actif) et 147wellington.com renvoie 403 même via Scrapfly ASP — aucun searchlisting public accessible. Immeuble unique ; à revisiter si le site revient en ligne."
163 + },
164 + {
165 + "id": "tricon",
166 + "name": "Tricon Residential",
167 + "site": "https://triconhomes.com",
168 + "search_url": "https://triconhomes.com/searchlisting.aspx",
169 + "pattern": "wordpress",
170 + "regions": "Toronto",
171 + "status": "echec",
172 + "note": "Vérifié 2026-08-27 : triconhomes.com est un WordPress (searchlisting.aspx = 404, triconresidential.com y redirige) et les immeubles (theselby.com…) sont des WordPress par propriété (searchlisting = page 404 WP). Aucun portail RentCafe central public. Piste : connecteurs par immeuble."
173 + },
174 + {
175 + "id": "taggart",
176 + "name": "Taggart Realty Management",
177 + "site": "https://taggart.ca",
178 + "search_url": "https://taggart.ca/searchlisting.aspx",
179 + "pattern": "wordpress",
180 + "regions": "Ottawa (Kenson Towers, 93 Norman)",
181 + "status": "echec",
182 + "note": "Vérifié 2026-08-27 : taggart.ca est un WordPress (searchlisting.aspx = 404, avec ou sans www) ; les annonces passent par SecureCafe intégré par immeuble. Piste restante : connecteur WP dédié (/apartments)."
183 + },
184 + {
185 + "id": "rezone",
186 + "name": "Rez-One",
187 + "site": "https://rez-one.ca",
188 + "search_url": "https://rez-one.ca/searchlisting.aspx",
189 + "pattern": "wordpress",
190 + "regions": "Waterloo (étudiant), Elora",
191 + "status": "echec",
192 + "note": "Vérifié 2026-08-27 : rez-one.ca est un WordPress (searchlisting.aspx = 404 ; rez-one.com ne répond pas en HTTPS) ; portail SecureCafe/Yardi par résidence. Gros parc étudiant — piste restante : connecteur WP dédié."
193 + },
194 + {
195 + "id": "wjproperties",
196 + "name": "WJ Properties",
197 + "site": "https://www.wjproperties.ca",
198 + "search_url": "https://www.wjproperties.ca/searchlisting.aspx",
199 + "pattern": "wordpress",
200 + "regions": "Old Toronto",
201 + "status": "echec",
202 + "note": "Vérifié 2026-08-27 : wjproperties.ca est un WordPress (searchlisting.aspx = 404, pas de sous-domaine rent.) ; le RentCafe est intégré par immeuble. Piste restante : connecteur WP dédié."
203 + },
204 + {
205 + "id": "northview",
206 + "name": "Northview (rentnorthview.com)",
207 + "site": "https://www.rentnorthview.com",
208 + "search_url": "https://www.rentnorthview.com/searchlisting.aspx",
209 + "pattern": "custom",
210 + "regions": "Ontario + pancanadien",
211 + "status": "echec",
212 + "note": "Vérifié 2026-08-27 : northviewreit.com redirige vers rentnorthview.com, un site de location maison (custom, searchlisting.aspx = 404) — pas un client RentCafe. Gros parc : piste restante, connecteur dédié rentnorthview.com (API interne à cartographier)."
213 + },
214 + {
215 + "id": "bakerfield",
216 + "name": "Rose Corporation (The Bakerfield)",
217 + "site": "https://www.thebakerfield.com",
218 + "search_url": "https://www.thebakerfield.com/searchlisting.aspx",
219 + "pattern": "rentsync",
220 + "regions": "Newmarket",
221 + "status": "echec",
222 + "note": "Vérifié 2026-08-27 : thebakerfield.com/searchlisting.aspx renvoie 204 No Content — site Rentsync, pas un searchlisting RentCafe public. Immeuble unique (225 unités) ; piste restante : connecteur Rentsync/LTL."
223 + }
224 + ]
225 +}
added data/rentsyncgw_clients.json +124 −0
@@ -0,0 +1,124 @@
1 +{
2 + "_comment": "Registre des clients Rentsync « nouvelle génération » (passerelle JSON publique website-gateway.rentsync.com/v1/<siteKey>/) — expansion Ontario 2026-08. Consommé par rentka/connectors/rentsyncgw.py qui génère un connecteur rsgw_<id> par entrée validée (même architecture data-driven que liftsystem.py). siteKeys découverts dans les bundles cdn.rentsync.com/site/<key> et VALIDÉS en live le 2026-08-27 (curl properties/units/cities/photos + HEAD S3). img_key = clé S3 lws_lift des galeries — peut différer du siteKey (drewlo_rebuild → drewlo, panoramic_properties → panoramic ; vérifié 200 vs 403). Connecteurs de la MÊME passerelle déjà dédiés (NE PAS dupliquer ici) : homestead.py (homestead_rebuild), skyline.py (skyline).",
3 + "updated": "2026-08-27",
4 + "clients": [
5 + {
6 + "id": "sterlingkaramar",
7 + "name": "Sterling Karamar Property Management",
8 + "site": "https://www.sterlingkaramar.com",
9 + "site_key": "sterlingkaramar",
10 + "img_key": "sterlingkaramar",
11 + "listing_url": "https://www.sterlingkaramar.com/apartments-for-rent",
12 + "regions": [
13 + "Toronto/GTA",
14 + "London",
15 + "Ottawa",
16 + "Kitchener-Waterloo",
17 + "Hamilton",
18 + "Barrie"
19 + ],
20 + "status": "valide",
21 + "properties": 217,
22 + "exclude_import_ids": [
23 + "yardi:ball0045",
24 + "yardi:davi0225",
25 + "yardi:davi0207"
26 + ],
27 + "notes": "~15 000 unités gérées ; 217 propriétés au flux dont 171 résidentielles (46 commerciales filtrées : office/retail/industrial) ; 1 187 rangées d'unités disponibles (2 pages), 39 villes ON ; exclude_import_ids = 3 immeubles Greenrock (couverts par greenrock.py)"
28 + },
29 + {
30 + "id": "drewlo",
31 + "name": "Drewlo Holdings",
32 + "site": "https://www.drewloholdings.com",
33 + "site_key": "drewlo_rebuild",
34 + "img_key": "drewlo",
35 + "listing_url": "https://www.drewloholdings.com/apartments-for-rent",
36 + "regions": [
37 + "London",
38 + "Kitchener-Waterloo",
39 + "Woodstock",
40 + "Sarnia",
41 + "St. Thomas",
42 + "Komoka"
43 + ],
44 + "status": "valide",
45 + "properties": 108,
46 + "notes": "~10 000 unités (constructeur-gestionnaire du Sud-Ouest ontarien) ; 108 propriétés dont ~94 résidentielles (filtrés : construction/coming-soon 4, office 4, retail 1, subdivision 5) ; 479 rangées d'unités disponibles ; typeName = noms de plans (« Elgin ») → unit_type dérivé des chambres"
47 + },
48 + {
49 + "id": "panoramic",
50 + "name": "Panoramic Properties",
51 + "site": "https://www.panoramicproperties.ca",
52 + "site_key": "panoramic_properties",
53 + "img_key": "panoramic",
54 + "listing_url": "https://www.panoramicproperties.ca/apartments-for-rent",
55 + "regions": [
56 + "Sudbury",
57 + "Nord de l'Ontario"
58 + ],
59 + "status": "valide",
60 + "properties": 49,
61 + "notes": "Gestionnaire dominant du Grand Sudbury ; 49 propriétés (toutes résidentielles : apartments + townhouses), 132 rangées d'unités disponibles, 11 villes"
62 + },
63 + {
64 + "id": "fitzrovia",
65 + "name": "Fitzrovia Real Estate",
66 + "site": "https://fitzrovia.ca",
67 + "site_key": "fitzrovia",
68 + "img_key": "fitzrovia",
69 + "listing_url": "https://fitzrovia.ca/residences",
70 + "regions": [
71 + "Toronto/GTA",
72 + "Guelph"
73 + ],
74 + "status": "valide",
75 + "properties": 25,
76 + "notes": "Vague 2b (2026-08-27) : 25 propriétés (Toronto/GTA + Guelph), 828 rangées d'unités disponibles (2 pages), 6 villes ; gros portefeuille luxe Toronto"
77 + },
78 + {
79 + "id": "drakepm",
80 + "name": "Drake Property Management",
81 + "site": "https://www.drakepm.com",
82 + "site_key": "drakepm",
83 + "img_key": "drake_pm",
84 + "listing_url": "https://www.drakepm.com/residential",
85 + "regions": [
86 + "Hamilton",
87 + "Sud de l'Ontario"
88 + ],
89 + "status": "valide",
90 + "properties": 55,
91 + "notes": "Vague 2b (2026-08-27) : 55 propriétés, 572 rangées d'unités disponibles, 14 villes ; img_key drake_pm (siteKey drakepm → 403)"
92 + },
93 + {
94 + "id": "arcanos",
95 + "name": "Arcanos Property Management",
96 + "site": "https://arcanos.ca",
97 + "site_key": "arcanos_rebuild",
98 + "img_key": "arcanos",
99 + "listing_url": "https://arcanos.ca/apartments-for-rent",
100 + "regions": [
101 + "Toronto",
102 + "Oakville"
103 + ],
104 + "status": "valide",
105 + "properties": 14,
106 + "notes": "Vague 2b (2026-08-27) : 14 propriétés (Toronto + Oakville), 41 rangées d'unités disponibles ; img_key arcanos (siteKey arcanos_rebuild → 403)"
107 + },
108 + {
109 + "id": "bgo",
110 + "name": "BGO Living (BentallGreenOak)",
111 + "site": "https://bgoliving.com",
112 + "site_key": "bgo_rebuild",
113 + "img_key": "bentall_green_oak",
114 + "listing_url": "https://bgoliving.com/apartments-for-rent",
115 + "regions": [
116 + "Toronto/GTA",
117 + "pancanadien"
118 + ],
119 + "status": "valide",
120 + "properties": 46,
121 + "notes": "Vague 2b (2026-08-27) : 46 propriétés pancanadiennes (20 villes, ON/BC/AB/QC — filtre ON strict du connecteur), 1 176 rangées d'unités disponibles (2 pages) ; img_key bentall_green_oak (trouvé dans le HTML de bgoliving.com)"
122 + }
123 + ]
124 +}
\ No newline at end of file
added data/sources.json +816 −0
@@ -0,0 +1,816 @@
1 +{
2 + "_comment": "Rent-Ka source registry — rental sources for Canada outside Québec (Ontario first, national expansion in progress). The real on/off switch is the connector's `disabled` class attribute; `status` here is documentary. One entry per source_id (see rentka/connectors/).",
3 + "sources": [
4 + {
5 + "id": "cogir",
6 + "name": "Cogir Immobilier",
7 + "url": "https://www.cogir.net",
8 + "listing_url": "https://www.cogir.net/gestion-immeubles-residentiels.html",
9 + "sectors": "All provinces except Québec",
10 + "connector": "cogir",
11 + "status": "actif",
12 + "region": "Canada (outside Québec)"
13 + },
14 + {
15 + "id": "capreit",
16 + "name": "CAPREIT",
17 + "url": "https://www.capreit.ca",
18 + "listing_url": "https://www.capreit.ca/fr/appartements-a-louer/ville-de-quebec-qc/appartements-le-samuel-holland",
19 + "sectors": "All provinces except Québec",
20 + "connector": "capreit",
21 + "status": "actif",
22 + "region": "Canada (outside Québec)"
23 + },
24 + {
25 + "id": "minto",
26 + "name": "Minto Apartments",
27 + "url": "https://www.mintoapartments.com",
28 + "listing_url": "https://www.mintoapartments.com/montreal/apartment-rentals/projects.html",
29 + "sectors": "All provinces except Québec",
30 + "connector": "minto",
31 + "status": "actif",
32 + "region": "Canada (outside Québec)"
33 + },
34 + {
35 + "id": "realstar",
36 + "name": "Realstar",
37 + "url": "https://www.realstar.ca",
38 + "listing_url": "https://www.realstar.ca/apartments/qc/montreal/excelsior-apartments",
39 + "sectors": "All provinces except Québec",
40 + "connector": "realstar",
41 + "status": "actif",
42 + "region": "Canada (outside Québec)"
43 + },
44 + {
45 + "id": "hazelview",
46 + "name": "Hazelview Properties",
47 + "url": "https://www.hazelviewproperties.com",
48 + "listing_url": "https://www.hazelviewproperties.com/cities/montreal",
49 + "sectors": "All provinces except Québec",
50 + "connector": "hazelview",
51 + "status": "actif",
52 + "region": "Canada (outside Québec)"
53 + },
54 + {
55 + "id": "interrent",
56 + "name": "InterRent REIT (CLV Group)",
57 + "url": "https://www.irent.com",
58 + "listing_url": "https://www.irent.com/communities/city/montreal",
59 + "sectors": "All provinces except Québec",
60 + "connector": "interrent",
61 + "status": "actif",
62 + "region": "Canada (outside Québec)"
63 + },
64 + {
65 + "id": "metcap",
66 + "name": "MetCap Living",
67 + "url": "https://www.metcap.com",
68 + "listing_url": "https://www.metcap.com/province-search-results?lang=en&province=115&city=Montréal",
69 + "sectors": "All provinces except Québec",
70 + "connector": "metcap",
71 + "status": "actif",
72 + "region": "Canada (outside Québec)"
73 + },
74 + {
75 + "id": "akelius",
76 + "name": "Akelius Montréal",
77 + "url": "https://rent.akelius.com",
78 + "listing_url": "https://rent.akelius.com/en/search/canada/apartment/montreal",
79 + "sectors": "All provinces except Québec",
80 + "connector": "akelius",
81 + "status": "actif",
82 + "region": "Ontario — Toronto & Ottawa"
83 + },
84 + {
85 + "id": "boardwalk",
86 + "name": "Boardwalk REIT",
87 + "url": "https://www.bwalk.com",
88 + "listing_url": "https://www.bwalk.com/fr-ca/appartements-a-louer-a-montreal-longueuil/longueuil",
89 + "sectors": "All provinces except Québec",
90 + "connector": "boardwalk",
91 + "status": "actif",
92 + "region": "Canada (outside Québec)"
93 + },
94 + {
95 + "id": "centurion",
96 + "name": "Centurion / CP Living (Quartier QB)",
97 + "url": "https://www.cpliving.com",
98 + "listing_url": "https://www.cpliving.com/apartments-for-rent/quebec-city",
99 + "sectors": "All provinces except Québec",
100 + "connector": "centurion",
101 + "status": "actif",
102 + "region": "Canada (outside Québec)"
103 + },
104 + {
105 + "id": "kijiji",
106 + "name": "Kijiji Québec (location)",
107 + "url": "https://www.kijiji.ca",
108 + "listing_url": "https://www.kijiji.ca/b-appartement-condo/quebec/c37l9001",
109 + "sectors": "All provinces except Québec",
110 + "connector": "kijiji",
111 + "status": "actif",
112 + "region": "Canada (outside Québec)"
113 + },
114 + {
115 + "id": "fb_marketplace",
116 + "name": "Facebook Marketplace (location)",
117 + "url": "https://www.facebook.com/marketplace",
118 + "listing_url": "https://www.facebook.com/marketplace/montreal/propertyrentals",
119 + "sectors": "All provinces except Québec",
120 + "connector": "fb_marketplace",
121 + "status": "actif",
122 + "region": "Canada (outside Québec)"
123 + },
124 + {
125 + "id": "rentals_ca",
126 + "name": "Rentals.ca (Québec)",
127 + "url": "https://rentals.ca",
128 + "listing_url": "https://rentals.ca/quebec-city",
129 + "sectors": "All provinces except Québec",
130 + "connector": "rentals_ca",
131 + "status": "actif",
132 + "region": "Canada (outside Québec)"
133 + },
134 + {
135 + "id": "zumper",
136 + "name": "Zumper (Québec)",
137 + "url": "https://www.zumper.com",
138 + "listing_url": "https://www.zumper.com/apartments-for-rent/montreal-qc",
139 + "sectors": "All provinces except Québec",
140 + "connector": "zumper",
141 + "status": "actif",
142 + "region": "Canada (outside Québec)"
143 + },
144 + {
145 + "id": "apartments_com",
146 + "name": "Apartments.com (CoStar)",
147 + "url": "https://www.apartments.com",
148 + "listing_url": "https://www.apartments.com/qc/",
149 + "sectors": "All provinces except Québec",
150 + "connector": "apartments_com",
151 + "status": "actif",
152 + "region": "Canada (outside Québec)"
153 + },
154 + {
155 + "id": "morguard",
156 + "name": "Morguard (Morguard Living)",
157 + "url": "https://www.morguardliving.ca",
158 + "listing_url": "https://www.morguardliving.ca/residential",
159 + "sectors": "All provinces except Québec",
160 + "connector": "morguard",
161 + "status": "actif",
162 + "region": "Canada (outside Québec)"
163 + },
164 + {
165 + "id": "skyline",
166 + "name": "Skyline Living",
167 + "url": "https://www.skylineliving.ca",
168 + "listing_url": "https://www.skylineliving.ca/apartments-for-rent",
169 + "sectors": "Guelph, villes secondaires ON (~38 villes : Est, Niagara, Sud-Ouest, Nord, Centre)",
170 + "connector": "skyline",
171 + "status": "actif",
172 + "region": "Ontario — multi-régions"
173 + },
174 + {
175 + "id": "lift_greenwin",
176 + "name": "Greenwin Corp",
177 + "url": "https://www.greenwin.ca",
178 + "listing_url": "https://www.greenwin.ca/apartments-for-rent/cities/toronto",
179 + "sectors": "Toronto/GTA, Kitchener, Hamilton, Brampton, Guelph",
180 + "connector": "lift_greenwin",
181 + "status": "actif",
182 + "region": "Ontario — Toronto/GTA"
183 + },
184 + {
185 + "id": "lift_park",
186 + "name": "Park Property Management",
187 + "url": "https://www.parkproperty.ca",
188 + "listing_url": "https://www.parkproperty.ca/apartments-for-rent",
189 + "sectors": "Toronto/GTA, Stratford, Waterloo, Cambridge, Burlington, London",
190 + "connector": "lift_park",
191 + "status": "actif",
192 + "region": "Ontario — Toronto/GTA"
193 + },
194 + {
195 + "id": "lift_williamsmcdaniel",
196 + "name": "Williams & McDaniel",
197 + "url": "https://www.williamsandmcdaniel.com",
198 + "listing_url": "https://www.williamsandmcdaniel.com/apartments",
199 + "sectors": "Guelph, Sault Ste. Marie, Fergus, Midland, Kitchener, Hamilton, Stratford",
200 + "connector": "lift_williamsmcdaniel",
201 + "status": "actif",
202 + "region": "Ontario — Sud-Ouest"
203 + },
204 + {
205 + "id": "lift_briarlane",
206 + "name": "Briarlane Rental Property Management",
207 + "url": "https://www.briarlane.ca",
208 + "listing_url": "https://www.briarlane.ca/apartments-for-rent",
209 + "sectors": "London, Toronto/GTA, Markham/York, Brampton, Mississauga",
210 + "connector": "lift_briarlane",
211 + "status": "actif",
212 + "region": "Ontario — Toronto/GTA"
213 + },
214 + {
215 + "id": "lift_dms",
216 + "name": "DMS Property Management",
217 + "url": "https://www.dmsproperty.com",
218 + "listing_url": "https://www.dmsproperty.com/apartments-for-rent",
219 + "sectors": "Toronto/GTA, Hamilton, Kingston, Barrie, Richmond Hill, Welland",
220 + "connector": "lift_dms",
221 + "status": "actif",
222 + "region": "Ontario — Toronto/GTA"
223 + },
224 + {
225 + "id": "lift_signet",
226 + "name": "Signet Group",
227 + "url": "https://www.signetgroup.ca",
228 + "listing_url": "https://www.signetgroup.ca/apartments",
229 + "sectors": "Toronto, Mississauga, Kitchener, Cambridge, Stratford",
230 + "connector": "lift_signet",
231 + "status": "actif",
232 + "region": "Ontario — Toronto/GTA"
233 + },
234 + {
235 + "id": "lift_shelter",
236 + "name": "Shelter Canadian Properties",
237 + "url": "https://www.scpl.com",
238 + "listing_url": "https://www.scpl.com/residential-rental",
239 + "sectors": "Toronto, Ottawa, Belleville, Thunder Bay, Orillia",
240 + "connector": "lift_shelter",
241 + "status": "actif",
242 + "region": "Ontario — multi-régions"
243 + },
244 + {
245 + "id": "lift_york",
246 + "name": "York Property Management",
247 + "url": "https://www.yorkproperty.ca",
248 + "listing_url": "https://www.yorkproperty.ca/apartments-for-rent/london",
249 + "sectors": "Windsor, London, Sarnia, Brantford, Woodstock",
250 + "connector": "lift_york",
251 + "status": "actif",
252 + "region": "Ontario — Sud-Ouest"
253 + },
254 + {
255 + "id": "lift_terracorp",
256 + "name": "Terracorp Management (Auburn Developments)",
257 + "url": "https://terracorp.ca",
258 + "listing_url": "https://terracorp.ca/apartments-for-rent",
259 + "sectors": "London, Windsor, Kitchener, Owen Sound",
260 + "connector": "lift_terracorp",
261 + "status": "actif",
262 + "region": "Ontario — Sud-Ouest"
263 + },
264 + {
265 + "id": "lift_rentsarnia",
266 + "name": "Mutual Property Management (RentSarnia)",
267 + "url": "https://rentsarnia.com",
268 + "listing_url": "https://rentsarnia.com/",
269 + "sectors": "Sarnia, Corunna",
270 + "connector": "lift_rentsarnia",
271 + "status": "actif",
272 + "region": "Ontario — Sud-Ouest"
273 + },
274 + {
275 + "id": "lift_firstnorth",
276 + "name": "First North Property Management",
277 + "url": "https://firstnorthpm.com",
278 + "listing_url": "https://firstnorthpm.com/apartments-for-rent",
279 + "sectors": "Sudbury, North Bay, Timmins",
280 + "connector": "lift_firstnorth",
281 + "status": "actif",
282 + "region": "Ontario — Nord"
283 + },
284 + {
285 + "id": "lift_tvm",
286 + "name": "TVM Group",
287 + "url": "https://www.tvm.ca",
288 + "listing_url": "https://www.tvm.ca/apartments-for-rent",
289 + "sectors": "Peterborough, Cobourg, Toronto, Port Hope",
290 + "connector": "lift_tvm",
291 + "status": "actif",
292 + "region": "Ontario — Centre-Est"
293 + },
294 + {
295 + "id": "lift_urbandale",
296 + "name": "Urbandale Corporation",
297 + "url": "https://urbandalecorporation.com",
298 + "listing_url": "https://urbandalecorporation.com/residential-rentals",
299 + "sectors": "Ottawa, Gloucester, Riverside South",
300 + "connector": "lift_urbandale",
301 + "status": "actif",
302 + "region": "Ontario — Ottawa"
303 + },
304 + {
305 + "id": "homestead",
306 + "name": "Homestead Land Holdings",
307 + "url": "https://www.homestead.ca",
308 + "listing_url": "https://www.homestead.ca/apartments",
309 + "sectors": "Kingston, Toronto/GTA, Ottawa, Hamilton, London, Kitchener-Waterloo, Guelph, Sarnia",
310 + "connector": "homestead",
311 + "status": "actif",
312 + "region": "Ontario — multi-régions"
313 + },
314 + {
315 + "id": "killam",
316 + "name": "Killam Apartment REIT (Ontario)",
317 + "url": "https://www.killamreit.com",
318 + "listing_url": "https://www.killamreit.com/apartments",
319 + "sectors": "Toronto, Ottawa (Kanata), London, Kitchener-Waterloo, Cambridge, Mississauga",
320 + "connector": "killam",
321 + "status": "actif",
322 + "region": "Ontario — multi-régions"
323 + },
324 + {
325 + "id": "rc_effort",
326 + "name": "Effort Trust",
327 + "url": "https://www.effortrentals.com",
328 + "listing_url": "https://www.effortrentals.com/searchlisting.aspx",
329 + "sectors": "Hamilton, Kitchener-Waterloo, Cambridge, Guelph, Brantford, Woodstock, St. Catharines, Burlington",
330 + "connector": "rc_effort",
331 + "status": "actif",
332 + "region": "Ontario — Hamilton/Niagara"
333 + },
334 + {
335 + "id": "rc_osgoode",
336 + "name": "Osgoode Properties (Ontario)",
337 + "url": "https://www.osgoodeproperties.com",
338 + "listing_url": "https://www.osgoodeproperties.com/searchlisting.aspx",
339 + "sectors": "Ottawa, Kingston",
340 + "connector": "rc_osgoode",
341 + "status": "actif",
342 + "region": "Ontario — Ottawa"
343 + },
344 + {
345 + "id": "rc_gwlra",
346 + "name": "GWLRA Residential (Ontario)",
347 + "url": "https://www.gwlraresidential.com",
348 + "listing_url": "https://www.gwlraresidential.com/searchlisting.aspx",
349 + "sectors": "Toronto, Mississauga, Burlington, Ottawa",
350 + "connector": "rc_gwlra",
351 + "status": "actif",
352 + "region": "Ontario — Toronto/GTA"
353 + },
354 + {
355 + "id": "rc_oshanter",
356 + "name": "O'Shanter Development",
357 + "url": "https://www.oshanter.com",
358 + "listing_url": "https://www.oshanter.com/searchlisting.aspx",
359 + "sectors": "Toronto, East York, Mississauga",
360 + "connector": "rc_oshanter",
361 + "status": "actif",
362 + "region": "Ontario — Toronto/GTA"
363 + },
364 + {
365 + "id": "rsgw_sterlingkaramar",
366 + "name": "Sterling Karamar Property Management",
367 + "url": "https://www.sterlingkaramar.com",
368 + "listing_url": "https://www.sterlingkaramar.com/apartments-for-rent",
369 + "sectors": "Toronto/GTA, London, Ottawa, Kitchener-Waterloo, Hamilton, Barrie",
370 + "connector": "rsgw_sterlingkaramar",
371 + "status": "actif",
372 + "region": "Ontario — multi-régions"
373 + },
374 + {
375 + "id": "rsgw_drewlo",
376 + "name": "Drewlo Holdings",
377 + "url": "https://www.drewloholdings.com",
378 + "listing_url": "https://www.drewloholdings.com/apartments-for-rent",
379 + "sectors": "London, Kitchener-Waterloo, Woodstock, Sarnia, St. Thomas, Komoka",
380 + "connector": "rsgw_drewlo",
381 + "status": "actif",
382 + "region": "Ontario — multi-régions"
383 + },
384 + {
385 + "id": "rsgw_panoramic",
386 + "name": "Panoramic Properties",
387 + "url": "https://www.panoramicproperties.ca",
388 + "listing_url": "https://www.panoramicproperties.ca/apartments-for-rent",
389 + "sectors": "Sudbury, Nord de l'Ontario",
390 + "connector": "rsgw_panoramic",
391 + "status": "actif",
392 + "region": "Ontario — multi-régions"
393 + },
394 + {
395 + "id": "rsgw_fitzrovia",
396 + "name": "Fitzrovia Real Estate",
397 + "url": "https://fitzrovia.ca",
398 + "listing_url": "https://fitzrovia.ca/residences",
399 + "sectors": "Toronto/GTA, Guelph",
400 + "connector": "rsgw_fitzrovia",
401 + "status": "actif",
402 + "region": "Ontario — multi-régions"
403 + },
404 + {
405 + "id": "rsgw_drakepm",
406 + "name": "Drake Property Management",
407 + "url": "https://www.drakepm.com",
408 + "listing_url": "https://www.drakepm.com/residential",
409 + "sectors": "Hamilton, Sud de l'Ontario",
410 + "connector": "rsgw_drakepm",
411 + "status": "actif",
412 + "region": "Ontario — multi-régions"
413 + },
414 + {
415 + "id": "rsgw_arcanos",
416 + "name": "Arcanos Property Management",
417 + "url": "https://arcanos.ca",
418 + "listing_url": "https://arcanos.ca/apartments-for-rent",
419 + "sectors": "Toronto, Oakville",
420 + "connector": "rsgw_arcanos",
421 + "status": "actif",
422 + "region": "Ontario — multi-régions"
423 + },
424 + {
425 + "id": "rsgw_bgo",
426 + "name": "BGO Living (BentallGreenOak)",
427 + "url": "https://bgoliving.com",
428 + "listing_url": "https://bgoliving.com/apartments-for-rent",
429 + "sectors": "Toronto/GTA, pancanadien",
430 + "connector": "rsgw_bgo",
431 + "status": "actif",
432 + "region": "Ontario — multi-régions"
433 + },
434 + {
435 + "id": "lift_browngroup",
436 + "name": "The Brown Group of Companies",
437 + "url": "https://thebrowngroup.net",
438 + "listing_url": "https://thebrowngroup.net/properties",
439 + "sectors": "Toronto/GTA, North York, Etobicoke, Brampton, St. Catharines, Cambridge",
440 + "connector": "lift_browngroup",
441 + "status": "actif",
442 + "region": "Ontario — multi-régions"
443 + },
444 + {
445 + "id": "lift_ppml",
446 + "name": "Prudential Property Management (PPML)",
447 + "url": "https://ppml.ca",
448 + "listing_url": "https://ppml.ca/properties",
449 + "sectors": "Toronto, Mississauga, Waterloo, Richmond Hill, Brampton, Scarborough",
450 + "connector": "lift_ppml",
451 + "status": "actif",
452 + "region": "Ontario — multi-régions"
453 + },
454 + {
455 + "id": "lift_equiton",
456 + "name": "Equiton Living",
457 + "url": "https://equitonliving.com",
458 + "listing_url": "https://equitonliving.com/properties",
459 + "sectors": "Toronto, Kingston, London, Guelph, Chatham, Brantford, Kitchener",
460 + "connector": "lift_equiton",
461 + "status": "actif",
462 + "region": "Ontario — multi-régions"
463 + },
464 + {
465 + "id": "lift_regional",
466 + "name": "Regional Group (availabilities.regionalgroup.com)",
467 + "url": "https://regionalgroup.com",
468 + "listing_url": "https://regionalgroup.com/availabilities/residential/",
469 + "sectors": "Ottawa, Gloucester, Nepean, Gatineau (QC)",
470 + "connector": "lift_regional",
471 + "status": "actif",
472 + "region": "Ontario — multi-régions"
473 + },
474 + {
475 + "id": "lift_ranee",
476 + "name": "Ranee Management",
477 + "url": "https://www.ranee.ca",
478 + "listing_url": "https://www.ranee.ca/apartments-for-rent",
479 + "sectors": "Toronto, Mississauga, Brampton, East York, North York, Scarborough",
480 + "connector": "lift_ranee",
481 + "status": "actif",
482 + "region": "Ontario — multi-régions"
483 + },
484 + {
485 + "id": "lift_stellen",
486 + "name": "Stellen Property Management",
487 + "url": "https://www.stellen.ca",
488 + "listing_url": "https://www.stellen.ca/apartments-for-rent",
489 + "sectors": "Burlington, North York, Toronto",
490 + "connector": "lift_stellen",
491 + "status": "actif",
492 + "region": "Ontario — multi-régions"
493 + },
494 + {
495 + "id": "lift_fpm",
496 + "name": "Fleming Property Management",
497 + "url": "https://www.fpm.ca",
498 + "listing_url": "https://www.fpm.ca/residential",
499 + "sectors": "Ottawa",
500 + "connector": "lift_fpm",
501 + "status": "actif",
502 + "region": "Ontario — Ottawa"
503 + },
504 + {
505 + "id": "lift_ottawapm",
506 + "name": "Ottawa Property Managers",
507 + "url": "https://www.ottawapropertymanagers.com",
508 + "listing_url": "https://www.ottawapropertymanagers.com/apartments",
509 + "sectors": "Ottawa, Richmond, Cumberland",
510 + "connector": "lift_ottawapm",
511 + "status": "actif",
512 + "region": "Ontario — multi-régions"
513 + },
514 + {
515 + "id": "lift_tribond",
516 + "name": "Tribond Property Management",
517 + "url": "https://www.tribondpm.com",
518 + "listing_url": "https://www.tribondpm.com/residential",
519 + "sectors": "Kingston, Napanee",
520 + "connector": "lift_tribond",
521 + "status": "actif",
522 + "region": "Ontario — multi-régions"
523 + },
524 + {
525 + "id": "lift_ekort",
526 + "name": "Ekort Property Management",
527 + "url": "https://www.ekortpm.com",
528 + "listing_url": "https://www.ekortpm.com/properties",
529 + "sectors": "Belleville, Quinte West, Trenton, Picton, Bay of Quinte",
530 + "connector": "lift_ekort",
531 + "status": "actif",
532 + "region": "Ontario — multi-régions"
533 + },
534 + {
535 + "id": "lift_marandola",
536 + "name": "Team Marandola Property Management",
537 + "url": "https://www.teammarandola.com",
538 + "listing_url": "https://www.teammarandola.com/residential",
539 + "sectors": "Thorold, St. Catharines, Niagara Falls, Niagara-on-the-Lake, Welland",
540 + "connector": "lift_marandola",
541 + "status": "actif",
542 + "region": "Ontario — multi-régions"
543 + },
544 + {
545 + "id": "lift_ceming",
546 + "name": "Ceming Apartments",
547 + "url": "https://www.cemingapartments.com",
548 + "listing_url": "https://www.cemingapartments.com/apartments",
549 + "sectors": "Sudbury",
550 + "connector": "lift_ceming",
551 + "status": "actif",
552 + "region": "Ontario — Sudbury"
553 + },
554 + {
555 + "id": "rc_claridge",
556 + "name": "Claridge Homes",
557 + "url": "https://rent.claridgehomes.com",
558 + "listing_url": "https://rent.claridgehomes.com/searchlisting.aspx",
559 + "sectors": "Ottawa (centre, Gloucester, Riverside Sud)",
560 + "connector": "rc_claridge",
561 + "status": "actif",
562 + "region": "Ontario — multi-régions"
563 + },
564 + {
565 + "id": "rc_richcraft",
566 + "name": "Richcraft Rentals",
567 + "url": "https://www.richcraftrentals.com",
568 + "listing_url": "https://www.richcraftrentals.com/searchlisting.aspx",
569 + "sectors": "Ottawa (Stittsville, Kanata, Barrhaven, Orléans, Riverside Sud)",
570 + "connector": "rc_richcraft",
571 + "status": "actif",
572 + "region": "Ontario — multi-régions"
573 + },
574 + {
575 + "id": "rc_arnon",
576 + "name": "Arnon Development",
577 + "url": "https://www.arnon.ca",
578 + "listing_url": "https://www.arnon.ca/searchlisting.aspx",
579 + "sectors": "Ottawa",
580 + "connector": "rc_arnon",
581 + "status": "actif",
582 + "region": "Ontario — Ottawa"
583 + },
584 + {
585 + "id": "rc_concert",
586 + "name": "Concert Properties",
587 + "url": "https://rent.concertproperties.com",
588 + "listing_url": "https://rent.concertproperties.com/searchlisting.aspx",
589 + "sectors": "Toronto, Etobicoke",
590 + "connector": "rc_concert",
591 + "status": "actif",
592 + "region": "Ontario — multi-régions"
593 + },
594 + {
595 + "id": "rc_caraco",
596 + "name": "CaraCo Property Management",
597 + "url": "https://www.caracoproperties.ca",
598 + "listing_url": "https://www.caracoproperties.ca/searchlisting.aspx",
599 + "sectors": "Kingston",
600 + "connector": "rc_caraco",
601 + "status": "actif",
602 + "region": "Ontario — Kingston"
603 + },
604 + {
605 + "id": "bld_richmond",
606 + "name": "Richmond Property Management",
607 + "url": "https://richmondpm.ca",
608 + "listing_url": "https://richmondmanagement.managebuilding.com/Resident/public/rentals",
609 + "sectors": "Windsor",
610 + "connector": "bld_richmond",
611 + "status": "actif",
612 + "region": "Ontario — Windsor"
613 + },
614 + {
615 + "id": "bld_dreampm",
616 + "name": "Dream Property Management",
617 + "url": "https://rentalsdreampm.com",
618 + "listing_url": "https://dreampm.managebuilding.com/Resident/public/rentals",
619 + "sectors": "Sarnia",
620 + "connector": "bld_dreampm",
621 + "status": "actif",
622 + "region": "Ontario — Sarnia"
623 + },
624 + {
625 + "id": "bld_grem",
626 + "name": "Guardian Real Estate Management (GREM)",
627 + "url": "https://grem.ca",
628 + "listing_url": "https://guardianrealestatemanagement.managebuilding.com/Resident/public/rentals",
629 + "sectors": "Sarnia, West Nipissing",
630 + "connector": "bld_grem",
631 + "status": "actif",
632 + "region": "Ontario — multi-régions"
633 + },
634 + {
635 + "id": "bld_elite",
636 + "name": "Elite Property Group",
637 + "url": "https://elitepropertygroup.org",
638 + "listing_url": "https://elitepropertygroup.managebuilding.com/Resident/public/rentals",
639 + "sectors": "Sarnia",
640 + "connector": "bld_elite",
641 + "status": "actif",
642 + "region": "Ontario — Sarnia"
643 + },
644 + {
645 + "id": "bld_paragon",
646 + "name": "Paragon Property Management & Maintenance",
647 + "url": "https://paragonproperty.ca",
648 + "listing_url": "https://paragonpropertymgt.managebuilding.com/Resident/public/rentals",
649 + "sectors": "Chatham-Kent",
650 + "connector": "bld_paragon",
651 + "status": "actif",
652 + "region": "Ontario — Chatham-Kent"
653 + },
654 + {
655 + "id": "bld_panadew",
656 + "name": "Panadew Property Management",
657 + "url": "https://panadew.ca",
658 + "listing_url": "https://panadew.managebuilding.com/Resident/public/rentals",
659 + "sectors": "Kingston",
660 + "connector": "bld_panadew",
661 + "status": "actif",
662 + "region": "Ontario — Kingston"
663 + },
664 + {
665 + "id": "bld_tag",
666 + "name": "TAG Property Management",
667 + "url": "https://tagpropertymanagement1.managebuilding.com/Resident/public/home",
668 + "listing_url": "https://tagpropertymanagement1.managebuilding.com/Resident/public/rentals",
669 + "sectors": "Cobourg, Port Hope",
670 + "connector": "bld_tag",
671 + "status": "actif",
672 + "region": "Ontario — multi-régions"
673 + },
674 + {
675 + "id": "bld_genedco",
676 + "name": "Genedco Property Management (GRANITE)",
677 + "url": "https://genedco.ca",
678 + "listing_url": "https://genedco.managebuilding.com/Resident/public/rentals",
679 + "sectors": "Cobourg, Port Hope, Peterborough",
680 + "connector": "bld_genedco",
681 + "status": "actif",
682 + "region": "Ontario — multi-régions"
683 + },
684 + {
685 + "id": "bld_rentcorp",
686 + "name": "RentCorp Property Management",
687 + "url": "https://www.rentcorp.ca",
688 + "listing_url": "https://rentcorppropertymanagement.managebuilding.com/Resident/public/rentals",
689 + "sectors": "Waterloo, North Bay, London",
690 + "connector": "bld_rentcorp",
691 + "status": "actif",
692 + "region": "Ontario — multi-régions"
693 + },
694 + {
695 + "id": "bld_sleepwell",
696 + "name": "Sleepwell Property Management",
697 + "url": "https://www.sleepwellmanagement.com",
698 + "listing_url": "https://sleepwellpropertymanagement.managebuilding.com/Resident/public/rentals",
699 + "sectors": "Ottawa",
700 + "connector": "bld_sleepwell",
701 + "status": "actif",
702 + "region": "Ontario — Ottawa"
703 + },
704 + {
705 + "id": "bld_athena",
706 + "name": "Athena Property Management",
707 + "url": "https://athenapropertymanagementinc1.managebuilding.com/Resident/public/home",
708 + "listing_url": "https://athenapropertymanagementinc1.managebuilding.com/Resident/public/rentals",
709 + "sectors": "Hamilton, St. Catharines",
710 + "connector": "bld_athena",
711 + "status": "actif",
712 + "region": "Ontario — multi-régions"
713 + },
714 + {
715 + "id": "apf_fusepm",
716 + "name": "Fuse Property Management",
717 + "url": "https://fusepm.ca",
718 + "listing_url": "https://fusepm.appfolio.com/listings",
719 + "sectors": "Barrie, Orillia, Oro-Medonte, Simcoe",
720 + "connector": "apf_fusepm",
721 + "status": "actif",
722 + "region": "Ontario — multi-régions"
723 + },
724 + {
725 + "id": "starlight",
726 + "name": "Starlight Investments",
727 + "url": "https://www.starlightinvest.com",
728 + "listing_url": "https://www.starlightinvest.com/real-estate/canadian-residential#property-finder",
729 + "sectors": "Toronto/GTA, Ottawa, Hamilton, London, Kitchener-Waterloo, multi-villes ON",
730 + "connector": "starlight",
731 + "status": "actif",
732 + "region": "Ontario — multi-régions"
733 + },
734 + {
735 + "id": "medallion",
736 + "name": "Medallion Corporation",
737 + "url": "https://medallioncorp.com",
738 + "listing_url": "https://medallioncorp.com/apartments/",
739 + "sectors": "Toronto/GTA, Brampton, Ajax, Whitby, Oshawa, Richmond Hill, Hamilton, London",
740 + "connector": "medallion",
741 + "status": "actif",
742 + "region": "Ontario — multi-régions"
743 + },
744 + {
745 + "id": "kggroup",
746 + "name": "K-G Group (MyRental.ca)",
747 + "url": "https://www.myrental.ca",
748 + "listing_url": "https://www.myrental.ca/apartments-for-rent",
749 + "sectors": "Toronto — Midtown, North York",
750 + "connector": "kggroup",
751 + "status": "actif",
752 + "region": "Ontario — multi-régions"
753 + },
754 + {
755 + "id": "qresidential",
756 + "name": "Q Residential",
757 + "url": "https://www.qresidential.ca",
758 + "listing_url": "https://www.qresidential.ca/searchlisting.aspx",
759 + "sectors": "Toronto, St. Catharines, Hamilton, Niagara, Kitchener",
760 + "connector": "qresidential",
761 + "status": "actif",
762 + "region": "Ontario — multi-régions"
763 + },
764 + {
765 + "id": "riocan",
766 + "name": "RioCan Living",
767 + "url": "https://riocanliving.com",
768 + "listing_url": "https://riocanliving.com/residences/",
769 + "sectors": "Toronto/GTA, Ottawa, Calgary (filtre ON)",
770 + "connector": "riocan",
771 + "status": "actif",
772 + "region": "Ontario — multi-régions"
773 + },
774 + {
775 + "id": "sifton",
776 + "name": "Sifton Properties",
777 + "url": "https://sifton.com",
778 + "listing_url": "https://sifton.com/apartments",
779 + "sectors": "London, Guelph, Brantford",
780 + "connector": "sifton",
781 + "status": "actif",
782 + "region": "Ontario — multi-régions"
783 + },
784 + {
785 + "id": "accommod8u",
786 + "name": "Accommod8u",
787 + "url": "https://www.accommod8u.com",
788 + "listing_url": "https://www.accommod8u.com/residential/apartments",
789 + "sectors": "Waterloo, Kitchener",
790 + "connector": "accommod8u",
791 + "status": "actif",
792 + "region": "Ontario — multi-régions"
793 + },
794 + {
795 + "id": "greenrock",
796 + "name": "Greenrock (Davisville Village)",
797 + "url": "https://greenrockreal.ca",
798 + "listing_url": "https://www.sterlingkaramar.com/apartments-for-rent",
799 + "sectors": "Toronto — Davisville Village",
800 + "connector": "greenrock",
801 + "status": "actif",
802 + "region": "Ontario — Toronto — Davisville Village"
803 + },
804 + {
805 + "id": "westdale",
806 + "name": "Westdale Properties",
807 + "url": "https://www.westdaleproperties.com",
808 + "listing_url": "https://www.westdaleproperties.com/residential",
809 + "sectors": "Toronto/North York",
810 + "connector": "westdale",
811 + "status": "actif",
812 + "region": "Ontario — Toronto/North York"
813 + }
814 + ],
815 + "updated": "2026-08-27"
816 +}
\ No newline at end of file
added docs/FAIRVALUE-VALIDATION.md +66 −0
@@ -0,0 +1,66 @@
1 +# Rapport de validation — juste valeur Lou-Ka (fv-logement-1.0)
2 +
3 +Généré le 2026-08-18 17:32 sur le parc publié de www.lou-ka.com.
4 +
5 +## 1. Distribution des classifications
6 +
7 +| Classification | Annonces | Part | Écart moyen |
8 +|---|---|---|---|
9 +| marche | 18,327 | 39.1 % | 0.0 % |
10 +| sous | 11,299 | 24.1 % | -18.8 % |
11 +| sur | 11,023 | 23.5 % | 22.6 % |
12 +| sans verdict (prudence) | 6,170 | 13.2 % | 7.4 % |
13 +
14 +| Confiance | Annonces |
15 +|---|---|
16 +| fort | 31,091 |
17 +| moyen | 11,748 |
18 +| faible | 3,980 |
19 +
20 +Centrage global : écart moyen 1.77 % (cible ≈ 0 : pas de biais systématique).
21 +
22 +## 2. Plausibilité par segment (juste valeur vs loyers réels)
23 +
24 +| Segment (ville, chambres) | n | FV moyenne | Loyer moyen réel | Écart |
25 +|---|---|---|---|---|
26 +| Montréal, 1 | 3590 | 1713 $ | 1710 $ | +0.2 % |
27 +| Montréal, 2 | 2767 | 2076 $ | 2176 $ | -4.6 % |
28 +| Montréal, 0 | 1567 | 1454 $ | 1483 $ | -2.0 % |
29 +| Montréal, 3 | 1304 | 2341 $ | 2511 $ | -6.8 % |
30 +| Montréal, ch | 749 | 794 $ | 867 $ | -8.4 % |
31 +| Montréal, 4+ | 660 | 2512 $ | 2671 $ | -6.0 % |
32 +| Laval, 2 | 643 | 2299 $ | 2303 $ | -0.2 % |
33 +| Gatineau, 2 | 534 | 1729 $ | 1843 $ | -6.2 % |
34 +| Laval, 1 | 515 | 1721 $ | 1692 $ | +1.7 % |
35 +| Québec, 2 | 487 | 1704 $ | 1626 $ | +4.8 % |
36 +| Québec, 1 | 453 | 1387 $ | 1328 $ | +4.4 % |
37 +| Gatineau, 1 | 307 | 1581 $ | 1548 $ | +2.1 % |
38 +| Québec, 0 | 245 | 1065 $ | 1129 $ | -5.7 % |
39 +| Saint-Constant, 2 | 227 | 2285 $ | 2277 $ | +0.4 % |
40 +
41 +## 3. Exemples par catégorie (confiance forte)
42 +
43 +### Sous le marché
44 +- 1491 Rue Marie-Rollet — unité 2 (4½, Québec) : 1095 $ demandé vs 1828 $ estimé (-40 %)
45 +- 1491 Rue Marie-Rollet — unité 3 (4½, Québec) : 1495 $ demandé vs 1828 $ estimé (-18 %)
46 +- 2110 Avenue Chapdelaine — unité 305 (3½, Québec) : 825 $ demandé vs 1028 $ estimé (-20 %)
47 +
48 +### Dans le marché
49 +- 7615 Rue des Métis — unité 203 (3½, Québec) : 1299 $ demandé vs 1285 $ estimé (+1 %)
50 +- 7615 Rue des Métis — unité 210 (3½, Québec) : 1299 $ demandé vs 1292 $ estimé (+1 %)
51 +- 7615 Rue des Métis — unité 511 (4½, Québec) : 1829 $ demandé vs 1878 $ estimé (-3 %)
52 +
53 +### Au-dessus du marché
54 +- 7615 Rue des Métis — unité 301 (4½, Québec) : 1999 $ demandé vs 1806 $ estimé (+11 %)
55 +- 7615 Rue des Métis — unité 315 (3½, Québec) : 1829 $ demandé vs 1420 $ estimé (+29 %)
56 +- 7615 Rue des Métis — unité 502 (4½, Québec) : 2429 $ demandé vs 1860 $ estimé (+31 %)
57 +
58 +## 4. Calibration temporelle (à re-mesurer)
59 +
60 +- Dans le marché : 3.4 jours en ligne en moyenne (18,327 annonces)
61 +- Sous le marché : 3.4 jours en ligne en moyenne (11,299 annonces)
62 +- Au-dessus du marché : 3.3 jours en ligne en moyenne (11,023 annonces)
63 +
64 +La base d'annonces est encore jeune : la durée de présence ne discrimine pas
65 +encore les verdicts. Re-mesurer après quelques semaines d'historique et
66 +recalibrer les seuils ±8 % (procédure : docs/FAIRVALUE.md §6).
\ No newline at end of file
added docs/FAIRVALUE.md +144 −0
@@ -0,0 +1,144 @@
1 +# Framework de juste valeur (fair value) — Lou-Ka / Groupe-KA
2 +
3 +**Module** : `louka/fairvalue.py` · **Version modèle** : `fv-logement-1.0` · **Créé** : 2026-08-18
4 +
5 +Estime en continu la **juste valeur locative** de chaque annonce publiée et la
6 +classe **sous le marché / dans le marché / au-dessus du marché**. Conçu comme le
7 +moteur générique « estimation de juste valeur » du Groupe-KA : toutes les
8 +spécificités logement vivent dans `CONFIG` (voir « Décliner sur immo-ka /
9 +auto-ka » plus bas).
10 +
11 +## 1. Données d'entrée
12 +
13 +- **Jeu de référence** : toutes les annonces `active=1 AND published=1 AND
14 + dup_of IS NULL` au prix mensuel plausible (175–20 000 $). Les annonces en
15 + **quarantaine qualité** (`quality.py`) sont exclues — le framework repose sur
16 + les données enrichies et nettoyées des connecteurs.
17 +- Exclusions supplémentaires : prix affichés **non mensuels** (« /semaine »,
18 + « par nuit » — regex `_NON_MENSUEL`) ; elles ne polluent ni les médianes ni
19 + les verdicts.
20 +- **Chambres/colocations** : segment dédié `bed="ch"` (détection
21 + `unit_type="Chambre"` ou regex `_CHAMBRE` sur le titre) — une chambre n'est
22 + jamais comparée au loyer d'un logement entier.
23 +- Rafraîchi **à chaque cycle de synchronisation** (`ingest.watch`, après
24 + géocodage, quality et dedup) et à la demande : `python run.py fairvalue`.
25 + Calcul complet : ~15 s pour ~47 000 annonces (pur Python + grille spatiale).
26 +
27 +## 2. Méthode en trois niveaux
28 +
29 +### N1 — Référence par segment
30 +Segments en échelle de repli, avec volume minimal `min_segment=8` :
31 +`ville|quartier|chambres` → `ville|chambres` → `chambres` (province) → global.
32 +Statistiques robustes : **médiane** + **p25/p75** (jamais la moyenne).
33 +Groupes de chambres : `0` (studio), `1`, `2`, `3`, `4+`, `ch` (chambre/coloc).
34 +
35 +### N2 — Ajustements par caractéristiques
36 +Coefficients multiplicatifs **appris des données** (aucun coefficient inventé) :
37 +médiane de `prix/p50(segment)` des annonces AVEC la caractéristique ÷ celle des
38 +annonces SANS (minimum `coef_min_pairs=60` de chaque côté, bornés à
39 +`coef_bounds=(0.85, 1.35)`). **Centrage obligatoire** : le p50 du segment
40 +contient déjà le mélange avec/sans, donc l'ajustement appliqué est
41 +`(c si présent, sinon 1) / (part×c + (1−part))` — sans ce centrage, toutes les
42 +estimations gonflent et tout paraît « sous-évalué » (bug observé lors de la
43 +première passe : 54 % de « sous »). Caractéristique au statut inconnu = effet
44 +moyen (neutre). Superficie : `(aire/aire_type_segment)^0.35`, borné.
45 +
46 +Coefficients appris le 2026-08-18 : stationnement ×1,27 · lave-vaisselle ×1,13 ·
47 +électricité incluse ×1,12 · meublé ×1,11 · chauffage inclus ×1,10 ·
48 +balcon ×1,08 · climatisation ×1,07 · ascenseur ×1,06 · piscine ×1,06.
49 +
50 +### N3 — Comparables kNN géographiques
51 +Pour les annonces géolocalisées : grille spatiale ~2 km, voisines du **même
52 +groupe de chambres** à < 3 km, pondération gaussienne `exp(−(d/800 m)²)` avec
53 +bonus ×1,3 si même type d'unité, k=12, minimum 4 comparables. Estimateur :
54 +**médiane pondérée** des loyers (la moyenne serait tirée par l'asymétrie).
55 +
56 +### Combinaison et garde-fous
57 +- `fv = 0,6×kNN + 0,4×(segment ajusté)` quand le kNN existe, sinon segment ajusté.
58 +- **Couloir de sécurité** : `fv` bornée à `[0,70×p25 ; 1,30×p75]` du segment.
59 +- **Fourchette** : dispersion relative du segment appliquée à `fv` ; élargie si
60 + les deux méthodes divergent de > 25 % (et la confiance est abaissée).
61 +- **Confiance** : `fort` (≥ 8 comparables, segment fin, méthodes concordantes),
62 + `moyen` (≥ 4 comparables ou segment ville ≥ 15 annonces), `faible` sinon.
63 +
64 +## 3. Classification et règles de prudence
65 +
66 +Écart relatif `d = (prix − fv)/fv` :
67 +**sous** si `d ≤ −8 %`, **sur** si `d ≥ +8 %`, **marche** entre les deux.
68 +
69 +**Pas de verdict** (pastille absente, estimation « indicative ») si :
70 +- confiance `faible` (peu de comparables / segment rare) ;
71 +- prix ambigu (`price_from` : « à partir de » = prix plancher) ;
72 +- écart hors du plausible (`verdict_dev_bounds = (−50 %, +100 %)`) — donnée
73 + probablement corrompue (coquille de prix, périodicité mal lue).
74 +
75 +## 4. Stockage, historisation, traçabilité
76 +
77 +- `fairvalue` (1 rangée/annonce) : fv, fourchette, écart, verdict, confiance,
78 + **méthode utilisée**, nb de comparables, **segment de référence**,
79 + **version du modèle**, horodatage — chaque estimation est explicable.
80 +- `fv_segments` (append-only) : p25/p50/p75 + volume de chaque segment (niveaux
81 + 1-2) à chaque calcul → évolution des loyers de référence dans le temps.
82 +- Cache miniatures/geo non touchés. `fairvalue.explain(uid)` reconstruit la
83 + distribution du segment pour la fiche (histogramme 24 classes, bornes p1-p99).
84 +
85 +## 5. Exposition
86 +
87 +- **Site** : pastille sur les cartes (`FairValueBadge`, icône + libellé + %,
88 + lisible daltoniens), bloc « Analyse de prix Lou-Ka » sur la fiche
89 + (`PriceAnalysis` : fv, fourchette, écart, confiance, mini-histogramme avec
90 + marqueurs « Ce loyer » / « Juste valeur »), filtre **« ▼ Sous le marché »**
91 + (tri meilleures affaires d'abord : `?deal=sous&sort=deal`), page de
92 + transparence `/juste-valeur`.
93 +- **API Lou-Ka** : champs `fv, fv_low, fv_high, fv_deviation, fv_verdict,
94 + fv_confidence` dans `/api/listings` (+ filtre `deal=`, tri `sort=deal`),
95 + pins GeoJSON, `/api/fairvalue/{uid}` (analyse détaillée + histogramme).
96 +- **API-KA** : `/api/v1/louka/fairvalue/{uid}` (proxy live pour l'app iOS KA) ;
97 + les snapshots quotidiens `louka_data` embarquent automatiquement les champs fv.
98 +- **Stats** : KPI « Annonces sous le marché », donut de répartition, tableau
99 + « Écart au marché par ville » (`statsdash.py`) — repris dans le PDF Groupe-KA.
100 +
101 +## 6. Calibration et recalibrage
102 +
103 +- Seuils initiaux ±8 % : distribution obtenue ≈ 24 % sous / 39 % dans /
104 + 24 % sur / 13 % sans verdict (2026-08-18, ~47 k annonces).
105 +- **Procédure de recalibrage** (périodique, trimestrielle suggérée) :
106 + 1. `SELECT fv_verdict, durée de présence` — comparer le temps en ligne
107 + (`last_seen − first_seen` des annonces désactivées) par verdict : les
108 + « sous » doivent partir significativement plus vite que les « sur ».
109 + 2. Ajuster `seuil_sous/seuil_sur` pour maximiser cette séparation.
110 + 3. Re-vérifier le centrage : `AVG(deviation)` sur le parc doit rester ≈ 0
111 + (±2 %) ; sinon revoir les coefficients (biais N2) ou le poids kNN.
112 + 4. Bumper `MODEL_VERSION` à tout changement (traçabilité des estimations).
113 +- Tests de non-régression : `tests/test_fairvalue.py` (synthétiques, hors-ligne).
114 +
115 +## 7. Décliner sur immo-ka (vente) et auto-ka (véhicules)
116 +
117 +Le moteur est générique : segments en échelle + coefficients centrés + kNN +
118 +couloir + confiance. Pour décliner :
119 +
120 +1. Copier `fairvalue.py`, remplacer `_load()` (requête du jeu de référence) et
121 + `CONFIG` :
122 + - **immo-ka** : segment = `ville|quartier|type de propriété|chambres`,
123 + valeur = prix demandé, caractéristiques = garage, sous-sol fini, piscine,
124 + année, terrain ; ajustement superficie avec élasticité plus forte (~0,6) ;
125 + kNN rayon 2 km. Bornes de plausibilité : 50 k–10 M $.
126 + - **auto-ka** : segment = `marque|modèle|année±1`, « géographie » remplacée
127 + par la distance en kilométrage (kNN sur km au lieu de lat/lng),
128 + caractéristiques = transmission, motorisation, groupe d'options ;
129 + dépréciation = équivalent de l'ajustement superficie.
130 +2. Garder tels quels : `_pct`, centrage des coefficients, couloir, médiane
131 + pondérée kNN, règles de prudence, historisation, `MODEL_VERSION`.
132 +3. Reprendre l'UI : `FairValueBadge`/`PriceAnalysis` sont paramétrés par les
133 + mêmes champs API (`fv_*`).
134 +
135 +## 8. Limites connues
136 +
137 +- Les « bonnes affaires » à −40/−50 % contiennent encore quelques loyers à la
138 + chambre non détectés (annonces ambiguës « chambre dans grand 4½ » sans
139 + mots-clés) — bornées par `verdict_dev_bounds`.
140 +- Les alertes « bonne affaire » sur critères sauvegardés nécessitent une
141 + infrastructure de notification (courriel/push) absente de Lou-Ka à ce jour ;
142 + l'URL `/?deal=sous` est partageable/enregistrable en attendant.
143 +- Segments ruraux minces → repli `ville|chambres` provincial, confiance
144 + `faible`, pas de pastille (comportement voulu : rien d'inventé).
added docs/canada-sources.md +97 −0
@@ -0,0 +1,97 @@
1 +# Rent-Ka — Canada (ex-Québec) rental source inventory
2 +
3 +Compiled 2026-08-27 from live-verified research (✅ = platform signature confirmed in
4 +page source / API response). This is the roadmap for national connector expansion.
5 +
6 +## Headline findings
7 +
8 +1. **LiftSystem/Rentsync API is wide open, fully enumerated.** Shared public token
9 + `auth_token=sswpREkUtyeYjeoahA2i` on `api.theliftsystem.com/v2/search`. Enumerating
10 + client_ids 1–2500 yielded **1,143 active clients nationally** → `data/liftsystem_national.txt`
11 + (`id|name|website`). Recipe:
12 + `GET https://api.theliftsystem.com/v2/search?client_id=<ID>&auth_token=sswpREkUtyeYjeoahA2i&min_bed=-1&max_bed=100&min_rate=0&max_rate=100000&limit=50&offset=0`
13 + → JSON (permalink, type, address incl. city/prov/postal, photos); `&count=true` for totals.
14 + ~2,500 rapid requests from one IP: **no block**. Hit the API, never the rate-limited websites.
15 +2. **RentFaster.ca: open JSON API, Canada-wide, no auth/Cloudflare.**
16 + `POST /api/map.json` with `l=<lat1,lng1,lat2,lng2>` bbox works for any province;
17 + `/api/search.json?city_id=N&cur_page=0` paginated with totals (Calgary 8,026 / Edmonton 4,202).
18 +3. **CAPREIT national portfolio JSON:** `https://www.capreit.ca/wp-admin/admin-ajax.php?action=property_json`
19 + returns the whole property array (lat/lng, city) in one call.
20 +4. **Rentsync-hosted SITES share an IP limiter: 10 req/3 s → 300 s ban** (shared across
21 + client domains; force gzip `--compressed`). Prefer the LiftSystem API (no limit observed).
22 +5. Platforms to route via Scrapfly (CF challenge): rentals.ca, rentboard.ca, rentcanada.com,
23 + killamreit/realstar/gwlraresidential/weidner corporate sites (or use their LiftSystem ids),
24 + effortrentals, accommod8u, qresidential, Baydo, liv.rent GraphQL, Craigslist/Kijiji at scale,
25 + Places4Students (429-prone).
26 +
27 +## Key LiftSystem client_ids (verified)
28 +
29 +National: Boardwalk=203 (AB/SK/ON ~34k) · CAPREIT=228 (~46k) · Killam=99 (Atlantic+ ~17k) ·
30 +Minto=153 · Homestead=6 (~26k ON/NS) · Skyline=96 (~20k) · Mainstreet=364 (AB/BC/SK/MB) ·
31 +Avenue Living=7 (AB/SK/MB ~22k) · Broadstreet=305 (BC/AB/SK/MB/NB/NS/YT) ·
32 +Northview=176 (AB/BC/SK/ON/NL/**NT/NU/YT** — the territories landlord) · Realstar=436 ·
33 +QuadReal=439 · Hazelview=497 · Centurion=21 · Park=65 · GWLRA/Vertica=140 · InterRent/CLV=518
34 +(also RentCafe Yardi company 119090) · Osgoode=5 · Devon(BC)=727 · Weidner=678 (AB/SK) ·
35 +Sterling Karamar=53 · RentSarnia=115 · TVM=359 · Keystone(Kingston)=157 · Tribe(BC)=118 ·
36 +Kelson(BC)=3 · Midwest(AB/NT)=76 · FirstService AB=1299 · ZEN=274 · Ayre&Oxford=222 ·
37 +Leston=460 · Braden=366 · Emerald=194 · Westcorp=180 · Strategic=679 · Qualico=200 ·
38 +HomeEd=1104 · Maclab/Laurent=1817 · Marwest(MB)=552 · Globe(MB)=313 · Towers(MB)=136 ·
39 +Templeton(NS)=584 · TwinCity(NS)=813 · MU student=473 · Effort Trust=17 · Devonshire=49/1117 ·
40 +Hollyburn=808 · KG Group=10 · Drewlo=4.
41 +
42 +## Other platform clients (verified)
43 +
44 +- **RentCafe (securecafe slugs)**: effortrentals, tricar, oldoakproperties, clvgroup,
45 + quadrealres, killamreit, osgoodeproperties, aoninc (AON Peterborough), mmcorp (Melchior Barrie),
46 + destaron (Barrie/Burlington), towersrealty (MB), calgary-housing (rentcafesocialhousing).
47 +- **Buildium (managebuilding subdomains)**: gooddoors (SK Regina/Saskatoon), ip (Imperial MB/SK),
48 + panadew (Kingston), frontenacproperty (Kingston), athenapropertymanagementinc1 (St. Catharines),
49 + elitepropertygroup (Sarnia), universalrealty (NS Halifax) + more NS.
50 +- **AppFolio**: regencypropmgmt (Regina).
51 +- **New platform — Building Stack** (`app.buildingstack.com`): Towers Realty (MB); worth a
52 + generic connector scoping pass.
53 +- **Custom WordPress SSR (one-off connectors)**: Globe globepm.ca (81 /apartment/ pages, 6k units),
54 + Sussex sussexrealty.ca (MB), Kay Four, Schinkel, Valery (Hamilton), Marda (Windsor),
55 + Dalron/Autumnwood (Sudbury), Valiant (Durham), Ironclad (London/Kitchener), Hollyburn,
56 + Concert (Drupal), Bosa (Next.js __NEXT_DATA__), Wesgroup, Onni, Austeville, Brown Bros,
57 + Proline, Mission Group (Kelowna), Elite elitesuite.ca (Saskatoon), Avana (SK), Civida (Edmonton
58 + affordable), MetCap custom .NET `/province-search-results`.
59 +- **Student boards**: McMaster macoffcampus (plain SSR HTML), Queen's listingservice.housing.queensu.ca
60 + (public HTML), Domus (KW, Rentsync), Accommod8u (CF), Places4Students (.NET, 429-prone).
61 +
62 +## National portals
63 +
64 +| Portal | Access | Anti-bot |
65 +|---|---|---|
66 +| RentFaster.ca | ✅ open JSON `/api/search.json`, `/api/map.json` bbox | none — best single API |
67 +| LiftSystem API | ✅ shared token, 1,143 clients | none |
68 +| CAPREIT feed | ✅ admin-ajax property_json | none |
69 +| Zumper/PadMapper | padmapper.com/api/* JSON (api.zumper.com DNS dead) | moderate |
70 +| liv.rent (BC) | Next.js SSR HTML embeds listings; GraphQL apigw TLS-fingerprints | SSR low / API Scrapfly |
71 +| 4rent.ca | window.__NUXT__ payload | low |
72 +| Rentals.ca | internal /phoenix/api — CF JS challenge | Scrapfly |
73 +| Kijiji | HTML/GraphQL, bot detection at scale | Scrapfly at scale |
74 +| Craigslist | plain HTML (RSS dead) | IP blocks at volume |
75 +| Realtor.ca leases | via CREA DDF (house-ka already consumes DDF) | n/a |
76 +| Dead: ApartmentLove (DNS), rentcafe.ca, renterspages.com (CF 522) | — | skip |
77 +
78 +## Province volume ranking
79 +
80 +1. ON (Homestead 26k, Skyline, Effort 11k, Drewlo 10k…) 2. AB (RentFaster ~12–15k live,
81 +Boardwalk 33k, Avenue Living, Mainstreet) 3. BC (CAPREIT, liv.rent, Concert, Hollyburn,
82 +Cressey, Craigslist) 4. MB (Globe 6k, Edison, Towers) 5. SK (Avenue Living, Boardwalk,
83 +RentFaster) 6. NS (Killam, Templeton, Southwest — Halifax) 7. NB (Killam + regional)
84 +8. NL (Killam, Northview) 9. PE (Killam) 10. YT/NT/NU (Northview 176 + classifieds).
85 +
86 +## Priority actions
87 +
88 +1. Bulk-register LiftSystem clients from `data/liftsystem_national.txt` (probe each client_id
89 + via the API to read its provinces from listing addresses; EXCLUDE Québec-based clients and
90 + ids already in liftsystem_clients.json).
91 +2. New connectors: RentFaster (JSON), CAPREIT feed (upgrade existing capreit connector).
92 +3. Extend rentcafe_clients.json / buildium_clients.json / appfolio_clients.json with the
93 + verified slugs above (with `province` per client).
94 +4. Building Stack generic connector (scoping).
95 +5. One-off WordPress connectors for the big customs (Globe, Sussex, Concert, Hollyburn, Bosa,
96 + MetCap, Elite Saskatoon…).
97 +6. Respect the Rentsync site limiter; prefer the API.
added docs/connecteurs/akelius.md +79 −0
@@ -0,0 +1,79 @@
1 +# Akelius Montréal — connecteur `akelius`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Montréal · Backend : direct · Annonces actives : 219/306
6 +
7 +## Description de la source
8 +
9 +connecteur Akelius Residential (rent.akelius.com) Page de recherche Canada rendue côté serveur (Angular SSR) : l'état TransferState (<script id="akeliusWebsite-state">) contient le JSON complet des unités canadiennes (adresse, loyer, pi², chambres, photos). On filtre sur le Grand Montréal (Montréal, Westmount, Mont-Royal, Saint-Lambert, Greenfield Park) — Toronto/Ottawa/Gatineau exclus. Chaque unité a aussi un JSON détail (/lettings/marketing/v2/CA/<id>.json) avec les keyfacts complets : inclusions (chauffage, eau chaude, électricité, internet), électroménagers, animaux, meublé, ascenseur, piscine, gym, année de construction, téléphone — visité via le cache self.detail() (max ~150 nouveaux fetchs par synchronisation).
10 +
11 +- **Site** : https://rent.akelius.com
12 +- **Page des annonces** : https://rent.akelius.com/en/search/canada/apartment/montreal
13 +- **Secteurs couverts** : Plateau, CDN, NDG, Villeray
14 +- **Module** : `louka/connectors/akelius.py` — classe `AkeliusConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : pages HTML (rendu serveur)
19 +- **Endpoint de base** : https://rent.akelius.com
20 +- **URLs de départ (constantes du module)** : https://rent.akelius.com
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : requests direct (session UA LouKaBot, throttling poli)
24 +- **Politesse** : 0.6 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, pages HTML (rendu serveur)) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 219 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | 4557 Sherbrooke Street West — unité 12 |
33 +| `address` | Adresse civique | 100 % | 4557 Sherbrooke Street West, Montréal, H3Z 1E8 |
34 +| `city` | Ville (normalisée) | 100 % | Montréal |
35 +| `sector` | Secteur / quartier | 100 % | Westmount |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 100 % | 3½ |
37 +| `price` | Loyer mensuel ($) | 100 % | 1 695 $ |
38 +| `bedrooms` | Chambres | 100 % | 1 |
39 +| `bathrooms` | Salles de bain | 100 % | 1 |
40 +| `area_sqft` | Superficie (pi²) | 100 % | 553 |
41 +| `availability_date` | Date de disponibilité | 100 % | 2026-10-01 |
42 +| `lat` | GPS (lat/lng) | 100 % | 45.482613 |
43 +| `description` | Description | 100 % | Type : groundFloor — 553 pi² — 1 salle(s) de bain |
44 +| `images` | Photos (JSON) | 100 % | 14 photo(s) — https://ak-let-api-gateway-production.azure-api.net/api/media/marketing/3df… |
45 +| `amenities` | Commodités (JSON) | 100 % | ["553 pi²", "1 sdb", "Laveuse-sécheuse", "Buanderie", "Chauffage inclus", "Eau chaude inc… |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"inclusions": {"heating": true, "hot_water": true, "electricity": false, "internet": fal… |
47 +| `url` | URL de l'annonce source | 100 % | https://rent.akelius.com/en/search/canada/detail/50030a-12 |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.8 h
52 +- **Dernier passage OK** : 2026-08-25 01:37 — 219 trouvées, +0 / ~0 / -0
53 +- **Throttling** : 0.6 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 306 au total, **219 actives**
59 +- **Première ingestion** : 2026-08-06 · **Dernière observation** : 2026-08-25
60 +- **Complétude clé (actives)** : GPS 100 % · prix 100 % · photos 100 % · description 100 %
61 +- **Runs journalisés (60 derniers)** : 60, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync akelius` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='akelius' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 0.6 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-06 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/apartments_com.md +79 −0
@@ -0,0 +1,79 @@
1 +# Apartments.com (CoStar) — connecteur `apartments_com`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Province · Backend : direct · Annonces actives : 615/615
6 +
7 +## Description de la source
8 +
9 +Apartments.com (CoStar) — réseau US avec un vrai parc québécois (~500 propriétés / 8 400 unités via /qc/, surtout de grands gestionnaires). Le site est verrouillé par Akamai (403 en direct, via Scrapfly ET via Oxylabs — sondé 2026-08-18/22) : le scraping est délégué à l'ACTEUR APIFY MAISON gorgeous_thistle/ka-apartments-com (source : actors/ka-apartments-com de ce repo) qui passe par le Web Unlocker Bright Data — HTML server-rendered, pas de rendu JS. UNE annonce = UNE PROPRIÉTÉ (immeuble) : prix « à partir de » (minimum des plans d'unités), tous les plans dans details["plans"]. Les fiches détail sont lues en FRANÇAIS (/fr/) : description, GPS, commodités, galerie. Cache détail 30 j + CADENCE : l'inventaire CoStar bouge lentement, l'acteur ne tourne qu'aux LOUKA_APTS_INTERVAL_H heures ; entre deux runs on ré-émet les annonces actives depuis la BD (coût 0).
10 +
11 +- **Site** : https://www.apartments.com
12 +- **Page des annonces** : https://www.apartments.com/qc/
13 +- **Secteurs couverts** : Toute la province via /qc/ (~500 propriétés / 8 400 unités, grands gestionnaires) — acteur Apify maison ka-apartments-com + Bright Data Web Unlocker
14 +- **Module** : `louka/connectors/apartments_com.py` — classe `ApartmentsComConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : API JSON interne du site
19 +- **Endpoint de base** : https://api.apify.com/v2
20 +- **URLs de départ (constantes du module)** : https://api.apify.com/v2 · https://www.apartments.com/qc/
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : pagination par offset
23 +- **Backend anti-bot / rendu** : requests direct
24 +- **Politesse** : 1.0 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, API JSON interne du site) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 615 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | Place Du Fort |
33 +| `address` | Adresse civique | 100 % | 1411 St Du Fort |
34 +| `city` | Ville (normalisée) | 100 % | Montréal |
35 +| `sector` | Secteur / quartier | 0 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 58 % | — |
37 +| `price` | Loyer mensuel ($) | 100 % | 1 250 $ |
38 +| `bedrooms` | Chambres | 66 % | 4 |
39 +| `bathrooms` | Salles de bain | 23 % | 1 |
40 +| `area_sqft` | Superficie (pi²) | 2 % | — |
41 +| `availability_date` | Date de disponibilité | 33 % | now |
42 +| `lat` | GPS (lat/lng) | 68 % | 45.49181 |
43 +| `description` | Description | 40 % | Place du Fort est un immeuble d'appartements situé au cœur de Montréal, une ville inspira… |
44 +| `images` | Photos (JSON) | 100 % | 23 photo(s) — https://images1.apartments.com/i2/U8NaqvSrDRFxjRV-gt5W7Q4kjHyqkDFW9FxafM5yi… |
45 +| `amenities` | Commodités (JSON) | 40 % | ["Cats Allowed", "Maintenance on site", "Controlled Access", "Elevator", "Laundry Facilit… |
46 +| `details` | Détails additionnels (JSON) | 49 % | {"inclusions": {"heating": true, "hot_water": true, "internet": true, "cable": true}, "ap… |
47 +| `url` | URL de l'annonce source | 100 % | https://www.apartments.com/fr/place-du-fort-montreal-qc/n28d2cm/ |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.8 h
52 +- **Dernier passage OK** : 2026-08-25 01:37 — 615 trouvées, +0 / ~0 / -0
53 +- **Throttling** : 1.0 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 615 au total, **615 actives**
59 +- **Première ingestion** : 2026-08-22 · **Dernière observation** : 2026-08-25
60 +- **Complétude clé (actives)** : GPS 68 % · prix 100 % · photos 100 % · description 40 %
61 +- **Runs journalisés (60 derniers)** : 26, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync apartments_com` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='apartments_com' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 1.0 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-22 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/boardwalk.md +79 −0
@@ -0,0 +1,79 @@
1 +# Boardwalk REIT — connecteur `boardwalk`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Montréal · Backend : direct · Annonces actives : 100/100
6 +
7 +## Description de la source
8 +
9 +connecteur Boardwalk REIT (bwalk.com) Pages de villes de la région de Montréal rendues serveur (HubSpot CMS) : Longueuil, Laval, Île-des-Sœurs/Verdun, Ville Saint-Laurent. Fiches propriétés avec cartes de types de suites (« À partir de … $ ») qui portent aussi superficie (pi ca), c. à c./sdb et le statut (« N logements disponibles » / « liste d'attente ») ; adresse complète + téléphone dans l'en-tête (p.address-and-phone). Les pages de suites ne servent plus qu'aux photos spécifiques, via le cache self.detail(). REIT pancanadien : seules les propriétés du Grand Montréal sont couvertes.
10 +
11 +- **Site** : https://www.bwalk.com
12 +- **Page des annonces** : https://www.bwalk.com/fr-ca/appartements-a-louer-a-montreal-longueuil/longueuil
13 +- **Secteurs couverts** : Longueuil
14 +- **Module** : `louka/connectors/boardwalk.py` — classe `BoardwalkConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : pages HTML (rendu serveur)
19 +- **Endpoint de base** : https://www.bwalk.com
20 +- **URLs de départ (constantes du module)** : https://www.bwalk.com
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : requests direct (session UA LouKaBot, throttling poli)
24 +- **Politesse** : 0.6 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, pages HTML (rendu serveur)) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 100 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | Domaine d'Iberville Apartments — 3 1/2 Pièces |
33 +| `address` | Adresse civique | 100 % | 101-300 Rue Joliette, Montréal - Longueuil, J4H 2G5 |
34 +| `city` | Ville (normalisée) | 100 % | Longueuil |
35 +| `sector` | Secteur / quartier | 89 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 100 % | 3½ |
37 +| `price` | Loyer mensuel ($) | 100 % | 1 199 $ |
38 +| `bedrooms` | Chambres | 100 % | 1 |
39 +| `bathrooms` | Salles de bain | 100 % | 1 |
40 +| `area_sqft` | Superficie (pi²) | 97 % | — |
41 +| `availability_date` | Date de disponibilité | 53 % | now |
42 +| `lat` | GPS (lat/lng) | 100 % | 45.531969 |
43 +| `description` | Description | 100 % | Située sur la rive sud du fleuve Saint-Laurent en face du centre-ville de Montréal, la pr… |
44 +| `images` | Photos (JSON) | 100 % | 7 photo(s) — https://www.bwalk.com/hubfs/001%20Organized%20Web%20Photos/Domaine%20dIbervi… |
45 +| `amenities` | Commodités (JSON) | 100 % | ["Piscine", "Sauna", "Dépanneur", "Ascenseur", "Espace d'entreposage", "Balcon", "Eau", "… |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"inclusions": {"heating": true, "electricity": true}, "elevator": true, "balcony": true,… |
47 +| `url` | URL de l'annonce source | 100 % | https://www.bwalk.com/fr-ca/appartements-a-louer-a-montreal-longueuil/longueuil/domaine-d… |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.9 h
52 +- **Dernier passage OK** : 2026-08-25 01:39 — 100 trouvées, +0 / ~0 / -0
53 +- **Throttling** : 0.6 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 100 au total, **100 actives**
59 +- **Première ingestion** : 2026-08-06 · **Dernière observation** : 2026-08-25
60 +- **Complétude clé (actives)** : GPS 100 % · prix 100 % · photos 100 % · description 100 %
61 +- **Runs journalisés (60 derniers)** : 60, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync boardwalk` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='boardwalk' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 0.6 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-06 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/capreit.md +79 −0
@@ -0,0 +1,79 @@
1 +# CAPREIT — connecteur `capreit`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Québec · Backend : direct · Annonces actives : 116/123
6 +
7 +## Description de la source
8 +
9 +connecteur CAPREIT (capreit.ca) Flux JSON officiel du moteur de recherche (admin-ajax `property_json`) filtré sur les villes de la région de Québec ET du Grand Montréal (île de Montréal, Laval, Rive-Sud, Rive-Nord proche); les fiches propriétés (rendu serveur) fournissent les types d'unités, prix, disponibilités, commodités et la galerie photo. Une annonce par type d'unité disponible. Exclus : hors-province (province != QC) et villes hors des deux régions.
10 +
11 +- **Site** : https://www.capreit.ca
12 +- **Page des annonces** : https://www.capreit.ca/fr/appartements-a-louer/ville-de-quebec-qc/appartements-le-samuel-holland
13 +- **Secteurs couverts** : Ville de Québec (Samuel-Holland)
14 +- **Module** : `louka/connectors/capreit.py` — classe `CapreitConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : API admin-ajax (WordPress)
19 +- **Endpoint de base** : https://www.capreit.ca
20 +- **URLs de départ (constantes du module)** : https://www.capreit.ca
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : requests direct (session UA LouKaBot, throttling poli)
24 +- **Politesse** : 0.6 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, API admin-ajax (WordPress)) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 116 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | Faubourg de la Pointe et Domaine Laudance — 1 1/2 |
33 +| `address` | Adresse civique | 100 % | 900, 920 Laudance, 3780, 3786, 3792, 3793, 3798 Le Marié, 3700, 3706 des Compagnons, Vill… |
34 +| `city` | Ville (normalisée) | 94 % | Québec |
35 +| `sector` | Secteur / quartier | 65 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 100 % | 1½ |
37 +| `price` | Loyer mensuel ($) | 100 % | 930 $ |
38 +| `bedrooms` | Chambres | 100 % | 0 |
39 +| `bathrooms` | Salles de bain | 0 % | — |
40 +| `area_sqft` | Superficie (pi²) | 100 % | 370 |
41 +| `availability_date` | Date de disponibilité | 100 % | now |
42 +| `lat` | GPS (lat/lng) | 100 % | 46.760387 |
43 +| `description` | Description | 100 % | Caractéristiques de l'immeuble Communauté avec beaux aménagements paysagers et des appart… |
44 +| `images` | Photos (JSON) | 100 % | 14 photo(s) — https://www.capreit.ca/wp-content/uploads/2021/09/FPDL-4.jpg |
45 +| `amenities` | Commodités (JSON) | 100 % | ["Voir ma liste", "Coin-buanderie dans l’unité", "Piscine *", "Balcons privés", "Ascenseu… |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"elevator": true, "balcony": true, "pool": true, "laundry": true, "parking": {"available… |
47 +| `url` | URL de l'annonce source | 100 % | https://www.capreit.ca/fr/appartements-a-louer/ville-de-quebec-qc/faubourg-de-la-pointe-e… |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.9 h
52 +- **Dernier passage OK** : 2026-08-25 01:44 — 116 trouvées, +0 / ~0 / -0
53 +- **Throttling** : 0.6 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 123 au total, **116 actives**
59 +- **Première ingestion** : 2026-08-06 · **Dernière observation** : 2026-08-25
60 +- **Complétude clé (actives)** : GPS 100 % · prix 100 % · photos 100 % · description 100 %
61 +- **Runs journalisés (60 derniers)** : 60, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync capreit` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='capreit' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 0.6 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-06 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/centurion.md +79 −0
@@ -0,0 +1,79 @@
1 +# Centurion / CP Living (Quartier QB) — connecteur `centurion`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Québec · Backend : direct · Annonces actives : 4/4
6 +
7 +## Description de la source
8 +
9 +connecteur Centurion / CP Living (cpliving.com) REIT national (Centurion Property Associates) — région de Québec : le Quartier QB, 2551 chemin des Quatre-Bourgeois (Sainte-Foy), 684 unités. Le site est propulsé par Rentsync : la page de recherche référence un proxy legacy `widgets/search/api_proxy.php?client_id=21&city_id=2310` (404 aujourd'hui) mais son JS (scripts/main.js) appelle en réalité le flux JSON officiel `https://api.theliftsystem.com/v2/search` avec un auth_token embarqué. On lit client_id/city_id sur la page, le token dans main.js (repli : constantes), puis on interroge le flux directement — aucune exécution JavaScript nécessaire, Cloudflare accepte le UA de base. Filtre région : city_ids 2310 (Ville de Québec) + 1039 (Gatineau, ajouté à l'expansion provinciale 2026-08) + garde-fou province/ville. La fiche propriété (rendu serveur) fournit la galerie photo, via le cache BD self.detail() (revisitée seulement quand le flux change).
10 +
11 +- **Site** : https://www.cpliving.com
12 +- **Page des annonces** : https://www.cpliving.com/apartments-for-rent/quebec-city
13 +- **Secteurs couverts** : ['Sainte-Foy']
14 +- **Module** : `louka/connectors/centurion.py` — classe `CenturionConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : API JSON interne du site
19 +- **Endpoint de base** : https://www.cpliving.com
20 +- **URLs de départ (constantes du module)** : https://api.theliftsystem.com/v2/search` · https://www.cpliving.com · https://api.theliftsystem.com/v2/search
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : requests direct (session UA LouKaBot, throttling poli)
24 +- **Politesse** : 0.7 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, API JSON interne du site) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 4 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | Quartier QB |
33 +| `address` | Adresse civique | 100 % | 2551 Chemin des Quatre-Bourgeois, Québec, QC G1V 0G2 |
34 +| `city` | Ville (normalisée) | 100 % | Québec |
35 +| `sector` | Secteur / quartier | 0 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 0 % | — |
37 +| `price` | Loyer mensuel ($) | 75 % | — |
38 +| `bedrooms` | Chambres | 0 % | — |
39 +| `bathrooms` | Salles de bain | 0 % | — |
40 +| `area_sqft` | Superficie (pi²) | 0 % | — |
41 +| `availability_date` | Date de disponibilité | 75 % | — |
42 +| `lat` | GPS (lat/lng) | 100 % | 46.7807897 |
43 +| `description` | Description | 100 % | Quartier QB is in the heart of Québec City's up and coming downtown hub – Sainte-Foy. The… |
44 +| `images` | Photos (JSON) | 100 % | 6 photo(s) — https://assets.rentsync.com/centurion/images/gallery/full/1651515067110_qb-u… |
45 +| `amenities` | Commodités (JSON) | 100 % | ["In-suite laundry", "Dishwasher", "Microwave", "Air conditioner", "Balcony"] |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"appliances": {"fridge": true, "stove": true, "dishwasher": true, "washer_dryer": true},… |
47 +| `url` | URL de l'annonce source | 100 % | https://cpliving.com/apartments/quartier-qb |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.9 h
52 +- **Dernier passage OK** : 2026-08-25 01:44 — 4 trouvées, +0 / ~0 / -0
53 +- **Throttling** : 0.7 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 4 au total, **4 actives**
59 +- **Première ingestion** : 2026-08-08 · **Dernière observation** : 2026-08-25
60 +- **Complétude clé (actives)** : GPS 100 % · prix 75 % · photos 100 % · description 100 %
61 +- **Runs journalisés (60 derniers)** : 60, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync centurion` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='centurion' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 0.7 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-08 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/cogir.md +79 −0
@@ -0,0 +1,79 @@
1 +# Cogir Immobilier — connecteur `cogir`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Québec · Backend : direct · Annonces actives : 283/301
6 +
7 +## Description de la source
8 +
9 +connecteur Cogir Immobilier (cogir.net) Page liste « gestion-immeubles-residentiels » (rendu serveur) filtrée sur les villes de la région de Québec/Lévis, du Grand Montréal (île de Montréal, Laval, Longueuil/Rive-Sud, Rive-Nord proche) et de l'Outaouais (Gatineau/Hull/Aylmer); chaque immeuble a une fiche avec un tableau « Modèles disponibles » (type + prix à partir de) et une galerie photos (DATA/PHOTO). Une annonce par modèle d'unité, sinon par immeuble. Exclus : Ontario/Halifax/etc., résidences pour aînés/retraités/étudiantes.
10 +
11 +- **Site** : https://www.cogir.net
12 +- **Page des annonces** : https://www.cogir.net/gestion-immeubles-residentiels.html
13 +- **Secteurs couverts** : Québec (Le Bacc, Loretteville, Père-Lelièvre...)
14 +- **Module** : `louka/connectors/cogir.py` — classe `CogirConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : pages HTML (rendu serveur)
19 +- **Endpoint de base** : https://www.cogir.net
20 +- **URLs de départ (constantes du module)** : https://www.cogir.net
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : requests direct (session UA LouKaBot, throttling poli)
24 +- **Politesse** : 0.6 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, pages HTML (rendu serveur)) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 283 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | L'Aventura Condos Locatifs — 3½ (1 chambre) |
33 +| `address` | Adresse civique | 100 % | 1769 Rue Careau, Québec, QC, Québec G1M 0E6 |
34 +| `city` | Ville (normalisée) | 100 % | Québec |
35 +| `sector` | Secteur / quartier | 13 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 99 % | 3½ |
37 +| `price` | Loyer mensuel ($) | 96 % | 1 496 $ |
38 +| `bedrooms` | Chambres | 98 % | 1 |
39 +| `bathrooms` | Salles de bain | 0 % | — |
40 +| `area_sqft` | Superficie (pi²) | 0 % | — |
41 +| `availability_date` | Date de disponibilité | 96 % | now |
42 +| `lat` | GPS (lat/lng) | 92 % | 46.8202665 |
43 +| `description` | Description | 100 % | Découvrez ce milieu de vie distinctif conçu pour vous : Inspirée des plus grands complexe… |
44 +| `images` | Photos (JSON) | 100 % | 12 photo(s) — https://www.cogir.net/DATA/PHOTO/22326_aprinc.jpg |
45 +| `amenities` | Commodités (JSON) | 57 % | ["Patinoire extérieure", "Air climatisé", "Balcons", "Piscine extérieure", "BBQ", "Gym", … |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"ac": true, "balcony": true, "pool": true, "gym": true, "price_from": true, "contact": {… |
47 +| `url` | URL de l'annonce source | 100 % | https://www.cogir.net/immeuble-residentiel-quebec/2677-l-aventura-condos-locatifs.html |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.9 h
52 +- **Dernier passage OK** : 2026-08-25 01:46 — 283 trouvées, +0 / ~0 / -0
53 +- **Throttling** : 0.6 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 301 au total, **283 actives**
59 +- **Première ingestion** : 2026-08-06 · **Dernière observation** : 2026-08-25
60 +- **Complétude clé (actives)** : GPS 92 % · prix 96 % · photos 100 % · description 100 %
61 +- **Runs journalisés (60 derniers)** : 60, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync cogir` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='cogir' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 0.6 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-06 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/fb_marketplace.md +79 −0
@@ -0,0 +1,79 @@
1 +# Facebook Marketplace (location) — connecteur `fb_marketplace`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Province · Backend : direct · Annonces actives : 1599/26851
6 +
7 +## Description de la source
8 +
9 +Facebook Marketplace — catégorie location (propertyrentals), TOUTE la province. Annonces de particuliers, fort complément aux gestionnaires. Depuis 2026-08-22 le scraping est délégué à l'ACTEUR APIFY MAISON gorgeous_thistle/ka-fb-marketplace (source : actors/ka-fb-marketplace de ce repo) : HTML public déconnecté SANS rendu JS (les JSON de recherche, la fiche PDP et la galerie MediaViewer préchargée vivent dans le HTML brut) via proxy résidentiel CA — ~100× moins cher que l'ancienne chaîne Scrapfly ASP+render_js, et couverture élargie de 10 à 33 villes (IDs Marketplace des villes régionales validés ; les slugs inventés retombent sur des villes aléatoires, ne pas en ajouter sans les valider). Le scroll infini reste bloqué hors connexion (~25 annonces/URL) : on pagine par TRANCHES DE PRIX et on accumule sur plusieurs synchronisations. Le flux public TOURNE : une phase de RATTRAPAGE (extraDetailIds) visite les fiches actives jamais enrichies (budget LOUKA_FBMP_BACKFILL). Le connecteur garde le cache détail BD (clé v2, payloads compatibles) et passe à l'acteur la liste des fiches fraîches à NE PAS revisiter. ⚠️ Données personnelles (Loi 25) : ne jamais republier nom/téléphone du vendeur, garder le lien sortant.
10 +
11 +- **Site** : https://www.facebook.com/marketplace
12 +- **Page des annonces** : https://www.facebook.com/marketplace/montreal/propertyrentals
13 +- **Secteurs couverts** : Toute la province (33 villes, rayon ~65 km chacune) — annonces de particuliers (acteur Apify maison ka-fb-marketplace)
14 +- **Module** : `louka/connectors/fb_marketplace.py` — classe `FacebookMarketplaceConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : API JSON interne du site
19 +- **Endpoint de base** : https://www.facebook.com/marketplace
20 +- **URLs de départ (constantes du module)** : https://www.facebook.com/marketplace · https://api.apify.com/v2
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : pagination par offset
23 +- **Backend anti-bot / rendu** : requests direct
24 +- **Politesse** : 1.0 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, API JSON interne du site) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 1599 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | 4 1/2 à louer |
33 +| `address` | Adresse civique | 48 % | — |
34 +| `city` | Ville (normalisée) | 99 % | Montréal |
35 +| `sector` | Secteur / quartier | 0 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 92 % | 4½ |
37 +| `price` | Loyer mensuel ($) | 100 % | 1 400 $ |
38 +| `bedrooms` | Chambres | 93 % | 2 |
39 +| `bathrooms` | Salles de bain | 49 % | — |
40 +| `area_sqft` | Superficie (pi²) | 11 % | — |
41 +| `availability_date` | Date de disponibilité | 34 % | — |
42 +| `lat` | GPS (lat/lng) | 61 % | 45.557556152344 |
43 +| `description` | Description | 61 % | 2222 rue Fleury Est |
44 +| `images` | Photos (JSON) | 100 % | 9 photo(s) — https://scontent-yyz1-1.xx.fbcdn.net/v/t39.84726-6/774513165_105675990685739… |
45 +| `amenities` | Commodités (JSON) | 40 % | [] |
46 +| `details` | Détails additionnels (JSON) | 59 % | {} |
47 +| `url` | URL de l'annonce source | 100 % | https://www.facebook.com/marketplace/item/1795905304891036/ |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.8 h
52 +- **Dernier passage OK** : 2026-08-24 22:42 — 1049 trouvées, +237 / ~429 / -373
53 +- **Throttling** : 1.0 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 26851 au total, **1599 actives**
59 +- **Première ingestion** : 2026-08-17 · **Dernière observation** : 2026-08-24
60 +- **Complétude clé (actives)** : GPS 61 % · prix 100 % · photos 100 % · description 61 %
61 +- **Runs journalisés (60 derniers)** : 60, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync fb_marketplace` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='fb_marketplace' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 1.0 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-17 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/hazelview.md +79 −0
@@ -0,0 +1,79 @@
1 +# Hazelview Properties — connecteur `hazelview`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Montréal · Backend : direct · Annonces actives : 6/6
6 +
7 +## Description de la source
8 +
9 +connecteur Hazelview Properties (hazelviewproperties.com — ex-Timbercreek). Le site est rendu côté client via l'API RentSync/LiftSystem (lift-api.rentsync.com/v2, client_id 497, jeton public embarqué dans le JS du site). On interroge /v2/cities pour les villes QC (toutes dans le Grand Montréal : Montréal, Verdun, Côte-Saint-Luc, Pointe-Claire, Longueuil...), puis /v2/search par ville et par nombre de chambres pour obtenir les types d'unités disponibles et leur loyer. Une annonce par immeuble et par type d'unité. La page immeuble du site (via cache BD) embarque un JSON complet (attribut data-locations de la carte) : commodités, galerie photos, animaux détaillés, suites.
10 +
11 +- **Site** : https://www.hazelviewproperties.com
12 +- **Page des annonces** : https://www.hazelviewproperties.com/cities/montreal
13 +- **Secteurs couverts** : Centre-ville, Vieux-Port, Plateau
14 +- **Module** : `louka/connectors/hazelview.py` — classe `HazelviewConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : API JSON interne du site
19 +- **Endpoint de base** : https://lift-api.rentsync.com/v2
20 +- **URLs de départ (constantes du module)** : https://assets.rentsync.com/timbercreek_communities/images/gallery/full/ · https://lift-api.rentsync.com/v2
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : requests direct (session UA LouKaBot, throttling poli)
24 +- **Politesse** : 0.6 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, API JSON interne du site) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 6 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | 1405 Bishop — Studio |
33 +| `address` | Adresse civique | 100 % | 1405 rue Bishop, Montréal, H4G 2E4 |
34 +| `city` | Ville (normalisée) | 100 % | Montréal |
35 +| `sector` | Secteur / quartier | 0 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 100 % | Studio |
37 +| `price` | Loyer mensuel ($) | 100 % | 1 425 $ |
38 +| `bedrooms` | Chambres | 100 % | 0 |
39 +| `bathrooms` | Salles de bain | 0 % | — |
40 +| `area_sqft` | Superficie (pi²) | 83 % | — |
41 +| `availability_date` | Date de disponibilité | 100 % | now |
42 +| `lat` | GPS (lat/lng) | 100 % | 45.4968177 |
43 +| `description` | Description | 100 % | Located in a lively, high pedestrian area at the corner of Saint Catherine Street, in pro… |
44 +| `images` | Photos (JSON) | 100 % | 14 photo(s) — https://assets.rentsync.com/timbercreek_communities/images/gallery/full/168… |
45 +| `amenities` | Commodités (JSON) | 100 % | ["24/7 emergency service", "Air conditioner", "AirConditioning", "Bike Friendly", "Certif… |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"appliances": {"fridge": true, "dishwasher": true}, "elevator": true, "laundry": true, "… |
47 +| `url` | URL de l'annonce source | 100 % | https://hazelviewproperties.com/properties/1405-bishop |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.9 h
52 +- **Dernier passage OK** : 2026-08-24 22:52 — 6 trouvées, +0 / ~0 / -0
53 +- **Throttling** : 0.6 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 6 au total, **6 actives**
59 +- **Première ingestion** : 2026-08-06 · **Dernière observation** : 2026-08-24
60 +- **Complétude clé (actives)** : GPS 100 % · prix 100 % · photos 100 % · description 100 %
61 +- **Runs journalisés (60 derniers)** : 60, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync hazelview` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='hazelview' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 0.6 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-06 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/interrent.md +79 −0
@@ -0,0 +1,79 @@
1 +# InterRent REIT (CLV Group) — connecteur `interrent`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Montréal · Backend : direct · Annonces actives : 189/215
6 +
7 +## Description de la source
8 +
9 +connecteur InterRent REIT (irent.com) Site Next.js (app router) : la page « communities/city/montreal » embarque dans son flux RSC (self.__next_f.push) le JSON complet des communautés (adresse, quartier, photos, commodités Amenities/Utilities, animaux PetFriendly*, stationnement, contact, description via référence $NN) avec leurs suites disponibles (type, chambres, sdb, pi², loyer, date). Une seule requête suffit. REIT pancanadien : filtre strict sur les villes du Grand Montréal.
10 +
11 +- **Site** : https://www.irent.com
12 +- **Page des annonces** : https://www.irent.com/communities/city/montreal
13 +- **Secteurs couverts** : Centre-ville, CDN, Côte Saint-Luc
14 +- **Module** : `louka/connectors/interrent.py` — classe `InterrentConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : pages HTML (rendu serveur)
19 +- **Endpoint de base** : https://www.irent.com
20 +- **URLs de départ (constantes du module)** : https://www.irent.com
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : requests direct (session UA LouKaBot, throttling poli)
24 +- **Politesse** : 0.6 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, pages HTML (rendu serveur)) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 189 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | Trent & Kingsley — 7460 Ch. Kingsley - Plan E (app. 803) |
33 +| `address` | Adresse civique | 100 % | 5550 Trent Ave, Côte-Saint-Luc, H4W 1P3 |
34 +| `city` | Ville (normalisée) | 83 % | — |
35 +| `sector` | Secteur / quartier | 76 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 100 % | Studio |
37 +| `price` | Loyer mensuel ($) | 100 % | 1 339 $ |
38 +| `bedrooms` | Chambres | 100 % | 0 |
39 +| `bathrooms` | Salles de bain | 100 % | 1 |
40 +| `area_sqft` | Superficie (pi²) | 99 % | 578 |
41 +| `availability_date` | Date de disponibilité | 100 % | now |
42 +| `lat` | GPS (lat/lng) | 100 % | 45.4611828 |
43 +| `description` | Description | 100 % | 578 pi² — 1 sdb — plan 7460 Ch. Kingsley - Plan E — Promotion : Two Months Free* — *E & O… |
44 +| `images` | Photos (JSON) | 100 % | 14 photo(s) — https://assets.rentsync.com/interrent_reit/images/floorplans/1772095886_746… |
45 +| `amenities` | Commodités (JSON) | 100 % | ["Laundry facilities", "Freshly Painted", "Convenient Location", "Courtyard/Gardens", "Sm… |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"inclusions": {"heating": true}, "appliances": {"fridge": true}, "elevator": true, "balc… |
47 +| `url` | URL de l'annonce source | 100 % | https://www.irent.com/communities/place-kingsley |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.9 h
52 +- **Dernier passage OK** : 2026-08-24 22:54 — 189 trouvées, +0 / ~0 / -0
53 +- **Throttling** : 0.6 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 215 au total, **189 actives**
59 +- **Première ingestion** : 2026-08-06 · **Dernière observation** : 2026-08-24
60 +- **Complétude clé (actives)** : GPS 100 % · prix 100 % · photos 100 % · description 100 %
61 +- **Runs journalisés (60 derniers)** : 60, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync interrent` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='interrent' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 0.6 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-06 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/kijiji.md +79 −0
@@ -0,0 +1,79 @@
1 +# Kijiji Québec (location) — connecteur `kijiji`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Province · Backend : direct · Annonces actives : 5372/10365
6 +
7 +## Description de la source
8 +
9 +Kijiji (kijiji.ca) — petites annonces de LOCATION UNIQUEMENT les catégories logement À LOUER, UNIQUEMENT le Québec (l9001) : c37 appartements & condos à louer · c36 chambres à louer & colocation Les pages listent 40+ annonces dans __NEXT_DATA__ (Apollo state) avec titre, prix, GPS, adresse, date de disponibilité et attributs (meublé, animaux, inclusions…) — aucune API privée nécessaire. Adapté du connecteur « à vendre » d'Immo-Ka (agent-courtage/immoka). Prix « Sur demande » (price.type=CONTACT, ~3 % des annonces) : le montant est souvent écrit dans la description (« Loyer : 1 550 $ / mois ») — on le récupère prudemment (montant collé à $ + mois/month plausible), sinon le libellé affiché devient « Sur demande » plutôt que vide. (Vérifié à la source : les annonces sans photo ou à photo unique le sont réellement sur Kijiji — imageUrls vide/à 1 sur la fiche aussi.)
10 +
11 +- **Site** : https://www.kijiji.ca
12 +- **Page des annonces** : https://www.kijiji.ca/b-appartement-condo/quebec/c37l9001
13 +- **Secteurs couverts** : Province entière — appartements/condos (c37) + chambres & colocation (c36), particuliers et gestionnaires
14 +- **Module** : `louka/connectors/kijiji.py` — classe `KijijiConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : pages HTML (rendu serveur)
19 +- **Endpoint de base** : https://www.kijiji.ca
20 +- **URLs de départ (constantes du module)** : https://www.kijiji.ca
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : requests direct (session UA LouKaBot, throttling poli)
24 +- **Politesse** : 1.2 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, pages HTML (rendu serveur)) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 5372 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | 4 1/2 4.5, 2,chambres,bedrooms,louer, Cartierville, metro Sauvé |
33 +| `address` | Adresse civique | 45 % | 10150 Lajeunesse |
34 +| `city` | Ville (normalisée) | 91 % | Montréal |
35 +| `sector` | Secteur / quartier | 0 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 100 % | 4½ |
37 +| `price` | Loyer mensuel ($) | 97 % | 1 495 $ |
38 +| `bedrooms` | Chambres | 100 % | 2 |
39 +| `bathrooms` | Salles de bain | 78 % | 1 |
40 +| `area_sqft` | Superficie (pi²) | 65 % | — |
41 +| `availability_date` | Date de disponibilité | 69 % | 2026-08-01 |
42 +| `lat` | GPS (lat/lng) | 100 % | 45.55274 |
43 +| `description` | Description | 100 % | <p data-start="191" data-end="358"><strong data-start="191" data-end="260">PROMOTION 2 MO… |
44 +| `images` | Photos (JSON) | 99 % | 9 photo(s) — https://media.kijiji.ca/api/v1/ca-prod-dealer-ads/images/c7/c77f45e6-d45a-41… |
45 +| `amenities` | Commodités (JSON) | 73 % | ["Chauffage inclus", "Balcon", "Buanderie dans l'immeuble", "Réfrigérateur/congélateur", … |
46 +| `details` | Détails additionnels (JSON) | 99 % | {"inclusions": {"heating": true, "hot_water": true}, "appliances": {"fridge": true, "stov… |
47 +| `url` | URL de l'annonce source | 100 % | https://www.kijiji.ca/v-apartments-condos/ville-de-montreal/4-1-2-4-5-2-chambres-bedrooms… |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.9 h
52 +- **Dernier passage OK** : 2026-08-24 23:09 — 5250 trouvées, +119 / ~21 / -44
53 +- **Throttling** : 1.2 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 10365 au total, **5372 actives**
59 +- **Première ingestion** : 2026-08-12 · **Dernière observation** : 2026-08-24
60 +- **Complétude clé (actives)** : GPS 100 % · prix 97 % · photos 99 % · description 100 %
61 +- **Runs journalisés (60 derniers)** : 60, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync kijiji` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='kijiji' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 1.2 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-12 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/metcap.md +79 −0
@@ -0,0 +1,79 @@
1 +# MetCap Living — connecteur `metcap`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Montréal · Backend : direct · Annonces actives : 8/8
6 +
7 +## Description de la source
8 +
9 +connecteur MetCap Living (metcap.com) Site WordPress rendu serveur. La page /province/quebec liste les villes QC (province=115) ; chaque page « province-search-results » liste les immeubles (avec lat/lng dans l'attribut onclick=centerMap) et leurs types d'unités (« Montreal 2 Bedrooms from $1,819 »). Les fiches /apartment/... donnent le détail structuré : tableau « Suite Details » (statut, lits, sdb, pi²), listes « Building Amenities », « Rent Includes », « Pet Friendly », contact du bureau de location, description bilingue et photos d'unité ; les fiches /property/... la galerie photo de l'immeuble. Fiches visitées via self.detail(...) (cache BD). Gestionnaire pancanadien : seules les villes du Grand Montréal sont couvertes.
10 +
11 +- **Site** : https://www.metcap.com
12 +- **Page des annonces** : https://www.metcap.com/province-search-results?lang=en&province=115&city=Montréal
13 +- **Secteurs couverts** : Montréal, Saint-Laurent, Pointe-Claire
14 +- **Module** : `louka/connectors/metcap.py` — classe `MetcapConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : pages HTML (rendu serveur)
19 +- **Endpoint de base** : https://www.metcap.com
20 +- **URLs de départ (constantes du module)** : https://www.metcap.com
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : requests direct (session UA LouKaBot, throttling poli)
24 +- **Politesse** : 0.6 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, pages HTML (rendu serveur)) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 8 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | Tour du Parc — Montreal Studio (Bachelor) |
33 +| `address` | Adresse civique | 100 % | 1150 Rue Sherbrooke Est, Montréal, QC H2L 1L8 |
34 +| `city` | Ville (normalisée) | 100 % | Montréal |
35 +| `sector` | Secteur / quartier | 38 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 100 % | Studio |
37 +| `price` | Loyer mensuel ($) | 100 % | 1 219 $ |
38 +| `bedrooms` | Chambres | 100 % | 0 |
39 +| `bathrooms` | Salles de bain | 0 % | — |
40 +| `area_sqft` | Superficie (pi²) | 88 % | 336 |
41 +| `availability_date` | Date de disponibilité | 100 % | now |
42 +| `lat` | GPS (lat/lng) | 100 % | 45.5220419 |
43 +| `description` | Description | 100 % | Combining the ultimate in comfort and convenience, Tour du Parc offers rental suites with… |
44 +| `images` | Photos (JSON) | 100 % | 15 photo(s) — https://www.metcap.com/wp-content/uploads/2023/06/Starlight-1150-Sherbrooke… |
45 +| `amenities` | Commodités (JSON) | 100 % | ["Above Ground Parking", "Assigned Parking", "Elevator", "Fitness Centre", "On-Premise Ca… |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"inclusions": {"heating": true, "electricity": true}, "elevator": true, "balcony": true,… |
47 +| `url` | URL de l'annonce source | 100 % | https://www.metcap.com/apartment/montreal-bachelor-at-1150-rue-sherbrooke-est-montreal-qc… |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.9 h
52 +- **Dernier passage OK** : 2026-08-24 23:39 — 8 trouvées, +0 / ~0 / -0
53 +- **Throttling** : 0.6 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 8 au total, **8 actives**
59 +- **Première ingestion** : 2026-08-06 · **Dernière observation** : 2026-08-24
60 +- **Complétude clé (actives)** : GPS 100 % · prix 100 % · photos 100 % · description 100 %
61 +- **Runs journalisés (60 derniers)** : 60, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync metcap` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='metcap' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 0.6 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-06 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/minto.md +79 −0
@@ -0,0 +1,79 @@
1 +# Minto Apartments — connecteur `minto`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Montréal · Backend : direct · Annonces actives : 19/20
6 +
7 +## Description de la source
8 +
9 +connecteur Minto Apartments (mintoapartments.com) Page « projects » Montréal rendue serveur : Rockhill, Haddon Hall, Le 4300, Le Hill-Park. Chaque fiche propriété (main.html) liste ses types de suites dans des cartes .projects-apartamets-unit-card : nom (h4), disponibilité (« Available now » / « Available September 17 » / « Not available »), prix ($X - $Y), pi² (span icon-svg-column), sdb, galerie photo par type (tableaux JS `lightboxImages…`). La section Contact donne l'adresse complète, le quartier (« Neighbourhood: ») et le téléphone/courriel ; les sections Features listent les commodités (items à coche). Une annonce par type de suite disponible.
10 +
11 +- **Site** : https://www.mintoapartments.com
12 +- **Page des annonces** : https://www.mintoapartments.com/montreal/apartment-rentals/projects.html
13 +- **Secteurs couverts** : Côte-des-Neiges, Westmount, centre-ville
14 +- **Module** : `louka/connectors/minto.py` — classe `MintoConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : pages HTML (rendu serveur)
19 +- **Endpoint de base** : https://www.mintoapartments.com
20 +- **URLs de départ (constantes du module)** : https://www.mintoapartments.com
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : requests direct (session UA LouKaBot, throttling poli)
24 +- **Politesse** : 0.6 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, pages HTML (rendu serveur)) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 19 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | Haddon Hall — Studio Suites |
33 +| `address` | Adresse civique | 100 % | 2255 Rue Lambert-Closse, Montréal, QC H3H 1Z9 |
34 +| `city` | Ville (normalisée) | 100 % | Montréal |
35 +| `sector` | Secteur / quartier | 84 % | Shaughnessy Village |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 100 % | Studio |
37 +| `price` | Loyer mensuel ($) | 100 % | 1 506 $ |
38 +| `bedrooms` | Chambres | 100 % | 0 |
39 +| `bathrooms` | Salles de bain | 100 % | 1 |
40 +| `area_sqft` | Superficie (pi²) | 100 % | 401 |
41 +| `availability_date` | Date de disponibilité | 100 % | now |
42 +| `lat` | GPS (lat/lng) | 84 % | 45.4917992 |
43 +| `description` | Description | 100 % | Haddon Hall apartments offer a luxurious, condo-like home with spacious areas to work and… |
44 +| `images` | Photos (JSON) | 100 % | 5 photo(s) — https://media.minto.com/slideshows/2531/original/Haddon Hall_Rate Chart_Stud… |
45 +| `amenities` | Commodités (JSON) | 100 % | ["Security cameras on-site", "Security-controlled access", "Secure fob access", "Pet-frie… |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"inclusions": {"heating": true}, "appliances": {"fridge": true, "dishwasher": true}, "el… |
47 +| `url` | URL de l'annonce source | 100 % | https://www.mintoapartments.com/montreal/apartment-rentals/Haddon-Hall/main.html |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.9 h
52 +- **Dernier passage OK** : 2026-08-24 23:39 — 19 trouvées, +0 / ~0 / -0
53 +- **Throttling** : 0.6 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 20 au total, **19 actives**
59 +- **Première ingestion** : 2026-08-06 · **Dernière observation** : 2026-08-24
60 +- **Complétude clé (actives)** : GPS 84 % · prix 100 % · photos 100 % · description 100 %
61 +- **Runs journalisés (60 derniers)** : 60, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync minto` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='minto' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 0.6 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-06 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/morguard.md +79 −0
@@ -0,0 +1,79 @@
1 +# Morguard (Morguard Living) — connecteur `morguard`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Montréal · Backend : direct · Annonces actives : 84/84
6 +
7 +## Description de la source
8 +
9 +connecteur Morguard (morguardliving.ca) REIT pancanadien (~126 immeubles) sur plateforme Rentsync : le site est une SPA mais son backend officiel (website-gateway.rentsync.com, sans auth) est interrogé directement — /properties pour les immeubles, /units pour les unités disponibles, /photos pour les galeries. Seul le Québec est conservé (code postal H/J/G) : 15 immeubles — centre-ville de Montréal (Maisonneuve, Crescent, Mackay, des Pins…), Dollard-des-Ormeaux, Côte-Saint-Luc et Pointe-Claire. Une annonce par unité disponible (available == 1).
10 +
11 +- **Site** : https://www.morguardliving.ca
12 +- **Page des annonces** : https://www.morguardliving.ca/residential
13 +- **Secteurs couverts** : Montréal (centre-ville — Maisonneuve, Crescent, Mackay, des Pins), Dollard-des-Ormeaux, Côte-Saint-Luc, Pointe-Claire
14 +- **Module** : `louka/connectors/morguard.py` — classe `MorguardConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : pages HTML (rendu serveur)
19 +- **Endpoint de base** : https://website-gateway.rentsync.com/v1/morguard_invest
20 +- **URLs de départ (constantes du module)** : https://www.morguardliving.ca · https://website-gateway.rentsync.com/v1/morguard_invest · https://s3.amazonaws.com/lws_lift/morguard_invest/images/gallery/1152
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : requests direct (session UA LouKaBot, throttling poli)
24 +- **Politesse** : 0.6 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, pages HTML (rendu serveur)) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 84 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | La Mosaïque — 0 Bedroom, 1 Bathroom |
33 +| `address` | Adresse civique | 100 % | 345a Rue De la Gauchetière Ouest, Montréal, QC H2Z 0A2 |
34 +| `city` | Ville (normalisée) | 95 % | Montréal |
35 +| `sector` | Secteur / quartier | 0 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 100 % | Studio |
37 +| `price` | Loyer mensuel ($) | 100 % | 1 535 $ |
38 +| `bedrooms` | Chambres | 100 % | 0 |
39 +| `bathrooms` | Salles de bain | 100 % | 1 |
40 +| `area_sqft` | Superficie (pi²) | 100 % | 506 |
41 +| `availability_date` | Date de disponibilité | 10 % | — |
42 +| `lat` | GPS (lat/lng) | 100 % | 45.5042086 |
43 +| `description` | Description | 100 % | Honour your own personal style at La Mosaïque, a stylish and upscale apartment community … |
44 +| `images` | Photos (JSON) | 100 % | 14 photo(s) — https://s3.amazonaws.com/lws_lift/morguard_invest/images/gallery/1152/17816… |
45 +| `amenities` | Commodités (JSON) | 0 % | [] |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"appliances": {"fridge": true}, "balcony": true, "gym": true, "pets": "oui", "price_from… |
47 +| `url` | URL de l'annonce source | 100 % | https://www.morguardliving.ca/residential/345-rue-de-la-gauchetiere-ouest |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : —
52 +- **Dernier passage OK** : 2026-08-25 01:46 — 84 trouvées, +84 / ~0 / -0
53 +- **Throttling** : 0.6 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 84 au total, **84 actives**
59 +- **Première ingestion** : 2026-08-25 · **Dernière observation** : 2026-08-25
60 +- **Complétude clé (actives)** : GPS 100 % · prix 100 % · photos 100 % · description 100 %
61 +- **Runs journalisés (60 derniers)** : 1, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync morguard` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='morguard' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 0.6 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-25 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/realstar.md +81 −0
@@ -0,0 +1,81 @@
1 +# Realstar — connecteur `realstar`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Montréal · Backend : direct · Annonces actives : 9/9
6 +
7 +## Description de la source
8 +
9 +connecteur Realstar (realstar.ca) Site protégé par Cloudflare (403 pour les robots) et rendu côté client (moteur RentCafe/Yardi) : tout passe par Firecrawl avec attente de rendu. 1) /searchlisting?province=Quebec -> cartes propriétés (nom, adresse, lits/sdb/pi², fourchette de prix, téléphone, vignette) ; 2) fiche de chaque propriété couverte (Grand Montréal, Gatineau, Sherbrooke) -> galerie photos, description, points forts ; 3) fiche /floorplans -> plans structurés (type, chambres, pi², prix, nombre d'unités disponibles) — disponibilité et prix réels. Une annonce par propriété (uid stables). Les pages détail passent par self.detail(...) (cache BD) : Firecrawl n'est rappelé que si la carte liste a changé.
10 +
11 +- **Site** : https://www.realstar.ca
12 +- **Page des annonces** : https://www.realstar.ca/apartments/qc/montreal/excelsior-apartments
13 +- **Secteurs couverts** : Côte-Saint-Luc, Brossard
14 +- **Module** : `louka/connectors/realstar.py` — classe `RealstarConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : API JSON interne du site
19 +- **Endpoint de base** : https://www.realstar.ca/searchlisting?province=Quebec
20 +- **URLs de départ (constantes du module)** : https://www.realstar.ca/searchlisting?province=Quebec
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : requests direct
24 +- **Politesse** : 1.0 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, API JSON interne du site) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 9 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | Bourg Du Village |
33 +| `address` | Adresse civique | 100 % | 25 Place Casavant, Sainte Therese, QC J7E 5X1 |
34 +| `city` | Ville (normalisée) | 89 % | Sainte-Thérèse |
35 +| `sector` | Secteur / quartier | 0 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 0 % | — |
37 +| `price` | Loyer mensuel ($) | 89 % | — |
38 +| `bedrooms` | Chambres | 100 % | 3 |
39 +| `bathrooms` | Salles de bain | 100 % | 2 |
40 +| `area_sqft` | Superficie (pi²) | 100 % | 1240 |
41 +| `availability_date` | Date de disponibilité | 78 % | — |
42 +| `lat` | GPS (lat/lng) | 100 % | 45.634686 |
43 +| `description` | Description | 100 % | Cloudflare Ray ID: a3034392bb364967 • Your IP: Click to reveal 195.96.134.255 • Performan… |
44 +| `images` | Photos (JSON) | 100 % | 1 photo(s) — https://resource.rentcafe.com/image/upload//q_auto,f_auto,w_600,h_400,c_lfil… |
45 +| `amenities` | Commodités (JSON) | 67 % | [] |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"contact": {"phone": "450-987-0868"}} |
47 +| `url` | URL de l'annonce source | 100 % | https://www.realstar.ca/apartments/qc/sainte-therese/bourg-du-village |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.9 h
52 +- **Dernier passage OK** : 2026-08-24 23:42 — 9 trouvées, +0 / ~0 / -0
53 +- **Throttling** : 1.0 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 9 au total, **9 actives**
59 +- **Première ingestion** : 2026-08-06 · **Dernière observation** : 2026-08-24
60 +- **Complétude clé (actives)** : GPS 100 % · prix 89 % · photos 100 % · description 100 %
61 +- **Runs journalisés (60 derniers)** : 60, dont 1 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +| Date | Message (sync_log) |
66 +|---|---|
67 +| 2026-08-21 01:15 | 502 Server Error: Bad Gateway for url: https://api.firecrawl.dev/v1/scrape |
68 +
69 +Rejouer la source seule : `python3 run.py sync realstar` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='realstar' ORDER BY ts DESC LIMIT 5;`).
70 +
71 +## Licence, attribution & conditions
72 +
73 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
74 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 1.0 s ; cache détail pour minimiser les requêtes.
75 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
76 +- Retrait sur demande : contact@spboucher.ai.
77 +
78 +## Historique
79 +
80 +- 2026-08-06 — premières annonces de la source ingérées dans la BD.
81 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/rentals_ca.md +79 −0
@@ -0,0 +1,79 @@
1 +# Rentals.ca (Québec) — connecteur `rentals_ca`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Province · Backend : Scrapfly · Annonces actives : 4692/5705
6 +
7 +## Description de la source
8 +
9 +Rentals.ca — portail locatif national, filtré PROVINCE DE QUÉBEC. Même plateforme GraphQL que Louer.ca (mêmes City.id, mutation acquireAuthInfo, clé publique `rentalsGqlKey` de window.appconf), mais l'inventaire de marque est un peu plus large (~+15 % vérifié sur Québec : 78 annonces contre 67 côté Louer.ca) — les doublons inter-marques sont résorbés par la dédup (rentals_ca est dans la liste PORTALS). ⚠️ rentals.ca est derrière Cloudflare (« Just a moment… ») : chaque appel GraphQL est REJOUÉ via Scrapfly ASP SANS rendu JS (POST JSON, coût minime par rapport à un render_js). Pour contenir le budget Scrapfly : - villes limitées (LOUKA_RENTALSCA_CITIES) et plafond par ville ; - pages de 200 annonces (1 à 4 requêtes liste par ville) ; - détails PAR LOTS de 40 via nodes(ids:[…]) — 1 requête pour 40 fiches — avec cache TTL : seuls les lots nouveaux/périmés sont re-téléchargés. Régime de croisière : ~25-40 appels Scrapfly par synchronisation. ⚠️ CGU Rentals.ca : extraction interdite sans accord écrit (même réserve que louer_ca).
10 +
11 +- **Site** : https://rentals.ca
12 +- **Page des annonces** : https://rentals.ca/quebec-city
13 +- **Secteurs couverts** : Grandes villes QC — même plateforme GraphQL que Louer.ca mais inventaire de marque ~15 % plus large ; accès via Scrapfly ASP (Cloudflare), volumes plafonnés par ville
14 +- **Module** : `louka/connectors/rentals_ca.py` — classe `RentalsCaConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : API GraphQL interne
19 +- **Endpoint de base** : https://rentals.ca/
20 +- **URLs de départ (constantes du module)** : https://rentals.ca/ · https://rentals.ca/graphql · https://rentals.ca
21 +- **Authentification** : aucune — contenu public (clé Scrapfly côté Lou-Ka)
22 +- **Pagination** : page de liste unique (pas de pagination)
23 +- **Backend anti-bot / rendu** : Scrapfly (asp + render_js — contournement anti-bot)
24 +- **Politesse** : 0.3 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (Scrapfly, API GraphQL interne) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 4692 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | 1225 Saint Marc Street |
33 +| `address` | Adresse civique | 100 % | 1225 Saint Marc Street |
34 +| `city` | Ville (normalisée) | 100 % | Montréal |
35 +| `sector` | Secteur / quartier | 17 % | — |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 100 % | 3½ |
37 +| `price` | Loyer mensuel ($) | 100 % | 1 450 $ |
38 +| `bedrooms` | Chambres | 100 % | 1 |
39 +| `bathrooms` | Salles de bain | 100 % | 1 |
40 +| `area_sqft` | Superficie (pi²) | 79 % | 400 |
41 +| `availability_date` | Date de disponibilité | 95 % | now |
42 +| `lat` | GPS (lat/lng) | 100 % | 45.492189 |
43 +| `description` | Description | 99 % | Le Marco Situé au 1225, rue Saint Marc, à proximité du métro Guy-Concordia, ce quartier o… |
44 +| `images` | Photos (JSON) | 100 % | 12 photo(s) — https://images.rentals.ca/property-pictures/large/montreal-qc/809380/apartm… |
45 +| `amenities` | Commodités (JSON) | 93 % | ["Cuisinière", "Non-fumeur", "Balcon", "Réfrigérateur", "Électricité incluse", "Chauffage… |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"inclusions": {"heating": true, "electricity": true}, "appliances": {"fridge": true, "st… |
47 +| `url` | URL de l'annonce source | 100 % | https://rentals.ca/montreal/le-marco-1225-st-marc-st |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.9 h
52 +- **Dernier passage OK** : 2026-08-24 23:55 — 4404 trouvées, +5 / ~15 / -160
53 +- **Throttling** : 0.3 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 5705 au total, **4692 actives**
59 +- **Première ingestion** : 2026-08-18 · **Dernière observation** : 2026-08-24
60 +- **Complétude clé (actives)** : GPS 100 % · prix 100 % · photos 100 % · description 99 %
61 +- **Runs journalisés (60 derniers)** : 56, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync rentals_ca` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='rentals_ca' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 0.3 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-18 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/connecteurs/zumper.md +79 −0
@@ -0,0 +1,79 @@
1 +# Zumper (Québec) — connecteur `zumper`
2 +
3 +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-25 01:59 — ne pas éditer à la main, régénérer._
4 +
5 +**État : actif** · Région : Province · Backend : direct · Annonces actives : 4804/5181
6 +
7 +## Description de la source
8 +
9 +Zumper (zumper.com) — portail locatif nord-américain, filtré PROVINCE DE QUÉBEC, en DIRECT (aucun proxy payant). API JSON interne (celle du site) : 1. GET /api/t/1/bundle → cookie `csrftoken` + jeton `csrf` 2. POST /api/t/1/pages/listables → annonces par bbox (en-tête `x-csrftoken`) ; paginé offset/limit, réponse = groupes « immeuble » (fourchettes prix/chambres) et pads individuels, champ `state` filtrable 3. GET /api/t/1/buildings/{id} → fiche immeuble : description, `floorplan_listings` = TOUTES les unités (prix, chambres, sdb, pi², disponibilité) — 1 requête par immeuble, mise en cache BD et invalidée par `modified_on` (le champ bouge quand l'inventaire change). Photos : https://img.zumpercdn.com/{media_id}/1280x960 (vérifié 2026-08-18). ⚠️ Le HTML du site est derrière un « Client Challenge » JS, mais l'API répond en direct avec des en-têtes navigateur + le rituel CSRF ci-dessus. Un 429 ponctuel = throttling : une seule reprise après pause, sinon on laisse le disjoncteur d'ingestion faire son travail. ⚠️ PadMapper (padmapper.com) = MÊME compagnie et MÊME API (`/api/t/1/…`, mêmes listing_id — vérifié 100/100 identiques sur Montréal 2026-08-18) : un seul connecteur suffit, `padmapper` reste dans PORTALS (dédup) au cas où. Recherche par bbox métropolitaines (le slug de ville est fragile, ex. « quebec-city-qc » ne matche rien) + filtre state == QC (Ottawa exclu).
10 +
11 +- **Site** : https://www.zumper.com
12 +- **Page des annonces** : https://www.zumper.com/apartments-for-rent/montreal-qc
13 +- **Secteurs couverts** : Grandes régions QC (Montréal/Laval/Longueuil, Québec-Lévis, Gatineau, Sherbrooke, Trois-Rivières, Saguenay, Drummondville, Granby/St-Jean) — API JSON interne en DIRECT (bundle CSRF + pages/listables par bbox + fiches immeubles /buildings), filtre state=QC ; ~3 900 annonces au premier relevé
14 +- **Module** : `louka/connectors/zumper.py` — classe `ZumperConnector`
15 +
16 +## Accès
17 +
18 +- **Type d'accès** : API JSON interne du site
19 +- **Endpoint de base** : https://www.zumper.com
20 +- **URLs de départ (constantes du module)** : https://img.zumpercdn.com/{media_id}/1280x960 · https://www.zumper.com · https://img.zumpercdn.com/{}/1280x960
21 +- **Authentification** : aucune — contenu public
22 +- **Pagination** : pagination par offset
23 +- **Backend anti-bot / rendu** : requests direct (session UA LouKaBot, throttling poli)
24 +- **Politesse** : 1.2 s entre requêtes, timeout 30 s, User-Agent identifiable `LouKaBot/1.0 (+https://www.lou-ka.com/bot)`
25 +
26 +## Champs récupérés → schéma cible
27 +
28 +Le connecteur (direct, API JSON interne du site) alimente les colonnes de la table `listings`. Complétude mesurée en SQL sur les 4804 annonces actives ; exemple tiré d'une ligne réelle de la BD.
29 +
30 +| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |
31 +|---|---|---|---|
32 +| `title` | Titre de l'annonce | 100 % | 170 René-Lévesque — Studio Promotional Price |
33 +| `address` | Adresse civique | 100 % | 170 Boul René-Lévesque E |
34 +| `city` | Ville (normalisée) | 99 % | Montréal |
35 +| `sector` | Secteur / quartier | 80 % | Quartier Ville-Marie |
36 +| `unit_type` | Type d'unité (3½, 4½…) | 100 % | Studio |
37 +| `price` | Loyer mensuel ($) | 100 % | 1 440 $ |
38 +| `bedrooms` | Chambres | 100 % | 0 |
39 +| `bathrooms` | Salles de bain | 100 % | 1 |
40 +| `area_sqft` | Superficie (pi²) | 75 % | 391 |
41 +| `availability_date` | Date de disponibilité | 20 % | — |
42 +| `lat` | GPS (lat/lng) | 100 % | 45.51020505957441 |
43 +| `description` | Description | 99 % | 3 Months Free* *Limited time offer. E & OE conditions apply. Advertised price reflects a … |
44 +| `images` | Photos (JSON) | 100 % | 8 photo(s) — https://img.zumpercdn.com/907672238/1280x960 |
45 +| `amenities` | Commodités (JSON) | 98 % | ["Onsite Laundry", "Air Conditioning", "Dishwasher", "Balcony", "In-Unit Laundry", "Heat"… |
46 +| `details` | Détails additionnels (JSON) | 100 % | {"inclusions": {"heating": true, "electricity": true, "hot_water": true}, "appliances": {… |
47 +| `url` | URL de l'annonce source | 100 % | https://www.zumper.com/apartment-buildings/p891463/170-rene-levesque-quartier-ville-marie… |
48 +
49 +## Fréquence & budget
50 +
51 +- **Cadence observée** (médiane des passages OK, sync_log) : ≈ 2.9 h
52 +- **Dernier passage OK** : 2026-08-25 00:06 — 4773 trouvées, +20 / ~30 / -32
53 +- **Throttling** : 1.2 s entre requêtes (constante de classe `request_delay`)
54 +- **Cache des pages détail (BD `detail_cache`)** : activé — évite de re-visiter les fiches inchangées
55 +
56 +## Volumétrie & complétude
57 +
58 +- **Annonces en BD** : 5181 au total, **4804 actives**
59 +- **Première ingestion** : 2026-08-18 · **Dernière observation** : 2026-08-25
60 +- **Complétude clé (actives)** : GPS 100 % · prix 100 % · photos 100 % · description 99 %
61 +- **Runs journalisés (60 derniers)** : 57, dont 0 en erreur
62 +
63 +## Erreurs connues & dépannage
64 +
65 +Aucune erreur dans les 60 derniers runs journalisés.
66 +
67 +Rejouer la source seule : `python3 run.py sync zumper` · vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE source='zumper' ORDER BY ts DESC LIMIT 5;`).
68 +
69 +## Licence, attribution & conditions
70 +
71 +- Scraping poli d'annonces **publiques** (données factuelles : prix, adresse, disponibilité) publiées par le gestionnaire sur son propre site.
72 +- User-Agent **identifiable** avec page d'information et contact : `LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)` ; throttling 1.2 s ; cache détail pour minimiser les requêtes.
73 +- Chaque annonce affichée sur Lou-Ka **pointe vers l'annonce d'origine** (colonne `url`) — la source garde le trafic de conversion.
74 +- Retrait sur demande : contact@spboucher.ai.
75 +
76 +## Historique
77 +
78 +- 2026-08-18 — premières annonces de la source ingérées dans la BD.
79 +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`).
added docs/ka-maps-lou-ka.md +36 −0
@@ -0,0 +1,36 @@
1 +# Lou-Ka Maps — intégration Ka Maps
2 +
3 +**Auteur : Simon-Pierre Boucher — contact@spboucher.ai — Groupe Ka / Ka Maps (intégration Lou-Ka)**
4 +
5 +Lou-Ka consomme le framework partagé `@groupe-ka/ka-maps`
6 +(`file:../../ka-maps`, moteur Mapbox GL, style Standard 3D). Voir
7 +`~/Desktop/ka-maps/docs/ka-maps.md` pour l'architecture du framework.
8 +
9 +## Fichiers de l'intégration
10 +
11 +| Fichier | Rôle |
12 +|---|---|
13 +| `frontend/src/kamaps/theme.ts` | thème Lou-Ka Maps (encre/lime/vert ; pastilles encre-blanc, sélection lime) |
14 +| `frontend/src/kamaps/adapter.ts` | `/api/listings.geojson?bbox=…` → `MapProperty` (kind `listing`, type `rent`) |
15 +| `frontend/src/kamaps/config.ts` | jeton public Mapbox (surcharge `VITE_MAPBOX_TOKEN`) |
16 +| `frontend/src/components/MapView.tsx` | montage : sync liste↔carte, URL caméra, aperçu React, 3D/2D |
17 +| `frontend/src/pages/Home.tsx` | sélection/survol des cartes de liste + recherche debouncée |
18 +| `louka/web.py` | `/api/listings.geojson` : `bbox`, `limit`, `totalGeocoded`, `totalMatching` |
19 +| `louka/db.py` | index `idx_listings_geo(lat,lng)` (+ `PRAGMA optimize` à l'ingestion) |
20 +
21 +## Spécificités Lou-Ka
22 +
23 +- `cluster={{ maxZoom: 15, valueClamp: [250, 8000] }}` : géocodage par
24 + immeuble (les logements d'une adresse se superposent) et loyers
25 + aberrants (maisons à vendre publiées en location) bornés.
26 +- Compat : sans `bbox`, l'endpoint GeoJSON garde son ancien comportement.
27 +- « N hors carte » = annonces filtrées sans coordonnées ou hors zone
28 + (`totalMatching`) — ~40 % des annonces actives ne sont pas géocodées.
29 +- L'app iOS (MapKit) est hors périmètre Ka Maps : elle partage seulement
30 + les API.
31 +
32 +## Vérifier
33 +
34 +`npm run build` (typecheck+bundle) ; `python run.py serve 8095` puis
35 +`/?view=carte` : grappes → zoom → pastilles → clic → aperçu → pan →
36 +« Rechercher dans cette zone » → filtre → compteur.
added frontend/index.html +38 −0
@@ -0,0 +1,38 @@
1 +<!doctype html>
2 +<!-- ---------------------------------------------------------------------------
3 + Rent-Ka — Rental listings aggregator (Canada, outside Québec)
4 + Author: Simon-Pierre Boucher — contact@spboucher.ai
5 +---------------------------------------------------------------------------- -->
6 +<html lang="en-CA">
7 + <head>
8 + <meta charset="UTF-8" />
9 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10 + <title>Rent-Ka — A Groupe KA service</title>
11 + <meta name="description" content="Rent-Ka aggregates apartments and homes for rent published by property managers across Canada — Toronto, Ottawa, Vancouver, Calgary, Halifax and more — always up to date." />
12 + <meta name="theme-color" content="#f5f3ee" />
13 + <meta name="mobile-web-app-capable" content="yes" />
14 + <meta name="apple-mobile-web-app-capable" content="yes" />
15 + <meta name="apple-mobile-web-app-status-bar-style" content="default" />
16 + <meta name="apple-mobile-web-app-title" content="Rent-Ka" />
17 + <link rel="manifest" href="/manifest.webmanifest" />
18 + <link rel="preconnect" href="https://fonts.googleapis.com" />
19 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
20 + <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400..800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
21 + <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
22 + <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
23 + <meta property="og:image" content="https://www.rent-ka.com/og.png" />
24 + <meta property="og:image:width" content="1200" />
25 + <meta property="og:image:height" content="630" />
26 + <meta name="twitter:card" content="summary_large_image" />
27 + <meta name="twitter:image" content="https://www.rent-ka.com/og.png" />
28 + </head>
29 + <body>
30 + <div id="root"></div>
31 + <script type="module" src="/src/main.tsx"></script>
32 + <script>window.KA_AGENT={site:"rent-ka"}</script>
33 + <script src="/ka-agent.js" defer></script>
34 + <!-- KA ID v2: browser signals for personalization (clicks, dwell) -->
35 + <script>window.KAID_CONF={card:"a[href^='/listing/']",detail:"^/listing/(.+)$",entity:"listing"}</script>
36 + <script src="/ka-id.js" defer></script>
37 + </body>
38 +</html>
added frontend/package-lock.json +1874 −0
@@ -0,0 +1,1874 @@
1 +{
2 + "name": "lou-ka-frontend",
3 + "version": "1.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "lou-ka-frontend",
9 + "version": "1.0.0",
10 + "dependencies": {
11 + "@groupe-ka/ka-maps": "file:../../ka-maps",
12 + "mapbox-gl": "^3.28.1",
13 + "react": "^18.3.1",
14 + "react-dom": "^18.3.1",
15 + "react-router-dom": "^6.26.0"
16 + },
17 + "devDependencies": {
18 + "@types/react": "^18.3.3",
19 + "@types/react-dom": "^18.3.0",
20 + "@vitejs/plugin-react": "^4.3.1",
21 + "typescript": "^5.5.4",
22 + "vite": "^5.4.0"
23 + }
24 + },
25 + "../../ka-maps": {
26 + "name": "@groupe-ka/ka-maps",
27 + "version": "0.2.0",
28 + "license": "UNLICENSED",
29 + "dependencies": {
30 + "@types/geojson": "^7946.0.16",
31 + "mapbox-gl": "^3.28.1"
32 + },
33 + "devDependencies": {
34 + "@types/react": "^18.3.3",
35 + "typescript": "^5.8.0",
36 + "vitest": "^3.0.0"
37 + },
38 + "peerDependencies": {
39 + "react": ">=18"
40 + },
41 + "peerDependenciesMeta": {
42 + "react": {
43 + "optional": true
44 + }
45 + }
46 + },
47 + "node_modules/@babel/code-frame": {
48 + "version": "7.29.7",
49 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
50 + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
51 + "dev": true,
52 + "license": "MIT",
53 + "dependencies": {
54 + "@babel/helper-validator-identifier": "^7.29.7",
55 + "js-tokens": "^4.0.0",
56 + "picocolors": "^1.1.1"
57 + },
58 + "engines": {
59 + "node": ">=6.9.0"
60 + }
61 + },
62 + "node_modules/@babel/compat-data": {
63 + "version": "7.29.7",
64 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
65 + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
66 + "dev": true,
67 + "license": "MIT",
68 + "engines": {
69 + "node": ">=6.9.0"
70 + }
71 + },
72 + "node_modules/@babel/core": {
73 + "version": "7.29.7",
74 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
75 + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
76 + "dev": true,
77 + "license": "MIT",
78 + "dependencies": {
79 + "@babel/code-frame": "^7.29.7",
80 + "@babel/generator": "^7.29.7",
81 + "@babel/helper-compilation-targets": "^7.29.7",
82 + "@babel/helper-module-transforms": "^7.29.7",
83 + "@babel/helpers": "^7.29.7",
84 + "@babel/parser": "^7.29.7",
85 + "@babel/template": "^7.29.7",
86 + "@babel/traverse": "^7.29.7",
87 + "@babel/types": "^7.29.7",
88 + "@jridgewell/remapping": "^2.3.5",
89 + "convert-source-map": "^2.0.0",
90 + "debug": "^4.1.0",
91 + "gensync": "^1.0.0-beta.2",
92 + "json5": "^2.2.3",
93 + "semver": "^6.3.1"
94 + },
95 + "engines": {
96 + "node": ">=6.9.0"
97 + },
98 + "funding": {
99 + "type": "opencollective",
100 + "url": "https://opencollective.com/babel"
101 + }
102 + },
103 + "node_modules/@babel/generator": {
104 + "version": "7.29.8",
105 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
106 + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
107 + "dev": true,
108 + "license": "MIT",
109 + "dependencies": {
110 + "@babel/parser": "^7.29.8",
111 + "@babel/types": "^7.29.8",
112 + "@jridgewell/gen-mapping": "^0.3.12",
113 + "@jridgewell/trace-mapping": "^0.3.28",
114 + "jsesc": "^3.0.2"
115 + },
116 + "engines": {
117 + "node": ">=6.9.0"
118 + }
119 + },
120 + "node_modules/@babel/helper-compilation-targets": {
121 + "version": "7.29.7",
122 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
123 + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
124 + "dev": true,
125 + "license": "MIT",
126 + "dependencies": {
127 + "@babel/compat-data": "^7.29.7",
128 + "@babel/helper-validator-option": "^7.29.7",
129 + "browserslist": "^4.24.0",
130 + "lru-cache": "^5.1.1",
131 + "semver": "^6.3.1"
132 + },
133 + "engines": {
134 + "node": ">=6.9.0"
135 + }
136 + },
137 + "node_modules/@babel/helper-globals": {
138 + "version": "7.29.7",
139 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
140 + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
141 + "dev": true,
142 + "license": "MIT",
143 + "engines": {
144 + "node": ">=6.9.0"
145 + }
146 + },
147 + "node_modules/@babel/helper-module-imports": {
148 + "version": "7.29.7",
149 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
150 + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
151 + "dev": true,
152 + "license": "MIT",
153 + "dependencies": {
154 + "@babel/traverse": "^7.29.7",
155 + "@babel/types": "^7.29.7"
156 + },
157 + "engines": {
158 + "node": ">=6.9.0"
159 + }
160 + },
161 + "node_modules/@babel/helper-module-transforms": {
162 + "version": "7.29.7",
163 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
164 + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
165 + "dev": true,
166 + "license": "MIT",
167 + "dependencies": {
168 + "@babel/helper-module-imports": "^7.29.7",
169 + "@babel/helper-validator-identifier": "^7.29.7",
170 + "@babel/traverse": "^7.29.7"
171 + },
172 + "engines": {
173 + "node": ">=6.9.0"
174 + },
175 + "peerDependencies": {
176 + "@babel/core": "^7.0.0"
177 + }
178 + },
179 + "node_modules/@babel/helper-plugin-utils": {
180 + "version": "7.29.7",
181 + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
182 + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
183 + "dev": true,
184 + "license": "MIT",
185 + "engines": {
186 + "node": ">=6.9.0"
187 + }
188 + },
189 + "node_modules/@babel/helper-string-parser": {
190 + "version": "7.29.7",
191 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
192 + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
193 + "dev": true,
194 + "license": "MIT",
195 + "engines": {
196 + "node": ">=6.9.0"
197 + }
198 + },
199 + "node_modules/@babel/helper-validator-identifier": {
200 + "version": "7.29.7",
201 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
202 + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
203 + "dev": true,
204 + "license": "MIT",
205 + "engines": {
206 + "node": ">=6.9.0"
207 + }
208 + },
209 + "node_modules/@babel/helper-validator-option": {
210 + "version": "7.29.7",
211 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
212 + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
213 + "dev": true,
214 + "license": "MIT",
215 + "engines": {
216 + "node": ">=6.9.0"
217 + }
218 + },
219 + "node_modules/@babel/helpers": {
220 + "version": "7.29.7",
221 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
222 + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
223 + "dev": true,
224 + "license": "MIT",
225 + "dependencies": {
226 + "@babel/template": "^7.29.7",
227 + "@babel/types": "^7.29.7"
228 + },
229 + "engines": {
230 + "node": ">=6.9.0"
231 + }
232 + },
233 + "node_modules/@babel/parser": {
234 + "version": "7.29.8",
235 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
236 + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
237 + "dev": true,
238 + "license": "MIT",
239 + "dependencies": {
240 + "@babel/types": "^7.29.8"
241 + },
242 + "bin": {
243 + "parser": "bin/babel-parser.js"
244 + },
245 + "engines": {
246 + "node": ">=6.0.0"
247 + }
248 + },
249 + "node_modules/@babel/plugin-transform-react-jsx-self": {
250 + "version": "7.29.7",
251 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
252 + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
253 + "dev": true,
254 + "license": "MIT",
255 + "dependencies": {
256 + "@babel/helper-plugin-utils": "^7.29.7"
257 + },
258 + "engines": {
259 + "node": ">=6.9.0"
260 + },
261 + "peerDependencies": {
262 + "@babel/core": "^7.0.0-0"
263 + }
264 + },
265 + "node_modules/@babel/plugin-transform-react-jsx-source": {
266 + "version": "7.29.7",
267 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
268 + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
269 + "dev": true,
270 + "license": "MIT",
271 + "dependencies": {
272 + "@babel/helper-plugin-utils": "^7.29.7"
273 + },
274 + "engines": {
275 + "node": ">=6.9.0"
276 + },
277 + "peerDependencies": {
278 + "@babel/core": "^7.0.0-0"
279 + }
280 + },
281 + "node_modules/@babel/template": {
282 + "version": "7.29.7",
283 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
284 + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
285 + "dev": true,
286 + "license": "MIT",
287 + "dependencies": {
288 + "@babel/code-frame": "^7.29.7",
289 + "@babel/parser": "^7.29.7",
290 + "@babel/types": "^7.29.7"
291 + },
292 + "engines": {
293 + "node": ">=6.9.0"
294 + }
295 + },
296 + "node_modules/@babel/traverse": {
297 + "version": "7.29.8",
298 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
299 + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
300 + "dev": true,
301 + "license": "MIT",
302 + "dependencies": {
303 + "@babel/code-frame": "^7.29.7",
304 + "@babel/generator": "^7.29.8",
305 + "@babel/helper-globals": "^7.29.7",
306 + "@babel/parser": "^7.29.8",
307 + "@babel/template": "^7.29.7",
308 + "@babel/types": "^7.29.8",
309 + "debug": "^4.3.1"
310 + },
311 + "engines": {
312 + "node": ">=6.9.0"
313 + }
314 + },
315 + "node_modules/@babel/types": {
316 + "version": "7.29.8",
317 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
318 + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
319 + "dev": true,
320 + "license": "MIT",
321 + "dependencies": {
322 + "@babel/helper-string-parser": "^7.29.7",
323 + "@babel/helper-validator-identifier": "^7.29.7"
324 + },
325 + "engines": {
326 + "node": ">=6.9.0"
327 + }
328 + },
329 + "node_modules/@esbuild/aix-ppc64": {
330 + "version": "0.21.5",
331 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
332 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
333 + "cpu": [
334 + "ppc64"
335 + ],
336 + "dev": true,
337 + "license": "MIT",
338 + "optional": true,
339 + "os": [
340 + "aix"
341 + ],
342 + "engines": {
343 + "node": ">=12"
344 + }
345 + },
346 + "node_modules/@esbuild/android-arm": {
347 + "version": "0.21.5",
348 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
349 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
350 + "cpu": [
351 + "arm"
352 + ],
353 + "dev": true,
354 + "license": "MIT",
355 + "optional": true,
356 + "os": [
357 + "android"
358 + ],
359 + "engines": {
360 + "node": ">=12"
361 + }
362 + },
363 + "node_modules/@esbuild/android-arm64": {
364 + "version": "0.21.5",
365 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
366 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
367 + "cpu": [
368 + "arm64"
369 + ],
370 + "dev": true,
371 + "license": "MIT",
372 + "optional": true,
373 + "os": [
374 + "android"
375 + ],
376 + "engines": {
377 + "node": ">=12"
378 + }
379 + },
380 + "node_modules/@esbuild/android-x64": {
381 + "version": "0.21.5",
382 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
383 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
384 + "cpu": [
385 + "x64"
386 + ],
387 + "dev": true,
388 + "license": "MIT",
389 + "optional": true,
390 + "os": [
391 + "android"
392 + ],
393 + "engines": {
394 + "node": ">=12"
395 + }
396 + },
397 + "node_modules/@esbuild/darwin-arm64": {
398 + "version": "0.21.5",
399 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
400 + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
401 + "cpu": [
402 + "arm64"
403 + ],
404 + "dev": true,
405 + "license": "MIT",
406 + "optional": true,
407 + "os": [
408 + "darwin"
409 + ],
410 + "engines": {
411 + "node": ">=12"
412 + }
413 + },
414 + "node_modules/@esbuild/darwin-x64": {
415 + "version": "0.21.5",
416 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
417 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
418 + "cpu": [
419 + "x64"
420 + ],
421 + "dev": true,
422 + "license": "MIT",
423 + "optional": true,
424 + "os": [
425 + "darwin"
426 + ],
427 + "engines": {
428 + "node": ">=12"
429 + }
430 + },
431 + "node_modules/@esbuild/freebsd-arm64": {
432 + "version": "0.21.5",
433 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
434 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
435 + "cpu": [
436 + "arm64"
437 + ],
438 + "dev": true,
439 + "license": "MIT",
440 + "optional": true,
441 + "os": [
442 + "freebsd"
443 + ],
444 + "engines": {
445 + "node": ">=12"
446 + }
447 + },
448 + "node_modules/@esbuild/freebsd-x64": {
449 + "version": "0.21.5",
450 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
451 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
452 + "cpu": [
453 + "x64"
454 + ],
455 + "dev": true,
456 + "license": "MIT",
457 + "optional": true,
458 + "os": [
459 + "freebsd"
460 + ],
461 + "engines": {
462 + "node": ">=12"
463 + }
464 + },
465 + "node_modules/@esbuild/linux-arm": {
466 + "version": "0.21.5",
467 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
468 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
469 + "cpu": [
470 + "arm"
471 + ],
472 + "dev": true,
473 + "license": "MIT",
474 + "optional": true,
475 + "os": [
476 + "linux"
477 + ],
478 + "engines": {
479 + "node": ">=12"
480 + }
481 + },
482 + "node_modules/@esbuild/linux-arm64": {
483 + "version": "0.21.5",
484 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
485 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
486 + "cpu": [
487 + "arm64"
488 + ],
489 + "dev": true,
490 + "license": "MIT",
491 + "optional": true,
492 + "os": [
493 + "linux"
494 + ],
495 + "engines": {
496 + "node": ">=12"
497 + }
498 + },
499 + "node_modules/@esbuild/linux-ia32": {
500 + "version": "0.21.5",
501 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
502 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
503 + "cpu": [
504 + "ia32"
505 + ],
506 + "dev": true,
507 + "license": "MIT",
508 + "optional": true,
509 + "os": [
510 + "linux"
511 + ],
512 + "engines": {
513 + "node": ">=12"
514 + }
515 + },
516 + "node_modules/@esbuild/linux-loong64": {
517 + "version": "0.21.5",
518 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
519 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
520 + "cpu": [
521 + "loong64"
522 + ],
523 + "dev": true,
524 + "license": "MIT",
525 + "optional": true,
526 + "os": [
527 + "linux"
528 + ],
529 + "engines": {
530 + "node": ">=12"
531 + }
532 + },
533 + "node_modules/@esbuild/linux-mips64el": {
534 + "version": "0.21.5",
535 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
536 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
537 + "cpu": [
538 + "mips64el"
539 + ],
540 + "dev": true,
541 + "license": "MIT",
542 + "optional": true,
543 + "os": [
544 + "linux"
545 + ],
546 + "engines": {
547 + "node": ">=12"
548 + }
549 + },
550 + "node_modules/@esbuild/linux-ppc64": {
551 + "version": "0.21.5",
552 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
553 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
554 + "cpu": [
555 + "ppc64"
556 + ],
557 + "dev": true,
558 + "license": "MIT",
559 + "optional": true,
560 + "os": [
561 + "linux"
562 + ],
563 + "engines": {
564 + "node": ">=12"
565 + }
566 + },
567 + "node_modules/@esbuild/linux-riscv64": {
568 + "version": "0.21.5",
569 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
570 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
571 + "cpu": [
572 + "riscv64"
573 + ],
574 + "dev": true,
575 + "license": "MIT",
576 + "optional": true,
577 + "os": [
578 + "linux"
579 + ],
580 + "engines": {
581 + "node": ">=12"
582 + }
583 + },
584 + "node_modules/@esbuild/linux-s390x": {
585 + "version": "0.21.5",
586 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
587 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
588 + "cpu": [
589 + "s390x"
590 + ],
591 + "dev": true,
592 + "license": "MIT",
593 + "optional": true,
594 + "os": [
595 + "linux"
596 + ],
597 + "engines": {
598 + "node": ">=12"
599 + }
600 + },
601 + "node_modules/@esbuild/linux-x64": {
602 + "version": "0.21.5",
603 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
604 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
605 + "cpu": [
606 + "x64"
607 + ],
608 + "dev": true,
609 + "license": "MIT",
610 + "optional": true,
611 + "os": [
612 + "linux"
613 + ],
614 + "engines": {
615 + "node": ">=12"
616 + }
617 + },
618 + "node_modules/@esbuild/netbsd-x64": {
619 + "version": "0.21.5",
620 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
621 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
622 + "cpu": [
623 + "x64"
624 + ],
625 + "dev": true,
626 + "license": "MIT",
627 + "optional": true,
628 + "os": [
629 + "netbsd"
630 + ],
631 + "engines": {
632 + "node": ">=12"
633 + }
634 + },
635 + "node_modules/@esbuild/openbsd-x64": {
636 + "version": "0.21.5",
637 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
638 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
639 + "cpu": [
640 + "x64"
641 + ],
642 + "dev": true,
643 + "license": "MIT",
644 + "optional": true,
645 + "os": [
646 + "openbsd"
647 + ],
648 + "engines": {
649 + "node": ">=12"
650 + }
651 + },
652 + "node_modules/@esbuild/sunos-x64": {
653 + "version": "0.21.5",
654 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
655 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
656 + "cpu": [
657 + "x64"
658 + ],
659 + "dev": true,
660 + "license": "MIT",
661 + "optional": true,
662 + "os": [
663 + "sunos"
664 + ],
665 + "engines": {
666 + "node": ">=12"
667 + }
668 + },
669 + "node_modules/@esbuild/win32-arm64": {
670 + "version": "0.21.5",
671 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
672 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
673 + "cpu": [
674 + "arm64"
675 + ],
676 + "dev": true,
677 + "license": "MIT",
678 + "optional": true,
679 + "os": [
680 + "win32"
681 + ],
682 + "engines": {
683 + "node": ">=12"
684 + }
685 + },
686 + "node_modules/@esbuild/win32-ia32": {
687 + "version": "0.21.5",
688 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
689 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
690 + "cpu": [
691 + "ia32"
692 + ],
693 + "dev": true,
694 + "license": "MIT",
695 + "optional": true,
696 + "os": [
697 + "win32"
698 + ],
699 + "engines": {
700 + "node": ">=12"
701 + }
702 + },
703 + "node_modules/@esbuild/win32-x64": {
704 + "version": "0.21.5",
705 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
706 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
707 + "cpu": [
708 + "x64"
709 + ],
710 + "dev": true,
711 + "license": "MIT",
712 + "optional": true,
713 + "os": [
714 + "win32"
715 + ],
716 + "engines": {
717 + "node": ">=12"
718 + }
719 + },
720 + "node_modules/@groupe-ka/ka-maps": {
721 + "resolved": "../../ka-maps",
722 + "link": true
723 + },
724 + "node_modules/@jridgewell/gen-mapping": {
725 + "version": "0.3.13",
726 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
727 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
728 + "dev": true,
729 + "license": "MIT",
730 + "dependencies": {
731 + "@jridgewell/sourcemap-codec": "^1.5.0",
732 + "@jridgewell/trace-mapping": "^0.3.24"
733 + }
734 + },
735 + "node_modules/@jridgewell/remapping": {
736 + "version": "2.3.5",
737 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
738 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
739 + "dev": true,
740 + "license": "MIT",
741 + "dependencies": {
742 + "@jridgewell/gen-mapping": "^0.3.5",
743 + "@jridgewell/trace-mapping": "^0.3.24"
744 + }
745 + },
746 + "node_modules/@jridgewell/resolve-uri": {
747 + "version": "3.1.2",
748 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
749 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
750 + "dev": true,
751 + "license": "MIT",
752 + "engines": {
753 + "node": ">=6.0.0"
754 + }
755 + },
756 + "node_modules/@jridgewell/sourcemap-codec": {
757 + "version": "1.5.5",
758 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
759 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
760 + "dev": true,
761 + "license": "MIT"
762 + },
763 + "node_modules/@jridgewell/trace-mapping": {
764 + "version": "0.3.31",
765 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
766 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
767 + "dev": true,
768 + "license": "MIT",
769 + "dependencies": {
770 + "@jridgewell/resolve-uri": "^3.1.0",
771 + "@jridgewell/sourcemap-codec": "^1.4.14"
772 + }
773 + },
774 + "node_modules/@napi-rs/lzma-linux-x64-gnu": {
775 + "version": "1.5.1",
776 + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
777 + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
778 + "cpu": [
779 + "x64"
780 + ],
781 + "dev": true,
782 + "libc": [
783 + "glibc"
784 + ],
785 + "license": "MIT",
786 + "optional": true,
787 + "os": [
788 + "linux"
789 + ],
790 + "engines": {
791 + "node": "^22.20 || ^24.12 || >=25"
792 + }
793 + },
794 + "node_modules/@remix-run/router": {
795 + "version": "1.23.3",
796 + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
797 + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
798 + "license": "MIT",
799 + "engines": {
800 + "node": ">=14.0.0"
801 + }
802 + },
803 + "node_modules/@rolldown/pluginutils": {
804 + "version": "1.0.0-beta.27",
805 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
806 + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
807 + "dev": true,
808 + "license": "MIT"
809 + },
810 + "node_modules/@rollup/rollup-android-arm-eabi": {
811 + "version": "4.62.4",
812 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
813 + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
814 + "cpu": [
815 + "arm"
816 + ],
817 + "dev": true,
818 + "license": "MIT",
819 + "optional": true,
820 + "os": [
821 + "android"
822 + ]
823 + },
824 + "node_modules/@rollup/rollup-android-arm64": {
825 + "version": "4.62.4",
826 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
827 + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
828 + "cpu": [
829 + "arm64"
830 + ],
831 + "dev": true,
832 + "license": "MIT",
833 + "optional": true,
834 + "os": [
835 + "android"
836 + ]
837 + },
838 + "node_modules/@rollup/rollup-darwin-arm64": {
839 + "version": "4.62.4",
840 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
841 + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
842 + "cpu": [
843 + "arm64"
844 + ],
845 + "dev": true,
846 + "license": "MIT",
847 + "optional": true,
848 + "os": [
849 + "darwin"
850 + ]
851 + },
852 + "node_modules/@rollup/rollup-darwin-x64": {
853 + "version": "4.62.4",
854 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
855 + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
856 + "cpu": [
857 + "x64"
858 + ],
859 + "dev": true,
860 + "license": "MIT",
861 + "optional": true,
862 + "os": [
863 + "darwin"
864 + ]
865 + },
866 + "node_modules/@rollup/rollup-freebsd-arm64": {
867 + "version": "4.62.4",
868 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
869 + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
870 + "cpu": [
871 + "arm64"
872 + ],
873 + "dev": true,
874 + "license": "MIT",
875 + "optional": true,
876 + "os": [
877 + "freebsd"
878 + ]
879 + },
880 + "node_modules/@rollup/rollup-freebsd-x64": {
881 + "version": "4.62.4",
882 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
883 + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
884 + "cpu": [
885 + "x64"
886 + ],
887 + "dev": true,
888 + "license": "MIT",
889 + "optional": true,
890 + "os": [
891 + "freebsd"
892 + ]
893 + },
894 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
895 + "version": "4.62.4",
896 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
897 + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
898 + "cpu": [
899 + "arm"
900 + ],
901 + "dev": true,
902 + "libc": [
903 + "glibc"
904 + ],
905 + "license": "MIT",
906 + "optional": true,
907 + "os": [
908 + "linux"
909 + ]
910 + },
911 + "node_modules/@rollup/rollup-linux-arm-musleabihf": {
912 + "version": "4.62.4",
913 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
914 + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
915 + "cpu": [
916 + "arm"
917 + ],
918 + "dev": true,
919 + "libc": [
920 + "musl"
921 + ],
922 + "license": "MIT",
923 + "optional": true,
924 + "os": [
925 + "linux"
926 + ]
927 + },
928 + "node_modules/@rollup/rollup-linux-arm64-gnu": {
929 + "version": "4.62.4",
930 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
931 + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
932 + "cpu": [
933 + "arm64"
934 + ],
935 + "dev": true,
936 + "libc": [
937 + "glibc"
938 + ],
939 + "license": "MIT",
940 + "optional": true,
941 + "os": [
942 + "linux"
943 + ]
944 + },
945 + "node_modules/@rollup/rollup-linux-arm64-musl": {
946 + "version": "4.62.4",
947 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
948 + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
949 + "cpu": [
950 + "arm64"
951 + ],
952 + "dev": true,
953 + "libc": [
954 + "musl"
955 + ],
956 + "license": "MIT",
957 + "optional": true,
958 + "os": [
959 + "linux"
960 + ]
961 + },
962 + "node_modules/@rollup/rollup-linux-loong64-gnu": {
963 + "version": "4.62.4",
964 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
965 + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
966 + "cpu": [
967 + "loong64"
968 + ],
969 + "dev": true,
970 + "libc": [
971 + "glibc"
972 + ],
973 + "license": "MIT",
974 + "optional": true,
975 + "os": [
976 + "linux"
977 + ]
978 + },
979 + "node_modules/@rollup/rollup-linux-loong64-musl": {
980 + "version": "4.62.4",
981 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
982 + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
983 + "cpu": [
984 + "loong64"
985 + ],
986 + "dev": true,
987 + "libc": [
988 + "musl"
989 + ],
990 + "license": "MIT",
991 + "optional": true,
992 + "os": [
993 + "linux"
994 + ]
995 + },
996 + "node_modules/@rollup/rollup-linux-ppc64-gnu": {
997 + "version": "4.62.4",
998 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
999 + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
1000 + "cpu": [
1001 + "ppc64"
1002 + ],
1003 + "dev": true,
1004 + "libc": [
1005 + "glibc"
1006 + ],
1007 + "license": "MIT",
1008 + "optional": true,
1009 + "os": [
1010 + "linux"
1011 + ]
1012 + },
1013 + "node_modules/@rollup/rollup-linux-ppc64-musl": {
1014 + "version": "4.62.4",
1015 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
1016 + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
1017 + "cpu": [
1018 + "ppc64"
1019 + ],
1020 + "dev": true,
1021 + "libc": [
1022 + "musl"
1023 + ],
1024 + "license": "MIT",
1025 + "optional": true,
1026 + "os": [
1027 + "linux"
1028 + ]
1029 + },
1030 + "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1031 + "version": "4.62.4",
1032 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
1033 + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
1034 + "cpu": [
1035 + "riscv64"
1036 + ],
1037 + "dev": true,
1038 + "libc": [
1039 + "glibc"
1040 + ],
1041 + "license": "MIT",
1042 + "optional": true,
1043 + "os": [
1044 + "linux"
1045 + ]
1046 + },
1047 + "node_modules/@rollup/rollup-linux-riscv64-musl": {
1048 + "version": "4.62.4",
1049 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
1050 + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
1051 + "cpu": [
1052 + "riscv64"
1053 + ],
1054 + "dev": true,
1055 + "libc": [
1056 + "musl"
1057 + ],
1058 + "license": "MIT",
1059 + "optional": true,
1060 + "os": [
1061 + "linux"
1062 + ]
1063 + },
1064 + "node_modules/@rollup/rollup-linux-s390x-gnu": {
1065 + "version": "4.62.4",
1066 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
1067 + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
1068 + "cpu": [
1069 + "s390x"
1070 + ],
1071 + "dev": true,
1072 + "libc": [
1073 + "glibc"
1074 + ],
1075 + "license": "MIT",
1076 + "optional": true,
1077 + "os": [
1078 + "linux"
1079 + ]
1080 + },
1081 + "node_modules/@rollup/rollup-linux-x64-gnu": {
1082 + "version": "4.62.4",
1083 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
1084 + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
1085 + "cpu": [
1086 + "x64"
1087 + ],
1088 + "dev": true,
1089 + "libc": [
1090 + "glibc"
1091 + ],
1092 + "license": "MIT",
1093 + "optional": true,
1094 + "os": [
1095 + "linux"
1096 + ]
1097 + },
1098 + "node_modules/@rollup/rollup-linux-x64-musl": {
1099 + "version": "4.62.4",
1100 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
1101 + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
1102 + "cpu": [
1103 + "x64"
1104 + ],
1105 + "dev": true,
1106 + "libc": [
1107 + "musl"
1108 + ],
1109 + "license": "MIT",
1110 + "optional": true,
1111 + "os": [
1112 + "linux"
1113 + ]
1114 + },
1115 + "node_modules/@rollup/rollup-openbsd-x64": {
1116 + "version": "4.62.4",
1117 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
1118 + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
1119 + "cpu": [
1120 + "x64"
1121 + ],
1122 + "dev": true,
1123 + "license": "MIT",
1124 + "optional": true,
1125 + "os": [
1126 + "openbsd"
1127 + ]
1128 + },
1129 + "node_modules/@rollup/rollup-openharmony-arm64": {
1130 + "version": "4.62.4",
1131 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
1132 + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
1133 + "cpu": [
1134 + "arm64"
1135 + ],
1136 + "dev": true,
1137 + "license": "MIT",
1138 + "optional": true,
1139 + "os": [
1140 + "openharmony"
1141 + ]
1142 + },
1143 + "node_modules/@rollup/rollup-win32-arm64-msvc": {
1144 + "version": "4.62.4",
1145 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
1146 + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
1147 + "cpu": [
1148 + "arm64"
1149 + ],
1150 + "dev": true,
1151 + "license": "MIT",
1152 + "optional": true,
1153 + "os": [
1154 + "win32"
1155 + ]
1156 + },
1157 + "node_modules/@rollup/rollup-win32-ia32-msvc": {
1158 + "version": "4.62.4",
1159 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
1160 + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
1161 + "cpu": [
1162 + "ia32"
1163 + ],
1164 + "dev": true,
1165 + "license": "MIT",
1166 + "optional": true,
1167 + "os": [
1168 + "win32"
1169 + ]
1170 + },
1171 + "node_modules/@rollup/rollup-win32-x64-gnu": {
1172 + "version": "4.62.4",
1173 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
1174 + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
1175 + "cpu": [
1176 + "x64"
1177 + ],
1178 + "dev": true,
1179 + "license": "MIT",
1180 + "optional": true,
1181 + "os": [
1182 + "win32"
1183 + ]
1184 + },
1185 + "node_modules/@rollup/rollup-win32-x64-msvc": {
1186 + "version": "4.62.4",
1187 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
1188 + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
1189 + "cpu": [
1190 + "x64"
1191 + ],
1192 + "dev": true,
1193 + "license": "MIT",
1194 + "optional": true,
1195 + "os": [
1196 + "win32"
1197 + ]
1198 + },
1199 + "node_modules/@types/babel__core": {
1200 + "version": "7.20.5",
1201 + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1202 + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1203 + "dev": true,
1204 + "license": "MIT",
1205 + "dependencies": {
1206 + "@babel/parser": "^7.20.7",
1207 + "@babel/types": "^7.20.7",
1208 + "@types/babel__generator": "*",
1209 + "@types/babel__template": "*",
1210 + "@types/babel__traverse": "*"
1211 + }
1212 + },
1213 + "node_modules/@types/babel__generator": {
1214 + "version": "7.27.0",
1215 + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1216 + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1217 + "dev": true,
1218 + "license": "MIT",
1219 + "dependencies": {
1220 + "@babel/types": "^7.0.0"
1221 + }
1222 + },
1223 + "node_modules/@types/babel__template": {
1224 + "version": "7.4.4",
1225 + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1226 + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1227 + "dev": true,
1228 + "license": "MIT",
1229 + "dependencies": {
1230 + "@babel/parser": "^7.1.0",
1231 + "@babel/types": "^7.0.0"
1232 + }
1233 + },
1234 + "node_modules/@types/babel__traverse": {
1235 + "version": "7.28.0",
1236 + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1237 + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1238 + "dev": true,
1239 + "license": "MIT",
1240 + "dependencies": {
1241 + "@babel/types": "^7.28.2"
1242 + }
1243 + },
1244 + "node_modules/@types/estree": {
1245 + "version": "1.0.9",
1246 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1247 + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1248 + "dev": true,
1249 + "license": "MIT"
1250 + },
1251 + "node_modules/@types/prop-types": {
1252 + "version": "15.7.15",
1253 + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1254 + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1255 + "dev": true,
1256 + "license": "MIT"
1257 + },
1258 + "node_modules/@types/react": {
1259 + "version": "18.3.31",
1260 + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
1261 + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1262 + "dev": true,
1263 + "license": "MIT",
1264 + "dependencies": {
1265 + "@types/prop-types": "*",
1266 + "csstype": "^3.2.2"
1267 + }
1268 + },
1269 + "node_modules/@types/react-dom": {
1270 + "version": "18.3.7",
1271 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1272 + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1273 + "dev": true,
1274 + "license": "MIT",
1275 + "peerDependencies": {
1276 + "@types/react": "^18.0.0"
1277 + }
1278 + },
1279 + "node_modules/@vitejs/plugin-react": {
1280 + "version": "4.7.0",
1281 + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1282 + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1283 + "dev": true,
1284 + "license": "MIT",
1285 + "dependencies": {
1286 + "@babel/core": "^7.28.0",
1287 + "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1288 + "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1289 + "@rolldown/pluginutils": "1.0.0-beta.27",
1290 + "@types/babel__core": "^7.20.5",
1291 + "react-refresh": "^0.17.0"
1292 + },
1293 + "engines": {
1294 + "node": "^14.18.0 || >=16.0.0"
1295 + },
1296 + "peerDependencies": {
1297 + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1298 + }
1299 + },
1300 + "node_modules/baseline-browser-mapping": {
1301 + "version": "2.11.12",
1302 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz",
1303 + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==",
1304 + "dev": true,
1305 + "license": "Apache-2.0",
1306 + "bin": {
1307 + "baseline-browser-mapping": "dist/cli.cjs"
1308 + },
1309 + "engines": {
1310 + "node": ">=6.0.0"
1311 + }
1312 + },
1313 + "node_modules/browserslist": {
1314 + "version": "4.28.7",
1315 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
1316 + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
1317 + "dev": true,
1318 + "funding": [
1319 + {
1320 + "type": "opencollective",
1321 + "url": "https://opencollective.com/browserslist"
1322 + },
1323 + {
1324 + "type": "tidelift",
1325 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1326 + },
1327 + {
1328 + "type": "github",
1329 + "url": "https://github.com/sponsors/ai"
1330 + }
1331 + ],
1332 + "license": "MIT",
1333 + "dependencies": {
1334 + "baseline-browser-mapping": "^2.10.44",
1335 + "caniuse-lite": "^1.0.30001806",
1336 + "electron-to-chromium": "^1.5.393",
1337 + "node-releases": "^2.0.51",
1338 + "update-browserslist-db": "^1.2.3"
1339 + },
1340 + "bin": {
1341 + "browserslist": "cli.js"
1342 + },
1343 + "engines": {
1344 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1345 + }
1346 + },
1347 + "node_modules/caniuse-lite": {
1348 + "version": "1.0.30001807",
1349 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz",
1350 + "integrity": "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==",
1351 + "dev": true,
1352 + "funding": [
1353 + {
1354 + "type": "opencollective",
1355 + "url": "https://opencollective.com/browserslist"
1356 + },
1357 + {
1358 + "type": "tidelift",
1359 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1360 + },
1361 + {
1362 + "type": "github",
1363 + "url": "https://github.com/sponsors/ai"
1364 + }
1365 + ],
1366 + "license": "CC-BY-4.0"
1367 + },
1368 + "node_modules/convert-source-map": {
1369 + "version": "2.0.0",
1370 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1371 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1372 + "dev": true,
1373 + "license": "MIT"
1374 + },
1375 + "node_modules/csstype": {
1376 + "version": "3.2.3",
1377 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1378 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1379 + "dev": true,
1380 + "license": "MIT"
1381 + },
1382 + "node_modules/debug": {
1383 + "version": "4.4.3",
1384 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1385 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1386 + "dev": true,
1387 + "license": "MIT",
1388 + "dependencies": {
1389 + "ms": "^2.1.3"
1390 + },
1391 + "engines": {
1392 + "node": ">=6.0"
1393 + },
1394 + "peerDependenciesMeta": {
1395 + "supports-color": {
1396 + "optional": true
1397 + }
1398 + }
1399 + },
1400 + "node_modules/electron-to-chromium": {
1401 + "version": "1.5.402",
1402 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz",
1403 + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==",
1404 + "dev": true,
1405 + "license": "ISC"
1406 + },
1407 + "node_modules/esbuild": {
1408 + "version": "0.21.5",
1409 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1410 + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1411 + "dev": true,
1412 + "hasInstallScript": true,
1413 + "license": "MIT",
1414 + "bin": {
1415 + "esbuild": "bin/esbuild"
1416 + },
1417 + "engines": {
1418 + "node": ">=12"
1419 + },
1420 + "optionalDependencies": {
1421 + "@esbuild/aix-ppc64": "0.21.5",
1422 + "@esbuild/android-arm": "0.21.5",
1423 + "@esbuild/android-arm64": "0.21.5",
1424 + "@esbuild/android-x64": "0.21.5",
1425 + "@esbuild/darwin-arm64": "0.21.5",
1426 + "@esbuild/darwin-x64": "0.21.5",
1427 + "@esbuild/freebsd-arm64": "0.21.5",
1428 + "@esbuild/freebsd-x64": "0.21.5",
1429 + "@esbuild/linux-arm": "0.21.5",
1430 + "@esbuild/linux-arm64": "0.21.5",
1431 + "@esbuild/linux-ia32": "0.21.5",
1432 + "@esbuild/linux-loong64": "0.21.5",
1433 + "@esbuild/linux-mips64el": "0.21.5",
1434 + "@esbuild/linux-ppc64": "0.21.5",
1435 + "@esbuild/linux-riscv64": "0.21.5",
1436 + "@esbuild/linux-s390x": "0.21.5",
1437 + "@esbuild/linux-x64": "0.21.5",
1438 + "@esbuild/netbsd-x64": "0.21.5",
1439 + "@esbuild/openbsd-x64": "0.21.5",
1440 + "@esbuild/sunos-x64": "0.21.5",
1441 + "@esbuild/win32-arm64": "0.21.5",
1442 + "@esbuild/win32-ia32": "0.21.5",
1443 + "@esbuild/win32-x64": "0.21.5"
1444 + }
1445 + },
1446 + "node_modules/escalade": {
1447 + "version": "3.2.0",
1448 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1449 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1450 + "dev": true,
1451 + "license": "MIT",
1452 + "engines": {
1453 + "node": ">=6"
1454 + }
1455 + },
1456 + "node_modules/fsevents": {
1457 + "version": "2.3.3",
1458 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1459 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1460 + "dev": true,
1461 + "hasInstallScript": true,
1462 + "license": "MIT",
1463 + "optional": true,
1464 + "os": [
1465 + "darwin"
1466 + ],
1467 + "engines": {
1468 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1469 + }
1470 + },
1471 + "node_modules/gensync": {
1472 + "version": "1.0.0-beta.2",
1473 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1474 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1475 + "dev": true,
1476 + "license": "MIT",
1477 + "engines": {
1478 + "node": ">=6.9.0"
1479 + }
1480 + },
1481 + "node_modules/js-tokens": {
1482 + "version": "4.0.0",
1483 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1484 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1485 + "license": "MIT"
1486 + },
1487 + "node_modules/jsesc": {
1488 + "version": "3.1.0",
1489 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1490 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1491 + "dev": true,
1492 + "license": "MIT",
1493 + "bin": {
1494 + "jsesc": "bin/jsesc"
1495 + },
1496 + "engines": {
1497 + "node": ">=6"
1498 + }
1499 + },
1500 + "node_modules/json5": {
1501 + "version": "2.2.3",
1502 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1503 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1504 + "dev": true,
1505 + "license": "MIT",
1506 + "bin": {
1507 + "json5": "lib/cli.js"
1508 + },
1509 + "engines": {
1510 + "node": ">=6"
1511 + }
1512 + },
1513 + "node_modules/loose-envify": {
1514 + "version": "1.4.0",
1515 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1516 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1517 + "license": "MIT",
1518 + "dependencies": {
1519 + "js-tokens": "^3.0.0 || ^4.0.0"
1520 + },
1521 + "bin": {
1522 + "loose-envify": "cli.js"
1523 + }
1524 + },
1525 + "node_modules/lru-cache": {
1526 + "version": "5.1.1",
1527 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1528 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1529 + "dev": true,
1530 + "license": "ISC",
1531 + "dependencies": {
1532 + "yallist": "^3.0.2"
1533 + }
1534 + },
1535 + "node_modules/mapbox-gl": {
1536 + "version": "3.28.1",
1537 + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
1538 + "integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
1539 + "license": "SEE LICENSE IN LICENSE.txt",
1540 + "workspaces": [
1541 + "src/style-spec",
1542 + "plugins/mapbox-gl-pmtiles-provider",
1543 + "test/bundlers/*",
1544 + "test/build/typings"
1545 + ]
1546 + },
1547 + "node_modules/ms": {
1548 + "version": "2.1.3",
1549 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1550 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1551 + "dev": true,
1552 + "license": "MIT"
1553 + },
1554 + "node_modules/nanoid": {
1555 + "version": "3.3.17",
1556 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
1557 + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
1558 + "dev": true,
1559 + "funding": [
1560 + {
1561 + "type": "github",
1562 + "url": "https://github.com/sponsors/ai"
1563 + }
1564 + ],
1565 + "license": "MIT",
1566 + "bin": {
1567 + "nanoid": "bin/nanoid.cjs"
1568 + },
1569 + "engines": {
1570 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1571 + }
1572 + },
1573 + "node_modules/node-releases": {
1574 + "version": "2.0.53",
1575 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
1576 + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
1577 + "dev": true,
1578 + "license": "MIT",
1579 + "engines": {
1580 + "node": ">=18"
1581 + }
1582 + },
1583 + "node_modules/picocolors": {
1584 + "version": "1.1.1",
1585 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1586 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1587 + "dev": true,
1588 + "license": "ISC"
1589 + },
1590 + "node_modules/postcss": {
1591 + "version": "8.5.26",
1592 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1593 + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1594 + "dev": true,
1595 + "funding": [
1596 + {
1597 + "type": "opencollective",
1598 + "url": "https://opencollective.com/postcss/"
1599 + },
1600 + {
1601 + "type": "tidelift",
1602 + "url": "https://tidelift.com/funding/github/npm/postcss"
1603 + },
1604 + {
1605 + "type": "github",
1606 + "url": "https://github.com/sponsors/ai"
1607 + }
1608 + ],
1609 + "license": "MIT",
1610 + "dependencies": {
1611 + "nanoid": "^3.3.17",
1612 + "picocolors": "^1.1.1",
1613 + "source-map-js": "^1.2.1"
1614 + },
1615 + "engines": {
1616 + "node": "^10 || ^12 || >=14"
1617 + }
1618 + },
1619 + "node_modules/react": {
1620 + "version": "18.3.1",
1621 + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1622 + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1623 + "license": "MIT",
1624 + "dependencies": {
1625 + "loose-envify": "^1.1.0"
1626 + },
1627 + "engines": {
1628 + "node": ">=0.10.0"
1629 + }
1630 + },
1631 + "node_modules/react-dom": {
1632 + "version": "18.3.1",
1633 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1634 + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1635 + "license": "MIT",
1636 + "dependencies": {
1637 + "loose-envify": "^1.1.0",
1638 + "scheduler": "^0.23.2"
1639 + },
1640 + "peerDependencies": {
1641 + "react": "^18.3.1"
1642 + }
1643 + },
1644 + "node_modules/react-refresh": {
1645 + "version": "0.17.0",
1646 + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1647 + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1648 + "dev": true,
1649 + "license": "MIT",
1650 + "engines": {
1651 + "node": ">=0.10.0"
1652 + }
1653 + },
1654 + "node_modules/react-router": {
1655 + "version": "6.30.4",
1656 + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
1657 + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
1658 + "license": "MIT",
1659 + "dependencies": {
1660 + "@remix-run/router": "1.23.3"
1661 + },
1662 + "engines": {
1663 + "node": ">=14.0.0"
1664 + },
1665 + "peerDependencies": {
1666 + "react": ">=16.8"
1667 + }
1668 + },
1669 + "node_modules/react-router-dom": {
1670 + "version": "6.30.4",
1671 + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
1672 + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
1673 + "license": "MIT",
1674 + "dependencies": {
1675 + "@remix-run/router": "1.23.3",
1676 + "react-router": "6.30.4"
1677 + },
1678 + "engines": {
1679 + "node": ">=14.0.0"
1680 + },
1681 + "peerDependencies": {
1682 + "react": ">=16.8",
1683 + "react-dom": ">=16.8"
1684 + }
1685 + },
1686 + "node_modules/rollup": {
1687 + "version": "4.62.4",
1688 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
1689 + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
1690 + "dev": true,
1691 + "license": "MIT",
1692 + "dependencies": {
1693 + "@types/estree": "1.0.9"
1694 + },
1695 + "bin": {
1696 + "rollup": "dist/bin/rollup"
1697 + },
1698 + "engines": {
1699 + "node": ">=18.0.0",
1700 + "npm": ">=8.0.0"
1701 + },
1702 + "optionalDependencies": {
1703 + "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1704 + "@rollup/rollup-android-arm-eabi": "4.62.4",
1705 + "@rollup/rollup-android-arm64": "4.62.4",
1706 + "@rollup/rollup-darwin-arm64": "4.62.4",
1707 + "@rollup/rollup-darwin-x64": "4.62.4",
1708 + "@rollup/rollup-freebsd-arm64": "4.62.4",
1709 + "@rollup/rollup-freebsd-x64": "4.62.4",
1710 + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
1711 + "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
1712 + "@rollup/rollup-linux-arm64-gnu": "4.62.4",
1713 + "@rollup/rollup-linux-arm64-musl": "4.62.4",
1714 + "@rollup/rollup-linux-loong64-gnu": "4.62.4",
1715 + "@rollup/rollup-linux-loong64-musl": "4.62.4",
1716 + "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
1717 + "@rollup/rollup-linux-ppc64-musl": "4.62.4",
1718 + "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
1719 + "@rollup/rollup-linux-riscv64-musl": "4.62.4",
1720 + "@rollup/rollup-linux-s390x-gnu": "4.62.4",
1721 + "@rollup/rollup-linux-x64-gnu": "4.62.4",
1722 + "@rollup/rollup-linux-x64-musl": "4.62.4",
1723 + "@rollup/rollup-openbsd-x64": "4.62.4",
1724 + "@rollup/rollup-openharmony-arm64": "4.62.4",
1725 + "@rollup/rollup-win32-arm64-msvc": "4.62.4",
1726 + "@rollup/rollup-win32-ia32-msvc": "4.62.4",
1727 + "@rollup/rollup-win32-x64-gnu": "4.62.4",
1728 + "@rollup/rollup-win32-x64-msvc": "4.62.4",
1729 + "fsevents": "~2.3.2"
1730 + }
1731 + },
1732 + "node_modules/scheduler": {
1733 + "version": "0.23.2",
1734 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1735 + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1736 + "license": "MIT",
1737 + "dependencies": {
1738 + "loose-envify": "^1.1.0"
1739 + }
1740 + },
1741 + "node_modules/semver": {
1742 + "version": "6.3.1",
1743 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1744 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1745 + "dev": true,
1746 + "license": "ISC",
1747 + "bin": {
1748 + "semver": "bin/semver.js"
1749 + }
1750 + },
1751 + "node_modules/source-map-js": {
1752 + "version": "1.2.1",
1753 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1754 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1755 + "dev": true,
1756 + "license": "BSD-3-Clause",
1757 + "engines": {
1758 + "node": ">=0.10.0"
1759 + }
1760 + },
1761 + "node_modules/typescript": {
1762 + "version": "5.9.3",
1763 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1764 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1765 + "dev": true,
1766 + "license": "Apache-2.0",
1767 + "bin": {
1768 + "tsc": "bin/tsc",
1769 + "tsserver": "bin/tsserver"
1770 + },
1771 + "engines": {
1772 + "node": ">=14.17"
1773 + }
1774 + },
1775 + "node_modules/update-browserslist-db": {
1776 + "version": "1.2.3",
1777 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
1778 + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
1779 + "dev": true,
1780 + "funding": [
1781 + {
1782 + "type": "opencollective",
1783 + "url": "https://opencollective.com/browserslist"
1784 + },
1785 + {
1786 + "type": "tidelift",
1787 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1788 + },
1789 + {
1790 + "type": "github",
1791 + "url": "https://github.com/sponsors/ai"
1792 + }
1793 + ],
1794 + "license": "MIT",
1795 + "dependencies": {
1796 + "escalade": "^3.2.0",
1797 + "picocolors": "^1.1.1"
1798 + },
1799 + "bin": {
1800 + "update-browserslist-db": "cli.js"
1801 + },
1802 + "peerDependencies": {
1803 + "browserslist": ">= 4.21.0"
1804 + }
1805 + },
1806 + "node_modules/vite": {
1807 + "version": "5.4.21",
1808 + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1809 + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1810 + "dev": true,
1811 + "license": "MIT",
1812 + "dependencies": {
1813 + "esbuild": "^0.21.3",
1814 + "postcss": "^8.4.43",
1815 + "rollup": "^4.20.0"
1816 + },
1817 + "bin": {
1818 + "vite": "bin/vite.js"
1819 + },
1820 + "engines": {
1821 + "node": "^18.0.0 || >=20.0.0"
1822 + },
1823 + "funding": {
1824 + "url": "https://github.com/vitejs/vite?sponsor=1"
1825 + },
1826 + "optionalDependencies": {
1827 + "fsevents": "~2.3.3"
1828 + },
1829 + "peerDependencies": {
1830 + "@types/node": "^18.0.0 || >=20.0.0",
1831 + "less": "*",
1832 + "lightningcss": "^1.21.0",
1833 + "sass": "*",
1834 + "sass-embedded": "*",
1835 + "stylus": "*",
1836 + "sugarss": "*",
1837 + "terser": "^5.4.0"
1838 + },
1839 + "peerDependenciesMeta": {
1840 + "@types/node": {
1841 + "optional": true
1842 + },
1843 + "less": {
1844 + "optional": true
1845 + },
1846 + "lightningcss": {
1847 + "optional": true
1848 + },
1849 + "sass": {
1850 + "optional": true
1851 + },
1852 + "sass-embedded": {
1853 + "optional": true
1854 + },
1855 + "stylus": {
1856 + "optional": true
1857 + },
1858 + "sugarss": {
1859 + "optional": true
1860 + },
1861 + "terser": {
1862 + "optional": true
1863 + }
1864 + }
1865 + },
1866 + "node_modules/yallist": {
1867 + "version": "3.1.1",
1868 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1869 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1870 + "dev": true,
1871 + "license": "ISC"
1872 + }
1873 + }
1874 +}
added frontend/package.json +26 −0
@@ -0,0 +1,26 @@
1 +{
2 + "name": "rent-ka-frontend",
3 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
4 + "private": true,
5 + "version": "1.0.0",
6 + "type": "module",
7 + "scripts": {
8 + "dev": "vite",
9 + "build": "tsc -b && vite build",
10 + "preview": "vite preview"
11 + },
12 + "dependencies": {
13 + "react": "^18.3.1",
14 + "react-dom": "^18.3.1",
15 + "react-router-dom": "^6.26.0",
16 + "@groupe-ka/ka-maps": "file:../../ka-maps",
17 + "mapbox-gl": "^3.28.1"
18 + },
19 + "devDependencies": {
20 + "@types/react": "^18.3.3",
21 + "@types/react-dom": "^18.3.0",
22 + "@vitejs/plugin-react": "^4.3.1",
23 + "typescript": "^5.5.4",
24 + "vite": "^5.4.0"
25 + }
26 +}
added frontend/public/apple-touch-icon.png +0 −0

Binary file not shown.

added frontend/public/doc/img/etape1.png +0 −0

Binary file not shown.

added frontend/public/doc/img/etape2.png +0 −0

Binary file not shown.

added frontend/public/doc/img/etape3.png +0 −0

Binary file not shown.

added frontend/public/doc/img/etape4.png +0 −0

Binary file not shown.

added frontend/public/doc/index.html +151 −0
@@ -0,0 +1,151 @@
1 +<!DOCTYPE html>
2 +<!-- SHARED TEMPLATE — /doc page of Groupe KA sites (2026-08-24), Rent-Ka edition.
3 + Self-sufficient page (no JS required). Screenshots in ./img/ to regenerate. -->
4 +<html lang="en-CA">
5 +<head>
6 +<meta charset="utf-8">
7 +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
8 +<title>Documentation — Rent·Ka</title>
9 +<meta name="description" content="How Rent·Ka works: step-by-step guide, data, architecture.">
10 +<style>
11 + :root{ --ink:#101014; --paper:#faf9f6; --accent:#2456e6; --muted:#6b6b70; --line:#e6e4de; }
12 + *{box-sizing:border-box} html,body{margin:0;padding:0;background:var(--paper);color:var(--ink);
13 + font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%}
14 + .container{max-width:960px;margin:0 auto;padding:0 20px}
15 + header.doc{background:var(--ink);color:var(--paper);padding:20px 0}
16 + header.doc .container{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
17 + header.doc a{color:var(--paper);text-decoration:none}
18 + .wordmark{font-weight:800;font-size:20px;letter-spacing:.02em}
19 + .wordmark .ka{color:var(--accent)}
20 + .btn-pdf{display:inline-block;background:var(--accent);color:#fff;font-weight:700;
21 + padding:10px 18px;border-radius:999px;text-decoration:none;font-size:15px}
22 + .hero{padding:48px 0 8px}
23 + .hero h1{font-size:clamp(28px,5vw,44px);line-height:1.1;margin:0 0 12px}
24 + .hero h1 mark{background:var(--accent);color:#fff;padding:2px 8px}
25 + .hero p.lead{font-size:18px;color:var(--muted);max-width:640px}
26 + .kicker{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);
27 + border-left:3px solid var(--accent);padding-left:10px;margin:40px 0 8px;font-weight:700}
28 + section{padding:8px 0 16px}
29 + h2{font-size:26px;margin:6px 0 12px}
30 + .step{display:grid;grid-template-columns:56px 1fr;gap:16px;margin:26px 0;align-items:start}
31 + .step .num{width:44px;height:44px;border-radius:50%;background:var(--ink);color:var(--accent);
32 + display:flex;align-items:center;justify-content:center;font-weight:800;font-size:18px}
33 + .step h3{margin:6px 0 6px;font-size:19px}
34 + .step p{margin:0 0 12px;color:#3a3a40}
35 + .shot{border:1px solid var(--line);border-radius:12px;overflow:hidden;box-shadow:0 8px 30px rgba(16,16,20,.08)}
36 + .shot img{display:block;width:100%;height:auto}
37 + .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin:16px 0}
38 + .card{border:1px solid var(--line);border-radius:12px;padding:16px;background:#fff}
39 + .card b{display:block;margin-bottom:6px}
40 + table{border-collapse:collapse;width:100%;font-size:15px}
41 + th,td{border:1px solid var(--line);padding:8px 10px;text-align:left;vertical-align:top}
42 + th{background:var(--ink);color:var(--paper)}
43 + footer.doc{margin-top:56px;background:var(--ink);color:var(--paper);padding:28px 0;font-size:14px}
44 + footer.doc a{color:var(--accent);text-decoration:none}
45 + @media print{
46 + header.doc .btn-pdf{display:none}
47 + .shot{box-shadow:none;break-inside:avoid}
48 + .step{break-inside:avoid}
49 + a{color:inherit;text-decoration:none}
50 + }
51 + @media(max-width:640px){ .step{grid-template-columns:40px 1fr} .step .num{width:34px;height:34px;font-size:15px} }
52 +</style>
53 +</head>
54 +<body>
55 +<header class="doc">
56 + <div class="container">
57 + <a class="wordmark" href="/">Rent<span class="ka">·Ka</span></a>
58 + <nav style="display:flex;gap:14px;align-items:center">
59 + <a href="/">← Back to the site</a>
60 + </nav>
61 + </div>
62 +</header>
63 +
64 +<div class="container">
65 + <div class="hero">
66 + <p class="kicker">Documentation</p>
67 + <h1>How <mark>Rent·Ka</mark> works</h1>
68 + <p class="lead">Apartments and homes for rent across Canada, gathered in one place — a faithful index, with no invented price or guessed coordinate.</p>
69 + </div>
70 +
71 + <section>
72 + <p class="kicker">Overview</p>
73 + <h2>What the site is for</h2>
74 + <p>Looking for an apartment in Canada means opening dozens of different sites. <b>Rent·Ka</b>
75 + (<a href="https://www.rent-ka.com">www.rent-ka.com</a>) flips the problem: a <b>dedicated connector per property manager</b>
76 + visits each site, normalizes every listing to a single schema and detects changes continuously — additions, updates
77 + and automatic removals. Every listing links back to the manager's original ad through the exit gateway
78 + <code>/gateway/{uid}</code>. For tenants (filtered search, 3D map, complete listings), market watchers
79 + (rent observatory, PDF reports) and managers (claimable pages).</p>
80 + <div class="cards">
81 + <div class="card"><b>Thousands of active listings</b>Across Canadian cities outside Québec, Ontario first — coverage growing continuously.</div>
82 + <div class="card"><b>Dedicated connectors</b>Each property manager has its own connector, resynced continuously.</div>
83 + <div class="card"><b>Nothing invented</b>No invented price or guessed coordinate; a vanished listing answers 410 Gone.</div>
84 + <div class="card"><b>Enriched listings</b>Fair value, price history, building passport, KA Scores, Winter Score, nearby amenities.</div>
85 + </div>
86 + </section>
87 +
88 + <section>
89 + <p class="kicker">Step-by-step guide</p>
90 + <h2>Use the site in 4 steps</h2>
91 + <div class="step">
92 + <div class="num">1</div>
93 + <div>
94 + <h3>Search from the home page</h3>
95 + <p>Enter a city or neighbourhood, pick the type (studio, 1 bedroom, 2 bedrooms…), the min/max rent and your options (pets, furnished, area). The list and the 3D map stay synchronized: the counters match the markers exactly.</p>
96 + </div>
97 + </div>
98 + <div class="step">
99 + <div class="num">2</div>
100 + <div>
101 + <h3>Browse a city's results</h3>
102 + <p>City pages (e.g. /city/toronto) list all active listings with their filters. On the Rent-Ka Maps view: clusters by average price, "Search this area", 3D/2D toggle.</p>
103 + </div>
104 + </div>
105 + <div class="step">
106 + <div class="num">3</div>
107 + <div>
108 + <h3>Open a listing page</h3>
109 + <p>All the photos, a structured digest of the description, the fair-value badge, price history, building passport, KA Scores and a Winter Score. The button to the original ad goes through the transparent gateway.</p>
110 + </div>
111 + </div>
112 + <div class="step">
113 + <div class="num">4</div>
114 + <div>
115 + <h3>Follow the market on /stats</h3>
116 + <p>The market observatory: real-time KPIs, median rents by region, city and size, plus a downloadable multi-page PDF market report.</p>
117 + </div>
118 + </div>
119 + </section>
120 +
121 + <section>
122 + <p class="kicker">Under the hood</p>
123 + <h2>Where the data comes from</h2>
124 + <p>Pipeline: <b>connectors → normalization → deduplication → SQLite → API/frontend</b>. Auto-discovered connectors
125 + visit each property manager at the source (direct extraction or Scrapfly depending on the site), and a resync loop
126 + (<code>rent-ka-sync</code>) runs continuously: each listing's content hash detects additions, updates
127 + and removals — a vanished listing answers <code>410 Gone</code>. An anti-drift guardrail suspends removals if a source
128 + suddenly returns far fewer listings. Enrichment crosses geocoding (Nominatim/OpenStreetMap), OpenStreetMap points of
129 + interest and Statistics Canada proximity measures. The public registry <a href="/sources">/sources</a> shows every source, its status and its last sync.</p>
130 + </section>
131 +
132 + <section>
133 + <p class="kicker">Frequently asked questions</p>
134 + <h2>FAQ</h2>
135 + <div class="card" style="margin:10px 0"><b>Are the prices reliable?</b><p style="margin:6px 0 0">Displayed rents are those published by the managers themselves, captured at the source. Rent·Ka invents no price and guesses no coordinate.</p></div>
136 + <div class="card" style="margin:10px 0"><b>Can I rent directly on Rent·Ka?</b><p style="margin:6px 0 0">No. Rent·Ka is an index: every listing links back to the manager's original ad through the exit gateway. Groupe KA is not a party to any transaction.</p></div>
137 + <div class="card" style="margin:10px 0"><b>How often are listings updated?</b><p style="margin:6px 0 0">The resync runs in a continuous loop; listings removed at the manager disappear automatically (410 Gone).</p></div>
138 + <div class="card" style="margin:10px 0"><b>Where do the location scores come from?</b><p style="margin:6px 0 0">From public sources: OpenStreetMap (amenities, roads, bike lanes) and Statistics Canada proximity measures (2021) — see /ka-scores for the full methodology.</p></div>
139 + <div class="card" style="margin:10px 0"><b>I'm a manager — can I manage my page?</b><p style="margin:6px 0 0">Yes: every source has a claimable page (/g/{source}); sign in with your KA ID to claim it.</p></div>
140 + </section>
141 +</div>
142 +
143 +<footer class="doc">
144 + <div class="container">
145 + <p><b>Rent·Ka</b> — a <a href="https://www.groupe-ka.com">Groupe KA</a> service.
146 + Ecosystem: all the platforms are linked in each site's footer.</p>
147 + <p>© 2026 Groupe KA — Simon-Pierre Boucher · <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a></p>
148 + </div>
149 +</footer>
150 +</body>
151 +</html>
added frontend/public/doc/lou-ka-documentation.pdf +0 −0

Binary file not shown.

added frontend/public/favicon.svg +5 −0
@@ -0,0 +1,5 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
2 +<rect width="64" height="64" rx="14" fill="#141814"/>
3 +<rect x="4.5" y="4.5" width="55" height="55" rx="11" fill="none" stroke="#2456e6" stroke-opacity="0.35" stroke-width="2"/>
4 +<text x="32" y="43" text-anchor="middle" font-family="'Bricolage Grotesque','Arial Black',sans-serif" font-size="30" font-weight="700" letter-spacing="-1" fill="#2456e6" transform="rotate(-4 32 32)">Ka</text>
5 +</svg>
added frontend/public/ka-agent.js +591 −0
@@ -0,0 +1,591 @@
1 +/* Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 + * ka-ui/agent/ka-agent.js — KA AGENT : widget de chat commun de l'écosystème
3 + * Groupe KA. Vanilla JS, zéro dépendance, injecté sur les 13 sites.
4 + * <script>window.KA_AGENT={site:"lou-ka"}</script>
5 + * <script src="/ka-agent.js" defer></script>
6 + * v2 (2026-08-19) : ouverture directement PLEIN ÉCRAN (desktop et mobile,
7 + * bouton ▭ pour réduire en fenêtre sur desktop) ; sur téléphone le panneau se
8 + * cale sur window.visualViewport (le clavier ne fait plus grossir/défiler la
9 + * page — fond verrouillé par body position:fixed) ; rendu MARKDOWN complet en
10 + * streaming (titres, listes, tableaux, code, liens nommés, citations).
11 + * Flux SSE en direct depuis le backend central (texte token par token +
12 + * indicateur d'outil). Historique conservé en localStorage (par site).
13 + * v3 (2026-08-22) : bulle DÉPLAÇABLE au doigt/à la souris (pointer events,
14 + * seuil 6 px pour distinguer clic et glisser, bornée aux marges de l'écran,
15 + * position mémorisée par site en localStorage, re-bornée au resize) ; en mode
16 + * fenêtré le panneau s'ancre du côté où se trouve la bulle.
17 + * v4 (2026-08-25) : CARTES d'annonces cliquables (bloc ```ka-card — un JSON
18 + * par ligne : site/titre/sous_titre/prix/badge/image/lien) rendues aux
19 + * couleurs de la plateforme visée (palette écosystème embarquée), et CHOIX
20 + * de clarification (bloc ```ka-choix — {question, options[]}) rendus en
21 + * boutons : un clic envoie l'option comme message ; seuls les boutons de la
22 + * dernière bulle assistante restent actifs.
23 + * v4.2 (2026-08-25) : extraction des cartes/choix par ÉQUILIBRAGE D'ACCOLADES
24 + * (jsonObjects) — tous les objets JSON d'un bloc sont trouvés quelle que soit
25 + * la mise en forme (une ligne, aéré, tableau) et un bloc tronqué en plein
26 + * streaming rend toutes ses cartes complètes au lieu d'une seule.
27 + */
28 +(function () {
29 + "use strict";
30 + var CFG = window.KA_AGENT || {};
31 + var SITE = CFG.site || "groupe-ka";
32 + var ENDPOINT = CFG.endpoint || "https://www.api-ka.com/api/agent/chat";
33 + var KEY = "ka-agent-" + SITE;
34 + var MAXLOG = 20;
35 +
36 + /* ---------- styles ---------- */
37 + var css = `
38 + .kaa-btn{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;width:58px;height:58px;
39 + border-radius:50%;border:2px solid var(--ink,#141814);background:var(--accent,#d9f26b);
40 + color:var(--on-accent,#141814);font:700 17px/1 "Space Grotesk",system-ui,sans-serif;
41 + cursor:pointer;box-shadow:4px 4px 0 rgba(20,24,20,.85);display:flex;align-items:center;
42 + justify-content:center;transition:transform .15s;touch-action:none}
43 + .kaa-btn:hover{transform:translate(-2px,-2px)}
44 + .kaa-btn.kaa-drag{transition:none;cursor:grabbing}
45 + .kaa-btn.kaa-drag:hover{transform:none}
46 + .kaa-panel{position:fixed;z-index:2147483001;display:none;flex-direction:column;background:var(--paper,#f5f3ee);
47 + overflow:hidden;font-family:Inter,system-ui,sans-serif;touch-action:manipulation}
48 + .kaa-panel.kaa-open{display:flex}
49 + .kaa-panel.kaa-full{left:0;top:0;right:0;bottom:0;width:100vw;height:100vh;height:100dvh;border:0;border-radius:0;box-shadow:none}
50 + .kaa-panel:not(.kaa-full){right:16px;bottom:calc(86px + env(safe-area-inset-bottom,0px));width:min(392px,calc(100vw - 24px));
51 + height:min(580px,calc(100vh - 110px));height:min(580px,calc(100dvh - 110px));
52 + border:2px solid var(--ink,#141814);border-radius:14px;box-shadow:8px 8px 0 rgba(20,24,20,.85)}
53 + .kaa-panel.kaa-left:not(.kaa-full){right:auto;left:16px}
54 + html.kaa-lock,html.kaa-lock body{overflow:hidden!important;overscroll-behavior:none;touch-action:none}
55 + .kaa-head{display:flex;align-items:center;gap:9px;padding:11px 14px;background:var(--ink,#141814);
56 + color:var(--paper,#f5f3ee);flex:none;padding-top:max(11px,env(safe-area-inset-top,0px))}
57 + .kaa-head b{font:700 15px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}
58 + .kaa-head .kaa-ka{display:inline-block;background:var(--accent,#d9f26b);color:var(--on-accent,#141814);
59 + border-radius:6px;padding:1px 7px 2px;transform:rotate(-2deg);font-weight:700}
60 + .kaa-head small{font:700 9.5px "JetBrains Mono",monospace;letter-spacing:.09em;text-transform:uppercase;opacity:.65}
61 + .kaa-head .kaa-sp{flex:1}
62 + .kaa-hbtn{width:34px;height:34px;border:1.5px solid rgba(245,243,238,.4);border-radius:8px;background:none;
63 + color:var(--paper,#f5f3ee);font-size:15px;cursor:pointer;line-height:1;flex:none}
64 + .kaa-hbtn:hover{border-color:var(--accent,#d9f26b);color:var(--accent,#d9f26b)}
65 + .kaa-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px;
66 + overscroll-behavior:contain;-webkit-overflow-scrolling:touch}
67 + .kaa-msg{max-width:86%;padding:9px 13px;border:1.5px solid var(--ink,#141814);border-radius:12px;
68 + font-size:13.5px;line-height:1.55;word-break:break-word}
69 + .kaa-full .kaa-msg{max-width:min(86%,760px)}
70 + .kaa-full .kaa-log{align-items:stretch}
71 + .kaa-msg.kaa-u{align-self:flex-end;background:var(--accent,#d9f26b);color:var(--on-accent,#141814);
72 + border-bottom-right-radius:4px;white-space:pre-wrap}
73 + .kaa-msg.kaa-a{align-self:flex-start;background:#fff;border-bottom-left-radius:4px;box-shadow:3px 3px 0 rgba(20,24,20,.12)}
74 + .kaa-msg.kaa-a a{color:inherit;text-decoration:underline;text-underline-offset:3px;font-weight:600}
75 + .kaa-msg.kaa-a p{margin:.35em 0}
76 + .kaa-msg.kaa-a p:first-child{margin-top:0}
77 + .kaa-msg.kaa-a p:last-child{margin-bottom:0}
78 + .kaa-msg.kaa-a h1,.kaa-msg.kaa-a h2,.kaa-msg.kaa-a h3,.kaa-msg.kaa-a h4{
79 + font:700 14px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.01em;margin:.7em 0 .3em}
80 + .kaa-msg.kaa-a h1:first-child,.kaa-msg.kaa-a h2:first-child,.kaa-msg.kaa-a h3:first-child,.kaa-msg.kaa-a h4:first-child{margin-top:.1em}
81 + .kaa-msg.kaa-a h1{font-size:15.5px}.kaa-msg.kaa-a h2{font-size:14.5px}
82 + .kaa-msg.kaa-a ul,.kaa-msg.kaa-a ol{margin:.35em 0;padding-left:1.35em}
83 + .kaa-msg.kaa-a li{margin:.18em 0}
84 + .kaa-msg.kaa-a code{font:600 12px "JetBrains Mono",monospace;background:var(--surface-2,#f0eee7);
85 + border:1px solid rgba(20,24,20,.15);border-radius:5px;padding:.5px 4px}
86 + .kaa-msg.kaa-a pre{margin:.45em 0;padding:9px 11px;background:var(--ink,#141814);color:var(--paper,#f5f3ee);
87 + border-radius:9px;overflow-x:auto}
88 + .kaa-msg.kaa-a pre code{background:none;border:0;color:inherit;padding:0;font-weight:400;font-size:11.5px;line-height:1.5}
89 + .kaa-msg.kaa-a blockquote{margin:.45em 0;padding:.15em .8em;border-left:3px solid var(--accent,#d9f26b);
90 + background:var(--surface-2,#faf9f5);color:var(--ink-2,#4d5551)}
91 + .kaa-msg.kaa-a hr{border:0;border-top:1.5px dashed rgba(20,24,20,.3);margin:.6em 0}
92 + .kaa-msg.kaa-a table{border-collapse:collapse;margin:.45em 0;font-size:12.5px;display:block;overflow-x:auto;max-width:100%}
93 + .kaa-msg.kaa-a th,.kaa-msg.kaa-a td{border:1.5px solid var(--ink,#141814);padding:4px 9px;text-align:left;white-space:nowrap}
94 + .kaa-msg.kaa-a th{background:var(--accent,#d9f26b);color:var(--on-accent,#141814);
95 + font:700 11px "Space Grotesk",system-ui,sans-serif;letter-spacing:.02em}
96 + .kaa-msg.kaa-a td{background:#fff}
97 + .kaa-tool{align-self:flex-start;font:700 10px "JetBrains Mono",monospace;letter-spacing:.07em;
98 + text-transform:uppercase;color:var(--ink-2,#4d5551);padding:3px 10px;border:1.5px dashed var(--ink-2,#4d5551);
99 + border-radius:999px;background:var(--surface-2,#faf9f5)}
100 + .kaa-in{display:flex;gap:8px;padding:11px;border-top:2px solid var(--ink,#141814);background:#fff;flex:none;
101 + padding-bottom:calc(11px + env(safe-area-inset-bottom,0))}
102 + .kaa-full .kaa-in{justify-content:center}
103 + .kaa-full .kaa-in textarea{max-width:700px}
104 + .kaa-in textarea{flex:1;resize:none;min-height:44px;max-height:110px;padding:10px 12px;font:16px/1.4 Inter,system-ui,sans-serif;
105 + border:1.5px solid var(--ink,#141814);border-radius:9px;background:var(--paper,#f5f3ee);outline:none}
106 + .kaa-in button{min-width:52px;min-height:44px;border:1.5px solid var(--ink,#141814);border-radius:9px;
107 + background:var(--ink,#141814);color:var(--accent,#d9f26b);font:700 16px "Space Grotesk",system-ui;cursor:pointer;touch-action:manipulation}
108 + .kaa-in button:disabled{opacity:.45}
109 + .kaa-cards{display:flex;flex-direction:column;gap:8px;margin:.5em 0 .2em}
110 + .kaa-card{display:flex;align-items:stretch;border:1.5px solid var(--ink,#141814);border-radius:11px;
111 + overflow:hidden;background:#fff;text-decoration:none!important;color:var(--ink,#141814);
112 + box-shadow:3px 3px 0 rgba(20,24,20,.14);border-left:5px solid var(--kaa-c,var(--accent,#d9f26b));transition:transform .12s}
113 + .kaa-msg.kaa-a a.kaa-card{text-decoration:none;font-weight:400}
114 + .kaa-card:hover{transform:translate(-1px,-1px)}
115 + .kaa-card-img{flex:none;width:76px;min-height:76px;background-size:cover;background-position:center;
116 + border-right:1.5px solid rgba(20,24,20,.15)}
117 + .kaa-card-body{flex:1;display:flex;flex-direction:column;gap:2px;padding:8px 11px;min-width:0}
118 + .kaa-card-top{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
119 + .kaa-card-site{font:700 9px "JetBrains Mono",monospace;letter-spacing:.08em;text-transform:uppercase;
120 + background:var(--kaa-c,var(--accent,#d9f26b));color:var(--kaa-on,var(--on-accent,#141814));border-radius:5px;padding:1px 6px 2px}
121 + .kaa-card-badge{font:700 9px "JetBrains Mono",monospace;letter-spacing:.06em;text-transform:uppercase;
122 + border:1px dashed var(--ink-2,#4d5551);border-radius:999px;padding:1px 7px;color:var(--ink-2,#4d5551)}
123 + .kaa-card-title{font:700 13.5px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.01em;line-height:1.25}
124 + .kaa-card-sub{font-size:11.5px;color:var(--ink-2,#4d5551);line-height:1.35}
125 + .kaa-card-price{font:700 13px "Space Grotesk",system-ui,sans-serif;margin-top:1px}
126 + .kaa-card-go{flex:none;align-self:center;padding:0 10px;font-weight:700;opacity:.55}
127 + .kaa-choices{display:flex;flex-wrap:wrap;gap:7px;margin:.45em 0 .15em;align-items:center}
128 + .kaa-choices-q{flex-basis:100%;font-size:12px;color:var(--ink-2,#4d5551);font-weight:600}
129 + .kaa-choice{border:1.5px solid var(--ink,#141814);border-radius:999px;background:var(--paper,#f5f3ee);
130 + color:var(--ink,#141814);font:600 12.5px Inter,system-ui,sans-serif;padding:7px 13px;cursor:pointer;
131 + box-shadow:2px 2px 0 rgba(20,24,20,.35);transition:transform .1s;touch-action:manipulation}
132 + .kaa-choice:hover{background:var(--accent,#d9f26b);color:var(--on-accent,#141814);transform:translate(-1px,-1px)}
133 + .kaa-choices.kaa-done .kaa-choice{opacity:.45;pointer-events:none;box-shadow:none}
134 + .kaa-hello{font-size:12.5px;color:var(--ink-2,#4d5551);padding:4px 2px;max-width:760px}
135 + .kaa-dots::after{content:"…";animation:kaa-b 1.2s infinite}
136 + @keyframes kaa-b{0%{opacity:.2}50%{opacity:1}100%{opacity:.2}}
137 + @media (max-width:640px){.kaa-x1{display:none}}
138 + `;
139 +
140 + /* ---------- markdown complet (rendu en streaming, texte échappé d'abord) ---------- */
141 + function esc(s) {
142 + return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
143 + }
144 +
145 + /* ---------- cartes + choix (v4) ----------
146 + Palette de l'écosystème (ka-ui/ecosystem.json) embarquée : chaque carte
147 + prend les couleurs de la plateforme de SA fiche, pas du site hôte. */
148 + var ECO = {
149 + "groupe-ka": ["Groupe KA", "#d9f26b", "#141814"],
150 + "trouve-ka": ["Trouve·Ka", "#1c7ed6", "#ffffff"],
151 + "lou-ka": ["Lou·Ka", "#ff6a00", "#ffffff"],
152 + "rent-ka": ["Rent·Ka", "#2456e6", "#ffffff"],
153 + "immo-ka": ["Immo·Ka", "#e23744", "#ffffff"],
154 + "vrai-prix": ["Vrai-Prix", "#ff5148", "#ffffff"],
155 + "auto-ka": ["Auto·Ka", "#ff5a2a", "#ffffff"],
156 + "fabri-ka": ["Fabri·Ka", "#c4532e", "#ffffff"],
157 + "food-ka": ["Food·Ka", "#1f9d55", "#ffffff"],
158 + "resto-ka": ["Resto·Ka", "#f08c00", "#141814"],
159 + "sorti-ka": ["Sorti·Ka", "#d6336c", "#ffffff"],
160 + "crea-ka": ["Créa·Ka", "#7048e8", "#ffffff"],
161 + "api-ka": ["API·Ka", "#3b5bdb", "#ffffff"],
162 + "job-ka": ["Job·Ka", "#0c8599", "#ffffff"],
163 + "valoplex": ["ValoPlex", "#ff9f45", "#141814"]
164 + };
165 + function httpUrl(u) { return typeof u === "string" && /^https:\/\//.test(u) ? u : ""; }
166 + /* Extrait chaque objet JSON de premier niveau d'un bloc ka-card, quelle que
167 + soit sa mise en forme (un par ligne, aéré, tableau, bloc TRONQUÉ par le
168 + streaming) : balayage à équilibrage d'accolades, hors chaînes. */
169 + function jsonObjects(txt) {
170 + var objs = [], depth = 0, start = -1, inStr = false, escp = false;
171 + for (var k = 0; k < txt.length; k++) {
172 + var c = txt[k];
173 + if (inStr) {
174 + if (escp) escp = false;
175 + else if (c === "\\") escp = true;
176 + else if (c === '"') inStr = false;
177 + continue;
178 + }
179 + if (c === '"') { inStr = true; continue; }
180 + if (c === "{") { if (!depth) start = k; depth++; }
181 + else if (c === "}" && depth) {
182 + depth--;
183 + if (!depth && start >= 0) {
184 + try { objs.push(JSON.parse(txt.slice(start, k + 1))); } catch (e) {}
185 + start = -1;
186 + }
187 + }
188 + }
189 + return objs; // un objet incomplet en fin de bloc est simplement ignoré
190 + }
191 + function cards(lines) {
192 + var html = "";
193 + jsonObjects(lines.join("\n")).forEach(function (o) {
194 + if (!o || !o.titre || !httpUrl(o.lien)) return;
195 + var pal = ECO[o.site] || ["Groupe KA", "var(--accent,#d9f26b)", "var(--on-accent,#141814)"];
196 + var img = httpUrl(o.image);
197 + html += '<a class="kaa-card" href="' + esc(o.lien) + '" target="_blank" rel="noopener noreferrer"' +
198 + ' style="--kaa-c:' + pal[1] + ";--kaa-on:" + pal[2] + '">' +
199 + (img ? '<span class="kaa-card-img" style="background-image:url(&quot;' + esc(img) + '&quot;)"></span>' : "") +
200 + '<span class="kaa-card-body"><span class="kaa-card-top">' +
201 + '<span class="kaa-card-site">' + esc(pal[0]) + "</span>" +
202 + (o.badge ? '<span class="kaa-card-badge">' + esc(String(o.badge)) + "</span>" : "") +
203 + "</span>" +
204 + '<span class="kaa-card-title">' + esc(String(o.titre)) + "</span>" +
205 + (o.sous_titre ? '<span class="kaa-card-sub">' + esc(String(o.sous_titre)) + "</span>" : "") +
206 + (o.prix ? '<span class="kaa-card-price">' + esc(String(o.prix)) + "</span>" : "") +
207 + '</span><span class="kaa-card-go">→</span></a>';
208 + });
209 + return html ? '<div class="kaa-cards">' + html + "</div>" : "";
210 + }
211 + function choices(lines) {
212 + var o = jsonObjects(lines.join("\n"))[0] || null; // bloc incomplet (streaming) → rien
213 + if (!o || !Array.isArray(o.options) || !o.options.length) return "";
214 + var h = '<span class="kaa-choices">' +
215 + (o.question ? '<span class="kaa-choices-q">' + esc(String(o.question)) + "</span>" : "");
216 + o.options.slice(0, 6).forEach(function (op) {
217 + h += '<button type="button" class="kaa-choice">' + esc(String(op)) + "</button>";
218 + });
219 + return h + "</span>";
220 + }
221 + function inline(s) {
222 + s = esc(s);
223 + s = s.replace(/`([^`\n]+)`/g, "<code>$1</code>");
224 + s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g,
225 + '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
226 + s = s.replace(/\*\*([^*\n]+)\*\*/g, "<b>$1</b>");
227 + s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\*)/g, "$1<i>$2</i>");
228 + s = s.replace(/(^|[^"'>=\w])(https?:\/\/[^\s<)"']+)/g, function (m, pre, u) {
229 + return pre + '<a href="' + u + '" target="_blank" rel="noopener noreferrer">' +
230 + u.replace(/^https?:\/\/(www\.)?/, "").slice(0, 42) + "</a>";
231 + });
232 + return s;
233 + }
234 + function md(src) {
235 + var lines = String(src).split("\n"), out = [], i = 0, m;
236 + function para(buf) { if (buf.length) out.push("<p>" + buf.map(inline).join("<br>") + "</p>"); }
237 + while (i < lines.length) {
238 + var l = lines[i];
239 + if (/^\s*```/.test(l)) { /* bloc de code / carte / choix */
240 + var lang = (l.match(/^\s*```\s*([\w-]+)?/) || [])[1] || "";
241 + var code = []; i++;
242 + while (i < lines.length && !/^\s*```/.test(lines[i])) code.push(lines[i++]);
243 + i++;
244 + if (lang === "ka-card") out.push(cards(code));
245 + else if (lang === "ka-choix") out.push(choices(code));
246 + else out.push("<pre><code>" + esc(code.join("\n")) + "</code></pre>");
247 + } else if ((m = l.match(/^\s*(#{1,4})\s+(.*)$/))) { /* titres */
248 + out.push("<h" + m[1].length + ">" + inline(m[2]) + "</h" + m[1].length + ">"); i++;
249 + } else if (/^\s*(---+|\*\*\*+)\s*$/.test(l)) { /* filet */
250 + out.push("<hr>"); i++;
251 + } else if (/^\s*&gt;|^\s*>/.test(l)) { /* citation */
252 + var q = [];
253 + while (i < lines.length && /^\s*>/.test(lines[i])) q.push(lines[i++].replace(/^\s*>\s?/, ""));
254 + out.push("<blockquote>" + q.map(inline).join("<br>") + "</blockquote>");
255 + } else if (/^\s*\|.*\|\s*$/.test(l)) { /* tableau */
256 + var rows = [];
257 + while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
258 + var cells = lines[i].trim().replace(/^\||\|$/g, "").split("|").map(function (c) { return c.trim(); });
259 + if (!/^[:\-\s|]+$/.test(lines[i].replace(/\|/g, ""))) rows.push(cells);
260 + i++;
261 + }
262 + if (rows.length) {
263 + var t = "<table>";
264 + rows.forEach(function (r, ri) {
265 + var tag = ri === 0 ? "th" : "td";
266 + t += "<tr>" + r.map(function (c) { return "<" + tag + ">" + inline(c) + "</" + tag + ">"; }).join("") + "</tr>";
267 + });
268 + out.push(t + "</table>");
269 + }
270 + } else if (/^\s*[-*+]\s+/.test(l)) { /* liste à puces */
271 + var ul = [];
272 + while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i]))
273 + ul.push("<li>" + inline(lines[i++].replace(/^\s*[-*+]\s+/, "")) + "</li>");
274 + out.push("<ul>" + ul.join("") + "</ul>");
275 + } else if (/^\s*\d+[.)]\s+/.test(l)) { /* liste numérotée */
276 + var ol = [];
277 + while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i]))
278 + ol.push("<li>" + inline(lines[i++].replace(/^\s*\d+[.)]\s+/, "")) + "</li>");
279 + out.push("<ol>" + ol.join("") + "</ol>");
280 + } else if (!l.trim()) { i++; } /* ligne vide */
281 + else { /* paragraphe */
282 + var buf = [];
283 + while (i < lines.length && lines[i].trim() &&
284 + !/^\s*(```|#{1,4}\s|[-*+]\s|\d+[.)]\s|>|\||---+\s*$)/.test(lines[i]))
285 + buf.push(lines[i++]);
286 + if (!buf.length) buf.push(lines[i++] || "");
287 + para(buf);
288 + }
289 + }
290 + return out.join("");
291 + }
292 +
293 + /* ---------- état ---------- */
294 + var log = [];
295 + try { log = JSON.parse(localStorage.getItem(KEY) || "[]"); } catch (e) {}
296 + function save() { try { localStorage.setItem(KEY, JSON.stringify(log.slice(-MAXLOG))); } catch (e) {} }
297 +
298 + /* ---------- DOM ---------- */
299 + var style = document.createElement("style");
300 + style.textContent = css;
301 + document.head.appendChild(style);
302 +
303 + var btn = document.createElement("button");
304 + btn.className = "kaa-btn";
305 + btn.setAttribute("aria-label", "Ouvrir KA Agent — assistant de l'écosystème Groupe KA");
306 + btn.innerHTML = "Ka";
307 +
308 + var panel = document.createElement("div");
309 + panel.className = "kaa-panel";
310 + panel.setAttribute("role", "dialog");
311 + panel.setAttribute("aria-label", "KA Agent");
312 + panel.innerHTML =
313 + '<div class="kaa-head"><b>KA<span class="kaa-ka">Agent</span></b><small>Groupe KA · IA</small><span class="kaa-sp"></span>' +
314 + '<button class="kaa-hbtn kaa-x1" title="Réduire / agrandir" aria-label="Réduire ou agrandir">▭</button>' +
315 + '<button class="kaa-hbtn kaa-x2" title="Fermer" aria-label="Fermer">✕</button></div>' +
316 + '<div class="kaa-log"></div>' +
317 + '<div class="kaa-in"><textarea rows="1" enterkeyhint="send" autocapitalize="sentences" autocomplete="off" placeholder="Posez votre question…" aria-label="Votre question"></textarea>' +
318 + "<button aria-label=\"Envoyer\">➤</button></div>";
319 + document.body.appendChild(btn);
320 + document.body.appendChild(panel);
321 +
322 + var logEl = panel.querySelector(".kaa-log");
323 + var ta = panel.querySelector("textarea");
324 + var send = panel.querySelector(".kaa-in button");
325 +
326 + function scroll() { logEl.scrollTop = logEl.scrollHeight; }
327 + function bubble(cls, html) {
328 + var d = document.createElement("div");
329 + d.className = "kaa-msg " + cls;
330 + d.innerHTML = html;
331 + logEl.appendChild(d);
332 + scroll();
333 + return d;
334 + }
335 + function toolChip(name) {
336 + var d = document.createElement("div");
337 + d.className = "kaa-tool kaa-dots";
338 + d.textContent = "🔎 " + name.replace(/_/g, " ");
339 + logEl.appendChild(d);
340 + scroll();
341 + return d;
342 + }
343 +
344 + function hello() {
345 + if (log.length) return;
346 + var d = document.createElement("div");
347 + d.className = "kaa-hello";
348 + d.textContent = "👋 Je suis KA Agent, l'assistant de l'écosystème Groupe KA. Je peux chercher dans TOUTES les plateformes du groupe : logements, propriétés, autos, emplois, épicerie, restos, sorties, créateurs, produits d'ici — plus estimations de valeur, annuaires (déménageurs, inspecteurs), historiques et statistiques. Posez votre question !";
349 + logEl.appendChild(d);
350 + }
351 + /* les boutons de choix ne restent actifs que dans la DERNIÈRE bulle assistante */
352 + function retireChoices(keepLast) {
353 + var groups = logEl.querySelectorAll(".kaa-choices:not(.kaa-done)");
354 + var msgs = logEl.querySelectorAll(".kaa-msg.kaa-a");
355 + var last = msgs.length ? msgs[msgs.length - 1] : null;
356 + groups.forEach(function (g) {
357 + if (!(keepLast && last && last.contains(g))) g.classList.add("kaa-done");
358 + });
359 + }
360 + function render() {
361 + logEl.innerHTML = "";
362 + hello();
363 + log.forEach(function (m) {
364 + m.role === "user" ? bubble("kaa-u", esc(m.content)) : bubble("kaa-a", md(m.content));
365 + });
366 + retireChoices(log.length && log[log.length - 1].role === "assistant");
367 + scroll();
368 + }
369 +
370 + /* ---------- plein écran + clavier mobile ----------
371 + Ouverture = PLEIN ÉCRAN direct (desktop et mobile). ▭ réduit en fenêtre
372 + (desktop seulement — caché sur mobile). Tant que le plein écran est ouvert,
373 + l'arrière-plan est GELÉ (body position:fixed, scroll restauré à la
374 + fermeture) et le panneau se cale sur window.visualViewport : quand le
375 + clavier s'ouvre, le panneau rétrécit à la place de laisser iOS
376 + zoomer/pousser la page. Pas de focus clavier forcé au tap sur mobile. */
377 + var fineInput = window.matchMedia("(hover: hover) and (pointer: fine)");
378 + var vv = window.visualViewport;
379 + var savedY = 0;
380 +
381 + function isOpen() { return panel.classList.contains("kaa-open"); }
382 + function isFull() { return panel.classList.contains("kaa-full"); }
383 +
384 + function fitVV() {
385 + if (!vv || !isOpen() || !isFull()) { panel.style.height = ""; panel.style.top = ""; return; }
386 + panel.style.top = Math.round(vv.offsetTop) + "px";
387 + panel.style.height = Math.round(vv.height) + "px";
388 + scroll();
389 + }
390 + if (vv) {
391 + vv.addEventListener("resize", fitVV);
392 + vv.addEventListener("scroll", fitVV);
393 + }
394 +
395 + function lockBg(on) {
396 + var b = document.body, h = document.documentElement;
397 + if (on && !h.classList.contains("kaa-lock")) {
398 + savedY = window.scrollY || 0;
399 + h.classList.add("kaa-lock");
400 + b.style.position = "fixed";
401 + b.style.top = -savedY + "px";
402 + b.style.left = "0"; b.style.right = "0"; b.style.width = "100%";
403 + } else if (!on && h.classList.contains("kaa-lock")) {
404 + h.classList.remove("kaa-lock");
405 + b.style.position = ""; b.style.top = ""; b.style.left = ""; b.style.right = ""; b.style.width = "";
406 + window.scrollTo(0, savedY);
407 + }
408 + }
409 + function sync() {
410 + lockBg(isOpen() && isFull());
411 + fitVV();
412 + }
413 +
414 + /* ---------- bulle déplaçable (v3) ----------
415 + pointerdown sur la bulle → si le doigt/curseur bouge de plus de 6 px c'est
416 + un GLISSER (la bulle suit, bornée à 8 px des bords) ; sinon c'est un CLIC
417 + (ouverture normale). Position mémorisée par site (localStorage) et
418 + re-bornée quand le viewport change. En mode fenêtré, le panneau s'ancre à
419 + gauche si la bulle est dans la moitié gauche de l'écran. */
420 + var POSKEY = "ka-agent-pos-" + SITE;
421 + var drag = { on: false, moved: false, id: null, sx: 0, sy: 0, bx: 0, by: 0 };
422 +
423 + function clampPos(l, t) {
424 + var m = 8, w = btn.offsetWidth || 58, h = btn.offsetHeight || 58;
425 + return {
426 + l: Math.min(Math.max(l, m), Math.max(m, window.innerWidth - w - m)),
427 + t: Math.min(Math.max(t, m), Math.max(m, window.innerHeight - h - m)),
428 + };
429 + }
430 + function applyPos(l, t) {
431 + var p = clampPos(l, t);
432 + btn.style.left = p.l + "px";
433 + btn.style.top = p.t + "px";
434 + btn.style.right = "auto";
435 + btn.style.bottom = "auto";
436 + panel.classList.toggle("kaa-left", p.l + (btn.offsetWidth || 58) / 2 < window.innerWidth / 2);
437 + }
438 + function savePos() {
439 + var r = btn.getBoundingClientRect();
440 + try { localStorage.setItem(POSKEY, JSON.stringify({ l: r.left, t: r.top })); } catch (e) {}
441 + }
442 + btn.addEventListener("pointerdown", function (e) {
443 + if (!e.isPrimary) return;
444 + drag.on = true; drag.moved = false; drag.id = e.pointerId;
445 + drag.sx = e.clientX; drag.sy = e.clientY;
446 + var r = btn.getBoundingClientRect();
447 + drag.bx = r.left; drag.by = r.top;
448 + try { btn.setPointerCapture(e.pointerId); } catch (err) {} // capture immédiate : un glisser rapide sort de la bulle avant le 1er move
449 + });
450 + btn.addEventListener("pointermove", function (e) {
451 + if (!drag.on || e.pointerId !== drag.id) return;
452 + var dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
453 + if (!drag.moved && dx * dx + dy * dy < 36) return; // < 6 px : encore un clic
454 + if (!drag.moved) {
455 + drag.moved = true;
456 + btn.classList.add("kaa-drag");
457 + }
458 + applyPos(drag.bx + dx, drag.by + dy);
459 + });
460 + function dragEnd(e) {
461 + if (!drag.on || e.pointerId !== drag.id) return;
462 + drag.on = false;
463 + if (drag.moved) {
464 + btn.classList.remove("kaa-drag");
465 + savePos();
466 + setTimeout(function () { drag.moved = false; }, 0); // filet si le click ne suit pas
467 + }
468 + }
469 + btn.addEventListener("pointerup", dragEnd);
470 + btn.addEventListener("pointercancel", dragEnd);
471 + window.addEventListener("resize", function () {
472 + if (!btn.style.left) return;
473 + var r = btn.getBoundingClientRect();
474 + applyPos(r.left, r.top);
475 + });
476 + (function restorePos() {
477 + var p = null;
478 + try { p = JSON.parse(localStorage.getItem(POSKEY) || "null"); } catch (e) {}
479 + if (p && typeof p.l === "number" && typeof p.t === "number") applyPos(p.l, p.t);
480 + })();
481 +
482 + btn.addEventListener("click", function (e) {
483 + if (drag.moved) { drag.moved = false; e.preventDefault(); return; } // fin de glisser, pas un clic
484 + panel.classList.add("kaa-open", "kaa-full"); // plein écran d'office
485 + render();
486 + if (fineInput.matches) ta.focus();
487 + sync();
488 + });
489 + panel.querySelector(".kaa-x2").addEventListener("click", function () {
490 + panel.classList.remove("kaa-open", "kaa-full");
491 + sync();
492 + });
493 + panel.querySelector(".kaa-x1").addEventListener("click", function () {
494 + panel.classList.toggle("kaa-full");
495 + sync();
496 + scroll();
497 + });
498 + document.addEventListener("keydown", function (e) {
499 + if (e.key === "Escape" && isOpen()) {
500 + panel.classList.remove("kaa-open", "kaa-full");
501 + sync();
502 + }
503 + });
504 +
505 + /* ---------- envoi + flux SSE ---------- */
506 + var busy = false;
507 + function ask() {
508 + var q = ta.value.trim();
509 + if (!q || busy) return;
510 + busy = true;
511 + send.disabled = true;
512 + ta.value = "";
513 + retireChoices(false); // un nouveau message clôt les choix en suspens
514 + log.push({ role: "user", content: q });
515 + save();
516 + bubble("kaa-u", esc(q));
517 + var out = bubble("kaa-a", '<span class="kaa-dots"></span>');
518 + var acc = "";
519 + var chips = [];
520 +
521 + fetch(ENDPOINT, {
522 + method: "POST",
523 + headers: { "Content-Type": "application/json" },
524 + body: JSON.stringify({ site: SITE, messages: log.slice(-MAXLOG) }),
525 + })
526 + .then(function (res) {
527 + if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
528 + var reader = res.body.getReader();
529 + var dec = new TextDecoder();
530 + var buf = "";
531 + function pump() {
532 + return reader.read().then(function (r) {
533 + if (r.done) return finish();
534 + buf += dec.decode(r.value, { stream: true });
535 + var blocks = buf.split("\n\n");
536 + buf = blocks.pop();
537 + blocks.forEach(function (b) {
538 + var ev = (b.match(/^event: (.+)$/m) || [])[1];
539 + var dm = (b.match(/^data: (.+)$/m) || [])[1];
540 + if (!ev || !dm) return;
541 + var data = {};
542 + try { data = JSON.parse(dm); } catch (e) {}
543 + if (ev === "delta" && data.text) {
544 + acc += data.text;
545 + out.innerHTML = md(acc); // markdown re-rendu à chaque delta
546 + scroll();
547 + } else if (ev === "tool") {
548 + chips.push(toolChip(data.name || "recherche"));
549 + logEl.appendChild(out); // la bulle reste après le chip
550 + scroll();
551 + } else if (ev === "error") {
552 + acc = acc || "Désolé, une erreur est survenue (" + (data.message || "inconnue") + "). Réessayez.";
553 + out.innerHTML = md(acc);
554 + }
555 + });
556 + return pump();
557 + });
558 + }
559 + return pump();
560 + })
561 + .catch(function (e) {
562 + acc = acc || "Impossible de joindre KA Agent (" + e.message + "). Réessayez dans un instant.";
563 + out.innerHTML = md(acc);
564 + })
565 + .then(function () { finish(); });
566 +
567 + function finish() {
568 + if (busy === false) return;
569 + busy = false;
570 + send.disabled = false;
571 + chips.forEach(function (c) { c.classList.remove("kaa-dots"); });
572 + if (!acc) { acc = "Je n'ai pas pu formuler de réponse — réessayez."; out.innerHTML = md(acc); }
573 + log.push({ role: "assistant", content: acc });
574 + save();
575 + scroll();
576 + }
577 + }
578 + send.addEventListener("click", ask);
579 + ta.addEventListener("keydown", function (e) {
580 + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); }
581 + });
582 + logEl.addEventListener("click", function (e) { /* clic sur un bouton de choix (v4) */
583 + var b = e.target && e.target.closest ? e.target.closest(".kaa-choice") : null;
584 + if (!b || busy) return;
585 + var g = b.closest(".kaa-choices");
586 + if (g && g.classList.contains("kaa-done")) return;
587 + ta.value = b.textContent;
588 + ask();
589 + });
590 +})();
591 +/*KA_ANALYTICS*/(function(){try{if(window.__kaAnL)return;window.__kaAnL=1;window.__kaSite="rent-ka";var s=document.createElement('script');s.async=1;s.src="https://www.administration-ka.com/ka-a.js";(document.head||document.documentElement).appendChild(s);}catch(e){}})();
added frontend/public/ka-id.js +322 −0
@@ -0,0 +1,322 @@
1 +/* Groupe KA — ka-id.js : SDK client KA ID v2 (personnalisation).
2 + * SOURCE CANONIQUE : ka-ui.git/kaid/ka-id.js — copié dans frontend/public/
3 + * (ou public/) de chaque app par sync-kaid.sh. Corriger ICI puis redistribuer.
4 + *
5 + * Rôle : signaux navigateur que le serveur ne voit pas — position des clics
6 + * dans les résultats, temps passé sur une fiche (dwell), visites de retour —
7 + * plus les aides d'interface : KAID.hide() (« Pas pour moi »),
8 + * KAID.saveSearch() (recherche sauvegardée), badge « Recommandé pour vous ».
9 + *
10 + * Intégration (index.html, AVANT le bundle) :
11 + * <script>
12 + * window.KAID_CONF = {
13 + * card: "a[href^='/emploi/']", // sélecteur des cartes résultat
14 + * detail: "^/emploi/(.+)$", // regex fiche → capture l'uid
15 + * entity: "job", // entity_type des événements
16 + * };
17 + * </script>
18 + * <script src="/ka-id.js" defer></script>
19 + *
20 + * Vie privée : ne fait RIEN si l'utilisateur n'est pas connecté via KA ID
21 + * (vérifié auprès de /api/kaid/status) ; le hub respecte ensuite ses
22 + * réglages Historique/Personnalisation (voir groupe-ka.com/mon-ka).
23 + */
24 +(function () {
25 + "use strict";
26 + var CONF = window.KAID_CONF || {};
27 + var API = CONF.api || "/api/kaid";
28 + var connected = null; // null = inconnu, sinon bool
29 + var queue = [];
30 + var flushTimer = null;
31 +
32 + /* ---------- transport (lots de 10, sendBeacon à la fermeture) ---------- */
33 +
34 + function post(events, useBeacon) {
35 + if (!events.length) return;
36 + var body = JSON.stringify({ events: events });
37 + if (useBeacon && navigator.sendBeacon) {
38 + navigator.sendBeacon(API + "/events",
39 + new Blob([body], { type: "application/json" }));
40 + return;
41 + }
42 + fetch(API + "/events", {
43 + method: "POST",
44 + headers: { "Content-Type": "application/json" },
45 + body: body,
46 + keepalive: true,
47 + credentials: "same-origin",
48 + }).catch(function () {});
49 + }
50 +
51 + function flush(useBeacon) {
52 + if (!queue.length) return;
53 + var batch = queue.splice(0, queue.length);
54 + post(batch, useBeacon);
55 + }
56 +
57 + function track(type, data) {
58 + if (connected === false) return;
59 + var e = { type: type };
60 + if (data) for (var k in data) if (data[k] != null) e[k] = data[k];
61 + queue.push(e);
62 + if (queue.length >= 10) flush(false);
63 + else if (!flushTimer)
64 + flushTimer = setTimeout(function () { flushTimer = null; flush(false); }, 4000);
65 + }
66 +
67 + /* ---------- connexion ---------- */
68 +
69 + function init(cb) {
70 + try {
71 + var cached = sessionStorage.getItem("kaid_status");
72 + if (cached) {
73 + var c = JSON.parse(cached);
74 + if (Date.now() - c.t < 600000) { connected = c.on; cb(); return; }
75 + }
76 + } catch (err) { /* stockage indisponible */ }
77 + fetch(API + "/status", { credentials: "same-origin" })
78 + .then(function (r) { return r.json(); })
79 + .then(function (d) {
80 + connected = !!d.connected;
81 + try {
82 + sessionStorage.setItem("kaid_status",
83 + JSON.stringify({ on: connected, t: Date.now() }));
84 + } catch (err) { /* stockage indisponible */ }
85 + cb();
86 + })
87 + .catch(function () { connected = false; });
88 + }
89 +
90 + /* ---------- clics sur les cartes résultat ---------- */
91 +
92 + function uidFromHref(el) {
93 + if (!CONF.detail) return null;
94 + var href = el.getAttribute("href") || "";
95 + var path = href.replace(/^https?:\/\/[^/]+/, "").split("?")[0];
96 + var m = path.match(new RegExp(CONF.detail));
97 + return m ? decodeURIComponent(m[1]) : null;
98 + }
99 +
100 + function watchClicks() {
101 + if (!CONF.card) return;
102 + document.addEventListener("click", function (ev) {
103 + var el = ev.target && ev.target.closest && ev.target.closest(CONF.card);
104 + if (!el) return;
105 + var uid = (CONF.uid ? CONF.uid(el) : null) || uidFromHref(el);
106 + if (!uid) return;
107 + var cards = document.querySelectorAll(CONF.card);
108 + var pos = Array.prototype.indexOf.call(cards, el);
109 + track("click", {
110 + entity_type: CONF.entity, entity_id: uid,
111 + position: pos >= 0 ? pos : null,
112 + });
113 + }, true);
114 + }
115 +
116 + /* ---------- temps passé sur une fiche (dwell) ---------- */
117 +
118 + var dwell = { uid: null, t0: 0 };
119 +
120 + function detailUid() {
121 + if (!CONF.detail) return null;
122 + var m = location.pathname.match(new RegExp(CONF.detail));
123 + return m ? decodeURIComponent(m[1]) : null;
124 + }
125 +
126 + function endDwell(useBeacon) {
127 + if (!dwell.uid) return;
128 + var ms = Date.now() - dwell.t0;
129 + if (ms >= 8000)
130 + track("detail_dwell",
131 + { entity_type: CONF.entity, entity_id: dwell.uid, dwell_ms: ms });
132 + dwell.uid = null;
133 + if (useBeacon) flush(true);
134 + }
135 +
136 + function checkRoute() {
137 + var uid = detailUid();
138 + if (uid !== dwell.uid) {
139 + endDwell(false);
140 + if (uid) dwell = { uid: uid, t0: Date.now() };
141 + }
142 + updateUi();
143 + }
144 +
145 + function watchRoutes() {
146 + ["pushState", "replaceState"].forEach(function (fn) {
147 + var orig = history[fn];
148 + history[fn] = function () {
149 + var out = orig.apply(this, arguments);
150 + setTimeout(checkRoute, 50);
151 + return out;
152 + };
153 + });
154 + window.addEventListener("popstate", function () { setTimeout(checkRoute, 50); });
155 + document.addEventListener("visibilitychange", function () {
156 + if (document.visibilityState === "hidden") { endDwell(true); }
157 + else checkRoute();
158 + });
159 + window.addEventListener("pagehide", function () { endDwell(true); });
160 + checkRoute();
161 + }
162 +
163 + /* ---------- interface : sauvegarder la recherche / pas pour moi ---------- */
164 +
165 + var INK = "#141814";
166 + var PAPER = "#f5f3ee";
167 +
168 + function injectCss() {
169 + if (document.getElementById("kaid-ui-css")) return;
170 + var st = document.createElement("style");
171 + st.id = "kaid-ui-css";
172 + st.textContent =
173 + ".kaid-pill{position:fixed;left:14px;bottom:14px;z-index:560;display:inline-flex;" +
174 + "align-items:center;gap:8px;padding:9px 14px;border-radius:999px;border:1.5px solid " + INK + ";" +
175 + "background:" + PAPER + ";color:" + INK + ";font:600 12.5px/1 system-ui,sans-serif;" +
176 + "box-shadow:0 2px 10px rgba(20,24,20,.18);cursor:pointer;max-width:78vw}" +
177 + ".kaid-pill b{font-weight:700}" +
178 + ".kaid-pill .kaid-x{margin-left:2px;opacity:.55;font-weight:700;cursor:pointer}" +
179 + ".kaid-pill a{color:inherit;text-decoration:underline}" +
180 + "@media (max-width:768px){.kaid-pill{bottom:calc(84px + env(safe-area-inset-bottom))}}";
181 + document.head.appendChild(st);
182 + }
183 +
184 + function removePill() {
185 + var el = document.getElementById("kaid-pill");
186 + if (el) el.remove();
187 + }
188 +
189 + function pillDismissed() {
190 + try {
191 + return Date.now() - Number(localStorage.getItem("kaid_pill_off") || 0) < 6048e5;
192 + } catch (err) { return false; }
193 + }
194 +
195 + function showSavePill() {
196 + if (pillDismissed() || document.getElementById("kaid-pill")) return;
197 + injectCss();
198 + var el = document.createElement("div");
199 + el.id = "kaid-pill";
200 + el.className = "kaid-pill";
201 + el.innerHTML = "<span>☆ <b>Sauvegarder cette recherche</b></span>" +
202 + "<span class='kaid-x' role='button' aria-label='Ne plus proposer'>✕</span>";
203 + el.querySelector(".kaid-x").addEventListener("click", function (ev) {
204 + ev.stopPropagation();
205 + try { localStorage.setItem("kaid_pill_off", String(Date.now())); } catch (err) { /* privé */ }
206 + removePill();
207 + });
208 + el.addEventListener("click", function () {
209 + el.innerHTML = "<span>…</span>";
210 + var filters = {};
211 + new URLSearchParams(location.search).forEach(function (v, k) {
212 + if (v) filters[k] = v;
213 + });
214 + window.KAID.saveSearch({ alert: true, filters: filters,
215 + query: filters.q || null })
216 + .then(function (r) {
217 + el.innerHTML = r && r.ok !== false && !r.error
218 + ? "<span>✓ Sauvegardée · alerte activée — <a href='https://www.groupe-ka.com/mon-ka'>Mon KA</a></span>"
219 + : "<span>Connexion KA ID requise</span>";
220 + setTimeout(removePill, 6000);
221 + })
222 + .catch(function () { removePill(); });
223 + });
224 + document.body.appendChild(el);
225 + }
226 +
227 + function showHideLink(uid) {
228 + if (document.getElementById("kaid-pill")) return;
229 + injectCss();
230 + var el = document.createElement("div");
231 + el.id = "kaid-pill";
232 + el.className = "kaid-pill";
233 + el.innerHTML = "<span>✕ Pas pour moi</span>" +
234 + "<span class='kaid-x' role='button' aria-label='Fermer'>✕</span>";
235 + el.querySelector(".kaid-x").addEventListener("click", function (ev) {
236 + ev.stopPropagation();
237 + try { localStorage.setItem("kaid_pill_off", String(Date.now())); } catch (err) { /* privé */ }
238 + removePill();
239 + });
240 + el.addEventListener("click", function () {
241 + el.innerHTML = "<span>…</span>";
242 + window.KAID.hide(uid).then(function () {
243 + el.innerHTML = "<span>Masquée — elle ne réapparaîtra plus dans vos résultats.</span>";
244 + setTimeout(removePill, 5000);
245 + }).catch(function () { removePill(); });
246 + });
247 + document.body.appendChild(el);
248 + }
249 +
250 + function updateUi() {
251 + if (!connected || CONF.ui === false) return;
252 + removePill();
253 + if (pillDismissed()) return;
254 + var uid = detailUid();
255 + if (uid) showHideLink(uid);
256 + else if (location.search.length > 1 && CONF.savePill !== false)
257 + showSavePill();
258 + }
259 +
260 + /* ---------- visite de retour ---------- */
261 +
262 + function returnVisit() {
263 + try {
264 + var last = Number(localStorage.getItem("kaid_last") || 0);
265 + var now = Date.now();
266 + if (last && now - last > 72e6) track("return_visit", {}); // > 20 h
267 + localStorage.setItem("kaid_last", String(now));
268 + } catch (err) { /* stockage indisponible */ }
269 + }
270 +
271 + /* ---------- API publique ---------- */
272 +
273 + window.KAID = {
274 + track: track,
275 + connected: function () { return connected; },
276 + /** « Pas pour moi » — masque l'annonce partout et nourrit le signal négatif. */
277 + hide: function (itemId, features) {
278 + return fetch(API + "/hide", {
279 + method: "POST",
280 + headers: { "Content-Type": "application/json" },
281 + credentials: "same-origin",
282 + body: JSON.stringify({ item_id: itemId, on: true, features: features || null }),
283 + }).then(function (r) { return r.json(); });
284 + },
285 + /** Sauvegarde la recherche courante (par défaut : URL + titre du document). */
286 + saveSearch: function (opts) {
287 + opts = opts || {};
288 + return fetch(API + "/saved-searches", {
289 + method: "POST",
290 + headers: { "Content-Type": "application/json" },
291 + credentials: "same-origin",
292 + body: JSON.stringify({
293 + action: "add",
294 + label: opts.label || document.title.split("|")[0].split("—")[0].trim(),
295 + query: opts.query || null,
296 + filters: opts.filters || null,
297 + url: opts.url || (location.origin + location.pathname + location.search),
298 + alert: !!opts.alert,
299 + frequency: opts.frequency || null,
300 + }),
301 + }).then(function (r) { return r.json(); });
302 + },
303 + external: function (itemId, features) {
304 + track("external_click",
305 + { entity_type: CONF.entity, entity_id: itemId, features: features });
306 + flush(true);
307 + },
308 + };
309 +
310 + /* ---------- démarrage ---------- */
311 +
312 + function start() {
313 + if (!connected) return;
314 + watchClicks();
315 + watchRoutes();
316 + returnVisit();
317 + }
318 +
319 + if (document.readyState === "loading")
320 + document.addEventListener("DOMContentLoaded", function () { init(start); });
321 + else init(start);
322 +})();
added frontend/public/manifest.webmanifest +18 −0
@@ -0,0 +1,18 @@
1 +{
2 + "name": "Rent-Ka — Rentals across Canada",
3 + "short_name": "Rent-Ka",
4 + "description": "Apartments and homes for rent across Canada, gathered in one place.",
5 + "start_url": "/",
6 + "display": "standalone",
7 + "background_color": "#ffffff",
8 + "theme_color": "#0b1330",
9 + "lang": "en",
10 + "icons": [
11 + {
12 + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Crect width='512' height='512' rx='112' fill='%230B1330'/%3E%3Crect x='117' y='47' width='278' height='418' rx='88' fill='none' stroke='%232456E6' stroke-width='40' stroke-linejoin='round'/%3E%3Ccircle cx='256' cy='170' r='74' fill='none' stroke='%23ffffff' stroke-width='44'/%3E%3Crect x='232' y='232' width='48' height='158' fill='%23ffffff'/%3E%3Crect x='256' y='306' width='72' height='36' fill='%23ffffff'/%3E%3Crect x='256' y='356' width='56' height='34' fill='%23ffffff'/%3E%3Cpath d='M176 420 L336 420 L376 465 L140 465 Z' fill='%232456E6'/%3E%3C/svg%3E",
13 + "sizes": "512x512",
14 + "type": "image/svg+xml",
15 + "purpose": "any"
16 + }
17 + ]
18 +}
added frontend/public/og.png +0 −0

Binary file not shown.

added frontend/scripts/check-order.mjs +44 −0
@@ -0,0 +1,44 @@
1 +// Validation ordre des sections — fiche Lou-Ka (ordre DOM = ordre visuel)
2 +import { chromium, devices } from "playwright";
3 +
4 +const UID = process.argv[2] || "lespac:225560010";
5 +const URL = `http://localhost:8095/logement/${encodeURIComponent(UID)}`;
6 +
7 +async function check(name, ctxOpts) {
8 + const browser = await chromium.launch();
9 + const ctx = await browser.newContext(ctxOpts);
10 + const page = await ctx.newPage();
11 + await page.goto(URL, { waitUntil: "networkidle" });
12 + await page.waitForSelector(".f-galerie", { timeout: 15000 });
13 + await page.waitForTimeout(1500); // laisse PriceAnalysis/carte se charger
14 + const data = await page.evaluate(() => {
15 + const sel = [".f-galerie", ".f-hero", ".f-desc", ".f-incl", ".f-pratique",
16 + ".f-fairvalue", ".f-carte", ".f-kascores", ".f-quartier", ".f-poi"];
17 + const out = [];
18 + for (const s of sel) {
19 + const el = document.querySelector(s);
20 + if (!el) { out.push({ s, missing: true }); continue; }
21 + const r = el.getBoundingClientRect();
22 + const hidden = r.height === 0 && r.width === 0;
23 + out.push({ s, hidden, top: Math.round(r.top + window.scrollY), left: Math.round(r.left), order: getComputedStyle(el).order });
24 + }
25 + return { out, scrollY: window.scrollY };
26 + });
27 + console.log(`\n=== ${name} === scrollY initial: ${data.scrollY}`);
28 + for (const b of data.out)
29 + console.log(b.missing ? `${b.s.padEnd(14)} (non rendue)` : b.hidden ? `${b.s.padEnd(14)} (vide/masquée)` :
30 + `${b.s.padEnd(14)} top=${String(b.top).padStart(6)} left=${String(b.left).padStart(4)} order=${b.order}`);
31 + await browser.close();
32 + return data;
33 +}
34 +
35 +const mob = await check("iPhone 14 (mobile)", { ...devices["iPhone 14"] });
36 +await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } });
37 +
38 +const vis = mob.out.filter(b => !b.missing && !b.hidden);
39 +const sorted = vis.every((b, i) => i === 0 || b.top >= vis[i - 1].top);
40 +const noOrder = vis.every(b => b.order === "0");
41 +const ok = sorted && mob.scrollY === 0 && vis[0].s === ".f-galerie" && vis[0].top < 300 && noOrder;
42 +console.log(`\nMOBILE: ordre visuel ${sorted ? "CROISSANT ✓" : "DÉSORDONNÉ ✗"} · scrollY=${mob.scrollY} ✓ · 1re section=${vis[0].s} top=${vis[0].top} · sans order CSS=${noOrder}`);
43 +console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC");
44 +process.exit(ok ? 0 : 1);
added frontend/src/App.tsx +388 −0
@@ -0,0 +1,388 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// App.tsx: global layout (header + live ticker + ink footer) and routing
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { createPortal } from "react-dom";
8 +import { NavLink, Route, Routes, useLocation } from "react-router-dom";
9 +import {
10 + fetchAuthConfig, fetchFacets, fetchSources, fetchStats,
11 + logout, registerSourceNames, sourceName, PROVINCE_NAMES,
12 +} from "./api";
13 +import { AccountProvider, useAccount } from "./account";
14 +import CookieConsent from "./components/CookieConsent";
15 +import {
16 + IcoChart, IcoCompass, IcoDoc, IcoFolder, IcoHeart, IcoHouse, IcoLock,
17 + IcoMap, IcoSearch, IcoUser,
18 +} from "./components/Icons";
19 +import { LogoIcon } from "./components/Logo";
20 +import GroupeKaBadge from "./ka/GroupeKaBadge";
21 +import KaFooter from "./ka/KaFooter";
22 +import ContactPage from "./pages/Contact";
23 +import Home from "./pages/Home";
24 +import ListingPage from "./pages/Listing";
25 +import BotPage from "./pages/Bot";
26 +import JusteValeurPage from "./pages/JusteValeur";
27 +import KaScoresPage from "./pages/KaScores";
28 +import PasserellePage from "./pages/Passerelle";
29 +import PrivacyPage from "./pages/Privacy";
30 +import ProfilePage from "./pages/Profile";
31 +import PublicProfilePage from "./pages/PublicProfile";
32 +import BienvenuePage from "./pages/Bienvenue";
33 +import FavorisPage from "./pages/Favoris";
34 +import GestionPage from "./pages/Gestion";
35 +import GestionPublicPage from "./pages/GestionPublic";
36 +import TermsPage from "./pages/Terms";
37 +import SourcesPage from "./pages/Sources";
38 +import StatsPage from "./pages/Stats";
39 +import VillePage, { VillesPage } from "./pages/Ville";
40 +
41 +function Ticker() {
42 + const [items, setItems] = useState<string[]>([]);
43 +
44 + useEffect(() => {
45 + Promise.all([fetchStats(), fetchFacets(), fetchSources()])
46 + .then(([stats, facets, src]) => {
47 + registerSourceNames(src.sources);
48 + const parts: string[] = [`${stats.total} active rentals`];
49 + const provs = stats.provinces || {};
50 + for (const [code, n] of Object.entries(provs)) {
51 + if ((n as number) > 0) parts.push(`${PROVINCE_NAMES[code] || code} ${n}`);
52 + }
53 + if (stats.avg_price != null)
54 + parts.push(`Average rent $${Math.round(stats.avg_price).toLocaleString("en-CA")}`);
55 + for (const s of facets.sources.slice(0, 10))
56 + parts.push(`${sourceName(s.source)} · ${s.n}`);
57 + parts.push("Updated automatically");
58 + setItems(parts);
59 + })
60 + .catch(() => setItems(["Rent-Ka — rentals across Canada"]));
61 + }, []);
62 +
63 + if (items.length === 0) return null;
64 + // content doubled for a continuous scroll loop
65 + return (
66 + <div className="ticker" aria-hidden="true">
67 + <div className="ticker-track">
68 + {[...items, ...items].map((t, i) => (
69 + <span key={i}>{t}</span>
70 + ))}
71 + </div>
72 + </div>
73 + );
74 +}
75 +
76 +const NAV_LINKS = [
77 + { to: "/", label: "Rentals", icon: <IcoHouse size={17} />, end: true },
78 + { to: "/?view=map", label: "Map", icon: <IcoMap size={17} />, end: false, force: true },
79 + { to: "/cities", label: "Cities", icon: <IcoCompass size={17} />, end: false },
80 + { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false },
81 + { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false },
82 + { to: "/contact", label: "Contact", icon: <IcoUser size={17} />, end: false },
83 + { to: "/terms", label: "Terms", icon: <IcoDoc size={17} />, end: false },
84 + { to: "/privacy", label: "Privacy", icon: <IcoLock size={17} />, end: false },
85 +];
86 +
87 +/** "Sign in" button or avatar + account menu (Google sign-in) */
88 +function AccountMenu() {
89 + const [enabled, setEnabled] = useState(false);
90 + const { me, refresh } = useAccount();
91 + const [menuOpen, setMenuOpen] = useState(false);
92 +
93 + useEffect(() => {
94 + fetchAuthConfig().then((c) => setEnabled(c.ka || c.google)).catch(() => {});
95 + }, []);
96 +
97 + // menu open: close on Escape (outside tap goes through the backdrop)
98 + useEffect(() => {
99 + if (!menuOpen) return;
100 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenuOpen(false); };
101 + window.addEventListener("keydown", onKey);
102 + return () => window.removeEventListener("keydown", onKey);
103 + }, [menuOpen]);
104 +
105 + if (!enabled) return null;
106 + if (!me) {
107 + return (
108 + <a className="login-btn" href="/api/auth/ka/login">
109 + <span
110 + aria-hidden="true"
111 + style={{
112 + display: "inline-flex",
113 + alignItems: "center",
114 + justifyContent: "center",
115 + width: 18,
116 + height: 16,
117 + borderRadius: 5,
118 + background: "var(--accent, #2456e6)",
119 + color: "#ffffff",
120 + font: "700 9px/1 'Inter', system-ui, sans-serif",
121 + letterSpacing: "-0.02em",
122 + }}
123 + >
124 + KA
125 + </span>
126 + Sign in
127 + </a>
128 + );
129 + }
130 + return (
131 + <div className="account">
132 + <button
133 + className="account-btn"
134 + onClick={() => setMenuOpen(!menuOpen)}
135 + aria-expanded={menuOpen}
136 + aria-label={`Account: ${me.name || me.email}`}
137 + >
138 + {me.picture
139 + ? <img src={me.picture} alt="" referrerPolicy="no-referrer" />
140 + : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>}
141 + </button>
142 + {menuOpen && (
143 + <>
144 + {/* Backdrop rendered at the root (createPortal): the header's
145 + backdrop-filter re-parented position:fixed → veil clipped to header. */}
146 + {createPortal(
147 + <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" />,
148 + document.body,
149 + )}
150 + <div className="account-menu" role="menu">
151 + <div className="account-id">
152 + <b>{me.name || "My account"}</b>
153 + <span>{me.email}</span>
154 + {me.ka_id && <span className="account-kaid">{me.ka_id}</span>}
155 + </div>
156 + <NavLink
157 + to="/profile"
158 + role="menuitem"
159 + className="account-link"
160 + onClick={() => setMenuOpen(false)}
161 + >
162 + My profile
163 + </NavLink>
164 + {me.role === "locataire" && (
165 + <NavLink
166 + to="/favorites"
167 + role="menuitem"
168 + className="account-link"
169 + onClick={() => setMenuOpen(false)}
170 + >
171 + My saved rentals
172 + </NavLink>
173 + )}
174 + {me.role === "gestionnaire" && (
175 + <NavLink
176 + to="/manage"
177 + role="menuitem"
178 + className="account-link"
179 + onClick={() => setMenuOpen(false)}
180 + >
181 + My manager page
182 + </NavLink>
183 + )}
184 + {!me.role && (
185 + <NavLink
186 + to="/welcome"
187 + role="menuitem"
188 + className="account-link"
189 + onClick={() => setMenuOpen(false)}
190 + >
191 + Choose my profile
192 + </NavLink>
193 + )}
194 + <button
195 + role="menuitem"
196 + onClick={async () => { await logout(); refresh(); setMenuOpen(false); }}
197 + >
198 + Sign out
199 + </button>
200 + </div>
201 + </>
202 + )}
203 + </div>
204 + );
205 +}
206 +
207 +function Header() {
208 + const [open, setOpen] = useState(false);
209 + const location = useLocation();
210 +
211 + // close the menu on every navigation + lock scrolling underneath
212 + useEffect(() => { setOpen(false); }, [location]);
213 + useEffect(() => {
214 + document.documentElement.classList.toggle("ka-scroll-lock", open);
215 + return () => { document.documentElement.classList.remove("ka-scroll-lock"); };
216 + }, [open]);
217 +
218 + return (
219 + <>
220 + <header className="header">
221 + <div className="container header-inner">
222 + <NavLink to="/" className="brand" aria-label="Rent-Ka — home">
223 + <LogoIcon size={30} />
224 + Rent<span className="ka">·Ka</span>
225 + <span className="brand-tag">Apartments and homes for rent across Canada.</span>
226 + </NavLink>
227 + <GroupeKaBadge />
228 + <nav className="nav" aria-label="Main navigation">
229 + <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
230 + Rentals
231 + </NavLink>
232 + <NavLink to="/cities" className={({ isActive }) => (isActive ? "active" : "")}>
233 + Cities
234 + </NavLink>
235 + <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
236 + Stats
237 + </NavLink>
238 + <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>
239 + Sources
240 + </NavLink>
241 + </nav>
242 + <AccountMenu />
243 + <button
244 + className={`menu-btn ${open ? "open" : ""}`}
245 + aria-expanded={open}
246 + aria-label={open ? "Close menu" : "Open menu"}
247 + onClick={() => setOpen(!open)}
248 + >
249 + <span /><span /><span />
250 + </button>
251 + </div>
252 +
253 + {/* mobile drawer menu */}
254 + <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Mobile menu">
255 + {/* fixed ✕ of the panel: visible regardless of scroll; the header burger is hidden while open */}
256 + <button type="button" className="mm-close" aria-label="Close menu"
257 + onClick={() => setOpen(false)}>✕</button>
258 + {NAV_LINKS.map((l, i) => (
259 + <NavLink
260 + key={l.to}
261 + to={l.to}
262 + end={l.end}
263 + style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}
264 + className={({ isActive }) =>
265 + `mm-link ${isActive && !l.force ? "active" : ""}`}
266 + onClick={() => setOpen(false)}
267 + >
268 + <span className="mm-ico" aria-hidden="true">{l.icon}</span>
269 + {l.label}
270 + <span className="mm-arrow" aria-hidden="true">→</span>
271 + </NavLink>
272 + ))}
273 + <div className="mm-badge">
274 + <GroupeKaBadge />
275 + </div>
276 + <div className="mm-foot">
277 + Independent aggregator — updated automatically; every listing links
278 + back to the original ad.
279 + </div>
280 + </div>
281 + </header>
282 + {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
283 + <Ticker />
284 + </>
285 + );
286 +}
287 +
288 +/** Footer: local navigation row + shared Groupe KA footer. */
289 +function Footer() {
290 + return (
291 + <>
292 + <div className="prefooter">
293 + <div className="container">
294 + <span className="baseline">
295 + <b>Rent-Ka</b> — Apartments and homes for rent across Canada.
296 + Independent aggregator: every listing links back to the original ad.
297 + </span>
298 + <NavLink to="/">Rentals</NavLink>
299 + <NavLink to="/cities">Cities</NavLink>
300 + <NavLink to="/stats">Stats</NavLink>
301 + <NavLink to="/sources">Sources</NavLink>
302 + <NavLink to="/contact">Contact</NavLink>
303 + <button
304 + className="flink"
305 + onClick={() => window.dispatchEvent(new Event("rentka:openConsent"))}
306 + >
307 + Manage cookies
308 + </button>
309 + </div>
310 + </div>
311 + <KaFooter
312 + siteId="rent-ka"
313 + localLegal={[
314 + { label: "Documentation", href: "/doc/" },
315 + { label: "Terms of use (Rent-Ka)", href: "/terms" },
316 + { label: "Privacy (Rent-Ka)", href: "/privacy" },
317 + ]}
318 + />
319 + </>
320 + );
321 +}
322 +
323 +/** Bottom navigation bar (mobile) — active icon in accent. */
324 +function MobileTabBar() {
325 + const location = useLocation();
326 + const isMap = new URLSearchParams(location.search).get("view") === "map";
327 + const tabs = [
328 + { to: "/", label: "Search", icon: <IcoSearch size={20} />, on: location.pathname === "/" && !isMap },
329 + { to: "/?view=map", label: "Map", icon: <IcoMap size={20} />, on: location.pathname === "/" && isMap },
330 + { to: "/favorites", label: "Saved", icon: <IcoHeart size={20} />, on: location.pathname.startsWith("/favorites") },
331 + { to: "/profile", label: "Profile", icon: <IcoUser size={20} />, on: location.pathname.startsWith("/profile") },
332 + ];
333 + return (
334 + <nav className="tabbar" aria-label="Mobile navigation">
335 + {tabs.map((t) => (
336 + <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>
337 + {t.icon}
338 + {t.label}
339 + </NavLink>
340 + ))}
341 + </nav>
342 + );
343 +}
344 +
345 +export default function App() {
346 + return (
347 + <AccountProvider>
348 + <Header />
349 + <main>
350 + <Routes>
351 + <Route path="/" element={<Home />} />
352 + <Route path="/listing/:uid" element={<ListingPage />} />
353 + <Route path="/cities" element={<VillesPage />} />
354 + <Route path="/city/:ville" element={<VillePage />} />
355 + <Route path="/city/:ville/:type" element={<VillePage />} />
356 + <Route path="/stats" element={<StatsPage />} />
357 + <Route path="/sources" element={<SourcesPage />} />
358 + <Route path="/contact" element={<ContactPage />} />
359 + <Route path="/privacy" element={<PrivacyPage />} />
360 + <Route path="/terms" element={<TermsPage />} />
361 + <Route path="/profile" element={<ProfilePage />} />
362 + <Route path="/u/:kaId" element={<PublicProfilePage />} />
363 + <Route path="/welcome" element={<BienvenuePage />} />
364 + <Route path="/favorites" element={<FavorisPage />} />
365 + <Route path="/manage" element={<GestionPage />} />
366 + <Route path="/g/:sourceId" element={<GestionPublicPage />} />
367 + <Route path="/gateway/:uid" element={<PasserellePage />} />
368 + <Route path="/bot" element={<BotPage />} />
369 + <Route path="/fair-value" element={<JusteValeurPage />} />
370 + <Route path="/ka-scores" element={<KaScoresPage />} />
371 + <Route
372 + path="*"
373 + element={
374 + <div className="notice container">
375 + <div className="big"><IcoCompass size={40} /></div>
376 + <h2>Page not found</h2>
377 + <p>The requested link does not exist.</p>
378 + </div>
379 + }
380 + />
381 + </Routes>
382 + </main>
383 + <Footer />
384 + <MobileTabBar />
385 + <CookieConsent />
386 + </AccountProvider>
387 + );
388 +}
added frontend/src/account.tsx +68 −0
@@ -0,0 +1,68 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// account.tsx: shared account context — signed-in profile + saved rentals
5 +// One fetch at app mount; card hearts, the account menu and the
6 +// Favorites/Manage pages read the same state (optimistic toggle).
7 +// -----------------------------------------------------------------------------
8 +import {
9 + ReactNode, createContext, useCallback, useContext, useEffect, useState,
10 +} from "react";
11 +import { Me, addFavorite, fetchFavorites, fetchMe, removeFavorite } from "./api";
12 +
13 +interface AccountState {
14 + me: Me | null;
15 + loaded: boolean; // first fetch done
16 + favs: Set<string>; // saved uids
17 + toggleFav: (uid: string) => void;
18 + refresh: () => void; // re-fetch profile + saved (after login/role)
19 +}
20 +
21 +const Ctx = createContext<AccountState>({
22 + me: null, loaded: false, favs: new Set(),
23 + toggleFav: () => {}, refresh: () => {},
24 +});
25 +
26 +export const useAccount = () => useContext(Ctx);
27 +
28 +export function AccountProvider({ children }: { children: ReactNode }) {
29 + const [me, setMe] = useState<Me | null>(null);
30 + const [loaded, setLoaded] = useState(false);
31 + const [favs, setFavs] = useState<Set<string>>(new Set());
32 +
33 + const refresh = useCallback(() => {
34 + fetchMe().then((m) => {
35 + setMe(m);
36 + setLoaded(true);
37 + if (m) {
38 + fetchFavorites()
39 + .then((f) => setFavs(new Set(f.uids)))
40 + .catch(() => setFavs(new Set()));
41 + } else {
42 + setFavs(new Set());
43 + }
44 + });
45 + }, []);
46 +
47 + useEffect(() => { refresh(); }, [refresh]);
48 +
49 + const toggleFav = useCallback((uid: string) => {
50 + setFavs((prev) => {
51 + const next = new Set(prev);
52 + if (next.has(uid)) {
53 + next.delete(uid);
54 + removeFavorite(uid).catch(() => {});
55 + } else {
56 + next.add(uid);
57 + addFavorite(uid).catch(() => {});
58 + }
59 + return next;
60 + });
61 + }, []);
62 +
63 + return (
64 + <Ctx.Provider value={{ me, loaded, favs, toggleFav, refresh }}>
65 + {children}
66 + </Ctx.Provider>
67 + );
68 +}
added frontend/src/api.ts +670 −0
@@ -0,0 +1,670 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// api.ts: types + robust API client (timeout, typed errors)
5 +// -----------------------------------------------------------------------------
6 +
7 +export interface ListingDetails {
8 + inclusions?: Record<string, boolean>;
9 + appliances?: Record<string, boolean>;
10 + parking?: { available?: boolean; type?: string; included?: boolean; price?: number };
11 + contact?: { phone?: string; email?: string };
12 + ac?: boolean;
13 + elevator?: boolean;
14 + balcony?: boolean;
15 + pool?: boolean;
16 + gym?: boolean;
17 + laundry?: boolean;
18 + storage?: boolean;
19 + smoking?: boolean;
20 + floor?: number;
21 + price_from?: boolean;
22 +}
23 +
24 +export interface Poi {
25 + cat: string; // grocery, pharmacy, school, park, bus…
26 + name: string;
27 + dist_m: number;
28 +}
29 +
30 +export interface Digest {
31 + version: number;
32 + texte_nettoye: string;
33 + en_bref: string | null;
34 + sections: { titre: string; texte: string }[];
35 + faits: {
36 + prix_mensuel: number | null;
37 + date_disponibilite: string | null;
38 + duree_bail_minimale_mois: number | null;
39 + nb_occupants_total: number | null;
40 + salle_de_bain: "commune" | "privee" | null;
41 + cuisine: "commune" | "privee" | null;
42 + electromenagers: string[];
43 + inclusions: string[];
44 + contraintes: string[];
45 + depot_mentionne: string | null;
46 + quartier_mentionne: string | null;
47 + };
48 + confiance: Record<string, "haute" | "faible">;
49 + incoherences: string[];
50 + completude: number;
51 +}
52 +
53 +export interface Listing {
54 + /** KA ID personalization: set by the server (rentka/kaid.py) when the
55 + personal score is clear — shows "Recommended for you" */
56 + ka_reco?: { score: number; reasons: string[] } | null;
57 + uid: string;
58 + source: string;
59 + external_id: string;
60 + url: string;
61 + title: string;
62 + address: string;
63 + sector: string;
64 + city: string;
65 + province?: string | null;
66 + unit_type: string;
67 + price: number | null;
68 + price_label: string;
69 + availability: string;
70 + availability_date: string | null; // ISO "2026-07-01" or "now"
71 + area_sqft: number | null;
72 + pets: string | null; // "oui" | "non" | "conditions"
73 + furnished: boolean | null;
74 + description: string;
75 + amenities: string[];
76 + details: ListingDetails;
77 + images: string[];
78 + lat: number | null;
79 + lng: number | null;
80 + bedrooms: number | null;
81 + poi?: Poi[]; // nearby amenities (detail page only)
82 + digest?: Digest | null; // structured description (detail page only)
83 + price_history?: { ts: number; price: number | null }[];
84 + first_seen?: number;
85 + last_seen: number;
86 + updated_at: number;
87 + active: number;
88 + // fair rental value — rentka/fairvalue.py
89 + fv?: number | null; // estimated value ($/month)
90 + fv_low?: number | null; // low bound
91 + fv_high?: number | null; // high bound
92 + fv_deviation?: number | null; // (price - fv) / fv
93 + fv_verdict?: "sous" | "marche" | "sur" | null;
94 + fv_confidence?: "fort" | "moyen" | "faible" | null;
95 + // KA Scores (0-100, null = insufficient data / not served)
96 + ks_walk?: number | null;
97 + ks_transit?: number | null;
98 + ks_bike?: number | null;
99 + ks_calme?: number | null;
100 + ks_services?: number | null;
101 + ks_global?: number | null;
102 + kascores?: KaScores | null; // full detail (detail page only)
103 + // rental intelligence file (detail page only)
104 + building_key?: string | null;
105 + immeuble?: Immeuble | null; // building passport (precomputed)
106 + hiver?: Hiver | null; // winter daily-life score (0-100)
107 +}
108 +
109 +// --- KA Scores — in-house score family (rentka/kascores.py) -----------------
110 +export interface KaScores {
111 + walk: number | null;
112 + transit: number | null;
113 + bike: number | null;
114 + calme: number | null;
115 + services: number | null;
116 + global: number | null;
117 + details: {
118 + walk?: { cats?: { cat: string; dist_m: number | null; pts: number }[]; bonus_choix?: number; raison?: string };
119 + transit?: { arret_bus_m?: number | null; station_metro_m?: number | null; pmd_percentile?: number | null; raison?: string };
120 + bike?: { km_cyclables_1km?: number; note?: string; raison?: string };
121 + calme?: { sources_bruit?: { source: string; dist_m: number | null; pen: number }[]; bonus_parc?: number; note?: string };
122 + services?: { familles?: Record<string, number>; raison?: string };
123 + labels?: Record<string, string | null>;
124 + };
125 + version: string;
126 + computed_at: number;
127 +}
128 +
129 +export interface KaScoresStats {
130 + annonces: number;
131 + avec_score: number;
132 + couverture_pct: number;
133 + version: string;
134 + moyennes: Record<string, { moyenne: number | null; n: number }>;
135 +}
136 +
137 +export const fetchKaScoresStats = () => get<KaScoresStats>("/api/kascores/stats");
138 +
139 +/** Label for a KA Score (same thresholds as rentka/kascores.py). */
140 +export function kaLabel(score: number | null | undefined): string | null {
141 + if (score == null) return null;
142 + if (score >= 85) return "Exceptional";
143 + if (score >= 70) return "Excellent";
144 + if (score >= 55) return "Very good";
145 + if (score >= 40) return "Average";
146 + return "Low";
147 +}
148 +
149 +/** Custom weights for the global KA Score (localStorage). */
150 +export const KA_DEFAULT_WEIGHTS: Record<string, number> = {
151 + walk: 0.30, transit: 0.20, bike: 0.15, calme: 0.20, services: 0.15,
152 +};
153 +
154 +export function kaWeights(): Record<string, number> {
155 + try {
156 + const raw = localStorage.getItem("rentka_ks_weights");
157 + if (!raw) return KA_DEFAULT_WEIGHTS;
158 + const w = JSON.parse(raw) as Record<string, number>;
159 + return Object.keys(KA_DEFAULT_WEIGHTS).every((k) => typeof w[k] === "number")
160 + ? w : KA_DEFAULT_WEIGHTS;
161 + } catch {
162 + return KA_DEFAULT_WEIGHTS;
163 + }
164 +}
165 +
166 +/** Global KA Score recomputed with the user's weights. */
167 +export function kaGlobal(
168 + s: { walk?: number | null; transit?: number | null; bike?: number | null;
169 + calme?: number | null; services?: number | null },
170 + weights: Record<string, number> = kaWeights(),
171 +): number | null {
172 + let poids = 0, acquis = 0;
173 + for (const k of Object.keys(KA_DEFAULT_WEIGHTS)) {
174 + const v = (s as Record<string, number | null | undefined>)[k];
175 + if (v != null) { poids += weights[k]!; acquis += weights[k]! * v; }
176 + }
177 + return poids < 0.5 ? null : Math.round((acquis / poids) * 10) / 10;
178 +}
179 +
180 +export interface FairValueDetail {
181 + uid: string;
182 + fv: number; fv_low: number; fv_high: number;
183 + deviation: number | null;
184 + verdict: "sous" | "marche" | "sur" | null;
185 + confidence: "fort" | "moyen" | "faible";
186 + method: string; comps: number; segment: string;
187 + model_version: string;
188 + segment_n: number;
189 + histogram: { x0: number; x1: number; n: number }[];
190 +}
191 +
192 +export const fetchFairValue = (uid: string) =>
193 + get<FairValueDetail>(`/api/fairvalue/${encodeURIComponent(uid)}`);
194 +
195 +export interface HistoriqueLouka {
196 + premiere_observation: number | null;
197 + derniere_observation: number | null;
198 + active: boolean;
199 + jours_en_ligne: number;
200 + prix_initial: number | null;
201 + prix_actuel: number | null;
202 + variation: number | null;
203 + modifications: number;
204 + timeline: {
205 + ts: number; type: string; statut: string;
206 + prix?: number | null; prix_avant?: number | null;
207 + avant?: unknown; apres?: unknown;
208 + }[];
209 + methode: string;
210 +}
211 +
212 +export const fetchHistorique = (uid: string) =>
213 + get<HistoriqueLouka>(`/api/listings/${encodeURIComponent(uid)}/historique`);
214 +
215 +export interface Recyclees {
216 + matches: {
217 + uid: string; source: string; prix: number | null;
218 + unit_type: string | null;
219 + derniere_observation: number | null; premiere_observation: number | null;
220 + confiance: number; signaux: string[];
221 + }[];
222 + statut: string;
223 + methode: string;
224 +}
225 +
226 +export const fetchRecyclees = (uid: string) =>
227 + get<Recyclees>(`/api/listings/${encodeURIComponent(uid)}/recyclees`);
228 +
229 +export interface Immeuble {
230 + bkey: string;
231 + address: string | null;
232 + city: string | null;
233 + computed_at: number;
234 + annonces_total: number;
235 + annonces_actives: number;
236 + annonces_30j: number;
237 + annonces_90j: number;
238 + annonces_12m: number;
239 + unites_identifiees: number;
240 + unites_estimees: number;
241 + loyer_median: number | null;
242 + loyer_median_par_cc: Record<string, number> | null;
243 + pi2_median: number | null;
244 + pression_loyers: {
245 + variation_12m: number; mediane_12m: number; mediane_12_24m: number;
246 + n_12m: number; n_12_24m: number; statut: string;
247 + } | null;
248 + rotation: {
249 + statut: string; classe?: string; ratio?: number;
250 + annonces_12m?: number; unites_estimees?: number;
251 + observation_jours?: number; methode?: string;
252 + };
253 + gestionnaires: string[];
254 + sources: Record<string, number>;
255 + premiere_observation: number;
256 + derniere_observation: number;
257 + unites_actives: { uid: string; unit_type: string | null; price: number | null; bedrooms: number | null }[];
258 +}
259 +
260 +export interface Hiver {
261 + score: number;
262 + classe: string;
263 + detail: { critere: string; score: number; distance_m: number | null; minutes?: number; nom?: string | null; note?: string }[];
264 + statut: string;
265 + methode: string;
266 +}
267 +
268 +export interface Gestionnaire {
269 + source_id: string;
270 + nom: string | null;
271 + site_web: string | null;
272 + telephone: string | null;
273 + annonces_actives: number;
274 + google_maps?: {
275 + statut: string;
276 + nom?: string; adresse?: string; note?: number | null;
277 + nombre_avis?: number | null; confiance_association?: number;
278 + signaux?: Record<string, unknown>;
279 + methode?: string; note_methode?: string;
280 + };
281 + avis?: {
282 + n: number; moyenne?: number; distribution?: Record<string, number>;
283 + pct_negatif?: number; pct_positif?: number;
284 + moyenne_12m?: number; n_12m?: number;
285 + tendance?: string; tendance_delta?: number;
286 + avec_reponse_proprietaire?: number;
287 + themes?: Record<string, { mentions: number; negatif: number; positif: number }>;
288 + plaintes_frequentes?: string[];
289 + methode?: string;
290 + } | null;
291 + avis_recents?: {
292 + note: number | null; texte: string | null; date: string | null;
293 + auteur: string | null; reponse_proprietaire: boolean;
294 + analyse: { topics?: string[]; sentiment?: string; severite?: string };
295 + }[];
296 + derniere_synchro?: number | null;
297 +}
298 +
299 +export const fetchGestionnaire = (sourceId: string) =>
300 + get<Gestionnaire>(`/api/managers/${encodeURIComponent(sourceId)}`);
301 +
302 +/** 250 -> "250 m", 1240 -> "1.2 km" */
303 +export const fmtDist = (m: number): string =>
304 + m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1)} km`;
305 +
306 +export interface Facets {
307 + cities: string[];
308 + sectors: string[];
309 + unit_types: string[];
310 + provinces?: string[];
311 + sources: { source: string; n: number }[];
312 +}
313 +
314 +export interface Source {
315 + id: string;
316 + name: string;
317 + url: string;
318 + listing_url: string;
319 + sectors: string;
320 + connector: string | null;
321 + status: string;
322 + active_listings: number;
323 + last_sync: number | null;
324 +}
325 +
326 +/** Province codes → display names (en-CA). */
327 +export const PROVINCE_NAMES: Record<string, string> = {
328 + ON: "Ontario",
329 + BC: "British Columbia",
330 + AB: "Alberta",
331 + SK: "Saskatchewan",
332 + MB: "Manitoba",
333 + NB: "New Brunswick",
334 + NS: "Nova Scotia",
335 + PE: "Prince Edward Island",
336 + NL: "Newfoundland and Labrador",
337 + YT: "Yukon",
338 + NT: "Northwest Territories",
339 + NU: "Nunavut",
340 +};
341 +
342 +export interface Stats {
343 + total: number;
344 + provinces?: Record<string, number>;
345 + sources: number;
346 + avg_price: number | null;
347 +}
348 +
349 +const SOURCE_NAMES: Record<string, string> = {};
350 +
351 +export function registerSourceNames(sources: Source[]) {
352 + for (const s of sources) SOURCE_NAMES[s.id] = s.name;
353 +}
354 +export function sourceName(id: string): string {
355 + return SOURCE_NAMES[id] ?? id;
356 +}
357 +
358 +async function get<T>(path: string): Promise<T> {
359 + const ctrl = new AbortController();
360 + const timer = setTimeout(() => ctrl.abort(), 20000);
361 + try {
362 + const res = await fetch(path, { signal: ctrl.signal });
363 + if (!res.ok) throw new Error(`API ${res.status} — ${path}`);
364 + return (await res.json()) as T;
365 + } finally {
366 + clearTimeout(timer);
367 + }
368 +}
369 +
370 +export interface ListingFilters {
371 + city?: string;
372 + sector?: string;
373 + province?: string;
374 + unit_type?: string;
375 + source?: string;
376 + price_min?: string;
377 + price_max?: string;
378 + pets?: string; // "oui" -> accepted (yes OR conditions)
379 + furnished?: string; // "1" | "0"
380 + available_by?: string; // ISO: available now or before this date
381 + area_min?: string; // minimum area (sq ft)
382 + q?: string;
383 + deal?: string; // "sous" | "marche" | "sur" (fair value)
384 + kascore_min?: string; // minimum global KA Score ("60" | "70" | "80")
385 + sort?: string; // "deal" deals | "ka" KA Score
386 +}
387 +
388 +export function fetchListings(f: ListingFilters, limit?: number, offset?: number) {
389 + const params = new URLSearchParams();
390 + for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
391 + if (limit != null) params.set("limit", String(limit));
392 + if (offset) params.set("offset", String(offset));
393 + return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`);
394 +}
395 +
396 +// --- Unified list + map search (/api/search) ---------------------------------
397 +// One response = the list page + ALL map points, same sort: the list counter
398 +// equals the number of markers by construction.
399 +
400 +/** Compact point: [uid, lng, lat, price, verdict ("s"|"m"|"o"|null)]. */
401 +export type SearchPoint = [string, number, number, number | null, string | null];
402 +
403 +export type SearchSort = "prix" | "prix_desc" | "recent" | "deal" | "ka";
404 +
405 +export interface SearchQuery extends Omit<ListingFilters, "sort"> {
406 + bbox?: string; // west,south,east,north — visible map area
407 + poly?: string; // lng,lat;lng,lat;… — drawn area
408 + sort?: SearchSort;
409 + page?: number;
410 + page_size?: number;
411 + include?: "tout" | "liste"; // "liste": points unchanged client-side
412 +}
413 +
414 +export interface SearchResponse {
415 + total: number; // shared counter list = map
416 + page: number;
417 + page_size: number;
418 + sort: SearchSort;
419 + listings: Listing[];
420 + points: SearchPoint[] | null; // null if include="liste"
421 + unpositioned: number; // filtered listings without coordinates
422 +}
423 +
424 +export async function fetchSearch(
425 + qy: SearchQuery,
426 + signal?: AbortSignal,
427 +): Promise<SearchResponse> {
428 + const params = new URLSearchParams();
429 + for (const [k, v] of Object.entries(qy)) if (v) params.set(k, String(v));
430 + const res = await fetch(`/api/search?${params}`, { signal });
431 + if (!res.ok) throw new Error(`API ${res.status} — /api/search`);
432 + return (await res.json()) as SearchResponse;
433 +}
434 +
435 +export interface GroupStat {
436 + key: string;
437 + count: number;
438 + sources?: number;
439 + avg_price: number | null;
440 + min_price: number | null;
441 +}
442 +
443 +export interface DetailedStats {
444 + totals: {
445 + total: number;
446 + with_price: number;
447 + avg: number | null;
448 + median: number | null;
449 + min: number | null;
450 + max: number | null;
451 + sources: number;
452 + cities: number;
453 + regions: number;
454 + gps_pct: number | null;
455 + superficie_moyenne: number | null;
456 + dispo_now: number;
457 + };
458 + histogram: { lo: number; hi: number | null; count: number }[];
459 + by_type: GroupStat[];
460 + by_city: GroupStat[];
461 + by_source: GroupStat[];
462 + by_region: GroupStat[];
463 + offre: {
464 + furnished_pct: number | null;
465 + pets_oui_pct: number | null;
466 + pets_connu: number;
467 + chauffage_pct: number | null;
468 + electricite_pct: number | null;
469 + eau_chaude_pct: number | null;
470 + internet_pct: number | null;
471 + clim_pct: number | null;
472 + stationnement_pct: number | null;
473 + balcon_pct: number | null;
474 + dispo_now: number;
475 + dispo_date: number;
476 + dispo_inconnue: number;
477 + superficie_moyenne: number | null;
478 + superficie_connue: number;
479 + prix_pi2: { key: string; count: number; val: number }[];
480 + };
481 + baisses: { uid: string; title: string; city: string; avant: number; apres: number; pct: number }[];
482 + sante: { sources_sync_24h: number; alertes_24h: { source: string; message: string; ts: number }[] };
483 +}
484 +
485 +export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed");
486 +
487 +export const fetchListing = (uid: string) =>
488 + get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);
489 +export const fetchFacets = (city?: string) =>
490 + get<Facets>(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`);
491 +
492 +/** ISO date at +n days (for "available within 1 month", etc.) */
493 +export function isoInDays(n: number): string {
494 + const d = new Date();
495 + d.setDate(d.getDate() + n);
496 + return d.toISOString().slice(0, 10);
497 +}
498 +export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
499 +export const fetchStats = () => get<Stats>("/api/stats");
500 +
501 +// -- user account (Google sign-in) ---------------------------------------------
502 +export type Socials = Partial<Record<
503 + "instagram" | "facebook" | "x" | "linkedin" | "tiktok" | "youtube", string>>;
504 +
505 +export interface Me {
506 + uid: number;
507 + ka_id: string; // member id "ka-0123456789" (Groupe KA ecosystem)
508 + email: string;
509 + name: string; // effective name (display_name else Google name)
510 + google_name: string;
511 + picture: string; // effective avatar (uploaded photo else Google)
512 + google_picture: string;
513 + avatar_url: string | null; // uploaded photo ("/uploads/avatars/…")
514 + display_name: string;
515 + bio: string;
516 + city: string;
517 + phone: string;
518 + website: string;
519 + socials: Socials;
520 + public: boolean; // public profile /u/{ka_id} enabled (opt-in)
521 + role: "locataire" | "gestionnaire" | null;
522 + org_source: string | null; // claimed source (manager)
523 + created_at: number | null;
524 + last_login: number | null;
525 + provider: string;
526 + // — profile managed at the Groupe KA HUB (source of truth: groupe-ka.com/compte) —
527 + profile_source?: "groupe-ka" | "local";
528 + role_label?: string; // Groupe KA status (e.g. "ÉQUIPE · GROUPE KA")
529 + job_title?: string;
530 + company?: string;
531 + age?: number | null;
532 + public_url?: string; // hub public page (if public profile)
533 +}
534 +
535 +export const setRole = (role: "locataire" | "gestionnaire") =>
536 + fetch(`/api/me/role?role=${role}`, { method: "POST" });
537 +
538 +export const updateMyProfile = (fields: {
539 + display_name: string; bio: string; city: string;
540 + phone: string; website: string; socials: Socials;
541 +}) => fetch("/api/me/profile", {
542 + method: "PUT",
543 + headers: { "Content-Type": "application/json" },
544 + body: JSON.stringify(fields),
545 +});
546 +
547 +export async function uploadAvatar(file: File): Promise<string | null> {
548 + const fd = new FormData();
549 + fd.append("file", file);
550 + const res = await fetch("/api/me/avatar", { method: "POST", body: fd });
551 + if (!res.ok) return null;
552 + return ((await res.json()) as { avatar_url: string }).avatar_url;
553 +}
554 +export const deleteAvatar = () => fetch("/api/me/avatar", { method: "DELETE" });
555 +
556 +// -- saved rentals (tenant) ------------------------------------------------------
557 +export const fetchFavorites = () =>
558 + get<{ uids: string[]; listings: Listing[] }>("/api/favorites");
559 +export const addFavorite = (uid: string) =>
560 + fetch(`/api/favorites/${encodeURIComponent(uid)}`, { method: "POST" });
561 +export const removeFavorite = (uid: string) =>
562 + fetch(`/api/favorites/${encodeURIComponent(uid)}`, { method: "DELETE" });
563 +
564 +// -- manager page ----------------------------------------------------------------
565 +export interface OrgProfile {
566 + source: string;
567 + name: string;
568 + active_listings: number;
569 + claimed?: boolean;
570 + tagline?: string;
571 + description?: string;
572 + website?: string;
573 + phone?: string;
574 + email?: string;
575 + logo_url?: string;
576 +}
577 +export const fetchMyOrg = () =>
578 + get<{ source: string | null; name?: string; active_listings?: number;
579 + profile?: Record<string, string> }>("/api/org/mine");
580 +export const claimOrg = (source: string) =>
581 + fetch(`/api/org/claim?source=${encodeURIComponent(source)}`, { method: "POST" });
582 +export const updateOrg = (fields: {
583 + tagline: string; description: string; website: string;
584 + phone: string; email: string; logo_url: string;
585 +}) => fetch("/api/org", {
586 + method: "PUT",
587 + headers: { "Content-Type": "application/json" },
588 + body: JSON.stringify(fields),
589 +});
590 +export const fetchOrg = (sourceId: string) =>
591 + get<OrgProfile>(`/api/org/${encodeURIComponent(sourceId)}`);
592 +
593 +export interface PublicProfile {
594 + ka_id: string;
595 + name: string;
596 + picture: string;
597 + bio: string;
598 + city: string;
599 + website: string;
600 + socials: Socials;
601 + role: "locataire" | "gestionnaire" | null;
602 + created_at: number | null;
603 + // — Groupe KA HUB fields (profile_source === "groupe-ka") —
604 + profile_source?: "groupe-ka" | "local";
605 + role_label?: string;
606 + job_title?: string;
607 + company?: string;
608 + age?: number | null;
609 +}
610 +export const fetchPublicProfile = (kaId: string) =>
611 + get<PublicProfile>(`/api/users/${encodeURIComponent(kaId)}`);
612 +export const setPublicProfile = (enabled: boolean) =>
613 + fetch(`/api/me/public?enabled=${enabled}`, { method: "POST" });
614 +/** Signed-in profile, or null (401 = simply not signed in). */
615 +export async function fetchMe(): Promise<Me | null> {
616 + try {
617 + const res = await fetch("/api/me");
618 + return res.ok ? ((await res.json()) as Me) : null;
619 + } catch {
620 + return null;
621 + }
622 +}
623 +export const fetchAuthConfig = () => get<{ google: boolean; ka: boolean }>("/api/auth/config");
624 +export const logout = () => fetch("/api/auth/logout", { method: "POST" });
625 +
626 +export const fmtPrice = (p: number | null, label?: string) =>
627 + p != null
628 + ? "$" + p.toLocaleString("en-CA", { maximumFractionDigits: 0 })
629 + : label || "Price on request";
630 +
631 +/** "now" -> "Now", "2026-12-01" -> "December 1, 2026" */
632 +export function fmtAvailability(iso: string | null): string | null {
633 + if (!iso) return null;
634 + if (iso === "now") return "Now";
635 + const [y, m, d] = iso.split("-").map(Number);
636 + if (!y || !m || !d) return null;
637 + return new Date(y, m - 1, d).toLocaleDateString("en-CA", {
638 + day: "numeric", month: "long", year: "numeric",
639 + });
640 +}
641 +
642 +// --- Optimized images + Rent-Ka fallback visual -------------------------------
643 +/** WebP thumbnail served at screen size (see /api/img on the API side).
644 + * If the API does not know the URL yet (audit in progress), it answers 404
645 + * and the component falls back to the original URL via onError. */
646 +export const thumb = (u: string, w: 160 | 480 | 800 | 1280 = 480) =>
647 + `/api/img?u=${encodeURIComponent(u)}&w=${w}`;
648 +
649 +/** Elegant fallback visual in Rent-Ka colours (never a broken-image icon) —
650 + * inline SVG with the unit type as a label. */
651 +export function placeholderImage(label?: string): string {
652 + const txt = (label || "Rental").slice(0, 12);
653 + const svg =
654 + `<svg xmlns='http://www.w3.org/2000/svg' width='800' height='525' viewBox='0 0 800 525'>` +
655 + `<defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>` +
656 + `<stop offset='0' stop-color='#e9efff'/><stop offset='1' stop-color='#c8d7fb'/>` +
657 + `</linearGradient></defs>` +
658 + `<rect width='800' height='525' fill='url(#g)'/>` +
659 + `<g transform='translate(400 235)' stroke='#2456e6' stroke-width='14' fill='none' stroke-linecap='round' stroke-linejoin='round'>` +
660 + `<path d='M -95 -10 L 0 -85 L 95 -10'/>` +
661 + `<path d='M -70 -25 L -70 70 L 70 70 L 70 -25'/>` +
662 + `<rect x='-18' y='14' width='36' height='56'/>` +
663 + `</g>` +
664 + `<text x='400' y='390' text-anchor='middle' font-family='system-ui,-apple-system,Segoe UI,sans-serif' ` +
665 + `font-size='30' font-weight='700' fill='#1738a8'>${txt}</text>` +
666 + `<text x='400' y='426' text-anchor='middle' font-family='system-ui,-apple-system,Segoe UI,sans-serif' ` +
667 + `font-size='19' fill='#1738a8' opacity='0.75'>Photo coming soon · Rent-Ka</text>` +
668 + `</svg>`;
669 + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
670 +}
added frontend/src/components/AmenityIco.tsx +113 −0
@@ -0,0 +1,113 @@
1 +// -----------------------------------------------------------------------------
2 +// Groupe KA — shared component (rent-ka edition, bilingual matching)
3 +// components/AmenityIco.tsx : icône contextuelle d'une commodité ou inclusion.
4 +// Fini l'icône unique répétée dix fois : chaque libellé est associé par
5 +// mots-clés (accents neutralisés) à l'un des ~30 pictos maison — trait 1.8,
6 +// 24×24, currentColor. Repli : ✓ (confirmé) ou étoile (mention libre).
7 +// -----------------------------------------------------------------------------
8 +import { ReactNode } from "react";
9 +
10 +const ICONS: Record<string, ReactNode> = {
11 + heat: (<><rect x="4" y="9" width="16" height="9" rx="2" /><path d="M8 9v9M12 9v9M16 9v9M7.6 3.5c0 1.6 1.2 1.6 1.2 3.2M11.4 3.5c0 1.6 1.2 1.6 1.2 3.2M15.2 3.5c0 1.6 1.2 1.6 1.2 3.2" /></>),
12 + bolt: <path d="M13 2.5 4.5 13.5H11l-1.5 8 8.5-11H11.5l1.5-8z" />,
13 + droplet: (<><path d="M12 3.5S6.2 9.8 6.2 13.6a5.8 5.8 0 0 0 11.6 0C17.8 9.8 12 3.5 12 3.5z" /><path d="M9.4 14a2.7 2.7 0 0 0 2.1 2.6" /></>),
14 + wifi: <path d="M3.5 9.5a13 13 0 0 1 17 0M6.5 13a8.5 8.5 0 0 1 11 0M9.5 16.4a4.4 4.4 0 0 1 5 0M12 19.6h.01" />,
15 + dishwasher: (<><rect x="4.5" y="3" width="15" height="18" rx="2" /><path d="M4.5 8.5h15M8 12.5V17M12 12.5V17M16 12.5V17M7.5 5.7h.01M10.5 5.7h.01" /></>),
16 + washer: (<><rect x="4.5" y="3" width="15" height="18" rx="2" /><circle cx="12" cy="14" r="4.2" /><path d="M4.5 7.5h15M16.4 5.2h.01M9.2 14c1 .8 1.9.8 2.8 0s1.9-.8 2.8 0" /></>),
17 + fridge: (<><rect x="6.5" y="2.5" width="11" height="19" rx="2" /><path d="M6.5 9.5h11M9.5 5.5v2M9.5 12.5V16" /></>),
18 + snow: <path d="M12 2.5v19M3.8 7.25l16.4 9.5M20.2 7.25 3.8 16.75" />,
19 + elevator: (<><rect x="4.5" y="3" width="15" height="18" rx="2" /><path d="M12 3v18M7 11l1.5-1.8L10 11M14 13l1.5 1.8L17 13" /></>),
20 + balcony: <path d="M4 11.5h16M5 11.5V20M19 11.5V20M9.5 11.5V20M14.5 11.5V20M4 20h16M7 11.5V6.5a5 5 0 0 1 10 0v5" />,
21 + pool: <path d="M9 4.5v9M13.5 4.5v9M9 7.5h4.5M3 16.5c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0M3 20c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0" />,
22 + gym: <path d="M6.7 6.7v10.6M17.3 6.7v10.6M3.5 9.2v5.6M20.5 9.2v5.6M6.7 12h10.6" />,
23 + laundry: <path d="M4 9.5h16l-1.8 10a1.8 1.8 0 0 1-1.8 1.5H7.6a1.8 1.8 0 0 1-1.8-1.5zM8 9.5 12 3l4 6.5M9.3 13.5v3.5M12 13.5v3.5M14.7 13.5v3.5" />,
24 + box: <path d="M3.5 8 12 3.5 20.5 8v8L12 20.5 3.5 16zM3.5 8 12 12.5 20.5 8M12 12.5v8" />,
25 + parking: (<><rect x="3.5" y="3.5" width="17" height="17" rx="3.5" /><path d="M9.5 16.5v-9H13a2.9 2.9 0 0 1 0 5.8H9.5" /></>),
26 + garage: <path d="M3.5 20V9.5L12 4l8.5 5.5V20M7 20v-6.5h10V20M7 16.8h10" />,
27 + sofa: <path d="M5.5 11V8.8A2.8 2.8 0 0 1 8.3 6h7.4a2.8 2.8 0 0 1 2.8 2.8V11M3.5 13.7a2.2 2.2 0 0 1 4.4 0v1h8.2v-1a2.2 2.2 0 0 1 4.4 0V17.5h-17zM5.5 17.5v1.8M18.5 17.5v1.8" />,
28 + nosmoke: <path d="M4 4l16 16M14.5 13H4v3h6.5M17.5 13H20v3h-1" />,
29 + paw: (<><circle cx="8.2" cy="7.8" r="1.7" /><circle cx="15.8" cy="7.8" r="1.7" /><circle cx="4.8" cy="12" r="1.6" /><circle cx="19.2" cy="12" r="1.6" /><path d="M12 11c2.8 0 5.2 2.3 5.2 4.8 0 1.8-1.4 3-3.2 3-.8 0-1.3-.3-2-.3s-1.2.3-2 .3c-1.8 0-3.2-1.2-3.2-3C6.8 13.3 9.2 11 12 11z" /></>),
30 + fire: (<><path d="M4 4.5h16M5.5 4.5V19.5M18.5 4.5V19.5M4 19.5h16" /><path d="M12 8.5c-1.8 2-3 3.4-3 5.3a3 3 0 0 0 6 0c0-1.9-1.2-3.3-3-5.3z" /></>),
31 + tree: <path d="M12 3 7.2 9.5h2.4L5.5 15.5h13L14.4 9.5h2.4zM12 15.5V21" />,
32 + spa: <path d="M8 3.5c0 1.7-1.4 2-1.4 3.7S8 9.5 8 11M12.5 3.5c0 1.7-1.4 2-1.4 3.7s1.4 2.3 1.4 3.8M17 3.5c0 1.7-1.4 2-1.4 3.7S17 9.5 17 11M3.5 15.5c1.4 1.3 2.8 1.3 4.2 0s2.9-1.3 4.3 0 2.8 1.3 4.2 0 2.9-1.3 4.3 0M3.5 19.5c1.4 1.3 2.8 1.3 4.2 0s2.9-1.3 4.3 0 2.8 1.3 4.2 0 2.9-1.3 4.3 0" />,
33 + shield: (<><path d="M12 3 5 6v5.5c0 4.4 3 7.6 7 9.5 4-1.9 7-5.1 7-9.5V6z" /><path d="m9 11.5 2.2 2.2L15.5 9" /></>),
34 + eye: (<><path d="M2.5 12S6.3 5.8 12 5.8 21.5 12 21.5 12 17.7 18.2 12 18.2 2.5 12 2.5 12z" /><circle cx="12" cy="12" r="2.8" /></>),
35 + waves: <path d="M3 9c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0M3 14c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0M3 19c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0" />,
36 + calendar: (<><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8.5 3v4M15.5 3v4M8 14h.01M12 14h.01M16 14h.01" /></>),
37 + ruler: <path d="M3.5 16.2 16.2 3.5l4.3 4.3L7.8 20.5zM7.8 15.9l1.8 1.8M10.6 13.1l1.8 1.8M13.4 10.3l1.8 1.8M16.2 7.5 18 9.3" />,
38 + bed: <path d="M3.5 18V6.5M3.5 13.5h17V18M3.5 10h6.5v3.5M20.5 13.5v-1a3 3 0 0 0-3-3H10" />,
39 + bath: <path d="M4 12.5h16v1.7a4.8 4.8 0 0 1-4.8 4.8H8.8A4.8 4.8 0 0 1 4 14.2zM6 12.5V6a2.5 2.5 0 0 1 4.6-1.3M7 19l-1 1.8M17 19l1 1.8" />,
40 + people: (<><circle cx="9" cy="8" r="3.2" /><path d="M3.5 20.5c0-3.1 2.4-5.2 5.5-5.2s5.5 2.1 5.5 5.2M15.5 5.4a3.2 3.2 0 0 1 0 5.2M20.5 20.5c0-2.7-1.8-4.6-4.3-5.1" /></>),
41 + stairs: <path d="M3.5 20.5h4.2v-4.2h4.2v-4.2h4.2V7.9h4.4" />,
42 + door: (<><rect x="6" y="3" width="12" height="18" rx="1.5" /><path d="M14.8 12h.01M4 21h16" /></>),
43 + home: <path d="m3.5 11 8.5-7 8.5 7M6 9.5V20h12V9.5" />,
44 + sun: (<><circle cx="12" cy="12" r="4" /><path d="M12 2.5v2.5M12 19v2.5M2.5 12H5M19 12h2.5M5.3 5.3l1.8 1.8M16.9 16.9l1.8 1.8M18.7 5.3l-1.8 1.8M7.1 16.9l-1.8 1.8" /></>),
45 + check: <path d="m4.5 12.5 5.3 5.3L19.5 6.5" />,
46 + spark: <path d="M12 3.5 13.9 10l6.6 2-6.6 2L12 20.5 10.1 14l-6.6-2 6.6-2z" />,
47 +};
48 +
49 +/** [pattern (on lowercase accent-stripped label), icon key] — order = priority.
50 + * Bilingual: sources publish in English AND French across Canada. */
51 +const RULES: [RegExp, string][] = [
52 + [/lave-?vaisselle|dish ?washer/, "dishwasher"],
53 + [/laveuse|secheuse|lessive|washer|dryer|in-?(suite|unit) laundry/, "washer"],
54 + [/buanderie|laundry/, "laundry"],
55 + [/electromenager|frigo|refrigerateur|cuisiniere|four incl|poele|appliance|fridge|refrigerator|stove|oven incl/, "fridge"],
56 + [/climatis|air clim|thermopompe|echangeur d.air|\ba\/?c\b|air condition|heat pump|central air/, "snow"],
57 + [/chauff|\bheat\b|heating|heated/, "heat"],
58 + [/eau chaude|hot water/, "droplet"],
59 + [/electric|eclair|hydro|utilities/, "bolt"],
60 + [/internet|wi-?fi|cablodistribution|\bcable\b|fibre|fiber/, "wifi"],
61 + [/ascenseur|elevator|lift/, "elevator"],
62 + [/balcon|terrasse|patio|loggia|veranda|terrace|deck/, "balcony"],
63 + [/piscine|pool/, "pool"],
64 + [/gym|salle d.entrainement|exercice|fitness|exercise/, "gym"],
65 + [/rangement|locker|entreposage|walk-?in|penderie|storage|closet/, "box"],
66 + [/garage/, "garage"],
67 + [/stationnement|parking|abri d.auto|carport/, "parking"],
68 + [/meuble|furnished/, "sofa"],
69 + [/non-?fumeur|sans fumee|non-?smoking|smoke-?free/, "nosmoke"],
70 + [/animau|chat|chien|\bpets?\b|\bcats?\b|\bdogs?\b/, "paw"],
71 + [/foyer|cheminee|poele a bois|fireplace|wood stove/, "fire"],
72 + [/spa\b|jacuzzi|sauna|bain tourbillon|hot tub|whirlpool/, "spa"],
73 + [/securite|surveill|camera|alarme|interphone|concierge|portier|security|intercom|doorman/, "shield"],
74 + [/\bvue\b|panoram|\bview\b/, "eye"],
75 + [/bord de l.eau|acces au lac|\blac\b|riviere|plage|navigable|waterfront|\blake\b|river|beach/, "waves"],
76 + [/cour|jardin|arbre|boise|verdure|gazon|amenagement paysager|yard|garden|landscap|green space/, "tree"],
77 + [/ensoleill|luminosite|lumineux|solarium|sunny|bright|natural light/, "sun"],
78 + [/dispo|libre |libre$|available/, "calendar"],
79 + [/pi2|pieds carres|superficie|\bm2\b|sq ?ft|square (feet|foot)/, "ruler"],
80 + [/chambre|bedroom/, "bed"],
81 + [/salle[s]? de bain|salle[s]? d.eau|douche|\bsdb\b|bathroom|shower|ensuite/, "bath"],
82 + [/occupant|personne|colocataire|roommate/, "people"],
83 + [/etage|niveau|escalier|mezzanine|\bfloor\b|stairs/, "stairs"],
84 + [/studio|loft|(^|\s)\d ?1\/2|½|bachelor/, "door"],
85 + [/maison|plain-?pied|unifamiliale|house|bungalow|townhouse/, "home"],
86 + [/egout|septique|fosse|sewer|septic/, "waves"],
87 + [/aqueduc|approvisionnement en eau|puits|water supply|\bwell\b/, "droplet"],
88 + [/construction|neuve|fondation|toiture|revetement|renov|newly built|roof/, "home"],
89 + [/commerce|zonage|usage|zoning/, "box"],
90 +];
91 +
92 +/** minuscules + accents neutralisés, pour un appariement robuste */
93 +const norm = (s: string) =>
94 + s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
95 +
96 +export function amenityKey(label: string, fallback = "check"): string {
97 + const n = norm(label);
98 + for (const [re, key] of RULES) if (re.test(n)) return key;
99 + return fallback;
100 +}
101 +
102 +export default function AmenityIco({ label, size = 16, fallback = "check" }:
103 + { label: string; size?: number; fallback?: string }) {
104 + return (
105 + <svg
106 + width={size} height={size} viewBox="0 0 24 24" aria-hidden="true"
107 + fill="none" stroke="currentColor" strokeWidth={1.8}
108 + strokeLinecap="round" strokeLinejoin="round"
109 + >
110 + {ICONS[amenityKey(label, fallback)] ?? ICONS.check}
111 + </svg>
112 + );
113 +}
added frontend/src/components/CookieConsent.tsx +121 −0
@@ -0,0 +1,121 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/CookieConsent.tsx: consent banner (cookies/storage)
5 +// Honest by design: Rent-Ka uses NO third-party cookies or trackers.
6 +// The selector covers local storage (preferences) and possible future
7 +// anonymous audience measurement. Choice saved in localStorage,
8 +// re-openable via the "rentka:openConsent" event (footer link).
9 +// -----------------------------------------------------------------------------
10 +import { useEffect, useState } from "react";
11 +import { Link } from "react-router-dom";
12 +
13 +const KEY = "rentka-consent-v1";
14 +
15 +export interface Consent {
16 + essential: true; // always on (site operation)
17 + preferences: boolean; // remember filters and list/map view
18 + statistics: boolean; // anonymous audience measurement (if ever enabled)
19 + ts: number;
20 +}
21 +
22 +export function getConsent(): Consent | null {
23 + try {
24 + const raw = localStorage.getItem(KEY);
25 + return raw ? (JSON.parse(raw) as Consent) : null;
26 + } catch {
27 + return null;
28 + }
29 +}
30 +
31 +function save(preferences: boolean, statistics: boolean): Consent {
32 + const c: Consent = { essential: true, preferences, statistics, ts: Date.now() };
33 + try { localStorage.setItem(KEY, JSON.stringify(c)); } catch { /* storage blocked */ }
34 + return c;
35 +}
36 +
37 +export default function CookieConsent() {
38 + const [visible, setVisible] = useState(false);
39 + const [custom, setCustom] = useState(false);
40 + const [prefs, setPrefs] = useState(true);
41 + const [stats, setStats] = useState(false);
42 +
43 + useEffect(() => {
44 + if (getConsent() === null) setVisible(true);
45 + const open = () => { setVisible(true); setCustom(true); };
46 + window.addEventListener("rentka:openConsent", open);
47 + return () => window.removeEventListener("rentka:openConsent", open);
48 + }, []);
49 +
50 + // the banner must never cover the floating controls (Filters button,
51 + // sticky CTA of the listing page): publish its height as a CSS variable
52 + // so they shift above it while it is displayed
53 + useEffect(() => {
54 + const root = document.documentElement;
55 + if (!visible) {
56 + root.style.setProperty("--consent-h", "0px");
57 + return;
58 + }
59 + const el = document.querySelector(".cookie-banner");
60 + const apply = () =>
61 + root.style.setProperty("--consent-h", `${(el?.getBoundingClientRect().height ?? 0) + 12}px`);
62 + apply();
63 + const ro = el ? new ResizeObserver(apply) : null;
64 + if (el && ro) ro.observe(el);
65 + return () => { ro?.disconnect(); root.style.setProperty("--consent-h", "0px"); };
66 + }, [visible, custom]);
67 +
68 + if (!visible) return null;
69 +
70 + const close = (p: boolean, s: boolean) => { save(p, s); setVisible(false); setCustom(false); };
71 +
72 + return (
73 + <div className="cookie-banner" role="dialog" aria-modal="false" aria-label="Cookie preferences">
74 + <div className="cookie-inner">
75 + <div className="cookie-text">
76 + <b>🍪 Your cookies, your choice.</b>{" "}
77 + Rent-Ka uses no advertising trackers or third-party cookies. The site
78 + locally remembers your preferences (filters, map view) and your
79 + consent choice. <Link to="/privacy">Privacy policy</Link>
80 + </div>
81 +
82 + {custom && (
83 + <div className="cookie-options">
84 + <label className="cookie-opt">
85 + <input type="checkbox" checked disabled />
86 + <span><b>Essential</b> — site operation (always on)</span>
87 + </label>
88 + <label className="cookie-opt">
89 + <input type="checkbox" checked={prefs} onChange={(e) => setPrefs(e.target.checked)} />
90 + <span><b>Preferences</b> — remember your filters and list/map view</span>
91 + </label>
92 + <label className="cookie-opt">
93 + <input type="checkbox" checked={stats} onChange={(e) => setStats(e.target.checked)} />
94 + <span><b>Statistics</b> — anonymous audience measurement, no profiling</span>
95 + </label>
96 + </div>
97 + )}
98 +
99 + <div className="cookie-actions">
100 + {!custom && (
101 + <button className="btn btn-ghost" onClick={() => setCustom(true)}>
102 + Customize
103 + </button>
104 + )}
105 + <button className="btn btn-ghost" onClick={() => close(false, false)}>
106 + Essential only
107 + </button>
108 + {custom ? (
109 + <button className="btn btn-primary" onClick={() => close(prefs, stats)}>
110 + Save my choices
111 + </button>
112 + ) : (
113 + <button className="btn btn-primary" onClick={() => close(true, true)}>
114 + Accept all
115 + </button>
116 + )}
117 + </div>
118 + </div>
119 + </div>
120 + );
121 +}
added frontend/src/components/FairValueBadge.tsx +39 −0
@@ -0,0 +1,39 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/FairValueBadge.tsx: fair-value badge
5 +// Three states: below market / at market / above market.
6 +// Colour-blind friendly: icon + label, never colour alone.
7 +// -----------------------------------------------------------------------------
8 +const NBSP = " ";
9 +
10 +export function fmtDeviation(dev: number | null | undefined): string | null {
11 + if (dev == null) return null;
12 + const pct = Math.round(Math.abs(dev) * 100);
13 + return `${dev < 0 ? "−" : "+"}${pct}${NBSP}%`;
14 +}
15 +
16 +const META = {
17 + sous: { cls: "deal-good", icon: "▼", label: "Below market" },
18 + marche: { cls: "deal-ok", icon: "≈", label: "At market" },
19 + sur: { cls: "deal-high", icon: "▲", label: "Above market" },
20 +} as const;
21 +
22 +export default function FairValueBadge({ verdict, deviation, compact = false }: {
23 + verdict: "sous" | "marche" | "sur" | null | undefined;
24 + deviation?: number | null;
25 + compact?: boolean; // list-card version (short)
26 +}) {
27 + if (!verdict || !(verdict in META)) return null;
28 + const m = META[verdict];
29 + const pct = fmtDeviation(deviation);
30 + return (
31 + <span className={`fv-badge ${m.cls} ${compact ? "fv-compact" : ""}`}
32 + title={`${m.label}${pct ? ` (${pct} vs estimated fair value)` : ""}`}>
33 + <span aria-hidden="true" className="fv-ico">{m.icon}</span>
34 + {compact
35 + ? (pct ?? m.label)
36 + : <>{m.label}{pct && <b className="fv-pct">{pct}</b>}</>}
37 + </span>
38 + );
39 +}
added frontend/src/components/GestionnaireBloc.tsx +129 −0
@@ -0,0 +1,129 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/GestionnaireBloc.tsx: "Who manages this rental?" block (detail)
5 +// Manager profile (direct source) + Google reputation: real rating
6 +// distribution, recent average, trend and recurring themes computed over
7 +// the reviews synced in OUR database (rentka/managers.py) — not just the
8 +// average Google shows, and zero API calls on load.
9 +// The Google profile is only shown when the match is confident.
10 +// -----------------------------------------------------------------------------
11 +import { useEffect, useState } from "react";
12 +import { Gestionnaire, fetchGestionnaire } from "../api";
13 +
14 +const NBSP = " ";
15 +
16 +const SENT_CLS: Record<string, string> = {
17 + "négatif": "zi-eleve", "neutre": "zi-nc", "positif": "zi-ok",
18 + "negative": "zi-eleve", "neutral": "zi-nc", "positive": "zi-ok",
19 +};
20 +
21 +export default function GestionnaireBloc({ source }: { source: string }) {
22 + const [d, setD] = useState<Gestionnaire | null>(null);
23 + useEffect(() => {
24 + setD(null);
25 + fetchGestionnaire(source).then(setD).catch(() => setD(null));
26 + }, [source]);
27 + // without an associated Google profile, the "Practical details" block is enough
28 + if (!d || !d.google_maps || d.google_maps.statut === "non_associe") return null;
29 +
30 + const g = d.google_maps;
31 + const avis = d.avis;
32 + const dist = avis?.distribution;
33 + const total = dist ? Object.values(dist).reduce((a, b) => a + b, 0) : 0;
34 + const recents = (d.avis_recents ?? []).filter((a) => a.texte).slice(0, 3);
35 +
36 + return (
37 + <section className="f-bloc f-gest" id="gestionnaire">
38 + <h2>Who manages this rental?</h2>
39 + <div className="gest-head">
40 + <div className="gest-nom">{d.nom}</div>
41 + <div className="gest-meta">
42 + {d.annonces_actives}{NBSP}active listing{d.annonces_actives > 1 ? "s" : ""} on Rent-Ka
43 + {d.site_web && (
44 + <>
45 + {" · "}
46 + <a href={d.site_web} target="_blank" rel="noopener noreferrer">website ↗</a>
47 + </>
48 + )}
49 + </div>
50 + </div>
51 +
52 + {g.note != null && (
53 + <div className="gest-google">
54 + <span className="gest-note">★ {g.note.toFixed(1)}</span>
55 + <span className="gest-navis">
56 + {g.nombre_avis}{NBSP}Google reviews — profile “{g.nom}”
57 + </span>
58 + </div>
59 + )}
60 +
61 + {avis && avis.n > 0 && (
62 + <>
63 + {dist && total > 0 && (
64 + <div className="gest-bars" aria-label="Rating distribution (analyzed reviews)">
65 + {[5, 4, 3, 2, 1].map((n) => {
66 + const c = dist[String(n)] ?? 0;
67 + return (
68 + <div className="gest-bar" key={n}>
69 + <span className="gb-n">{n}★</span>
70 + <span className="gb-track">
71 + <span className={`gb-fill ${n <= 2 ? "neg" : n >= 4 ? "pos" : ""}`}
72 + style={{ width: `${Math.round((100 * c) / total)}%` }} />
73 + </span>
74 + <span className="gb-c">{c}</span>
75 + </div>
76 + );
77 + })}
78 + </div>
79 + )}
80 + <div className="gest-stats">
81 + {avis.moyenne_12m != null && (
82 + <span>Last 12 months average: <b>{avis.moyenne_12m.toFixed(1)}</b> ({avis.n_12m} reviews)</span>
83 + )}
84 + {avis.tendance && (
85 + <span className={`zi-badge ${avis.tendance === "en amélioration" || avis.tendance === "improving" ? "zi-ok" : avis.tendance === "en dégradation" || avis.tendance === "declining" ? "zi-modere" : "zi-nc"}`}>
86 + {avis.tendance}
87 + </span>
88 + )}
89 + </div>
90 + {(avis.plaintes_frequentes?.length ?? 0) > 0 && (
91 + <div className="gest-themes">
92 + <span className="k">Recurring complaints in reviews:</span>{" "}
93 + {avis.plaintes_frequentes!.map((t) => (
94 + <span className="zi-badge zi-modere" key={t}>{t}</span>
95 + ))}
96 + </div>
97 + )}
98 + {recents.length > 0 && (
99 + <details className="gest-avis">
100 + <summary>Recent review excerpts ({recents.length})</summary>
101 + {recents.map((a, i) => (
102 + <blockquote className="gest-citation" key={i}>
103 + <span className={`zi-badge ${SENT_CLS[a.analyse.sentiment ?? ""] ?? "zi-nc"}`}>
104 + {a.note != null ? `${a.note}★` : "—"}
105 + </span>{" "}
106 + {a.texte}
107 + <footer>
108 + {a.date ? new Date(a.date).toLocaleDateString("en-CA", { month: "long", year: "numeric" }) : ""}
109 + {a.reponse_proprietaire && " · the manager replied"}
110 + </footer>
111 + </blockquote>
112 + ))}
113 + </details>
114 + )}
115 + </>
116 + )}
117 +
118 + <p className="fine">
119 + Google Maps profile matched automatically ({Math.round((g.confiance_association ?? 0) * 100)}{NBSP}% confidence —{" "}
120 + {g.methode}). Statistics computed over the {avis?.n ?? 0} reviews
121 + synced in the Rent-Ka database
122 + {g.nombre_avis && avis && avis.n < g.nombre_avis
123 + ? ` (sample of the most recent; Google reports ${g.nombre_avis})`
124 + : ""}. Themes detected by lexicon — indicative inference, not a
125 + human reading.
126 + </p>
127 + </section>
128 + );
129 +}
added frontend/src/components/HistoriqueLouka.tsx +125 −0
@@ -0,0 +1,125 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/HistoriqueLouka.tsx: "Listing history" block (detail page)
5 +// Timeline of REAL sync observations: price, description, area,
6 +// availability, inclusions, photo changes, removals/returns. + "Past
7 +// lives": likely recycled listings of the same unit (multi-signal score,
8 +// explainable inference).
9 +// -----------------------------------------------------------------------------
10 +import { useEffect, useState } from "react";
11 +import {
12 + HistoriqueLouka as HistoT, Recyclees,
13 + fetchHistorique, fetchRecyclees, fmtPrice,
14 +} from "../api";
15 +
16 +const NBSP = " ";
17 +
18 +const fmtTs = (ts: number | null | undefined): string =>
19 + ts
20 + ? new Date(ts * 1000).toLocaleDateString("en-CA",
21 + { day: "numeric", month: "short", year: "numeric" })
22 + : "—";
23 +
24 +const EVENT_LABEL: Record<string, string> = {
25 + prix: "Price",
26 + description: "Description changed",
27 + superficie: "Area changed",
28 + dispo: "Availability changed",
29 + inclusions: "Inclusions changed",
30 + photos: "Photos changed",
31 + disparition: "Listing removed",
32 + reapparition: "Listing republished",
33 +};
34 +
35 +export default function HistoriqueLouka({ uid }: { uid: string }) {
36 + const [d, setD] = useState<HistoT | null>(null);
37 + const [rec, setRec] = useState<Recyclees | null>(null);
38 + useEffect(() => {
39 + setD(null); setRec(null);
40 + fetchHistorique(uid).then(setD).catch(() => setD(null));
41 + fetchRecyclees(uid).then(setRec).catch(() => setRec(null));
42 + }, [uid]);
43 + if (!d) return null;
44 +
45 + const variationPct = d.variation != null
46 + ? `${d.variation > 0 ? "+" : "−"}${Math.abs(Math.round(d.variation * 100))}${NBSP}%`
47 + : null;
48 + const items = d.timeline.slice(0, 12);
49 + const matches = rec?.matches ?? [];
50 +
51 + return (
52 + <section className="f-bloc f-histolk" id="historique-louka">
53 + <h2>Listing history</h2>
54 + <div className="kv">
55 + <div className="cell">
56 + <div className="k">Tracked since</div>
57 + <div className="v">{fmtTs(d.premiere_observation)}</div>
58 + </div>
59 + <div className="cell">
60 + <div className="k">Online</div>
61 + <div className="v">{d.jours_en_ligne}{NBSP}day{d.jours_en_ligne > 1 ? "s" : ""}</div>
62 + </div>
63 + {d.prix_initial != null && d.prix_actuel != null && d.prix_initial !== d.prix_actuel && (
64 + <div className="cell">
65 + <div className="k">Initial → current price</div>
66 + <div className="v">
67 + {fmtPrice(d.prix_initial)} → {fmtPrice(d.prix_actuel)}
68 + {variationPct && ` (${variationPct})`}
69 + </div>
70 + </div>
71 + )}
72 + <div className="cell">
73 + <div className="k">Changes observed</div>
74 + <div className="v">{d.modifications}</div>
75 + </div>
76 + </div>
77 +
78 + {items.length > 0 && (
79 + <ul className="hl-timeline">
80 + {items.map((it, i) => (
81 + <li key={`${it.ts}-${it.type}-${i}`}
82 + className={`hl-item hl-${it.type}`}>
83 + <span className="hl-date">{fmtTs(it.ts)}</span>
84 + <span className="hl-texte">
85 + {it.type === "prix" ? (
86 + it.prix_avant != null
87 + ? <>Price {it.prix_avant! > (it.prix ?? 0) ? "lowered" : "raised"} from {fmtPrice(it.prix_avant ?? null)} to <b>{fmtPrice(it.prix ?? null)}</b></>
88 + : <>First observed price: <b>{fmtPrice(it.prix ?? null)}</b></>
89 + ) : (
90 + EVENT_LABEL[it.type] ?? it.type
91 + )}
92 + </span>
93 + </li>
94 + ))}
95 + </ul>
96 + )}
97 + {items.length === 0 && (
98 + <p className="fine">
99 + No change observed since this listing's first sync.
100 + </p>
101 + )}
102 +
103 + {matches.length > 0 && (
104 + <div className="hl-recyclees">
105 + <h3>Likely past lives of this unit</h3>
106 + {matches.slice(0, 3).map((m) => (
107 + <div className="hl-match" key={m.uid}>
108 + <div>
109 + <span className="zi-badge zi-modere">
110 + likely repost ({m.confiance}{NBSP}% confidence)
111 + </span>{" "}
112 + {m.prix != null && <>advertised at {fmtPrice(m.prix)}</>}
113 + {m.derniere_observation != null && <> until {fmtTs(m.derniere_observation)}</>}
114 + </div>
115 + <div className="hl-signaux">{m.signaux.join(" · ")}</div>
116 + </div>
117 + ))}
118 + <p className="fine">{rec?.methode} — inference, no automatic merging.</p>
119 + </div>
120 + )}
121 +
122 + <p className="fine">{d.methode}</p>
123 + </section>
124 + );
125 +}
added frontend/src/components/HiverScore.tsx +46 −0
@@ -0,0 +1,46 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/HiverScore.tsx: "Winter Score" block (detail page)
5 +// Can you run your daily errands on foot at −20 °C? Deterministic 0-100
6 +// score (rentka/hiver.py): grocery, pharmacy, bus, convenience store within
7 +// walking distance. The methodology owns what is NOT taken into account.
8 +// -----------------------------------------------------------------------------
9 +import { Hiver } from "../api";
10 +
11 +const NBSP = " ";
12 +
13 +const CLS: Record<string, string> = {
14 + // backend classes (kept bilingual for safety during migration)
15 + "very practical": "zi-ok", "practical": "zi-ok",
16 + "demanding": "zi-modere", "difficult": "zi-eleve",
17 + "très pratique": "zi-ok", "pratique": "zi-ok",
18 + "exigeant": "zi-modere", "difficile": "zi-eleve",
19 +};
20 +
21 +export default function HiverScore({ h }: { h: Hiver }) {
22 + return (
23 + <section className="f-bloc f-hiver" id="hiver">
24 + <h2>Winter Score</h2>
25 + <div className="hiver-head">
26 + <span className="hiver-score">{h.score}</span>
27 + <span className={`zi-badge ${CLS[h.classe] ?? "zi-nc"}`}>{h.classe}</span>
28 + <span className="hiver-sur">daily errands on foot, even at −20{NBSP}°C</span>
29 + </div>
30 + <ul className="hiver-detail">
31 + {h.detail.map((c) => (
32 + <li key={c.critere}>
33 + <span className="hd-crit">{c.critere}</span>
34 + <span className="hd-val">
35 + {c.distance_m != null
36 + ? `≈${NBSP}${c.minutes}${NBSP}min walk${c.nom ? ` (${c.nom})` : ""}`
37 + : c.note}
38 + </span>
39 + <span className="hd-score">{c.score}</span>
40 + </li>
41 + ))}
42 + </ul>
43 + <p className="fine">{h.methode}</p>
44 + </section>
45 + );
46 +}
added frontend/src/components/Icons.tsx +225 −0
@@ -0,0 +1,225 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/Icons.tsx : icônes SVG maison — style linéaire (outline),
5 +// trait moyen 2 px, extrémités arrondies, currentColor — conformes à
6 +// the Rent-Ka visual identity (minimal and warm).
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="round"
17 + strokeLinejoin="round" aria-hidden="true" focusable="false" {...rest}
18 + >
19 + {children}
20 + </svg>
21 + );
22 +}
23 +
24 +/** Line house — marked gable + offset door (Rent-Ka signature) */
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 IcoHeart = (p: P & { filled?: boolean }) => {
156 + const { filled, ...rest } = p;
157 + return (
158 + <Base {...rest} fill={filled ? "currentColor" : "none"}>
159 + <path d="M12 20.5C7 16.5 3.5 13 3.5 9.3 3.5 6.8 5.5 5 7.8 5c1.7 0 3.2 1 4.2 2.6C13 6 14.5 5 16.2 5c2.3 0 4.3 1.8 4.3 4.3 0 3.7-3.5 7.2-8.5 11.2z" />
160 + </Base>
161 + );
162 +};
163 +
164 +// -- réseaux sociaux (tracés au trait, même langage que le reste) --------------
165 +
166 +export const IcoUser = (p: P) => (
167 + <Base {...p}>
168 + <circle cx="12" cy="8" r="3.5" />
169 + <path d="M5 20c1.2-3.4 3.8-5 7-5s5.8 1.6 7 5" />
170 + </Base>
171 +);
172 +
173 +export const IcoInstagram = (p: P) => (
174 + <Base {...p}>
175 + <rect x="4" y="4" width="16" height="16" rx="4.5" />
176 + <circle cx="12" cy="12" r="3.6" />
177 + <path d="M16.6 7.4v.1" strokeWidth="2.6" />
178 + </Base>
179 +);
180 +
181 +export const IcoFacebook = (p: P) => (
182 + <Base {...p}>
183 + <path d="M14.8 4h-2.3a3.4 3.4 0 0 0-3.4 3.4V10H6.8v3.4h2.3V21h3.4v-7.6h2.6l.6-3.4h-3.2V7.8c0-.5.4-.9.9-.9h2.4z" />
184 + </Base>
185 +);
186 +
187 +export const IcoX = (p: P) => (
188 + <Base {...p}>
189 + <path d="M4.5 4.5 19.5 19.5M19 4.5 5 19.5" />
190 + </Base>
191 +);
192 +
193 +export const IcoLinkedIn = (p: P) => (
194 + <Base {...p}>
195 + <rect x="4" y="4" width="16" height="16" rx="2.5" />
196 + <path d="M8.2 10.5V16M8.2 8v.1" strokeWidth="2.2" />
197 + <path d="M11.8 16v-3.2a2 2 0 0 1 4 0V16" strokeWidth="2.2" />
198 + </Base>
199 +);
200 +
201 +export const IcoTikTok = (p: P) => (
202 + <Base {...p}>
203 + <path d="M13.5 4v10.3a3.3 3.3 0 1 1-3.3-3.3" />
204 + <path d="M13.5 6.2c.8 2 2.6 3.4 4.8 3.6" />
205 + </Base>
206 +);
207 +
208 +export const IcoYouTube = (p: P) => (
209 + <Base {...p}>
210 + <rect x="3" y="6" width="18" height="12" rx="3.5" />
211 + <path d="m10.3 9.5 4.6 2.5-4.6 2.5z" />
212 + </Base>
213 +);
214 +
215 +export const IcoChevronLeft = (p: P) => (
216 + <Base {...p}>
217 + <path d="m14.5 5-7 7 7 7" />
218 + </Base>
219 +);
220 +
221 +export const IcoChevronRight = (p: P) => (
222 + <Base {...p}>
223 + <path d="m9.5 5 7 7-7 7" />
224 + </Base>
225 +);
added frontend/src/components/ImmeubleBloc.tsx +112 −0
@@ -0,0 +1,112 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/ImmeubleBloc.tsx: "Building passport" block (detail page)
5 +// Everything is computed from what Rent-Ka actually observes (active AND
6 +// historical listings of the same building): units, median rents, turnover,
7 +// rent pressure. An indicator without a sufficient sample is shown as
8 +// "insufficient data" — never invented.
9 +// -----------------------------------------------------------------------------
10 +import { Immeuble, fmtPrice } from "../api";
11 +
12 +const NBSP = " ";
13 +
14 +const fmtTs = (ts: number | null | undefined): string =>
15 + ts
16 + ? new Date(ts * 1000).toLocaleDateString("en-CA", { month: "long", year: "numeric" })
17 + : "—";
18 +
19 +const ROTATION_CLS: Record<string, string> = {
20 + // backend classes (bilingual for migration safety)
21 + "low": "zi-ok", "normal": "zi-nc", "high": "zi-modere", "very high": "zi-eleve",
22 + "faible": "zi-ok", "normale": "zi-nc", "élevée": "zi-modere", "très élevée": "zi-eleve",
23 +};
24 +
25 +export default function ImmeubleBloc({ im }: { im: Immeuble }) {
26 + // at least 2 grouped listings, otherwise the "passport" adds nothing
27 + if (!im || im.annonces_total < 2) return null;
28 + const rot = im.rotation;
29 + const pres = im.pression_loyers;
30 + const parCc = im.loyer_median_par_cc;
31 +
32 + return (
33 + <section className="f-bloc f-immeuble" id="immeuble">
34 + <h2>Building passport</h2>
35 + <div className="kv">
36 + <div className="cell">
37 + <div className="k">Listings observed</div>
38 + <div className="v">{im.annonces_total} <small>incl. {im.annonces_actives} active</small></div>
39 + </div>
40 + <div className="cell">
41 + <div className="k">Estimated units</div>
42 + <div className="v">≥{NBSP}{im.unites_estimees}</div>
43 + </div>
44 + {im.loyer_median != null && (
45 + <div className="cell">
46 + <div className="k">Median rent (active)</div>
47 + <div className="v">{fmtPrice(im.loyer_median)}</div>
48 + </div>
49 + )}
50 + {im.pi2_median != null && (
51 + <div className="cell">
52 + <div className="k">Median $/sq ft</div>
53 + <div className="v">${im.pi2_median.toFixed(2)}</div>
54 + </div>
55 + )}
56 + </div>
57 +
58 + {parCc && Object.keys(parCc).length > 0 && (
59 + <p className="im-parcc">
60 + By bedroom count:{" "}
61 + {Object.entries(parCc)
62 + .map(([cc, v]) => `${cc}${NBSP}bd ${fmtPrice(v)}`)
63 + .join(" · ")}
64 + </p>
65 + )}
66 +
67 + <div className="im-indicateurs">
68 + <div className="im-indic">
69 + <span className="k">Unit turnover</span>{" "}
70 + {rot.statut === "calculated" ? (
71 + <>
72 + <span className={`zi-badge ${ROTATION_CLS[rot.classe ?? ""] ?? "zi-nc"}`}>
73 + {rot.classe}
74 + </span>{" "}
75 + <span className="im-detail">
76 + {rot.annonces_12m} listing{(rot.annonces_12m ?? 0) > 1 ? "s" : ""} over
77 + 12 months for ≥{NBSP}{rot.unites_estimees} units
78 + </span>
79 + </>
80 + ) : (
81 + <span className="zi-badge zi-nc">insufficient data</span>
82 + )}
83 + </div>
84 + <div className="im-indic">
85 + <span className="k">Rent pressure</span>{" "}
86 + {pres ? (
87 + <>
88 + <span className={`zi-badge ${pres.variation_12m > 0.05 ? "zi-modere" : pres.variation_12m < -0.02 ? "zi-ok" : "zi-nc"}`}>
89 + {pres.variation_12m > 0 ? "+" : "−"}
90 + {Math.abs(Math.round(pres.variation_12m * 100))}{NBSP}% over 12 months
91 + </span>{" "}
92 + <span className="im-detail">
93 + median asking price: {fmtPrice(pres.mediane_12_24m)} →{" "}
94 + {fmtPrice(pres.mediane_12m)} ({pres.n_12_24m} vs {pres.n_12m} listings)
95 + </span>
96 + </>
97 + ) : (
98 + <span className="zi-badge zi-nc">insufficient sample</span>
99 + )}
100 + </div>
101 + </div>
102 +
103 + <p className="fine">
104 + Building tracked by Rent-Ka since {fmtTs(im.premiere_observation)}
105 + {rot.statut === "calculated" && rot.methode ? ` — ${rot.methode}` : (
106 + " — indicators computed only from listings observed by Rent-Ka " +
107 + "syncs (not a census of the building)."
108 + )}
109 + </p>
110 + </section>
111 + );
112 +}
added frontend/src/components/KaScoreBadge.tsx +65 −0
@@ -0,0 +1,65 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/KaScoreBadge.tsx: KA Score badges (Groupe-KA design system).
5 +// · compact: "KA 78" on listing cards and mini-sheets;
6 +// · circle: circular gauge of a sub-score on the detail page.
7 +// -----------------------------------------------------------------------------
8 +import { kaLabel } from "../api";
9 +
10 +/** Tint class per bracket (styles in styles.css). */
11 +export function kaTint(score: number | null | undefined): string {
12 + if (score == null) return "na";
13 + if (score >= 70) return "haut";
14 + if (score >= 55) return "bon";
15 + if (score >= 40) return "moyen";
16 + return "bas";
17 +}
18 +
19 +/** Compact "KA 78" badge — list cards, carousel, mini-sheets. */
20 +export default function KaScoreBadge({ score, title }: {
21 + score: number | null | undefined;
22 + title?: string;
23 +}) {
24 + if (score == null) return null;
25 + return (
26 + <span
27 + className={`ka-badge ka-${kaTint(score)}`}
28 + title={title ?? `KA Score ${Math.round(score)} — ${kaLabel(score)} · see the methodology at /ka-scores`}
29 + aria-label={`KA Score ${Math.round(score)} out of 100, ${kaLabel(score)}`}
30 + >
31 + <span className="ka-badge-logo">KA</span> {Math.round(score)}
32 + </span>
33 + );
34 +}
35 +
36 +/** Circular gauge of a sub-score (listing detail, methodology page). */
37 +export function KaScoreCircle({ score, nom, note }: {
38 + score: number | null;
39 + nom: string;
40 + note?: string | null;
41 +}) {
42 + const r = 26;
43 + const c = 2 * Math.PI * r;
44 + const part = score == null ? 0 : Math.max(0, Math.min(1, score / 100));
45 + return (
46 + <div className={`ka-circle ka-${kaTint(score)}`} role="img"
47 + aria-label={`${nom} : ${score == null ? note ?? "insufficient data" : `${Math.round(score)} out of 100`}`}>
48 + <svg viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
49 + <circle cx="32" cy="32" r={r} className="ka-circle-fond" />
50 + <circle
51 + cx="32" cy="32" r={r} className="ka-circle-arc"
52 + strokeDasharray={`${c * part} ${c}`}
53 + transform="rotate(-90 32 32)"
54 + />
55 + <text x="32" y="37" textAnchor="middle" className="ka-circle-val">
56 + {score == null ? "—" : Math.round(score)}
57 + </text>
58 + </svg>
59 + <div className="ka-circle-nom">{nom}</div>
60 + <div className="ka-circle-label">
61 + {score == null ? (note ?? "Insufficient data") : kaLabel(score)}
62 + </div>
63 + </div>
64 + );
65 +}
added frontend/src/components/KaScoresBlock.tsx +125 −0
@@ -0,0 +1,125 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/KaScoresBlock.tsx: "KA Scores" section of the listing page.
5 +// Global badge + 5 gauges (Walk/Transit/Bike/Quiet/Services), honest
6 +// detail per score, personalized score from the user's weights
7 +// (/ka-scores), link to the methodology.
8 +// -----------------------------------------------------------------------------
9 +import { Link } from "react-router-dom";
10 +import { KaScores, KA_DEFAULT_WEIGHTS, fmtDist, kaGlobal, kaWeights } from "../api";
11 +import KaScoreBadge, { KaScoreCircle } from "./KaScoreBadge";
12 +
13 +const CAT_LABELS: Record<string, string> = {
14 + epicerie: "Grocery", pharmacie: "Pharmacy", parc: "Park", cafe: "Café",
15 + ecole: "School", clinique: "Clinic", garderie: "Daycare",
16 + depanneur: "Convenience store", gym: "Gym", bibliotheque: "Library",
17 +};
18 +const FAM_LABELS: Record<string, string> = {
19 + commerces: "shops", sante: "health", education: "education",
20 + loisirs: "leisure",
21 +};
22 +
23 +export default function KaScoresBlock({ ks }: { ks: KaScores }) {
24 + const weights = kaWeights();
25 + const custom = JSON.stringify(weights) !== JSON.stringify(KA_DEFAULT_WEIGHTS);
26 + const perso = custom ? kaGlobal(ks, weights) : null;
27 + const d = ks.details ?? {};
28 + const walkCats = (d.walk?.cats ?? []).filter((c) => c.dist_m != null).slice(0, 5);
29 +
30 + return (
31 + <section className="f-bloc f-kascores" id="ka-scores">
32 + <h2>
33 + KA Scores
34 + {ks.global != null && <KaScoreBadge score={ks.global} />}
35 + {perso != null && (
36 + <span className="ka-perso" title="Global score recomputed from your priorities (set on the KA Scores page)">
37 + you: <b>{Math.round(perso)}</b>
38 + </span>
39 + )}
40 + </h2>
41 +
42 + <div className="ka-circles">
43 + <KaScoreCircle score={ks.walk} nom="Walk" />
44 + <KaScoreCircle score={ks.transit} nom="Transit"
45 + note={ks.transit == null ? "Not served" : undefined} />
46 + <KaScoreCircle score={ks.bike} nom="Bike" />
47 + <KaScoreCircle score={ks.calme} nom="Quiet" />
48 + <KaScoreCircle score={ks.services} nom="Services" />
49 + </div>
50 +
51 + <details className="ka-detail">
52 + <summary>The detail behind this area's scores</summary>
53 + <div className="ka-detail-grille">
54 + {walkCats.length > 0 && (
55 + <div>
56 + <h4>Walk</h4>
57 + <ul>
58 + {walkCats.map((c) => (
59 + <li key={c.cat}>
60 + {CAT_LABELS[c.cat] ?? c.cat} : {fmtDist(c.dist_m as number)}
61 + {" "}{c.pts >= 99 ? "✓" : c.pts >= 50 ? "~" : "·"}
62 + </li>
63 + ))}
64 + </ul>
65 + </div>
66 + )}
67 + <div>
68 + <h4>Transit</h4>
69 + <ul>
70 + {d.transit?.arret_bus_m != null && (
71 + <li>Bus stop at {fmtDist(d.transit.arret_bus_m)}</li>
72 + )}
73 + {d.transit?.station_metro_m != null && (
74 + <li>Subway station at {fmtDist(d.transit.station_metro_m)}</li>
75 + )}
76 + {d.transit?.pmd_percentile != null && (
77 + <li>Area transit service: {d.transit.pmd_percentile}th Canadian percentile (StatCan)</li>
78 + )}
79 + {d.transit?.raison && <li>{d.transit.raison}</li>}
80 + </ul>
81 + <h4>Bike</h4>
82 + <ul>
83 + {d.bike?.km_cyclables_1km != null && (
84 + <li>{d.bike.km_cyclables_1km.toLocaleString("en-CA")} km of bike lanes within 1 km</li>
85 + )}
86 + {d.bike?.raison && <li>{d.bike.raison}</li>}
87 + </ul>
88 + </div>
89 + <div>
90 + <h4>Quiet</h4>
91 + <ul>
92 + {(d.calme?.sources_bruit ?? []).length === 0 && (
93 + <li>No major noise source detected nearby</li>
94 + )}
95 + {(d.calme?.sources_bruit ?? []).map((s) => (
96 + <li key={s.source}>
97 + {s.source}{s.dist_m != null ? ` at ${fmtDist(s.dist_m)}` : ""}
98 + </li>
99 + ))}
100 + {(d.calme?.bonus_parc ?? 0) > 0 && <li>Park nearby ✓</li>}
101 + </ul>
102 + {d.services?.familles && (
103 + <>
104 + <h4>Services</h4>
105 + <ul>
106 + {Object.entries(d.services.familles).map(([f, n]) => (
107 + <li key={f}>{n} {FAM_LABELS[f] ?? f} in the area</li>
108 + ))}
109 + </ul>
110 + </>
111 + )}
112 + </div>
113 + </div>
114 + </details>
115 +
116 + <p className="fine">
117 + 0-100 scores computed from OpenStreetMap and Statistics Canada
118 + proximity measures — Quiet is an environment estimate, not a sound
119 + measurement. Computed on{" "}
120 + {new Date(ks.computed_at * 1000).toLocaleDateString("en-CA")} (scale {ks.version}).{" "}
121 + <Link to="/ka-scores">How are KA Scores calculated? — and set your priorities</Link>
122 + </p>
123 + </section>
124 + );
125 +}
added frontend/src/components/ListingCard.tsx +65 −0
@@ -0,0 +1,65 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/ListingCard.tsx: listing card (results grid)
5 +// -----------------------------------------------------------------------------
6 +import { Link } from "react-router-dom";
7 +import { Listing, fmtPrice, sourceName } from "../api";
8 +import { useAccount } from "../account";
9 +import { IcoCamera, IcoHeart } from "./Icons";
10 +import SmartImg from "./SmartImg";
11 +import FairValueBadge from "./FairValueBadge";
12 +import KaScoreBadge from "./KaScoreBadge";
13 +
14 +export default function ListingCard({ l }: { l: Listing }) {
15 + const img = l.images && l.images.length > 0 ? l.images[0] : null;
16 + const { me, favs, toggleFav } = useAccount();
17 + const fav = favs.has(l.uid);
18 + return (
19 + <Link to={`/listing/${encodeURIComponent(l.uid)}`} className="card">
20 + <div className="card-img">
21 + <SmartImg src={img} width_={480} fallbackLabel={l.unit_type || undefined}
22 + alt={l.title} loading="lazy" decoding="async" />
23 + {l.unit_type && <span className="badge type">{l.unit_type}</span>}
24 + {l.ka_reco && (
25 + <span
26 + className="badge ka-reco"
27 + title="Matches your KA ID preferences — manage them at groupe-ka.com/mon-ka"
28 + >
29 + Recommended for you
30 + </span>
31 + )}
32 + {l.images.length > 1 && (
33 + <span className="badge right"><IcoCamera size={12} /> {l.images.length}</span>
34 + )}
35 + {me && (
36 + <button
37 + className={`fav-btn ${fav ? "on" : ""}`}
38 + aria-label={fav ? "Remove from saved" : "Save this rental"}
39 + aria-pressed={fav}
40 + onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(l.uid); }}
41 + >
42 + <IcoHeart size={16} filled={fav} />
43 + </button>
44 + )}
45 + </div>
46 + <div className="card-body">
47 + <div className="card-price">
48 + {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/ month</small>}
49 + <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />
50 + </div>
51 + <div className="card-title">{l.title || l.address}</div>
52 + <div className="card-meta">
53 + {l.sector && <span>{l.sector}</span>}
54 + {l.sector && l.city && <span className="sep" />}
55 + {l.city && <span>{l.city}</span>}
56 + {l.ks_global != null && <KaScoreBadge score={l.ks_global} />}
57 + </div>
58 + <div className="card-foot">
59 + <span className="source-tag">{sourceName(l.source)}</span>
60 + {l.availability && <span className="avail">{l.availability}</span>}
61 + </div>
62 + </div>
63 + </Link>
64 + );
65 +}
added frontend/src/components/ListingMap3D.tsx +58 −0
@@ -0,0 +1,58 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Groupe Ka / Ka Maps (Rent-Ka integration)
5 +// components/ListingMap3D.tsx: 3D mini-map of the detail page — tight camera
6 +// on the address (zoom 17, 62° pitch), realistic Mapbox Standard basemap,
7 +// and the listing's building highlighted in COBALT ("buildings" featureset
8 +// via KaSpotlightMap). Lazily loaded from Listing.tsx.
9 +// -----------------------------------------------------------------------------
10 +import { useMemo } from "react";
11 +import "mapbox-gl/dist/mapbox-gl.css";
12 +import "@groupe-ka/ka-maps/styles.css";
13 +import type { MapProperty } from "@groupe-ka/ka-maps";
14 +import { KaBrandBadge, KaSpotlightMap } from "@groupe-ka/ka-maps/react";
15 +import type { Listing } from "../api";
16 +import { louKaMapTheme } from "../kamaps/theme";
17 +import { MAPBOX_TOKEN } from "../kamaps/config";
18 +
19 +/** Signature highlight of the page — same language as the badges. */
20 +export const BUILDING_ACCENT = "#2456e6"; // official Rent-Ka cobalt
21 +
22 +export default function ListingMap3D({ l }: { l: Listing }) {
23 + const property = useMemo<MapProperty | null>(() => {
24 + if (l.lat == null || l.lng == null) return null;
25 + return {
26 + id: l.uid,
27 + appSource: "rent-ka",
28 + latitude: l.lat,
29 + longitude: l.lng,
30 + kind: "listing",
31 + listingType: "rent",
32 + price: l.price ?? undefined,
33 + propertyType: l.unit_type || undefined,
34 + address: l.address || l.title || undefined,
35 + city: l.city || undefined,
36 + thumbnailUrl: l.images?.[0],
37 + };
38 + }, [l.uid, l.lat, l.lng, l.price, l.unit_type, l.address, l.title, l.city, l.images]);
39 +
40 + if (!property) return null;
41 +
42 + return (
43 + <div className="lmap3d" role="img"
44 + aria-label={`3D map — ${l.address || l.title}, the listing's building highlighted`}>
45 + <KaSpotlightMap
46 + theme={louKaMapTheme}
47 + mapboxToken={MAPBOX_TOKEN}
48 + property={property}
49 + buildingColor={BUILDING_ACCENT}
50 + >
51 + <KaBrandBadge />
52 + </KaSpotlightMap>
53 + <span className="lmap3d-legende" aria-hidden="true">
54 + <i /> The listing's building
55 + </span>
56 + </div>
57 + );
58 +}
added frontend/src/components/Logo.tsx +107 −0
@@ -0,0 +1,107 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/Logo.tsx: official Rent-Ka icon — the key that opens your next
5 +// home. Geometry (100 × 100 grid):
6 +// A. frame: vertical 60 × 90 squircle centred, rx 19, stroke only 7.5 u,
7 +// cobalt gradient #3D6BFF → #1738A8, rounded caps;
8 +// B. key: round bow (circle, negative-space keyhole) top-centre + straight
9 +// shaft going down to the threshold, two teeth pointing right, deep navy
10 +// #101A3D → #0B1330;
11 +// C. light mat: trapezoid at the threshold flaring forward, front edge
12 +// rounded touching the frame's bottom stroke, gradient #6E92FF → #2456E6.
13 +// Layer order: key → mat → frame → gloss. "Liquid glass" finish: 28 % white
14 +// highlight on the top curve + soft navy 14 % drop shadow. Flat version
15 +// (no gradients): favicon + PWA icon.
16 +// Baseline: "Apartments and homes for rent across Canada."
17 +// -----------------------------------------------------------------------------
18 +
19 +/** Icon alone (transparent background — for light backgrounds; dark = white key). */
20 +export function LogoIcon({ size = 30, dark = false }: { size?: number; dark?: boolean }) {
21 + // dark = version for navy backgrounds: the key turns white
22 + const keyTop = dark ? "#ffffff" : "#101a3d";
23 + const keyBot = dark ? "#e8edf8" : "#0b1330";
24 + const uid = dark ? "rk-dark" : "rk-light-bg";
25 + return (
26 + <svg
27 + className="logo-ico"
28 + width={size}
29 + height={size}
30 + viewBox="0 0 100 100"
31 + fill="none"
32 + aria-hidden="true"
33 + >
34 + <defs>
35 + {/* frame: brighter cobalt on top, deeper at the bottom */}
36 + <linearGradient id={`${uid}-frame`} x1="0" y1="0" x2="0" y2="1">
37 + <stop offset="0" stopColor="#3d6bff" />
38 + <stop offset="1" stopColor="#1738a8" />
39 + </linearGradient>
40 + {/* key: micro depth gradient */}
41 + <linearGradient id={`${uid}-key`} x1="0" y1="0" x2="0" y2="1">
42 + <stop offset="0" stopColor={keyTop} />
43 + <stop offset="1" stopColor={keyBot} />
44 + </linearGradient>
45 + {/* mat: cool light spreading forward */}
46 + <linearGradient id={`${uid}-light`} x1="0" y1="0" x2="0" y2="1">
47 + <stop offset="0" stopColor="#6e92ff" />
48 + <stop offset="1" stopColor="#2456e6" />
49 + </linearGradient>
50 + {/* very soft navy drop shadow — never a hard shadow */}
51 + <filter id={`${uid}-soft`} x="-25%" y="-25%" width="150%" height="150%">
52 + <feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#0b1330" floodOpacity="0.14" />
53 + </filter>
54 + </defs>
55 + <g filter={`url(#${uid}-soft)`}>
56 + {/* B — the key: bow with negative-space hole, shaft down, teeth right */}
57 + <path
58 + d="M50 14
59 + a 14.5 14.5 0 1 1 -0.01 0 Z
60 + M50 24 a 5 5 0 1 0 0.01 0 Z"
61 + fill={`url(#${uid}-key)`}
62 + fillRule="evenodd"
63 + />
64 + <path
65 + d="M44.5 41 L55.5 41 L55.5 60 L64 60 L64 66.5 L55.5 66.5 L55.5 70 L61 70 L61 76.5 L44.5 76.5 Z"
66 + fill={`url(#${uid}-key)`}
67 + />
68 + {/* C — light mat: from the threshold forward, rounded front edge
69 + touching the frame's lower stroke */}
70 + <path
71 + d="M37 77.4 L63 77.4 L74.8 88.5 Q76.4 92.5 71.6 92.5 L28.5 92.5 Q24.2 92.5 26 88.6 Z"
72 + fill={`url(#${uid}-light)`}
73 + opacity="0.9"
74 + />
75 + {/* A — frame: stroke only, very rounded corners (vertical squircle) */}
76 + <rect
77 + x="20" y="5" width="60" height="90" rx="19"
78 + stroke={`url(#${uid}-frame)`}
79 + strokeWidth="7.5"
80 + strokeLinecap="round"
81 + strokeLinejoin="round"
82 + />
83 + {/* "liquid glass" specular highlight on the top curve */}
84 + <path
85 + d="M31 6.8 Q50 4.4 69 6.8"
86 + stroke="#ffffff"
87 + strokeOpacity="0.28"
88 + strokeWidth="2.6"
89 + strokeLinecap="round"
90 + />
91 + </g>
92 + </svg>
93 + );
94 +}
95 +
96 +/** Full horizontal logo: icon + "Rent·Ka" wordmark
97 + * (Rent navy, ·Ka cobalt; icon↔text gap ≈ 40 % of icon width). */
98 +export default function Logo({ size = 30 }: { size?: number }) {
99 + return (
100 + <span style={{ display: "inline-flex", alignItems: "center", gap: size * 0.4 * 0.6 }}>
101 + <LogoIcon size={size} />
102 + <span style={{ color: "#0b1330", fontWeight: 700, letterSpacing: "-0.005em" }}>
103 + Rent<span style={{ color: "#2456e6" }}>·Ka</span>
104 + </span>
105 + </span>
106 + );
107 +}
added frontend/src/components/MapView.tsx +155 −0
@@ -0,0 +1,155 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Groupe Ka / Ka Maps (Rent-Ka integration)
5 +// components/MapView.tsx: Rent-Ka Maps — the product map, powered by
6 +// le framework partagé Ka Maps (moteur MapLibre, pastilles de prix GPU,
7 +// clustering natif, « Rechercher dans cette zone », aperçu React).
8 +// · chargée paresseusement (React.lazy) comme avant ;
9 +// · données pilotées par le viewport : /api/listings.geojson?bbox=…
10 +// (requêtes annulables, cache, jamais de tempête pendant le pan) ;
11 +// · caméra partageable dans l'URL (?lat=…&lng=…&zoom=…).
12 +// -----------------------------------------------------------------------------
13 +import { useCallback, useEffect, useMemo } from "react";
14 +import { useNavigate } from "react-router-dom";
15 +import "mapbox-gl/dist/mapbox-gl.css";
16 +import "@groupe-ka/ka-maps/styles.css";
17 +import type { MapProperty } from "@groupe-ka/ka-maps";
18 +import { cameraFromParams, cameraToParams } from "@groupe-ka/ka-maps";
19 +import {
20 + KaBrandBadge,
21 + KaMapView,
22 + LoadingIndicator,
23 + PropertyPreview,
24 + ResultCount,
25 + SearchAreaControl,
26 + Tilt3DControl,
27 + useKaMap,
28 +} from "@groupe-ka/ka-maps/react";
29 +import { ListingFilters, sourceName } from "../api";
30 +import { louKaMapTheme } from "../kamaps/theme";
31 +import { louKaMapAdapter } from "../kamaps/adapter";
32 +import { MAPBOX_TOKEN } from "../kamaps/config";
33 +
34 +const CANADA_CENTER = { lat: 47.5, lng: -84.5 };
35 +
36 +const fmtDispo = (iso: string | null | undefined): string =>
37 + !iso ? "" : iso === "now" ? "Available now"
38 + : `Available ${new Date(iso + "T12:00:00").toLocaleDateString("en-CA", { day: "numeric", month: "short", year: "numeric" })}`;
39 +
40 +/** Compact rental preview — same visual language as ListingCard. */
41 +function PreviewCard({ p }: { p: MapProperty }) {
42 + const navigate = useNavigate();
43 + const extra = (p.extra ?? {}) as {
44 + title?: string | null; priceLabel?: string | null;
45 + availabilityDate?: string | null; source?: string; areaSqft?: number | null;
46 + };
47 + const prix = p.price != null
48 + ? `$${p.price.toLocaleString("en-CA", { maximumFractionDigits: 0 })}`
49 + : extra.priceLabel || "Price on request";
50 + const meta = [
51 + p.propertyType,
52 + extra.areaSqft ? `${Math.round(extra.areaSqft)} sq ft` : "",
53 + fmtDispo(extra.availabilityDate),
54 + ].filter(Boolean).join(" · ");
55 + return (
56 + <div className="mv-pop">
57 + {p.thumbnailUrl ? (
58 + <img src={p.thumbnailUrl} alt="" loading="lazy" />
59 + ) : (
60 + <div className="mv-noimg" aria-hidden="true">⌂</div>
61 + )}
62 + <div className="mv-pop-body">
63 + <div className="mv-pop-price">
64 + {prix}
65 + <small>{p.price != null ? " /month" : ""}</small>
66 + </div>
67 + <div className="mv-pop-title">{extra.title ?? p.address ?? ""}</div>
68 + <div className="mv-pop-meta">{meta}</div>
69 + <div className="mv-pop-src">{sourceName(extra.source ?? "")}</div>
70 + <a
71 + className="mv-pop-cta"
72 + href={p.originalUrl}
73 + onClick={(e) => {
74 + e.preventDefault();
75 + navigate(p.originalUrl ?? "/");
76 + }}
77 + >
78 + See the listing →
79 + </a>
80 + </div>
81 + </div>
82 + );
83 +}
84 +
85 +/** Pont déclaratif : reflète la sélection/le survol venus de la liste. */
86 +function SelectionBridge({ selectedUid, hoveredUid }: {
87 + selectedUid: string | null;
88 + hoveredUid: string | null;
89 +}) {
90 + const map = useKaMap();
91 + useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);
92 + useEffect(() => { map?.setHovered(hoveredUid, "app"); }, [map, hoveredUid]);
93 + return null;
94 +}
95 +
96 +export interface MapViewProps {
97 + filters: ListingFilters;
98 + selectedUid?: string | null;
99 + hoveredUid?: string | null;
100 + onSelect?: (uid: string | null) => void;
101 +}
102 +
103 +export default function MapView({
104 + filters, selectedUid = null, hoveredUid = null, onSelect,
105 +}: MapViewProps) {
106 + // initial camera: shared URL (?lat&lng&zoom) else Canada
107 + const initialCamera = useMemo(() => {
108 + const cam = cameraFromParams(new URLSearchParams(window.location.search));
109 + return cam ?? { ...CANADA_CENTER, zoom: 4.4 };
110 + }, []);
111 +
112 + // caméra → URL (replaceState : pas de re-render du routeur)
113 + const onMoveEnd = useCallback((center: { lat: number; lng: number }, zoom: number) => {
114 + const url = new URL(window.location.href);
115 + url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();
116 + window.history.replaceState(null, "", url);
117 + }, []);
118 +
119 + const mapFilters = useMemo(
120 + () => Object.fromEntries(Object.entries(filters).filter(([, v]) => v)),
121 + [filters],
122 + );
123 +
124 + return (
125 + <div className="mapview" role="application" aria-label="Rentals map">
126 + <KaMapView
127 + theme={louKaMapTheme}
128 + adapter={louKaMapAdapter}
129 + mapboxToken={MAPBOX_TOKEN}
130 + filters={mapFilters}
131 + center={initialCamera}
132 + zoom={initialCamera.zoom}
133 + pitch={50}
134 + // Rendu réaliste : Standard pleine couleur + repères 3D (stades,
135 + // ponts, églises) — les pastilles encre/blanc gardent leur contraste.
136 + basemap={{ theme: "default", showLandmarks: true }}
137 + cluster={{ maxZoom: 15, valueClamp: [250, 8000] }}
138 + searchMode="manual"
139 + onMoveEnd={onMoveEnd}
140 + onSelect={(p) => onSelect?.(p?.id ?? null)}
141 + >
142 + <SelectionBridge selectedUid={selectedUid} hoveredUid={hoveredUid} />
143 + <KaBrandBadge />
144 + <Tilt3DControl />
145 + <SearchAreaControl />
146 + <LoadingIndicator label="Updating rentals…" />
147 + <ResultCount
148 + emptyTitle="No rentals found in this area."
149 + emptyHint="Widen the map or change your filters."
150 + />
151 + <PropertyPreview render={(p) => <PreviewCard p={p} />} />
152 + </KaMapView>
153 + </div>
154 + );
155 +}
added frontend/src/components/Pager.tsx +73 −0
@@ -0,0 +1,73 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/Pager.tsx: editorial pagination (12 listings/page)
5 +// Previous / numbers with ellipses / Next + "X–Y of N" recap.
6 +// -----------------------------------------------------------------------------
7 +import { IcoChevronLeft, IcoChevronRight } from "./Icons";
8 +
9 +/** 1 … 4 [5] 6 … 12 — window around the current page, bounds always visible */
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="Results pagination">
36 + <span className="pager-info">
37 + {from}–{to} <em>of</em> {total.toLocaleString("en-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="Previous page"
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="Next page"
67 + >
68 + <IcoChevronRight size={14} />
69 + </button>
70 + </div>
71 + </nav>
72 + );
73 +}
added frontend/src/components/PriceAnalysis.tsx +110 −0
@@ -0,0 +1,110 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/PriceAnalysis.tsx: "Rent-Ka price analysis" block (detail page)
5 +// Estimated fair value + range + deviation + confidence + mini histogram
6 +// of the rent's position in the market segment distribution.
7 +// Indicative estimate computed over comparable listings (fairvalue.py).
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useState } from "react";
10 +import { Link } from "react-router-dom";
11 +import { FairValueDetail, fetchFairValue, fmtPrice } from "../api";
12 +import FairValueBadge, { fmtDeviation } from "./FairValueBadge";
13 +
14 +const CONF_LABEL = { fort: "High confidence", moyen: "Medium confidence",
15 + faible: "Indicative estimate" } as const;
16 +
17 +/** Mini histogram: segment rent distribution, text markers for "this
18 + * rent" and the fair value (never colour alone). */
19 +function MiniHisto({ d, price }: { d: FairValueDetail; price: number }) {
20 + const bins = d.histogram;
21 + if (!bins || bins.length < 4) return null;
22 + const W = 320, H = 96, top = 18, bottom = 16;
23 + const lo = bins[0].x0, hi = bins[bins.length - 1].x1;
24 + if (hi <= lo) return null;
25 + const max = Math.max(...bins.map((b) => b.n), 1);
26 + const x = (v: number) => ((Math.min(Math.max(v, lo), hi) - lo) / (hi - lo)) * W;
27 + const bw = W / bins.length;
28 + const plotH = H - top - bottom;
29 + const priceX = x(price), fvX = x(d.fv);
30 + // spread the labels if both markers are close
31 + const close = Math.abs(priceX - fvX) < 64;
32 + const lblAnchor = (px: number) => (px < 56 ? "start" : px > W - 56 ? "end" : "middle");
33 + return (
34 + <svg className="fv-histo" viewBox={`0 0 ${W} ${H}`} role="img"
35 + aria-label={`Position of the rent (${fmtPrice(price)}) among ${d.segment_n} comparable listings`}>
36 + {bins.map((b, i) => {
37 + const h = Math.max(1.5, (b.n / max) * plotH);
38 + const inBin = price >= b.x0 && price < b.x1;
39 + return (
40 + <rect key={i} x={i * bw + 1} y={H - bottom - h} rx="2"
41 + width={Math.max(1, bw - 2)} height={h}
42 + fill={inBin ? "var(--accent, #2456e6)" : "rgba(204, 85, 0, 0.28)"}>
43 + <title>{`$${b.x0} – $${b.x1}: ${b.n} listing${b.n > 1 ? "s" : ""}`}</title>
44 + </rect>
45 + );
46 + })}
47 + {/* fair-value range (band) */}
48 + <rect x={x(d.fv_low)} y={H - bottom} width={Math.max(2, x(d.fv_high) - x(d.fv_low))}
49 + height="3.5" rx="1.5" fill="rgba(204, 85, 0, 0.45)" />
50 + {/* fair-value marker */}
51 + <line x1={fvX} x2={fvX} y1={top - 2} y2={H - bottom} stroke="var(--accent-deep, #1738a8)"
52 + strokeWidth="1.6" strokeDasharray="3 3" />
53 + {!close && (
54 + <text x={fvX} y={top - 7} textAnchor={lblAnchor(fvX)}
55 + className="fv-histo-lbl fv-histo-lbl-fv">Fair value</text>
56 + )}
57 + {/* asking-rent marker */}
58 + <line x1={priceX} x2={priceX} y1={top - 2} y2={H - bottom}
59 + stroke="var(--ink, #141814)" strokeWidth="2" />
60 + <text x={priceX} y={close ? top - 7 : H - 4} textAnchor={lblAnchor(priceX)}
61 + className="fv-histo-lbl">This rent{close ? " / fair value" : ""}</text>
62 + {/* axis bounds */}
63 + <text x="1" y={H - 4} className="fv-histo-axis" textAnchor="start">${lo}</text>
64 + <text x={W - 1} y={H - 4} className="fv-histo-axis" textAnchor="end">${hi}</text>
65 + </svg>
66 + );
67 +}
68 +
69 +export default function PriceAnalysis({ uid, price }: { uid: string; price: number | null }) {
70 + const [d, setD] = useState<FairValueDetail | null>(null);
71 + useEffect(() => {
72 + setD(null);
73 + fetchFairValue(uid).then(setD).catch(() => setD(null));
74 + }, [uid]);
75 + if (!d || price == null) return null;
76 +
77 + const pct = fmtDeviation(d.deviation);
78 + return (
79 + <section className="f-bloc f-fairvalue" id="analyse-prix">
80 + <h2>Rent-Ka price analysis</h2>
81 + <div className="fv-head">
82 + <div>
83 + <div className="fv-value">{fmtPrice(d.fv)} <small>/ month</small></div>
84 + <div className="fv-range">
85 + Estimated fair value · range {fmtPrice(d.fv_low)} – {fmtPrice(d.fv_high)}
86 + </div>
87 + </div>
88 + <FairValueBadge verdict={d.verdict} deviation={d.deviation} />
89 + </div>
90 + {d.verdict == null && (
91 + <p className="fine">
92 + {CONF_LABEL[d.confidence]} — not enough reliable comparables to
93 + classify this rent; the estimate is provided as an indication.
94 + </p>
95 + )}
96 + <MiniHisto d={d} price={price} />
97 + <div className="fv-meta">
98 + {pct && <span>Deviation: <b>{pct}</b> vs fair value</span>}
99 + <span>{CONF_LABEL[d.confidence]}</span>
100 + <span>{d.segment_n.toLocaleString("en-CA")} comparable listings</span>
101 + {d.comps > 0 && <span>{d.comps} neighbours kept</span>}
102 + </div>
103 + <p className="fine">
104 + Indicative estimate computed continuously from comparable market
105 + listings — not an official appraisal.{" "}
106 + <Link to="/fair-value">How is fair value calculated?</Link>
107 + </p>
108 + </section>
109 + );
110 +}
added frontend/src/components/SmartImg.tsx +33 −0
@@ -0,0 +1,33 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/SmartImg.tsx: robust image — optimized WebP thumbnail first,
5 +// fallback to the original URL if the thumbnail is not (yet) available,
6 +// then to the Rent-Ka fallback visual. Never a broken-image icon.
7 +// -----------------------------------------------------------------------------
8 +import { ImgHTMLAttributes, useEffect, useState } from "react";
9 +import { placeholderImage, thumb } from "../api";
10 +
11 +type Props = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
12 + src: string | null | undefined; // original source URL
13 + width_?: 160 | 480 | 800 | 1280; // desired thumbnail width
14 + fallbackLabel?: string; // fallback visual label (e.g. "2 bedrooms")
15 + original?: boolean; // true = no thumbnail (full size)
16 +};
17 +
18 +export default function SmartImg({ src, width_ = 480, fallbackLabel,
19 + original = false, ...rest }: Props) {
20 + const chain = src
21 + ? (original ? [src] : [thumb(src, width_), src])
22 + : [];
23 + const [step, setStep] = useState(0);
24 + useEffect(() => { setStep(0); }, [src, width_]);
25 + const url = step < chain.length ? chain[step] : placeholderImage(fallbackLabel);
26 + return (
27 + <img
28 + {...rest}
29 + src={url}
30 + onError={() => setStep((s) => s + 1)}
31 + />
32 + );
33 +}
added frontend/src/ka/GroupeKaBadge.tsx +21 −0
@@ -0,0 +1,21 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// ka-ui/react/GroupeKaBadge.tsx — badge « Un service Groupe KA », cliquable
3 +// vers le hub. À placer dans le header de chaque site (à côté du logo de la
4 +// marque). Variante dark pour les surfaces encre. Zone de protection : le
5 +// padding interne du badge suffit ; ne rien coller à moins de 8 px du badge.
6 +export default function GroupeKaBadge({ dark = false }: { dark?: boolean }) {
7 + return (
8 + <a
9 + className={`gk-badge${dark ? " gk-badge--dark" : ""}`}
10 + href="https://www.groupe-ka.com"
11 + target="_blank"
12 + rel="noopener noreferrer"
13 + aria-label="A Groupe KA service — visit the ecosystem portal"
14 + >
15 + A service by
16 + <b>
17 + Groupe<span className="ka">KA</span>
18 + </b>
19 + </a>
20 + );
21 +}
added frontend/src/ka/KaFooter.tsx +91 −0
@@ -0,0 +1,91 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// ka-ui/react/KaFooter.tsx — footer commun Groupe KA (fond encre), à vendorer
3 +// dans chaque app Vite/React sous frontend/src/ka/. Consomme ecosystem.json :
4 +// toute modification des infos de contact se fait dans ka-ui/ecosystem.json
5 +// puis se resynchronise sur les 12 sites (voir ka-ui/README.md).
6 +import eco from "./ecosystem.json";
7 +
8 +type LocalLink = { label: string; href: string };
9 +
10 +export default function KaFooter({
11 + siteId,
12 + localLegal = [],
13 +}: {
14 + /** id du site courant dans ecosystem.json (ex. "resto-ka") */
15 + siteId: string;
16 + /** pages légales PROPRES au site (ex. /conditions), affichées avant celles du hub */
17 + localLegal?: LocalLink[];
18 +}) {
19 + const year = new Date().getFullYear();
20 + const site = eco.sites.find((s) => s.id === siteId);
21 + const others = eco.sites.filter((s) => s.id !== siteId);
22 + return (
23 + <footer className="ka-footer" id="contact">
24 + <div className="container">
25 + <a
26 + className="wordmark"
27 + href={eco.hub.url}
28 + target={siteId === "groupe-ka" ? undefined : "_blank"}
29 + rel="noopener noreferrer"
30 + aria-label="Groupe KA — the ecosystem portal"
31 + >
32 + Groupe <span className="ka">KA</span>
33 + </a>
34 + <p className="desc">
35 + {eco.org.tagline} Hundreds of connectors read the sites at the
36 + source, normalize the data and resync on their own.{" "}
37 + {site ? (
38 + <>
39 + <b style={{ color: "var(--paper)" }}>{site.wordmark}</b> —{" "}
40 + {site.tagline} — is a Groupe KA service.
41 + </>
42 + ) : null}
43 + </p>
44 + <p className="notice">
45 + <b>{eco.org.disclaimer}</b>
46 + </p>
47 + <ul className="sites">
48 + {others.map((s) => (
49 + <li key={s.id}>
50 + <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer">
51 + {s.wordmark}
52 + </a>
53 + </li>
54 + ))}
55 + {eco.extraFooterLinks.map((l) => (
56 + <li key={l.href}>
57 + <a href={l.href} target="_blank" rel="noopener noreferrer">
58 + {l.label}
59 + </a>
60 + </li>
61 + ))}
62 + </ul>
63 + <div className="contacts">
64 + {eco.contacts.map((c) => (
65 + <p key={c.email} style={{ margin: 0 }}>
66 + <a href={`mailto:${c.email}`}>{c.email}</a>
67 + <span>{c.role}</span>
68 + </p>
69 + ))}
70 + </div>
71 + <p className="legal">
72 + © {year} {eco.org.copyrightHolder}
73 + {localLegal.map((l) => (
74 + <span key={l.href}>
75 + {" · "}
76 + <a href={l.href}>{l.label}</a>
77 + </span>
78 + ))}
79 + {eco.legal.map((l) => (
80 + <span key={l.href}>
81 + {" · "}
82 + <a href={l.href} target="_blank" rel="noopener noreferrer">
83 + {l.label}
84 + </a>
85 + </span>
86 + ))}
87 + </p>
88 + </div>
89 + </footer>
90 + );
91 +}
added frontend/src/ka/ecosystem.json +216 −0
@@ -0,0 +1,216 @@
1 +{
2 + "org": {
3 + "name": "Groupe KA",
4 + "legalName": "Groupe KA — Simon-Pierre Boucher",
5 + "tagline": "Holding québécois d'agrégateurs de produits et services entièrement automatisés.",
6 + "disclaimer": "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction.",
7 + "copyrightHolder": "Groupe KA — Simon-Pierre Boucher"
8 + },
9 + "hub": {
10 + "url": "https://www.groupe-ka.com",
11 + "loginPath": "/connexion",
12 + "signupNote": "La création de compte KA ID se fait sur le hub groupe-ka.com ; chaque site délègue sa connexion via /api/auth/ka/login."
13 + },
14 + "contacts": [
15 + {
16 + "email": "contact@groupe-ka.com",
17 + "role": "Projets, partenariats & données"
18 + },
19 + {
20 + "email": "info@groupe-ka.com",
21 + "role": "Médias & questions générales"
22 + },
23 + {
24 + "email": "admin@groupe-ka.com",
25 + "role": "Légal, vie privée & Loi 25"
26 + }
27 + ],
28 + "legal": [
29 + {
30 + "label": "Conditions d'utilisation",
31 + "href": "https://www.groupe-ka.com/conditions"
32 + },
33 + {
34 + "label": "Politique de confidentialité",
35 + "href": "https://www.groupe-ka.com/confidentialite"
36 + },
37 + {
38 + "label": "Protection des renseignements personnels (Loi 25)",
39 + "href": "https://www.groupe-ka.com/loi-25"
40 + },
41 + {
42 + "label": "Transparence des robots d'indexation",
43 + "href": "https://www.groupe-ka.com/bots"
44 + }
45 + ],
46 + "sites": [
47 + {
48 + "id": "groupe-ka",
49 + "wordmark": "Groupe KA",
50 + "domain": "www.groupe-ka.com",
51 + "accent": "#d9f26b",
52 + "accentSoft": "#f0f9d2",
53 + "accentDeep": "#123f2e",
54 + "onAccent": "#141814",
55 + "tagline": "Le portail de l'écosystème ·Ka"
56 + },
57 + {
58 + "id": "trouve-ka",
59 + "wordmark": "Trouve·Ka",
60 + "domain": "www.trouve-ka.com",
61 + "accent": "#1c7ed6",
62 + "accentSoft": "#e7f2fd",
63 + "accentDeep": "#14508f",
64 + "onAccent": "#ffffff",
65 + "tagline": "Le moteur de recherche du web québécois"
66 + },
67 + {
68 + "id": "lou-ka",
69 + "wordmark": "Lou·Ka",
70 + "domain": "www.lou-ka.com",
71 + "accent": "#ff6a00",
72 + "accentSoft": "#fff1e6",
73 + "accentDeep": "#cc5500",
74 + "onAccent": "#ffffff",
75 + "tagline": "Tous les logements à louer"
76 + },
77 + {
78 + "id": "rent-ka",
79 + "wordmark": "Rent·Ka",
80 + "domain": "www.rent-ka.com",
81 + "accent": "#2456e6",
82 + "accentSoft": "#e9efff",
83 + "accentDeep": "#1738a8",
84 + "onAccent": "#ffffff",
85 + "tagline": "Rentals across Canada"
86 + },
87 + {
88 + "id": "immo-ka",
89 + "wordmark": "Immo·Ka",
90 + "domain": "www.immo-ka.com",
91 + "accent": "#e23744",
92 + "accentSoft": "#fbe0e2",
93 + "accentDeep": "#a8232e",
94 + "onAccent": "#ffffff",
95 + "tagline": "Toutes les propriétés à vendre"
96 + },
97 + {
98 + "id": "vrai-prix",
99 + "wordmark": "Vrai-Prix",
100 + "domain": "www.vrai-prix.com",
101 + "accent": "#ff5148",
102 + "accentSoft": "#ffe3e0",
103 + "accentDeep": "#9e2a25",
104 + "onAccent": "#ffffff",
105 + "tagline": "La valeur réelle de chaque propriété"
106 + },
107 + {
108 + "id": "auto-ka",
109 + "wordmark": "Auto·Ka",
110 + "domain": "www.auto-ka.com",
111 + "accent": "#ff5a2a",
112 + "accentSoft": "#ffe8de",
113 + "accentDeep": "#cc3f16",
114 + "onAccent": "#ffffff",
115 + "tagline": "Les voitures usagées du Québec"
116 + },
117 + {
118 + "id": "fabri-ka",
119 + "wordmark": "Fabri·Ka",
120 + "domain": "www.fabri-ka.com",
121 + "accent": "#c4532e",
122 + "accentSoft": "#f7e3da",
123 + "accentDeep": "#a94525",
124 + "onAccent": "#ffffff",
125 + "tagline": "Les produits fabriqués au Québec"
126 + },
127 + {
128 + "id": "food-ka",
129 + "wordmark": "Food·Ka",
130 + "domain": "www.food-ka.com",
131 + "accent": "#1f9d55",
132 + "accentSoft": "#e2f5ea",
133 + "accentDeep": "#157a40",
134 + "onAccent": "#ffffff",
135 + "tagline": "Les prix d'épicerie, suivis à la source"
136 + },
137 + {
138 + "id": "resto-ka",
139 + "wordmark": "Resto·Ka",
140 + "domain": "www.resto-ka.com",
141 + "accent": "#f08c00",
142 + "accentSoft": "#fdeed7",
143 + "accentDeep": "#b96a00",
144 + "onAccent": "#141814",
145 + "tagline": "Chaque resto, chaque plat, chaque prix"
146 + },
147 + {
148 + "id": "sorti-ka",
149 + "wordmark": "Sorti·Ka",
150 + "domain": "www.sorti-ka.com",
151 + "accent": "#d6336c",
152 + "accentSoft": "#fbe0eb",
153 + "accentDeep": "#a12551",
154 + "onAccent": "#ffffff",
155 + "tagline": "Toutes les sorties, dans les 17 régions"
156 + },
157 + {
158 + "id": "crea-ka",
159 + "wordmark": "Créa·Ka",
160 + "domain": "www.crea-ka.com",
161 + "accent": "#7048e8",
162 + "accentSoft": "#ece5fc",
163 + "accentDeep": "#5433b8",
164 + "onAccent": "#ffffff",
165 + "tagline": "Les créateurs d'ici, tous leurs liens"
166 + },
167 + {
168 + "id": "api-ka",
169 + "wordmark": "API·Ka",
170 + "domain": "www.api-ka.com",
171 + "accent": "#3b5bdb",
172 + "accentSoft": "#e4eafb",
173 + "accentDeep": "#2b44a8",
174 + "onAccent": "#ffffff",
175 + "tagline": "La donnée de l'écosystème, par API"
176 + },
177 + {
178 + "id": "job-ka",
179 + "wordmark": "Job·Ka",
180 + "domain": "www.job-ka.com",
181 + "accent": "#0c8599",
182 + "accentSoft": "#def0f4",
183 + "accentDeep": "#095c6b",
184 + "onAccent": "#ffffff",
185 + "tagline": "Tous les emplois des employeurs québécois"
186 + },
187 + {
188 + "id": "ka-stats",
189 + "wordmark": "Ka·Stats",
190 + "domain": "www.ka-stats.com",
191 + "accent": "#095797",
192 + "accentSoft": "#e2edf6",
193 + "accentDeep": "#063a63",
194 + "onAccent": "#ffffff",
195 + "tagline": "L'explorateur de statistiques du Québec"
196 + }
197 + ],
198 + "extraFooterLinks": [
199 + {
200 + "label": "ValoPlex",
201 + "href": "https://www.valoplex.com"
202 + },
203 + {
204 + "label": "Ka2",
205 + "href": "https://www.ka2.bot"
206 + },
207 + {
208 + "label": "Ka4",
209 + "href": "https://www.ka4.bot"
210 + },
211 + {
212 + "label": "Ka6",
213 + "href": "https://www.ka6.bot"
214 + }
215 + ]
216 +}
\ No newline at end of file
added frontend/src/ka/stats/SPEC.md +246 −0
@@ -0,0 +1,246 @@
1 +# ka-stats — module Stats commun Groupe KA (spec v3)
2 +
3 +Contrat partagé par les plateformes pour leurs pages **/stats** (tableau de
4 +bord analytique) et les **exports PDF** estampillés Groupe-KA. Le visuel suit
5 +le design system ka-ui (tokens.css) avec l'accent de la marque.
6 +
7 +**v2 (2026-08-19)** : sparklines dans les KPI, jauges, multi-courbes,
8 +barres empilées, distributions (histogrammes), heatmap horaire 7×24, deltas
9 +sur les répartitions, statistiques de séries (min/max/moy/méd/σ), et **5
10 +rapports PDF** au lieu de 2. Tous les nouveaux champs sont **optionnels** :
11 +un dashboard v1 reste valide et se rend tel quel.
12 +
13 +**v3 (2026-08-23)** : **rapports personnalisés** — l'utilisateur compose son
14 +propre rapport PDF bloc par bloc : choix des données (catalogue dérivé du
15 +dashboard), du **rendu par bloc** (courbe/aire/barres/anneau/histogramme/
16 +heatmap/tableau…), de l'ordre, avec **modèles sauvegardés** (localStorage du
17 +site). Deux endpoints (`/api/stats/catalog`, `POST /api/stats/report/custom`,
18 +voir §3bis) + un constructeur dans la page /stats (`ReportBuilder` du kit —
19 +bouton « 🛠 Rapport personnalisé » à côté du menu PDF). Le PDF garde le
20 +gabarit estampillé Groupe-KA avec l'accent de la plateforme. v2 inchangée.
21 +
22 +## 1. Page /stats — structure obligatoire (dans cet ordre)
23 +
24 +1. **Bandeau KPI** : 6–10 grandes cartes (`KpiCard`) — valeur, libellé,
25 + variation vs période précédente (▲/▼ + %, vert `--green` / rouge
26 + `--danger`), **sparkline** de tendance quand une série existe.
27 +2. **Sélecteur de période global** (`PeriodSelector`) : `aujourd'hui · 7 j ·
28 + 30 j · 3 m · 6 m · 12 m · année en cours · tout` + plage personnalisée
29 + (2 champs date). Toute la page se recalcule (state → refetch dashboard).
30 +3. **Jauges** (`GaugeCard`) quand pertinent : taux, couvertures, complétude.
31 +4. **Graphiques d'évolution** : courbes/aires (`LineChart`, survol =
32 + infobulle, légende cliquable, comparaison N vs N-1 en pointillé), barres
33 + verticales (`VBarChart` — volumes quotidiens), **multi-courbes ≤ 4 séries**
34 + (`MultiLineChart` — motifs de trait distincts, jamais la couleur seule),
35 + **barres empilées** (`StackedBarChart` — composition dans le temps).
36 + Sous les courbes clés : `StatSummary` (min/max/moyenne/médiane/écart-type).
37 +5. **Répartitions** : barres horizontales (`BarChart`, deltas optionnels),
38 + anneaux (`Donut`), **distributions/histogrammes** (`Histogram`).
39 +6. **Répartition géographique** (par ville/région) — barres horizontales
40 + triées (pas besoin de vraie carte).
41 +7. **Calendriers** : `CalendarHeatmap` (26 semaines) et, quand l'activité
42 + horaire est journalisée, `HourHeatmap` (7 jours × 24 h).
43 +8. **Tableaux détaillés** (`DataTable`) : tri par colonne, recherche interne,
44 + pagination (25/pg), débordement horizontal propre sur mobile (.tbl-wrap).
45 + Viser 3 à 5 tableaux par plateforme.
46 +9. **Records & faits marquants** : générés depuis les données (jour record,
47 + plus forte croissance, meilleure entrée…) — cartes compactes, 6–12.
48 +10. **Fraîcheur** : « Mis à jour le {date heure} » + bouton Rafraîchir.
49 +11. **Bouton PDF** bien visible en haut (`PdfButton`) : bouton principal
50 + « Rapport PDF complet » + menu « Autres rapports ▾ » listant les
51 + **5 rapports** (voir §3). Indicateur de progression si > 2 s.
52 +
53 +Responsive : KPI empilés < 768 px, graphiques pleine largeur redimensionnés
54 +(SVG viewBox), tableaux en défilement horizontal contenu, tactile ≥ 44 px.
55 +AUCUNE donnée inventée : une stat indisponible = bloc « Pas encore mesuré »
56 +(carte grise propre), jamais un faux chiffre.
57 +
58 +Accessibilité (règles fermes) : un seul axe Y par graphique (jamais de
59 +double échelle) ; ≥ 2 séries ⇒ légende obligatoire ; l'identité d'une série
60 +multi-courbes passe par le **motif de trait** en plus de la couleur ; le
61 +texte reste en encre (jamais coloré à la couleur de série) ; chaque
62 +graphique a son infobulle de survol et un équivalent tableau existe.
63 +
64 +## 2. API — contrat commun
65 +
66 +`GET /api/stats/dashboard?period=7j|30j|3m|6m|12m|annee|tout|auj&from=YYYY-MM-DD&to=YYYY-MM-DD`
67 +
68 +```jsonc
69 +{
70 + "updated": "2026-08-19T01:00:00-04:00",
71 + "period": { "from": "2026-07-20", "to": "2026-08-19", "label": "30 jours" },
72 + "kpis": [ { "id": "total", "label": "Annonces actives", "value": 33744,
73 + "unit": "", "delta_pct": 4.2, "direction": "up",
74 + "spark": [{ "t": "2026-08-01", "v": 31200 }] } ], // spark optionnel
75 + "gauges": [ { "id": "geo", "label": "Fiches géolocalisées", "value": 92,
76 + "max": 100, "unit": "%" } ], // optionnel
77 + "series": [ { "id": "vol", "title": "Annonces actives par jour", "unit": "annonces",
78 + "kind": "line", // line | bar | area
79 + "points": [{ "t": "2026-07-20", "v": 31200 }],
80 + "compare": [{ "t": "2025-07-20", "v": 24100 }] } ],
81 + "multiseries": [ { "id": "seg", "title": "Prix médian par taille", "unit": "$",
82 + "series": [ { "label": "3½", "points": [/* … */] },
83 + { "label": "4½", "points": [/* … */] } ] } ], // ≤ 4
84 + "stacked": [ { "id": "src", "title": "Ajouts par source", "unit": "ajouts",
85 + "keys": ["Kijiji", "Centris", "Autres"],
86 + "points": [{ "t": "2026-08-01", "values": [120, 80, 30] }] } ],
87 + "breakdowns": [ { "id": "types", "title": "Par type", "kind": "donut", // donut | bars
88 + "items": [{ "label": "4½", "value": 9120, "delta_pct": 2.1 }] } ],
89 + "distributions": [ { "id": "prix", "title": "Distribution des loyers", "unit": "annonces",
90 + "bins": [{ "label": "800-1000$", "value": 3120 }] } ],
91 + "geo": { "title": "Par région", "items": [{ "label": "Montréal", "value": 15680 }] },
92 + "heatmap": { "title": "Activité", "cells": [{ "date": "2026-08-01", "value": 210 }] },
93 + "hourly": { "title": "Activité par heure",
94 + "cells": [{ "dow": 0, "hour": 9, "value": 40 }] }, // dow 0=lun … 6=dim
95 + "tables": [ { "id": "top", "title": "Top villes", "columns": ["Ville", "Annonces", "Δ 30 j"],
96 + "rows": [["Montréal", 15680, "+3,1 %"]] } ],
97 + "records": [ { "label": "Jour record d'ajouts", "value": "412 annonces", "date": "2026-08-09" } ]
98 +}
99 +```
100 +
101 +Champs absents = section masquée. Cache serveur recommandé (≥ 5 min par
102 +période). Les valeurs proviennent des données réelles (DB de la plateforme,
103 +journaux de sync des connecteurs, /api/v1/runs d'API-KA…). Arrondir les
104 +`delta_pct` à 1 décimale côté serveur.
105 +
106 +## 3. Rapports PDF — 5 modes
107 +
108 +`GET /api/stats/report?period=…&from=&to=&mode=complet|synthese|tendances|repartitions|donnees`
109 +→ `application/pdf`, en-tête `Content-Disposition: attachment; filename=
110 +groupe-ka_<plateforme>_stats_<periode>[_<mode>]_<YYYY-MM-DD>.pdf`
111 +(pas de suffixe pour `complet` — rétrocompatible v1 ; `kapdf.filename()`
112 +accepte maintenant `mode` en 3e argument).
113 +
114 +| Mode | Contenu |
115 +|---|---|
116 +| `complet` | tout : sommaire, KPI, jauges, séries + stats de séries, multi-séries, empilées, répartitions, distributions, géo, heatmap horaire, tableaux (200 lignes), records |
117 +| `synthese` | couverture + KPI + jauges + records (2–3 pages) |
118 +| `tendances` | KPI + toutes les séries temporelles + min/max/moy/méd/σ + records |
119 +| `repartitions` | breakdowns, distributions, géo, activité horaire |
120 +| `donnees` | tous les tableaux en version longue (400 lignes) |
121 +
122 +Un `mode` inconnu retombe sur `complet`. Le gabarit (implémentations :
123 +`kapdf.py` fpdf2 pour les apps Python ; les apps Next portent le même
124 +gabarit en pdfkit) :
125 +
126 +- **Couverture** : cadre encre, kicker « GROUPE KA · RAPPORT STATISTIQUE »,
127 + wordmark de la plateforme (boîte encre + accent), **type de rapport**,
128 + période couverte, date/heure de génération, bande encre au pied avec
129 + « par Groupe KA — groupe-ka.com ».
130 +- **Sommaire** avec numéros de pages (modes complet et donnees).
131 +- **KPI** : grille de cartes (bordure encre, valeur en gros, delta coloré
132 + arrondi à 1 décimale). **Jauges** : demi-arcs accent.
133 +- **Graphiques VECTORIELS** (dessinés en primitives, jamais de capture) :
134 + courbes/aires, barres verticales, multi-courbes (motifs distincts),
135 + empilées (nuances d'accent), anneaux, heatmap horaire — accent de la
136 + plateforme, axes/graduations encre.
137 +- **Tableaux** paginés proprement (lignes zébrées `--surface-2`, jamais
138 + coupés en deux à cheval sur une ligne).
139 +- **Records** puis **page de fin** : coordonnées Groupe KA (3 courriels +
140 + rôles d'ecosystem.json, groupe-ka.com), avertissement d'agrégateur,
141 + mentions légales courtes.
142 +- **Chaque page** : en-tête discret (« Groupe KA · {Plateforme} », filet
143 + encre) + pied (« © Groupe-KA — {année} — groupe-ka.com · {période} · p. X/Y »).
144 +- A4 portrait, marges 18 mm, typo : Helvetica (fallback sûr) ou fonts TTF du
145 + DS si présentes.
146 +
147 +## 3bis. Rapports personnalisés (v3)
148 +
149 +### Catalogue
150 +
151 +`GET /api/stats/catalog?period=…&from=&to=` →
152 +
153 +```jsonc
154 +{ "updated": "…", "period": { … },
155 + "blocks": [ { "key": "series:ajouts", // section[:id] — clé stable
156 + "section": "series",
157 + "title": "Événements ajoutés par jour",
158 + "renders": ["line","area","bar","table"], // rendus compatibles
159 + "default_render": "line",
160 + "count": 30 } ] } // taille indicative (optionnel)
161 +```
162 +
163 +Sections → rendus : `kpis` cards|table · `gauges` gauges|table ·
164 +`series:<id>` line|area|bar|table · `multiseries:<id>` lines|table ·
165 +`stacked:<id>` stacked|table · `breakdowns:<id>` donut|bars|table ·
166 +`distributions:<id>` histogram|table · `geo` bars|table ·
167 +`heatmap` heatmap|table · `hourly` heatmap|table · `tables:<id>` table ·
168 +`records` cards|table. **Toute donnée a un équivalent tableau.** Le catalogue
169 +est dérivé du dashboard (implémentation : `kapdf.catalog(dash)` /
170 +`catalogFromDashboard()` en TS) — zéro maintenance quand une métrique s'ajoute.
171 +
172 +### Génération
173 +
174 +`POST /api/stats/report/custom` — corps JSON :
175 +
176 +```jsonc
177 +{ "title": "Revue mensuelle", // ≤ 80 car., affiché en couverture
178 + "period": "30j", "from": "", "to": "", // mêmes règles que le dashboard
179 + "blocks": [ { "key": "kpis", "render": "cards" },
180 + { "key": "series:ajouts", "render": "bar" } ] } // ordre = ordre du PDF
181 +```
182 +
183 +→ `application/pdf`, filename `groupe-ka_<plateforme>_stats_<periode>_personnalise_<date>.pdf`.
184 +Clés inconnues ignorées ; rendu incompatible → rendu par défaut ; aucun bloc
185 +valide → **400**. Maximum 40 blocs. Couverture : type = « Rapport
186 +personnalisé — {title} » ; sommaire ; page de fin habituelle. Un même bloc
187 +peut apparaître plusieurs fois (ex. graphique + tableau).
188 +
189 +### Constructeur (front, kit)
190 +
191 +`ReportBuilder` (kacharts.tsx ; port vanilla pour les SPA sans React) :
192 +panneau modal 2 colonnes — catalogue groupé par section à gauche, composition
193 +ordonnée à droite (↑ ↓ ✕, sélecteur de rendu par bloc, titre). **Modèles** :
194 +sauvegarde/chargement/suppression nommés en localStorage (clé
195 +`ka-stats-rapports`, propre à l'origine du site). Bouton « Générer le PDF »
196 +→ POST + téléchargement blob. États busy/erreur propres, tactile ≥ 44 px,
197 +z-index `var(--z-modal, 900)`.
198 +
199 +## 4. Spécifique par plateforme (sections métier attendues)
200 +
201 +- **groupe-ka** : tableau de bord maître — consolidation des plateformes
202 + (volume total, croissance), classement, bloc résumé par plateforme + lien
203 + vers sa page /stats ; « Rapport écosystème complet » = PDF consolidé.
204 +- **lou-ka** : annonces actives/nouvelles/retirées, loyers moyens/médians par
205 + ville & taille (multiseries), distribution des loyers, évolution,
206 + répartition par type, top villes, sources (stacked).
207 +- **immo-ka** : annonces actives/nouvelles/vendues-retirées, prix
208 + moyen/médian par ville/région/type (multiseries), distribution des prix,
209 + délai de présence, top villes, tension du marché.
210 +- **vrai-prix** : couverture du rôle (unités, valeur totale), estimations
211 + servies si journalisées, répartitions par municipalité/type, distribution
212 + des valeurs, indices marché.
213 +- **auto-ka** : volume par marque/modèle/année/carburant/boîte, prix moyens
214 + et km moyens par segment (multiseries), distributions prix/km/année,
215 + top marques/modèles.
216 +- **fabri-ka** : produits par catégorie/région/boutique, fourchettes de prix
217 + (distribution), nouveautés par période, top catégories.
218 +- **food-ka** : produits suivis, relevés de prix, soldes détectés
219 + (baisses/hausses, amplitude — stacked), top produits en solde, prix moyens
220 + par catégorie, distribution des rabais.
221 +- **resto-ka** : restos par cuisine/ville/gamme, menus & plats, distribution
222 + des prix de plats, nouveautés/fermetures détectées, top établissements.
223 +- **sorti-ka** : événements à venir/passés par catégorie/ville, gratuits vs
224 + payants (stacked), heatmap calendrier + horaire, top lieux.
225 +- **crea-ka** : créateurs par plateforme/niche/tier, comptes reliés
226 + (stacked par plateforme), distribution des audiences, top créateurs,
227 + croissance du répertoire.
228 +- **trouve-ka** : pages indexées, domaines, rythme de crawl (indexées/h —
229 + hourly), erreurs, file frontier, tendances des requêtes journalisées.
230 +- **api-ka** : appels par endpoint/jour/heure (hourly), latences moyennes +
231 + p95 (multiseries), taux d'erreur, top endpoints/clés, uptime.
232 +- **job-ka** : offres actives/nouvelles/expirées par catégorie/ville/
233 + entreprise, distribution des salaires affichés, top employeurs.
234 +- **Transverse (tous)** : volume total agrégé + croissance, connecteurs
235 + actifs et éléments ajoutés/mis à jour par période (journaux de sync —
236 + stacked par source quand possible), complétude/fraîcheur moyenne des
237 + fiches quand mesurable (jauges). Trafic web : seulement si des journaux
238 + d'accès existent — sinon état vide propre.
239 +
240 +## 5. Ajouter une métrique / un graphique / une plateforme
241 +
242 +1 métrique = 1 entrée `kpis[]`, `series[]`, `multiseries[]`, `stacked[]`,
243 +`distributions[]` ou `gauges[]` côté API (requête SQL agrégée + cache) — le
244 +front la rend automatiquement. 1 plateforme = implémenter les 2 endpoints du
245 +contrat + une page /stats montée sur les composants du kit + `kapdf.py` (ou
246 +gabarit pdfkit) branché sur le même JSON de dashboard.
added frontend/src/ka/stats/kacharts.tsx +964 −0
@@ -0,0 +1,964 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// ka-ui/stats/kacharts.tsx — kit de graphiques SVG du module Stats commun
3 +// Groupe KA (zéro dépendance, React 18+). Style : design system ka-ui
4 +// (bordures encre, accent de la plateforme via var(--accent)).
5 +// v2 — composants : KpiCard (+sparkline), PeriodSelector, LineChart (ligne/
6 +// aire, infobulle + légende cliquable + comparaison N-1), MultiLineChart
7 +// (≤4 séries, pointillés distincts = jamais la couleur seule), VBarChart
8 +// (barres verticales / histogrammes), StackedBarChart, BarChart (horizontal,
9 +// deltas), Donut, GaugeCard, CalendarHeatmap, HourHeatmap (7×24),
10 +// StatSummary (min/max/moy/méd/σ), DataTable (tri/recherche/pagination),
11 +// RecordCard, PdfButton (menu de rapports), EmptyBlock, Fraicheur.
12 +import { useEffect, useMemo, useRef, useState } from "react";
13 +
14 +/* ---------- types (contrat SPEC.md v2) ---------- */
15 +export type Kpi = {
16 + id: string; label: string; value: number | string; unit?: string;
17 + delta_pct?: number | null; direction?: "up" | "down";
18 + spark?: Point[]; help?: string;
19 +};
20 +export type Point = { t: string; v: number };
21 +export type Serie = {
22 + id: string; title: string; unit?: string;
23 + kind?: "line" | "bar" | "area";
24 + points: Point[]; compare?: Point[];
25 +};
26 +export type MultiSerie = {
27 + id: string; title: string; unit?: string;
28 + series: { label: string; points: Point[] }[]; // ≤ 4 séries
29 +};
30 +export type StackedSerie = {
31 + id: string; title: string; unit?: string;
32 + keys: string[]; points: { t: string; values: number[] }[];
33 +};
34 +export type BreakItem = { label: string; value: number; delta_pct?: number | null };
35 +export type Distribution = { id: string; title: string; unit?: string; bins: { label: string; value: number }[] };
36 +export type Gauge = { id: string; label: string; value: number; max: number; unit?: string; help?: string };
37 +export type HourCell = { dow: number; hour: number; value: number }; // dow 0=lun … 6=dim
38 +export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] };
39 +export type RecordFact = { label: string; value: string; date?: string };
40 +
41 +export const PERIODS: { id: string; label: string }[] = [
42 + { id: "auj", label: "Today" },
43 + { id: "7j", label: "7 days" },
44 + { id: "30j", label: "30 days" },
45 + { id: "3m", label: "3 months" },
46 + { id: "6m", label: "6 months" },
47 + { id: "12m", label: "12 months" },
48 + { id: "annee", label: "Year to date" },
49 + { id: "tout", label: "All" },
50 +];
51 +
52 +export const REPORT_MODES: { id: string; label: string; desc: string }[] = [
53 + { id: "complet", label: "Full report", desc: "All sections — KPIs, trends, breakdowns, tables, records" },
54 + { id: "synthese", label: "Executive summary", desc: "2 pages — key indicators and highlights" },
55 + { id: "tendances", label: "Trends & evolution", desc: "Curves, year-over-year comparisons and series statistics" },
56 + { id: "repartitions", label: "Breakdowns & geography", desc: "Categories, distributions, regions and activity" },
57 + { id: "donnees", label: "Detailed data", desc: "All tables, in long form" },
58 +];
59 +
60 +export const fmtInt = (n: number) => n.toLocaleString("en-CA");
61 +export const fmtNum = (n: number) =>
62 + Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("en-CA", { maximumFractionDigits: 2 });
63 +const fmtPct = (n: number) => `${n >= 0 ? "+" : ""}${fmtNum(n)} %`;
64 +
65 +/* Styles des séries multiples : couleur + motif de trait (l'identité n'est
66 + jamais portée par la couleur seule — règle d'accessibilité). */
67 +const MULTI_STYLES = [
68 + { stroke: "var(--accent)", dash: undefined, width: 2.4 },
69 + { stroke: "var(--ink)", dash: undefined, width: 1.6 },
70 + { stroke: "var(--accent-deep, var(--accent))", dash: "6 3", width: 2 },
71 + { stroke: "var(--ink-3)", dash: "2 3", width: 2 },
72 +];
73 +
74 +/* ---------- KPI (+ sparkline) ---------- */
75 +export function KpiCard({ k }: { k: Kpi }) {
76 + const up = (k.direction ?? ((k.delta_pct ?? 0) >= 0 ? "up" : "down")) === "up";
77 + const sp = (k.spark ?? []).filter((p) => typeof p.v === "number");
78 + const spark = useMemo(() => {
79 + if (sp.length < 2) return null;
80 + const w = 120, h = 30;
81 + const vmax = Math.max(...sp.map((p) => p.v));
82 + const vmin = Math.min(...sp.map((p) => p.v));
83 + const rng = vmax - vmin || 1;
84 + const X = (i: number) => (w * i) / (sp.length - 1);
85 + const Y = (v: number) => 2 + (h - 4) * (1 - (v - vmin) / rng);
86 + const d = sp.map((p, i) => `${i ? "L" : "M"}${X(i).toFixed(1)},${Y(p.v).toFixed(1)}`).join("");
87 + return { w, h, d, area: `${d}L${w},${h}L0,${h}Z` };
88 + }, [k.spark]);
89 + return (
90 + <article className="card" style={{ padding: "14px 16px", minWidth: 0 }} title={k.help}>
91 + <p style={{ margin: 0, fontFamily: "var(--font-display)", fontWeight: 700, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em" }}>
92 + {typeof k.value === "number" ? fmtNum(k.value) : k.value}
93 + {k.unit ? <span style={{ fontSize: "0.6em", color: "var(--ink-2)" }}> {k.unit}</span> : null}
94 + </p>
95 + <p className="klabel" style={{ margin: "6px 0 0" }}>{k.label}</p>
96 + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: 8 }}>
97 + {k.delta_pct !== undefined && k.delta_pct !== null ? (
98 + <p style={{ margin: "8px 0 0", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, color: up ? "var(--green)" : "var(--danger)" }}>
99 + {up ? "▲" : "▼"} {fmtPct(k.delta_pct)} <span style={{ color: "var(--ink-3)", fontWeight: 500 }}>vs previous period</span>
100 + </p>
101 + ) : <span />}
102 + {spark && (
103 + <svg viewBox={`0 0 ${spark.w} ${spark.h}`} style={{ width: 96, height: 24, flex: "none" }} aria-hidden="true">
104 + <path d={spark.area} fill="var(--accent)" opacity={0.14} />
105 + <path d={spark.d} fill="none" stroke="var(--accent)" strokeWidth={1.8} />
106 + </svg>
107 + )}
108 + </div>
109 + </article>
110 + );
111 +}
112 +
113 +/* ---------- Sélecteur de période ---------- */
114 +export function PeriodSelector({
115 + value, onChange, custom, onCustom,
116 +}: {
117 + value: string; onChange: (p: string) => void;
118 + custom?: { from: string; to: string }; onCustom?: (from: string, to: string) => void;
119 +}) {
120 + return (
121 + <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
122 + {PERIODS.map((p) => (
123 + <button key={p.id} type="button" onClick={() => onChange(p.id)}
124 + className="chip" aria-pressed={value === p.id}
125 + style={{ cursor: "pointer", minHeight: 44, background: value === p.id ? "var(--accent)" : "var(--surface)", color: value === p.id ? "var(--on-accent)" : "var(--ink)" }}>
126 + {p.label}
127 + </button>
128 + ))}
129 + {onCustom && (
130 + <span style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>
131 + <input className="input" type="date" style={{ width: 150 }} value={custom?.from ?? ""} aria-label="Du"
132 + onChange={(e) => onCustom(e.target.value, custom?.to ?? "")} />
133 + <span className="klabel">au</span>
134 + <input className="input" type="date" style={{ width: 150 }} value={custom?.to ?? ""} aria-label="Au"
135 + onChange={(e) => onCustom(custom?.from ?? "", e.target.value)} />
136 + </span>
137 + )}
138 + </div>
139 + );
140 +}
141 +
142 +/* ---------- Courbe / aire ---------- */
143 +export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) {
144 + const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false });
145 + const [hover, setHover] = useState<number | null>(null);
146 + const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
147 + const pts = serie.points ?? [];
148 + if (serie.kind === "bar") return <VBarChart serie={serie} height={height} />;
149 + if (pts.length < 2) return <EmptyBlock title={serie.title} />;
150 + const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])];
151 + const vmax = Math.max(...all.map((p) => p.v), 1);
152 + const vmin = Math.min(0, ...all.map((p) => p.v));
153 + const X = (i: number, n: number) => PL + ((W - PL - PR) * i) / (n - 1);
154 + const Y = (v: number) => PT + (H - PT - PB) * (1 - (v - vmin) / (vmax - vmin || 1));
155 + const path = (s: Point[]) => s.map((p, i) => `${i ? "L" : "M"}${X(i, s.length)},${Y(p.v)}`).join("");
156 + const hi = hover !== null ? Math.min(pts.length - 1, Math.max(0, hover)) : null;
157 + return (
158 + <figure className="card" style={{ margin: 0, padding: 16 }}>
159 + <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
160 + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{serie.title}</b>
161 + <span style={{ display: "flex", gap: 10 }}>
162 + <LegendChip label="Current period" color="var(--accent)" off={hide.cur} onClick={() => setHide((h) => ({ ...h, cur: !h.cur }))} />
163 + {serie.compare && <LegendChip label="Compared period" color="var(--ink-3)" dashed off={hide.cmp} onClick={() => setHide((h) => ({ ...h, cmp: !h.cmp }))} />}
164 + </span>
165 + </figcaption>
166 + <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10, touchAction: "pan-y" }} role="img" aria-label={serie.title}
167 + onMouseMove={(e) => {
168 + const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
169 + const fx = ((e.clientX - r.left) / r.width) * W;
170 + setHover(Math.round(((fx - PL) / (W - PL - PR)) * (pts.length - 1)));
171 + }}
172 + onMouseLeave={() => setHover(null)}>
173 + {[0, 1, 2, 3, 4].map((g) => {
174 + const y = PT + ((H - PT - PB) * g) / 4;
175 + const v = vmax - ((vmax - vmin) * g) / 4;
176 + return (
177 + <g key={g}>
178 + <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
179 + <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>
180 + </g>
181 + );
182 + })}
183 + {[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => (
184 + <text key={i} x={X(i, pts.length)} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
185 + ))}
186 + {serie.kind === "area" && !hide.cur && (
187 + <path d={`${path(pts)}L${X(pts.length - 1, pts.length)},${Y(0)}L${X(0, pts.length)},${Y(0)}Z`} fill="var(--accent)" opacity={0.13} />
188 + )}
189 + {!hide.cmp && serie.compare && serie.compare.length > 1 && (
190 + <path d={path(serie.compare)} fill="none" stroke="var(--ink-3)" strokeWidth={1.4} strokeDasharray="4 4" />
191 + )}
192 + {!hide.cur && <path d={path(pts)} fill="none" stroke="var(--accent)" strokeWidth={2.4} />}
193 + {hi !== null && (
194 + <g>
195 + <line x1={X(hi, pts.length)} x2={X(hi, pts.length)} y1={PT} y2={H - PB} stroke="var(--ink)" strokeWidth={1} strokeDasharray="2 3" />
196 + <circle cx={X(hi, pts.length)} cy={Y(pts[hi].v)} r={4} fill="var(--accent)" stroke="var(--ink)" strokeWidth={1.5} />
197 + </g>
198 + )}
199 + </svg>
200 + {hi !== null && (
201 + <p className="chip" style={{ marginTop: 8 }}>
202 + {pts[hi].t} — <b>{fmtNum(pts[hi].v)}{serie.unit ? ` ${serie.unit}` : ""}</b>
203 + {serie.compare?.[hi] && !hide.cmp ? <span style={{ color: "var(--ink-3)" }}> · N-1 : {fmtNum(serie.compare[hi].v)}</span> : null}
204 + </p>
205 + )}
206 + </figure>
207 + );
208 +}
209 +
210 +function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off?: boolean; dashed?: boolean; onClick?: () => void }) {
211 + return (
212 + <button type="button" onClick={onClick} aria-pressed={!off} disabled={!onClick}
213 + style={{ display: "inline-flex", alignItems: "center", gap: 6, border: 0, background: "none", cursor: onClick ? "pointer" : "default", opacity: off ? 0.4 : 1, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", minHeight: 44 }}>
214 + <span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} />
215 + {label}
216 + </button>
217 + );
218 +}
219 +
220 +/* ---------- Multi-courbes (≤ 4 séries, motifs distincts) ---------- */
221 +export function MultiLineChart({ ms, height = 260 }: { ms: MultiSerie; height?: number }) {
222 + const series = (ms.series ?? []).filter((s) => (s.points ?? []).length > 1).slice(0, 4);
223 + const [off, setOff] = useState<Record<string, boolean>>({});
224 + const [hover, setHover] = useState<number | null>(null);
225 + if (!series.length) return <EmptyBlock title={ms.title} />;
226 + const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
227 + const n = Math.max(...series.map((s) => s.points.length));
228 + const shown = series.filter((s) => !off[s.label]);
229 + const all = shown.flatMap((s) => s.points.map((p) => p.v));
230 + const vmax = Math.max(...(all.length ? all : [1]), 1);
231 + const vmin = Math.min(0, ...(all.length ? all : [0]));
232 + const X = (i: number, len: number) => PL + ((W - PL - PR) * i) / (len - 1);
233 + const Y = (v: number) => PT + (H - PT - PB) * (1 - (v - vmin) / (vmax - vmin || 1));
234 + const ref = series[0].points;
235 + const hi = hover !== null ? Math.min(n - 1, Math.max(0, hover)) : null;
236 + return (
237 + <figure className="card" style={{ margin: 0, padding: 16 }}>
238 + <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
239 + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{ms.title}</b>
240 + <span style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
241 + {series.map((s, i) => (
242 + <LegendChip key={s.label} label={s.label} color={MULTI_STYLES[i].stroke}
243 + dashed={!!MULTI_STYLES[i].dash} off={!!off[s.label]}
244 + onClick={() => setOff((o) => ({ ...o, [s.label]: !o[s.label] }))} />
245 + ))}
246 + </span>
247 + </figcaption>
248 + <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10, touchAction: "pan-y" }} role="img" aria-label={ms.title}
249 + onMouseMove={(e) => {
250 + const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
251 + const fx = ((e.clientX - r.left) / r.width) * W;
252 + setHover(Math.round(((fx - PL) / (W - PL - PR)) * (n - 1)));
253 + }}
254 + onMouseLeave={() => setHover(null)}>
255 + {[0, 1, 2, 3, 4].map((g) => {
256 + const y = PT + ((H - PT - PB) * g) / 4;
257 + const v = vmax - ((vmax - vmin) * g) / 4;
258 + return (
259 + <g key={g}>
260 + <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
261 + <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>
262 + </g>
263 + );
264 + })}
265 + {[0, Math.floor(ref.length / 2), ref.length - 1].map((i) => (
266 + <text key={i} x={X(i, ref.length)} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{ref[i]?.t}</text>
267 + ))}
268 + {series.map((s, i) => off[s.label] ? null : (
269 + <path key={s.label}
270 + d={s.points.map((p, j) => `${j ? "L" : "M"}${X(j, s.points.length)},${Y(p.v)}`).join("")}
271 + fill="none" stroke={MULTI_STYLES[i].stroke} strokeWidth={MULTI_STYLES[i].width}
272 + strokeDasharray={MULTI_STYLES[i].dash} />
273 + ))}
274 + {hi !== null && (
275 + <line x1={X(hi, n)} x2={X(hi, n)} y1={PT} y2={H - PB} stroke="var(--ink)" strokeWidth={1} strokeDasharray="2 3" />
276 + )}
277 + </svg>
278 + {hi !== null && (
279 + <p className="chip" style={{ marginTop: 8, display: "inline-flex", gap: 12, flexWrap: "wrap" }}>
280 + <b>{ref[hi]?.t}</b>
281 + {shown.map((s) => (
282 + <span key={s.label}>{s.label} : <b>{s.points[hi] ? fmtNum(s.points[hi].v) : "—"}{ms.unit ? ` ${ms.unit}` : ""}</b></span>
283 + ))}
284 + </p>
285 + )}
286 + </figure>
287 + );
288 +}
289 +
290 +/* ---------- Barres verticales (volumes quotidiens, histogrammes) ---------- */
291 +export function VBarChart({ serie, height = 240 }: { serie: Serie; height?: number }) {
292 + const [hover, setHover] = useState<number | null>(null);
293 + const pts = serie.points ?? [];
294 + if (!pts.length) return <EmptyBlock title={serie.title} />;
295 + const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
296 + const vmax = Math.max(...pts.map((p) => p.v), 1);
297 + const bw = Math.max(2, (W - PL - PR) / pts.length - 2);
298 + return (
299 + <figure className="card" style={{ margin: 0, padding: 16 }}>
300 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{serie.title}</b></figcaption>
301 + <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10 }} role="img" aria-label={serie.title}
302 + onMouseLeave={() => setHover(null)}>
303 + {[0, 1, 2, 3, 4].map((g) => {
304 + const y = PT + ((H - PT - PB) * g) / 4;
305 + const v = vmax - (vmax * g) / 4;
306 + return (
307 + <g key={g}>
308 + <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
309 + <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>
310 + </g>
311 + );
312 + })}
313 + {pts.map((p, i) => {
314 + const x = PL + ((W - PL - PR) * i) / pts.length;
315 + const h = (H - PT - PB) * (p.v / vmax);
316 + return (
317 + <rect key={i} x={x + 1} y={H - PB - h} width={bw} height={Math.max(h, p.v > 0 ? 1.5 : 0)} rx={2}
318 + fill="var(--accent)" opacity={hover === null || hover === i ? 1 : 0.45}
319 + stroke="var(--ink)" strokeWidth={0.5}
320 + onMouseEnter={() => setHover(i)}>
321 + <title>{`${p.t} — ${fmtNum(p.v)}${serie.unit ? ` ${serie.unit}` : ""}`}</title>
322 + </rect>
323 + );
324 + })}
325 + {[0, Math.floor(pts.length / 2), pts.length - 1].filter((v, i, a) => a.indexOf(v) === i).map((i) => (
326 + <text key={i} x={PL + ((W - PL - PR) * (i + 0.5)) / pts.length} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
327 + ))}
328 + </svg>
329 + {hover !== null && (
330 + <p className="chip" style={{ marginTop: 8 }}>{pts[hover].t} — <b>{fmtNum(pts[hover].v)}{serie.unit ? ` ${serie.unit}` : ""}</b></p>
331 + )}
332 + </figure>
333 + );
334 +}
335 +
336 +/* ---------- Histogramme (distribution) ---------- */
337 +export function Histogram({ dist }: { dist: Distribution }) {
338 + const serie: Serie = {
339 + id: dist.id, title: dist.title, unit: dist.unit, kind: "bar",
340 + points: (dist.bins ?? []).map((b) => ({ t: b.label, v: b.value })),
341 + };
342 + return <VBarChart serie={serie} height={220} />;
343 +}
344 +
345 +/* ---------- Barres empilées (composition dans le temps) ---------- */
346 +export function StackedBarChart({ st, height = 260 }: { st: StackedSerie; height?: number }) {
347 + const [hover, setHover] = useState<number | null>(null);
348 + const keys = (st.keys ?? []).slice(0, 6);
349 + const pts = st.points ?? [];
350 + if (!keys.length || !pts.length) return <EmptyBlock title={st.title} />;
351 + const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
352 + const totals = pts.map((p) => p.values.slice(0, keys.length).reduce((s, v) => s + (v || 0), 0));
353 + const vmax = Math.max(...totals, 1);
354 + const bw = Math.max(2, (W - PL - PR) / pts.length - 2);
355 + const shades = [1, 0.72, 0.5, 0.34, 0.22, 0.13];
356 + return (
357 + <figure className="card" style={{ margin: 0, padding: 16 }}>
358 + <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
359 + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{st.title}</b>
360 + <span style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
361 + {keys.map((k, i) => (
362 + <span key={k} style={{ display: "inline-flex", alignItems: "center", gap: 5, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em" }}>
363 + <span style={{ width: 11, height: 11, borderRadius: 3, border: "1px solid var(--ink)", background: "var(--accent)", opacity: shades[i] }} />
364 + {k}
365 + </span>
366 + ))}
367 + </span>
368 + </figcaption>
369 + <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10 }} role="img" aria-label={st.title}
370 + onMouseLeave={() => setHover(null)}>
371 + {[0, 1, 2, 3, 4].map((g) => {
372 + const y = PT + ((H - PT - PB) * g) / 4;
373 + return (
374 + <g key={g}>
375 + <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
376 + <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(vmax - (vmax * g) / 4))}</text>
377 + </g>
378 + );
379 + })}
380 + {pts.map((p, i) => {
381 + const x = PL + ((W - PL - PR) * i) / pts.length;
382 + let yAcc = H - PB;
383 + return (
384 + <g key={i} onMouseEnter={() => setHover(i)} opacity={hover === null || hover === i ? 1 : 0.5}>
385 + {keys.map((k, j) => {
386 + const v = p.values[j] || 0;
387 + const h = (H - PT - PB) * (v / vmax);
388 + yAcc -= h;
389 + return v > 0 ? (
390 + <rect key={k} x={x + 1} y={yAcc} width={bw} height={Math.max(h - 1, 0.8)} rx={1.5}
391 + fill="var(--accent)" opacity={shades[j]} stroke="var(--ink)" strokeWidth={0.4}>
392 + <title>{`${p.t} · ${k} — ${fmtNum(v)}${st.unit ? ` ${st.unit}` : ""}`}</title>
393 + </rect>
394 + ) : null;
395 + })}
396 + </g>
397 + );
398 + })}
399 + {[0, Math.floor(pts.length / 2), pts.length - 1].filter((v, i, a) => a.indexOf(v) === i).map((i) => (
400 + <text key={i} x={PL + ((W - PL - PR) * (i + 0.5)) / pts.length} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
401 + ))}
402 + </svg>
403 + {hover !== null && (
404 + <p className="chip" style={{ marginTop: 8, display: "inline-flex", gap: 12, flexWrap: "wrap" }}>
405 + <b>{pts[hover].t}</b>
406 + {keys.map((k, j) => <span key={k}>{k} : <b>{fmtNum(pts[hover].values[j] || 0)}</b></span>)}
407 + <span style={{ color: "var(--ink-3)" }}>total {fmtNum(totals[hover])}</span>
408 + </p>
409 + )}
410 + </figure>
411 + );
412 +}
413 +
414 +/* ---------- Barres horizontales (répartitions, géo) — deltas optionnels ---------- */
415 +export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) {
416 + const rows = (items ?? []).slice(0, 14);
417 + if (!rows.length) return <EmptyBlock title={title} />;
418 + const max = Math.max(...rows.map((r) => r.value), 1);
419 + return (
420 + <figure className="card" style={{ margin: 0, padding: 16 }}>
421 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption>
422 + <div style={{ marginTop: 12, display: "grid", gap: 9 }}>
423 + {rows.map((r) => (
424 + <div key={r.label} title={`${r.label} — ${fmtNum(r.value)}${unit ? ` ${unit}` : ""}`}>
425 + <div style={{ display: "flex", justifyContent: "space-between", gap: 8, fontSize: 12.5 }}>
426 + <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
427 + <span style={{ display: "inline-flex", gap: 8, alignItems: "baseline", flex: "none" }}>
428 + {r.delta_pct !== undefined && r.delta_pct !== null && (
429 + <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 700, color: r.delta_pct >= 0 ? "var(--green)" : "var(--danger)" }}>
430 + {r.delta_pct >= 0 ? "▲" : "▼"} {fmtPct(r.delta_pct)}
431 + </span>
432 + )}
433 + <b style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}>{fmtNum(r.value)}{unit ? ` ${unit}` : ""}</b>
434 + </span>
435 + </div>
436 + <div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}>
437 + <div style={{ height: "100%", width: `${Math.max((r.value / max) * 100, 1)}%`, background: "var(--accent)", border: "1px solid var(--ink)", borderRadius: "0 3px 3px 0", boxSizing: "border-box" }} />
438 + </div>
439 + </div>
440 + ))}
441 + </div>
442 + </figure>
443 + );
444 +}
445 +
446 +/* ---------- Anneau ---------- */
447 +export function Donut({ title, items }: { title: string; items: BreakItem[] }) {
448 + const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8);
449 + const total = rows.reduce((s, r) => s + r.value, 0);
450 + if (!total) return <EmptyBlock title={title} />;
451 + const R = 74, C = 2 * Math.PI * R;
452 + let acc = 0;
453 + const shades = [1, 0.78, 0.58, 0.42, 0.3, 0.22, 0.15, 0.1];
454 + return (
455 + <figure className="card" style={{ margin: 0, padding: 16 }}>
456 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption>
457 + <div style={{ display: "flex", flexWrap: "wrap", gap: 18, alignItems: "center", marginTop: 12 }}>
458 + <svg viewBox="0 0 200 200" style={{ width: 180, maxWidth: "100%" }} role="img" aria-label={title}>
459 + {rows.map((r, i) => {
460 + const frac = r.value / total;
461 + const off = acc; acc += frac;
462 + return (
463 + <circle key={r.label} cx={100} cy={100} r={R} fill="none"
464 + stroke="var(--accent)" strokeOpacity={shades[i % shades.length]}
465 + strokeWidth={30} strokeDasharray={`${frac * C} ${C}`} strokeDashoffset={-off * C}
466 + transform="rotate(-90 100 100)">
467 + <title>{`${r.label} — ${fmtNum(r.value)} (${((100 * r.value) / total).toFixed(1)} %)`}</title>
468 + </circle>
469 + );
470 + })}
471 + <circle cx={100} cy={100} r={R} fill="none" stroke="var(--ink)" strokeWidth={1} opacity={0.5} />
472 + </svg>
473 + <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 6, minWidth: 200, flex: 1 }}>
474 + {rows.map((r, i) => (
475 + <li key={r.label} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5 }}>
476 + <span style={{ width: 11, height: 11, borderRadius: 3, border: "1px solid var(--ink)", background: "var(--accent)", opacity: shades[i % shades.length] }} />
477 + <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
478 + <b style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}>{((100 * r.value) / total).toFixed(1)} %</b>
479 + </li>
480 + ))}
481 + </ul>
482 + </div>
483 + </figure>
484 + );
485 +}
486 +
487 +/* ---------- Jauge (taux, complétude, couverture) ---------- */
488 +export function GaugeCard({ g }: { g: Gauge }) {
489 + const frac = Math.max(0, Math.min(1, g.max ? g.value / g.max : 0));
490 + const R = 60, C = Math.PI * R;
491 + return (
492 + <article className="card" style={{ padding: "14px 16px", minWidth: 0 }} title={g.help}>
493 + <svg viewBox="0 0 150 84" style={{ width: "100%", maxWidth: 190, display: "block", margin: "0 auto" }} role="img" aria-label={`${g.label} : ${fmtNum(g.value)}${g.unit ?? ""} sur ${fmtNum(g.max)}`}>
494 + <path d={`M15,75 A${R},${R} 0 0 1 135,75`} fill="none" stroke="rgba(20,24,20,0.08)" strokeWidth={13} strokeLinecap="round" />
495 + <path d={`M15,75 A${R},${R} 0 0 1 135,75`} fill="none" stroke="var(--accent)" strokeWidth={13} strokeLinecap="round"
496 + strokeDasharray={`${frac * C} ${C}`} />
497 + <text x={75} y={66} textAnchor="middle" fontFamily="var(--font-display)" fontWeight={700} fontSize={22} fill="var(--ink)">
498 + {fmtNum(g.value)}{g.unit ? <tspan fontSize={12} fill="var(--ink-2)"> {g.unit}</tspan> : null}
499 + </text>
500 + <text x={75} y={80} textAnchor="middle" fontSize={9} fill="var(--ink-3)" fontFamily="var(--font-mono)">{(frac * 100).toFixed(0)} % de {fmtNum(g.max)}{g.unit ? ` ${g.unit}` : ""}</text>
501 + </svg>
502 + <p className="klabel" style={{ margin: "8px 0 0", textAlign: "center" }}>{g.label}</p>
503 + </article>
504 + );
505 +}
506 +
507 +/* ---------- Calendrier de chaleur ---------- */
508 +export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) {
509 + if (!cells?.length) return <EmptyBlock title={title} />;
510 + const byDate = new Map(cells.map((c) => [c.date, c.value]));
511 + const dates = cells.map((c) => c.date).sort();
512 + const end = new Date(dates[dates.length - 1] + "T12:00:00");
513 + const max = Math.max(...cells.map((c) => c.value), 1);
514 + const weeks = 26, cols: { date: string; v: number }[][] = [];
515 + const cur = new Date(end);
516 + cur.setDate(cur.getDate() - (weeks * 7 - 1));
517 + for (let w = 0; w < weeks; w++) {
518 + const col: { date: string; v: number }[] = [];
519 + for (let d = 0; d < 7; d++) {
520 + const iso = cur.toISOString().slice(0, 10);
521 + col.push({ date: iso, v: byDate.get(iso) ?? 0 });
522 + cur.setDate(cur.getDate() + 1);
523 + }
524 + cols.push(col);
525 + }
526 + return (
527 + <figure className="card" style={{ margin: 0, padding: 16 }}>
528 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b> <span className="klabel">last 26 weeks</span></figcaption>
529 + <div className="tbl-wrap" style={{ marginTop: 12 }}>
530 + <svg viewBox={`0 0 ${weeks * 14} ${7 * 14}`} style={{ minWidth: 480, width: "100%", height: "auto" }} role="img" aria-label={title}>
531 + {cols.map((col, w) => col.map((c, d) => (
532 + <rect key={c.date} x={w * 14} y={d * 14} width={12} height={12} rx={2.5}
533 + fill={c.v ? "var(--accent)" : "rgba(20,24,20,0.07)"} fillOpacity={c.v ? 0.25 + 0.75 * (c.v / max) : 1}
534 + stroke="rgba(20,24,20,0.15)" strokeWidth={0.5}>
535 + <title>{`${c.date} — ${fmtNum(c.v)}`}</title>
536 + </rect>
537 + )))}
538 + </svg>
539 + </div>
540 + </figure>
541 + );
542 +}
543 +
544 +/* ---------- Heatmap horaire 7 × 24 (activité par heure) ---------- */
545 +const DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
546 +export function HourHeatmap({ title, cells }: { title: string; cells: HourCell[] }) {
547 + if (!cells?.length) return <EmptyBlock title={title} />;
548 + const grid = new Map(cells.map((c) => [`${c.dow}-${c.hour}`, c.value]));
549 + const max = Math.max(...cells.map((c) => c.value), 1);
550 + const CW = 24, CH = 20, LX = 34, LY = 16;
551 + return (
552 + <figure className="card" style={{ margin: 0, padding: 16 }}>
553 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b> <span className="klabel">jour × heure</span></figcaption>
554 + <div className="tbl-wrap" style={{ marginTop: 12 }}>
555 + <svg viewBox={`0 0 ${LX + 24 * CW} ${LY + 7 * CH}`} style={{ minWidth: 520, width: "100%", height: "auto" }} role="img" aria-label={title}>
556 + {[0, 6, 12, 18, 23].map((h) => (
557 + <text key={h} x={LX + h * CW + CW / 2} y={11} textAnchor="middle" fontSize={9} fill="var(--ink-3)" fontFamily="var(--font-mono)">{h} h</text>
558 + ))}
559 + {DOW.map((d, i) => (
560 + <text key={d} x={LX - 6} y={LY + i * CH + CH / 2 + 3} textAnchor="end" fontSize={9} fill="var(--ink-3)" fontFamily="var(--font-mono)">{d}</text>
561 + ))}
562 + {Array.from({ length: 7 }, (_, d) => Array.from({ length: 24 }, (_, h) => {
563 + const v = grid.get(`${d}-${h}`) ?? 0;
564 + return (
565 + <rect key={`${d}-${h}`} x={LX + h * CW} y={LY + d * CH} width={CW - 2} height={CH - 2} rx={2.5}
566 + fill={v ? "var(--accent)" : "rgba(20,24,20,0.07)"} fillOpacity={v ? 0.22 + 0.78 * (v / max) : 1}
567 + stroke="rgba(20,24,20,0.15)" strokeWidth={0.5}>
568 + <title>{`${DOW[d]} ${h} h — ${fmtNum(v)}`}</title>
569 + </rect>
570 + );
571 + }))}
572 + </svg>
573 + </div>
574 + </figure>
575 + );
576 +}
577 +
578 +/* ---------- Statistiques de série (min/max/moy/méd/σ) ---------- */
579 +export function StatSummary({ serie }: { serie: Serie }) {
580 + const vs = (serie.points ?? []).map((p) => p.v).filter((v) => typeof v === "number");
581 + if (vs.length < 2) return null;
582 + const sorted = [...vs].sort((a, b) => a - b);
583 + const mean = vs.reduce((s, v) => s + v, 0) / vs.length;
584 + const med = sorted[Math.floor(sorted.length / 2)];
585 + const sd = Math.sqrt(vs.reduce((s, v) => s + (v - mean) ** 2, 0) / vs.length);
586 + const items: [string, number][] = [
587 + ["Min", sorted[0]], ["Max", sorted[sorted.length - 1]],
588 + ["Average", Math.round(mean * 100) / 100], ["Median", med],
589 + ["Écart-type", Math.round(sd * 100) / 100],
590 + ];
591 + return (
592 + <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
593 + {items.map(([l, v]) => (
594 + <span key={l} className="chip" style={{ fontSize: 11 }}>
595 + <span style={{ color: "var(--ink-3)" }}>{l}</span> <b style={{ fontFamily: "var(--font-mono)" }}>{fmtNum(v)}</b>
596 + </span>
597 + ))}
598 + </div>
599 + );
600 +}
601 +
602 +/* ---------- Tableau : tri, recherche, pagination ---------- */
603 +export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) {
604 + const [q, setQ] = useState("");
605 + const [sort, setSort] = useState<{ col: number; dir: 1 | -1 } | null>(null);
606 + const [page, setPage] = useState(0);
607 + const rows = useMemo(() => {
608 + let r = spec.rows ?? [];
609 + if (q) r = r.filter((row) => row.some((c) => String(c).toLowerCase().includes(q.toLowerCase())));
610 + if (sort) r = [...r].sort((a, b) => {
611 + const x = a[sort.col], y = b[sort.col];
612 + const nx = typeof x === "number" ? x : parseFloat(String(x).replace(/[^\d.,-]/g, "").replace(",", "."));
613 + const ny = typeof y === "number" ? y : parseFloat(String(y).replace(/[^\d.,-]/g, "").replace(",", "."));
614 + if (!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx - ny) * sort.dir;
615 + return String(x).localeCompare(String(y), "fr") * sort.dir;
616 + });
617 + return r;
618 + }, [spec.rows, q, sort]);
619 + const pages = Math.max(1, Math.ceil(rows.length / pageSize));
620 + const cur = Math.min(page, pages - 1);
621 + return (
622 + <section className="card" style={{ padding: 16 }}>
623 + <div style={{ display: "flex", flexWrap: "wrap", gap: 10, justifyContent: "space-between", alignItems: "center" }}>
624 + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{spec.title}</b>
625 + <input className="input" style={{ maxWidth: 240 }} placeholder="Search…" value={q}
626 + onChange={(e) => { setQ(e.target.value); setPage(0); }} aria-label={`Search in ${spec.title}`} />
627 + </div>
628 + <div className="tbl-wrap" style={{ marginTop: 10 }}>
629 + <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
630 + <thead>
631 + <tr>
632 + {spec.columns.map((c, i) => (
633 + <th key={c} onClick={() => setSort((s) => ({ col: i, dir: s?.col === i && s.dir === 1 ? -1 : 1 }))}
634 + style={{ cursor: "pointer", textAlign: "left", padding: "8px 10px", background: "var(--ink)", color: "var(--paper)", fontFamily: "var(--font-mono)", fontSize: 10.5, textTransform: "uppercase", letterSpacing: "0.06em", whiteSpace: "nowrap", userSelect: "none" }}
635 + aria-sort={sort?.col === i ? (sort.dir === 1 ? "ascending" : "descending") : "none"}>
636 + {c} {sort?.col === i ? (sort.dir === 1 ? "▲" : "▼") : "↕"}
637 + </th>
638 + ))}
639 + </tr>
640 + </thead>
641 + <tbody>
642 + {rows.slice(cur * pageSize, (cur + 1) * pageSize).map((row, ri) => (
643 + <tr key={ri} style={{ background: ri % 2 ? "var(--surface-2)" : "var(--surface)" }}>
644 + {row.map((c, ci) => (
645 + <td key={ci} style={{ padding: "7px 10px", borderBottom: "1px solid var(--line)", whiteSpace: "nowrap" }}>
646 + {typeof c === "number" ? fmtNum(c) : c}
647 + </td>
648 + ))}
649 + </tr>
650 + ))}
651 + </tbody>
652 + </table>
653 + </div>
654 + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, flexWrap: "wrap", gap: 8 }}>
655 + <span className="klabel">{fmtInt(rows.length)} rows</span>
656 + <span style={{ display: "flex", gap: 6 }}>
657 + <button type="button" className="btn btn-ghost" disabled={cur === 0} onClick={() => setPage(cur - 1)}>←</button>
658 + <span className="chip">{cur + 1} / {pages}</span>
659 + <button type="button" className="btn btn-ghost" disabled={cur >= pages - 1} onClick={() => setPage(cur + 1)}>→</button>
660 + </span>
661 + </div>
662 + </section>
663 + );
664 +}
665 +
666 +/* ---------- Records / faits marquants ---------- */
667 +export function RecordCard({ r }: { r: RecordFact }) {
668 + return (
669 + <article className="card" style={{ padding: "12px 16px", display: "flex", justifyContent: "space-between", gap: 12, alignItems: "baseline", background: "var(--surface-2)" }}>
670 + <span style={{ fontSize: 13, color: "var(--ink-2)" }}>{r.label}</span>
671 + <span style={{ textAlign: "right" }}>
672 + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{r.value}</b>
673 + {r.date && <span className="klabel" style={{ display: "block" }}>{r.date}</span>}
674 + </span>
675 + </article>
676 + );
677 +}
678 +
679 +/* ---------- Menu de rapports PDF (5 rapports + personnalisé v3) ---------- */
680 +export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) {
681 + const [open, setOpen] = useState(false);
682 + const [busy, setBusy] = useState<string | null>(null);
683 + const [builder, setBuilder] = useState(false);
684 + const box = useRef<HTMLSpanElement>(null);
685 + useEffect(() => {
686 + if (!open) return;
687 + const close = (e: MouseEvent) => {
688 + if (box.current && !box.current.contains(e.target as Node)) setOpen(false);
689 + };
690 + document.addEventListener("mousedown", close);
691 + return () => document.removeEventListener("mousedown", close);
692 + }, [open]);
693 + const url = (mode: string) => {
694 + const p = new URLSearchParams({ period, mode });
695 + if (from) p.set("from", from);
696 + if (to) p.set("to", to);
697 + return `${endpoint}?${p}`;
698 + };
699 + const dl = (mode: string) => {
700 + setBusy(mode);
701 + setOpen(false);
702 + const a = document.createElement("a");
703 + a.href = url(mode);
704 + a.download = "";
705 + document.body.appendChild(a);
706 + a.click();
707 + a.remove();
708 + setTimeout(() => setBusy(null), 3000);
709 + };
710 + return (
711 + <span ref={box} style={{ position: "relative", display: "inline-flex", gap: 8, flexWrap: "wrap" }}>
712 + <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={!!busy}>
713 + {busy ? "Generating…" : "⬇ Full PDF report"}
714 + </button>
715 + <button type="button" className="btn btn-ghost" onClick={() => setOpen((o) => !o)} disabled={!!busy}
716 + aria-haspopup="menu" aria-expanded={open}>
717 + Autres rapports ▾
718 + </button>
719 + <button type="button" className="btn btn-ghost" onClick={() => setBuilder(true)} disabled={!!busy}>
720 + 🛠 Custom report
721 + </button>
722 + {builder && (
723 + <ReportBuilder period={period} from={from} to={to} endpoint={endpoint}
724 + onClose={() => setBuilder(false)} />
725 + )}
726 + {open && (
727 + <div role="menu" className="card" style={{ position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 50, minWidth: 300, padding: 6, background: "var(--surface)", boxShadow: "0 10px 28px rgba(20,24,20,0.18)" }}>
728 + {REPORT_MODES.map((m) => (
729 + <button key={m.id} type="button" role="menuitem" onClick={() => dl(m.id)}
730 + style={{ display: "block", width: "100%", textAlign: "left", border: 0, background: "none", cursor: "pointer", padding: "9px 10px", borderRadius: 6 }}
731 + onMouseEnter={(e) => (e.currentTarget.style.background = "var(--surface-2)")}
732 + onMouseLeave={(e) => (e.currentTarget.style.background = "none")}>
733 + <b style={{ display: "block", fontSize: 13, fontFamily: "var(--font-display)" }}>{m.label}</b>
734 + <span className="klabel" style={{ fontSize: 11 }}>{m.desc}</span>
735 + </button>
736 + ))}
737 + </div>
738 + )}
739 + </span>
740 + );
741 +}
742 +
743 +/* ---------- v3 : constructeur de rapports personnalisés ----------
744 + Compose un PDF bloc par bloc : catalogue dérivé du dashboard
745 + (GET /api/stats/catalog), rendu au choix par bloc, ordre libre, modèles
746 + sauvegardés en localStorage (clé ka-stats-rapports, propre au site).
747 + Contrat : SPEC.md §3bis. Rendu dans PdfButton — aucune modif des pages. */
748 +export type CatalogBlock = {
749 + key: string; section: string; title: string;
750 + renders: string[]; default_render: string; count?: number;
751 +};
752 +type BuilderSel = { key: string; render: string };
753 +type BuilderTpl = { name: string; title: string; blocks: BuilderSel[] };
754 +
755 +const RENDER_LABELS: Record<string, string> = {
756 + line: "Courbe", area: "Aire", bar: "Barres verticales",
757 + bars: "Barres horizontales", donut: "Anneau", lines: "Multi-courbes",
758 + stacked: "Stacked bars", histogram: "Histogram", heatmap: "Heatmap",
759 + cards: "Cartes", gauges: "Jauges", table: "Tableau",
760 +};
761 +const SECTION_LABELS: Record<string, string> = {
762 + kpis: "Indicateurs", gauges: "Jauges", series: "Évolution",
763 + multiseries: "Multi-courbes", stacked: "Compositions",
764 + breakdowns: "Breakdowns", distributions: "Distributions",
765 + geo: "Geography", heatmap: "Calendar", hourly: "Hourly activity",
766 + tables: "Tableaux", records: "Records",
767 +};
768 +const TPL_KEY = "ka-stats-rapports";
769 +
770 +function loadTemplates(): BuilderTpl[] {
771 + try { return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]"); }
772 + catch { return []; }
773 +}
774 +function saveTemplates(t: BuilderTpl[]) {
775 + try { localStorage.setItem(TPL_KEY, JSON.stringify(t)); } catch { /* plein/privé */ }
776 +}
777 +
778 +export function ReportBuilder({
779 + period, from, to, endpoint = "/api/stats/report", onClose,
780 +}: {
781 + period: string; from?: string; to?: string; endpoint?: string; onClose: () => void;
782 +}) {
783 + const [cat, setCat] = useState<CatalogBlock[] | null>(null);
784 + const [err, setErr] = useState("");
785 + const [sel, setSel] = useState<BuilderSel[]>([]);
786 + const [title, setTitle] = useState("");
787 + const [busy, setBusy] = useState(false);
788 + const [tpls, setTpls] = useState<BuilderTpl[]>(loadTemplates);
789 + const catalogUrl = endpoint.replace(/\/report$/, "/catalog");
790 +
791 + useEffect(() => {
792 + const p = new URLSearchParams({ period });
793 + if (from) p.set("from", from);
794 + if (to) p.set("to", to);
795 + fetch(`${catalogUrl}?${p}`)
796 + .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
797 + .then((d) => setCat(d.blocks ?? []))
798 + .catch(() => setErr("Catalog unavailable — try again later."));
799 + }, [period, from, to, catalogUrl]);
800 +
801 + useEffect(() => {
802 + const esc = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
803 + document.addEventListener("keydown", esc);
804 + const prev = document.body.style.overflow;
805 + document.body.style.overflow = "hidden";
806 + return () => { document.removeEventListener("keydown", esc); document.body.style.overflow = prev; };
807 + }, [onClose]);
808 +
809 + const add = (b: CatalogBlock) =>
810 + setSel((s) => s.some((x) => x.key === b.key && x.render === b.default_render)
811 + ? s : [...s, { key: b.key, render: b.default_render }]);
812 + const move = (i: number, d: number) => setSel((s) => {
813 + const j = i + d;
814 + if (j < 0 || j >= s.length) return s;
815 + const n = [...s]; [n[i], n[j]] = [n[j], n[i]]; return n;
816 + });
817 +
818 + const generate = async () => {
819 + if (busy || !sel.length) return;
820 + setBusy(true); setErr("");
821 + try {
822 + const body: Record<string, unknown> = { title, period, blocks: sel };
823 + if (from && to) { body.from = from; body.to = to; }
824 + const r = await fetch(`${endpoint}/custom`, {
825 + method: "POST", headers: { "Content-Type": "application/json" },
826 + body: JSON.stringify(body),
827 + });
828 + if (!r.ok) throw new Error(String(r.status));
829 + const blob = await r.blob();
830 + const m = (r.headers.get("Content-Disposition") ?? "").match(/filename="?([^";]+)/);
831 + const a = document.createElement("a");
832 + a.href = URL.createObjectURL(blob);
833 + a.download = m ? m[1] : "rapport-personnalise.pdf";
834 + document.body.appendChild(a); a.click(); a.remove();
835 + setTimeout(() => URL.revokeObjectURL(a.href), 4000);
836 + } catch {
837 + setErr("Generation failed — try again.");
838 + }
839 + setBusy(false);
840 + };
841 +
842 + const groups: [string, CatalogBlock[]][] = [];
843 + for (const b of cat ?? []) {
844 + const g = groups.find(([s]) => s === b.section);
845 + if (g) g[1].push(b); else groups.push([b.section, [b]]);
846 + }
847 + const selKeys = new Set(sel.map((s) => s.key));
848 + const mono: React.CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em" };
849 +
850 + return (
851 + <div role="dialog" aria-modal="true" aria-label="Custom report"
852 + onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
853 + style={{ position: "fixed", inset: 0, zIndex: "var(--z-modal, 900)" as never, background: "rgba(20,24,20,0.45)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "4vh 14px", overflow: "auto" }}>
854 + <div className="card" style={{ width: "min(980px,100%)", maxHeight: "92vh", display: "flex", flexDirection: "column", background: "var(--surface)", padding: 0, textAlign: "left", cursor: "default" }}>
855 + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, padding: "16px 20px", borderBottom: "1px solid var(--line)" }}>
856 + <b style={{ fontFamily: "var(--font-display)", fontSize: 18 }}>
857 + Custom report <span className="klabel">· period: {from && to ? `${from} → ${to}` : (PERIODS.find((p) => p.id === period)?.label ?? period)}</span>
858 + </b>
859 + <button type="button" className="btn btn-ghost" onClick={onClose}>✕ Fermer</button>
860 + </div>
861 + <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", overflow: "auto", flex: 1 }}>
862 + <div style={{ padding: "14px 20px", minWidth: 0, borderRight: "1px solid var(--line)" }}>
863 + <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Blocs disponibles ({cat?.length ?? "…"})</h3>
864 + {!cat && !err && <p className="klabel">Chargement du catalogue…</p>}
865 + {groups.map(([secId, bs]) => (
866 + <div key={secId}>
867 + <p style={{ ...mono, color: "var(--ink-2)", margin: "12px 0 6px" }}>{SECTION_LABELS[secId] ?? secId}</p>
868 + {bs.map((b) => (
869 + <div key={b.key} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, padding: "7px 10px", border: "1px solid var(--line)", borderRadius: 8, marginBottom: 6, fontSize: 13, opacity: selKeys.has(b.key) ? 0.45 : 1 }}>
870 + <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}>{b.title}</span>
871 + <button type="button" className="btn btn-ghost" onClick={() => add(b)} aria-label={`Ajouter ${b.title}`} style={{ flex: "none" }}>+</button>
872 + </div>
873 + ))}
874 + </div>
875 + ))}
876 + </div>
877 + <div style={{ padding: "14px 20px", minWidth: 0 }}>
878 + <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Composition du rapport ({sel.length})</h3>
879 + <label className="klabel" htmlFor="rb-title">Titre du rapport</label>
880 + <input id="rb-title" className="input" style={{ width: "100%", margin: "4px 0 12px", boxSizing: "border-box" }}
881 + maxLength={80} placeholder="Ex. : Revue mensuelle" value={title} onChange={(e) => setTitle(e.target.value)} />
882 + {sel.length ? sel.map((s, i) => {
883 + const b = (cat ?? []).find((x) => x.key === s.key) ?? { title: s.key, renders: [s.render] } as CatalogBlock;
884 + return (
885 + <div key={`${s.key}:${s.render}:${i}`} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", border: "1px solid var(--ink)", borderRadius: 8, marginBottom: 6, background: "var(--surface-2)", fontSize: 13 }}>
886 + <button type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label="Monter" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === 0 ? 0.25 : 1 }}>▲</button>
887 + <button type="button" onClick={() => move(i, 1)} disabled={i === sel.length - 1} aria-label="Descendre" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === sel.length - 1 ? 0.25 : 1 }}>▼</button>
888 + <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}><b>{i + 1}.</b> {b.title}</span>
889 + {b.renders.length > 1 ? (
890 + <select className="input" value={s.render} aria-label="Rendu" style={{ maxWidth: 150, padding: "4px 6px", fontSize: 12 }}
891 + onChange={(e) => setSel((xs) => xs.map((x, j) => j === i ? { ...x, render: e.target.value } : x))}>
892 + {b.renders.map((r) => <option key={r} value={r}>{RENDER_LABELS[r] ?? r}</option>)}
893 + </select>
894 + ) : <span className="klabel">{RENDER_LABELS[s.render] ?? s.render}</span>}
895 + <button type="button" onClick={() => setSel((xs) => xs.filter((_, j) => j !== i))} aria-label="Retirer" style={{ border: 0, background: "none", cursor: "pointer" }}>✕</button>
896 + </div>
897 + );
898 + }) : (
899 + <div style={{ border: "1px dashed var(--line)", borderRadius: 8, padding: 16, color: "var(--ink-3)", fontSize: 13, textAlign: "center" }}>
900 + No blocks yet — add blocks from the left column, or load a template below.
901 + </div>
902 + )}
903 + <p style={{ display: "flex", gap: 8, margin: "10px 0 0" }}>
904 + <button type="button" className="btn btn-ghost" disabled={!cat?.length}
905 + onClick={() => setSel((cat ?? []).map((b) => ({ key: b.key, render: b.default_render })))}>Add all</button>
906 + <button type="button" className="btn btn-ghost" disabled={!sel.length} onClick={() => setSel([])}>Vider</button>
907 + </p>
908 + {err && <p style={{ color: "var(--danger)", fontSize: 12.5, margin: "6px 0 0" }}>{err}</p>}
909 + </div>
910 + </div>
911 + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, flexWrap: "wrap", padding: "14px 20px", borderTop: "1px solid var(--line)" }}>
912 + <span style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
913 + <select className="input" aria-label="Saved templates" style={{ maxWidth: 210 }} value=""
914 + onChange={(e) => {
915 + const t = tpls[Number(e.target.value)];
916 + if (!t) return;
917 + setTitle(t.title || t.name);
918 + setSel((t.blocks ?? []).filter((s) => (cat ?? []).some((b) => b.key === s.key)).map((s) => ({ ...s })));
919 + }}>
920 + <option value="">Templates ({tpls.length})…</option>
921 + {tpls.map((t, i) => <option key={t.name} value={i}>{t.name}</option>)}
922 + </select>
923 + <button type="button" className="btn btn-ghost" disabled={!sel.length}
924 + onClick={() => {
925 + const name = window.prompt("Template name:", title || "My report");
926 + if (!name) return;
927 + const next = [...tpls.filter((t) => t.name !== name), { name, title, blocks: sel.map((s) => ({ ...s })) }];
928 + setTpls(next); saveTemplates(next);
929 + }}>💾 Sauvegarder</button>
930 + <button type="button" className="btn btn-ghost" disabled={!tpls.length}
931 + onClick={() => {
932 + const name = window.prompt(`Name of the template to delete:\n${tpls.map((t) => `· ${t.name}`).join("\n")}`);
933 + if (!name) return;
934 + const next = tpls.filter((t) => t.name !== name);
935 + setTpls(next); saveTemplates(next);
936 + }}>🗑 Supprimer</button>
937 + </span>
938 + <button type="button" className="btn btn-primary" disabled={!sel.length || busy} onClick={generate}>
939 + {busy ? "Generating…" : "⬇ Generate the PDF"}
940 + </button>
941 + </div>
942 + </div>
943 + </div>
944 + );
945 +}
946 +
947 +/* ---------- États ---------- */
948 +export function EmptyBlock({ title }: { title: string }) {
949 + return (
950 + <div className="card" style={{ padding: 20, background: "var(--surface-2)", borderStyle: "dashed" }}>
951 + <b style={{ fontFamily: "var(--font-display)", fontSize: 14 }}>{title}</b>
952 + <p className="klabel" style={{ margin: "6px 0 0" }}>Not measured yet — no data available for this period.</p>
953 + </div>
954 + );
955 +}
956 +
957 +export function Fraicheur({ updated, onRefresh }: { updated: string; onRefresh: () => void }) {
958 + return (
959 + <p style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap", margin: 0 }}>
960 + <span className="klabel">Updated {new Date(updated).toLocaleString("en-CA", { dateStyle: "medium", timeStyle: "short" })}</span>
961 + <button type="button" className="btn btn-ghost" onClick={onRefresh}>↻ Refresh</button>
962 + </p>
963 + );
964 +}
added frontend/src/ka/stats/kapdf.py +877 −0
@@ -0,0 +1,877 @@
1 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v2
3 +# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4 +# les rapports estampillés Groupe-KA. 5 modes :
5 +# complet — toutes les sections (KPI, jauges, séries + stats, multi-
6 +# séries, empilées, distributions, répartitions, géo,
7 +# heatmap horaire, tableaux, records)
8 +# synthese — couverture + KPI + records (2-3 pages)
9 +# tendances — KPI + toutes les séries temporelles + stats de séries
10 +# repartitions — breakdowns, distributions, géo, activité horaire
11 +# donnees — tous les tableaux en version longue (400 lignes max)
12 +# Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.
13 +# Usage :
14 +# from kapdf import GroupeKAReport, REPORT_MODES, filename
15 +# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
16 +# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
17 +# mode="complet").build()
18 +# Dépendance : pip install fpdf2 (aucune autre)
19 +from __future__ import annotations
20 +
21 +import math
22 +from datetime import datetime
23 +from zoneinfo import ZoneInfo
24 +
25 +from fpdf import FPDF
26 +
27 +INK = (20, 24, 20)
28 +INK2 = (77, 85, 81)
29 +INK3 = (139, 146, 140)
30 +PAPER = (245, 243, 238)
31 +SURFACE2 = (250, 249, 245)
32 +GREEN = (28, 92, 65)
33 +DANGER = (179, 66, 58)
34 +WHITE = (255, 255, 255)
35 +
36 +REPORT_MODES = {
37 + "complet": "Rapport complet",
38 + "synthese": "Synthèse exécutive",
39 + "tendances": "Tendances & évolution",
40 + "repartitions": "Répartitions & géographie",
41 + "donnees": "Données détaillées",
42 +}
43 +
44 +EMAILS = [
45 + ("contact@groupe-ka.com", "Projets, partenariats & données"),
46 + ("info@groupe-ka.com", "Médias & questions générales"),
47 + ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),
48 +]
49 +DISCLAIMER = (
50 + "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "
51 + "rien et ne sommes partie à aucune transaction. Données lues à la source, "
52 + "rien d'inventé, tout est traçable."
53 +)
54 +
55 +
56 +def _hex(c: str) -> tuple[int, int, int]:
57 + c = c.lstrip("#")
58 + return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore
59 +
60 +
61 +def _fr(n) -> str:
62 + if isinstance(n, float) and not n.is_integer():
63 + return f"{n:,.2f}".replace(",", " ").replace(".", ",")
64 + return f"{int(n):,}".replace(",", " ")
65 +
66 +
67 +_SUBST = {
68 + "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
69 + "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
70 + "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
71 + "×": "x", "·": ".", "σ": "sigma", "Δ": "delta",
72 +}
73 +
74 +
75 +def _latin1(s: str) -> str:
76 + for k, v in _SUBST.items():
77 + s = s.replace(k, v)
78 + return s.encode("latin-1", "replace").decode("latin-1")
79 +
80 +
81 +class _PDF(FPDF):
82 + """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
83 + Les polices core sont latin-1 : normalize_text sanitise en amont."""
84 +
85 + def normalize_text(self, text):
86 + return super().normalize_text(_latin1(text))
87 +
88 + def __init__(self, brand: str, accent: tuple, period_label: str):
89 + super().__init__(orientation="P", unit="mm", format="A4")
90 + self.brand = brand
91 + self.accent = accent
92 + self.period_label = period_label
93 + self.cover_mode = False
94 + self.set_margins(18, 20, 18)
95 + self.set_auto_page_break(True, margin=22)
96 +
97 + def header(self):
98 + if self.cover_mode or self.page_no() == 1:
99 + return
100 + self.set_font("helvetica", "B", 8.5)
101 + self.set_text_color(*INK)
102 + self.set_xy(18, 9)
103 + self.cell(0, 5, f"Groupe KA · {self.brand}")
104 + self.set_font("helvetica", "", 8)
105 + self.set_text_color(*INK3)
106 + self.set_xy(18, 9)
107 + self.cell(0, 5, "Rapport statistique", align="R")
108 + self.set_draw_color(*INK)
109 + self.set_line_width(0.5)
110 + self.line(18, 15.5, 192, 15.5)
111 + self.set_y(20)
112 +
113 + def footer(self):
114 + # page 1 = couverture (le flag cover_mode est déjà retombé quand
115 + # add_page() clôt la page 1 → tester aussi le numéro de page)
116 + if self.cover_mode or self.page_no() == 1:
117 + return
118 + self.set_y(-15)
119 + self.set_draw_color(*INK3)
120 + self.set_line_width(0.2)
121 + self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)
122 + self.set_font("helvetica", "", 7.5)
123 + self.set_text_color(*INK3)
124 + year = datetime.now(ZoneInfo("America/Toronto")).year
125 + self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")
126 + self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")
127 +
128 +
129 +class GroupeKAReport:
130 + def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
131 + self.site = site
132 + self.d = dashboard
133 + self.mode = mode if mode in REPORT_MODES else "complet"
134 + self.accent = _hex(site.get("accent", "#d9f26b"))
135 + period = dashboard.get("period", {}) or {}
136 + self.period_label = period.get("label") or "toute la période"
137 + self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
138 + self.toc: list[tuple[str, int]] = []
139 +
140 + # ---------- primitives ----------
141 + def _card(self, x, y, w, h, fill=WHITE):
142 + p = self.pdf
143 + p.set_draw_color(*INK)
144 + p.set_line_width(0.45)
145 + p.set_fill_color(*fill)
146 + p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
147 +
148 + def _shade(self, i, n=8):
149 + shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
150 + f = shades[i % len(shades)]
151 + return tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
152 +
153 + def _kicker(self, text):
154 + p = self.pdf
155 + p.set_font("helvetica", "B", 8)
156 + p.set_text_color(*GREEN)
157 + p.set_draw_color(*GREEN)
158 + p.set_line_width(0.6)
159 + y = p.get_y() + 2
160 + p.line(p.l_margin, y, p.l_margin + 7, y)
161 + p.set_xy(p.l_margin + 9, y - 2.5)
162 + p.cell(0, 5, text.upper())
163 + p.ln(8)
164 +
165 + def _section_title(self, title):
166 + if self.pdf.get_y() > 240:
167 + self.pdf.add_page()
168 + self._kicker("Groupe KA · " + self.site.get("wordmark", ""))
169 + self.pdf.set_font("helvetica", "B", 15)
170 + self.pdf.set_text_color(*INK)
171 + self.pdf.set_x(self.pdf.l_margin)
172 + self.pdf.cell(0, 8, title)
173 + self.toc.append((title, self.pdf.page_no()))
174 + self.pdf.ln(11)
175 +
176 + def _chart_title(self, title):
177 + p = self.pdf
178 + p.set_font("helvetica", "B", 10)
179 + p.set_text_color(*INK)
180 + p.set_x(p.l_margin)
181 + p.cell(0, 6, title)
182 + p.ln(7)
183 +
184 + # ---------- pages ----------
185 + def _cover(self):
186 + p = self.pdf
187 + p.cover_mode = True
188 + p.set_auto_page_break(False)
189 + p.add_page()
190 + p.set_fill_color(*PAPER)
191 + p.rect(0, 0, 210, 297, style="F")
192 + p.set_draw_color(*INK)
193 + p.set_line_width(1.0)
194 + p.rect(10, 10, 190, 277)
195 + p.set_font("helvetica", "B", 10)
196 + p.set_text_color(*GREEN)
197 + p.set_xy(24, 34)
198 + p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
199 + wm = self.site.get("wordmark", "")
200 + left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
201 + p.set_xy(24, 70)
202 + p.set_font("helvetica", "B", 40)
203 + p.set_text_color(*INK)
204 + p.cell(p.get_string_width(left) + 2, 20, left)
205 + if boxed:
206 + bw = p.get_string_width(boxed) + 12
207 + x = p.get_x() + 2
208 + p.set_fill_color(*INK)
209 + p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)
210 + p.set_text_color(*self.accent)
211 + p.set_xy(x + 6, 70)
212 + p.cell(bw - 12, 18, boxed)
213 + p.set_xy(24, 100)
214 + p.set_font("helvetica", "", 13)
215 + p.set_text_color(*INK2)
216 + p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}")
217 + now = datetime.now(ZoneInfo("America/Toronto"))
218 + per = self.d.get("period", {}) or {}
219 + p.set_xy(24, 125)
220 + p.set_font("helvetica", "", 10.5)
221 + rows = [
222 + ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
223 + ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
224 + ("Plateforme", "https://" + self.site.get("domain", "")),
225 + ("Type de rapport", REPORT_MODES[self.mode]),
226 + ]
227 + y = 128
228 + for k, v in rows:
229 + p.set_xy(24, y)
230 + p.set_text_color(*INK3)
231 + p.cell(40, 6, k)
232 + p.set_text_color(*INK)
233 + p.set_font("helvetica", "B", 10.5)
234 + p.cell(0, 6, str(v))
235 + p.set_font("helvetica", "", 10.5)
236 + y += 8
237 + p.set_fill_color(*INK)
238 + p.rect(10, 262, 190, 25, style="F")
239 + p.set_xy(24, 270)
240 + p.set_font("helvetica", "B", 12)
241 + p.set_text_color(*WHITE)
242 + p.cell(60, 8, "par Groupe ")
243 + p.set_text_color(*self.accent)
244 + p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)
245 + p.cell(20, 8, "KA")
246 + p.set_font("helvetica", "B", 10)
247 + p.set_xy(24, 270)
248 + p.set_text_color(*self.accent)
249 + p.cell(162, 8, "groupe-ka.com", align="R")
250 + p.set_auto_page_break(True, margin=22)
251 + p.cover_mode = False
252 +
253 + def _kpis(self):
254 + kpis = self.d.get("kpis") or []
255 + if not kpis:
256 + return
257 + self._section_title("Synthèse des indicateurs")
258 + p = self.pdf
259 + cols, gw, gh, gap = 3, 56, 26, 3
260 + x0, y = p.l_margin, p.get_y()
261 + for i, k in enumerate(kpis[:12]):
262 + x = x0 + (i % cols) * (gw + gap)
263 + if i and i % cols == 0:
264 + y += gh + gap
265 + if y > 250:
266 + p.add_page(); y = p.get_y()
267 + self._card(x, y, gw, gh)
268 + p.set_xy(x + 4, y + 4)
269 + p.set_font("helvetica", "B", 14)
270 + p.set_text_color(*INK)
271 + val = k.get("value")
272 + p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))
273 + p.set_xy(x + 4, y + 12)
274 + p.set_font("helvetica", "", 7.6)
275 + p.set_text_color(*INK2)
276 + p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])
277 + if k.get("delta_pct") is not None:
278 + up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"
279 + p.set_xy(x + 4, y + gh - 6.5)
280 + p.set_font("helvetica", "B", 8)
281 + p.set_text_color(*(GREEN if up else DANGER))
282 + arrow = "+" if k["delta_pct"] >= 0 else ""
283 + dv = round(float(k["delta_pct"]), 1)
284 + dv = int(dv) if float(dv).is_integer() else dv
285 + p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(dv).replace('.', ',')} % vs période préc.")
286 + p.set_y(y + gh + 8)
287 +
288 + def _gauges(self):
289 + gs = self.d.get("gauges") or []
290 + gs = [g for g in gs if isinstance(g.get("value"), (int, float)) and g.get("max")]
291 + if not gs:
292 + return
293 + self._section_title("Taux & couvertures")
294 + p = self.pdf
295 + cols, gw, gh, gap = 3, 56, 34, 3
296 + x0, y = p.l_margin, p.get_y()
297 + for i, g in enumerate(gs[:9]):
298 + x = x0 + (i % cols) * (gw + gap)
299 + if i and i % cols == 0:
300 + y += gh + gap
301 + if y > 240:
302 + p.add_page(); y = p.get_y()
303 + self._card(x, y, gw, gh)
304 + frac = max(0.0, min(1.0, g["value"] / g["max"]))
305 + cx, cy, r = x + gw / 2, y + 20, 14
306 + # arc de fond + arc de valeur (demi-cercle en petits segments)
307 + for pass_col, pass_frac, lw in (((225, 223, 217), 1.0, 2.6), (self.accent, frac, 2.6)):
308 + p.set_draw_color(*pass_col)
309 + p.set_line_width(lw)
310 + steps = max(2, int(60 * pass_frac))
311 + last = None
312 + for st in range(steps + 1):
313 + a = math.pi + math.pi * pass_frac * st / steps
314 + pt = (cx + r * math.cos(a), cy + r * math.sin(a))
315 + if last:
316 + p.line(last[0], last[1], pt[0], pt[1])
317 + last = pt
318 + p.set_font("helvetica", "B", 11)
319 + p.set_text_color(*INK)
320 + p.set_xy(x + 4, cy - 5)
321 + p.cell(gw - 8, 6, f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", align="C")
322 + p.set_font("helvetica", "", 6.6)
323 + p.set_text_color(*INK3)
324 + p.set_xy(x + 4, cy + 1.5)
325 + p.cell(gw - 8, 4, f"{frac * 100:.0f} % de {_fr(g['max'])}", align="C")
326 + p.set_xy(x + 3, y + gh - 7)
327 + p.set_font("helvetica", "", 7)
328 + p.set_text_color(*INK2)
329 + p.multi_cell(gw - 6, 3.3, str(g.get("label", ""))[:60], align="C")
330 + p.set_y(y + gh + 8)
331 +
332 + def _serie_stats_row(self, s):
333 + """Ligne min/max/moyenne/médiane sous un graphique de série."""
334 + p = self.pdf
335 + vs = [pt["v"] for pt in (s.get("points") or []) if isinstance(pt.get("v"), (int, float))]
336 + if len(vs) < 2:
337 + return
338 + sv = sorted(vs)
339 + mean = sum(vs) / len(vs)
340 + med = sv[len(sv) // 2]
341 + sd = math.sqrt(sum((v - mean) ** 2 for v in vs) / len(vs))
342 + p.set_font("helvetica", "", 6.8)
343 + p.set_text_color(*INK3)
344 + p.cell(0, 4, f"min {_fr(sv[0])} · max {_fr(sv[-1])} · moyenne {_fr(round(mean, 2))} · médiane {_fr(med)} · écart-type {_fr(round(sd, 2))}")
345 + p.ln(5.5)
346 +
347 + def _line_chart(self, s, with_stats=False):
348 + p = self.pdf
349 + pts = s.get("points") or []
350 + if len(pts) < 2:
351 + return
352 + if s.get("kind") == "bar":
353 + self._vbars(s)
354 + return
355 + if p.get_y() > 200:
356 + p.add_page()
357 + self._chart_title(s.get("title", ""))
358 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
359 + self._card(x0, y0, w, h, fill=WHITE)
360 + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
361 + vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]
362 + vmax = max(vals) or 1
363 + vmin = min(0, min(vals))
364 + rng = (vmax - vmin) or 1
365 + p.set_font("helvetica", "", 6.3)
366 + p.set_text_color(*INK3)
367 + p.set_draw_color(200, 200, 195)
368 + p.set_line_width(0.15)
369 + for g in range(5):
370 + gy = cy + ch - ch * g / 4
371 + p.line(cx, gy, cx + cw, gy)
372 + p.set_xy(x0 + 1, gy - 1.6)
373 + p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
374 +
375 + def xy(i, n, v):
376 + return (cx + cw * (i / (n - 1)), cy + ch - ch * ((v - vmin) / rng))
377 +
378 + # aire sous la courbe (kind=area) : petits trapèzes accent pâle
379 + if s.get("kind") == "area":
380 + fill = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * 0.18) for j in range(3))
381 + p.set_fill_color(*fill)
382 + p.set_draw_color(*fill)
383 + n = len(pts)
384 + for i in range(n - 1):
385 + x1, y1 = xy(i, n, pts[i]["v"])
386 + x2, y2 = xy(i + 1, n, pts[i + 1]["v"])
387 + p.polygon([(x1, y1), (x2, y2), (x2, cy + ch), (x1, cy + ch)], style="DF")
388 +
389 + def draw(series, color, width, dash=None):
390 + n = len(series)
391 + p.set_draw_color(*color)
392 + p.set_line_width(width)
393 + if dash:
394 + p.set_dash_pattern(dash=1.2, gap=1.2)
395 + last = None
396 + for i, pt in enumerate(series):
397 + px, py = xy(i, n, pt["v"])
398 + if last:
399 + p.line(last[0], last[1], px, py)
400 + last = (px, py)
401 + p.set_dash_pattern()
402 +
403 + if s.get("compare"):
404 + draw(s["compare"], INK3, 0.35, dash=True)
405 + draw(pts, self.accent, 0.7)
406 + p.set_text_color(*INK3)
407 + for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
408 + p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
409 + p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")
410 + p.set_y(y0 + h + 4)
411 + if s.get("compare"):
412 + p.set_font("helvetica", "", 6.8)
413 + p.set_text_color(*INK3)
414 + p.cell(0, 4, "— période courante (accent) · ---- période comparée")
415 + p.ln(5.5)
416 + if with_stats:
417 + self._serie_stats_row(s)
418 + p.ln(1.5)
419 +
420 + def _vbars(self, s):
421 + """Barres verticales : série kind=bar ou distribution (bins)."""
422 + p = self.pdf
423 + pts = s.get("points") or [{"t": b.get("label"), "v": b.get("value")} for b in (s.get("bins") or [])]
424 + pts = [pt for pt in pts if isinstance(pt.get("v"), (int, float))]
425 + if not pts:
426 + return
427 + if p.get_y() > 205:
428 + p.add_page()
429 + self._chart_title(s.get("title", ""))
430 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 48
431 + self._card(x0, y0, w, h, fill=WHITE)
432 + cx, cy, cw, ch = x0 + 12, y0 + 5, w - 20, h - 14
433 + vmax = max(pt["v"] for pt in pts) or 1
434 + p.set_font("helvetica", "", 6.3)
435 + p.set_text_color(*INK3)
436 + p.set_draw_color(200, 200, 195)
437 + p.set_line_width(0.15)
438 + for g in range(5):
439 + gy = cy + ch - ch * g / 4
440 + p.line(cx, gy, cx + cw, gy)
441 + p.set_xy(x0 + 1, gy - 1.6)
442 + p.cell(10, 3, _fr(vmax * g / 4), align="R")
443 + n = len(pts)
444 + bw = max(0.8, cw / n - 0.6)
445 + p.set_fill_color(*self.accent)
446 + p.set_draw_color(*INK)
447 + p.set_line_width(0.15)
448 + for i, pt in enumerate(pts):
449 + bh = ch * (pt["v"] / vmax)
450 + p.rect(cx + cw * i / n + 0.3, cy + ch - bh, bw, max(bh, 0.4 if pt["v"] > 0 else 0), style="DF")
451 + p.set_text_color(*INK3)
452 + for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):
453 + p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)
454 + p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")
455 + p.set_y(y0 + h + 5)
456 +
457 + def _multiline(self, ms):
458 + """Multi-séries (≤4) : accent plein / encre fin / accent pointillé /
459 + gris pointillé — l'identité passe par le motif, pas la couleur seule."""
460 + p = self.pdf
461 + series = [s for s in (ms.get("series") or []) if len(s.get("points") or []) > 1][:4]
462 + if not series:
463 + return
464 + if p.get_y() > 195:
465 + p.add_page()
466 + self._chart_title(ms.get("title", ""))
467 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
468 + self._card(x0, y0, w, h, fill=WHITE)
469 + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
470 + vals = [pt["v"] for s in series for pt in s["points"]]
471 + vmax = max(vals) or 1
472 + vmin = min(0, min(vals))
473 + rng = (vmax - vmin) or 1
474 + p.set_font("helvetica", "", 6.3)
475 + p.set_text_color(*INK3)
476 + p.set_draw_color(200, 200, 195)
477 + p.set_line_width(0.15)
478 + for g in range(5):
479 + gy = cy + ch - ch * g / 4
480 + p.line(cx, gy, cx + cw, gy)
481 + p.set_xy(x0 + 1, gy - 1.6)
482 + p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
483 + styles = [
484 + (self.accent, 0.7, None),
485 + (INK, 0.45, None),
486 + (self.accent, 0.55, True),
487 + (INK3, 0.5, True),
488 + ]
489 + for si, s in enumerate(series):
490 + col, lw, dash = styles[si]
491 + p.set_draw_color(*col)
492 + p.set_line_width(lw)
493 + if dash:
494 + p.set_dash_pattern(dash=1.4, gap=1.2)
495 + n = len(s["points"])
496 + last = None
497 + for i, pt in enumerate(s["points"]):
498 + px = cx + cw * (i / (n - 1))
499 + py = cy + ch - ch * ((pt["v"] - vmin) / rng)
500 + if last:
501 + p.line(last[0], last[1], px, py)
502 + last = (px, py)
503 + p.set_dash_pattern()
504 + ref = series[0]["points"]
505 + p.set_text_color(*INK3)
506 + for frac, idx in ((0, 0), (0.5, len(ref) // 2), (1, -1)):
507 + p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
508 + p.cell(18, 3, str(ref[idx].get("t", ""))[:10], align="C")
509 + p.set_y(y0 + h + 4)
510 + p.set_font("helvetica", "", 6.8)
511 + p.set_text_color(*INK3)
512 + marks = ["—", "—", "----", "----"]
513 + leg = " · ".join(f"{marks[i]} {s.get('label', '')}" for i, s in enumerate(series))
514 + p.cell(0, 4, leg[:120])
515 + p.ln(6)
516 +
517 + def _stacked(self, st):
518 + p = self.pdf
519 + keys = (st.get("keys") or [])[:6]
520 + pts = st.get("points") or []
521 + if not keys or not pts:
522 + return
523 + if p.get_y() > 195:
524 + p.add_page()
525 + self._chart_title(st.get("title", ""))
526 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
527 + self._card(x0, y0, w, h, fill=WHITE)
528 + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
529 + totals = [sum(v or 0 for v in pt.get("values", [])[: len(keys)]) for pt in pts]
530 + vmax = max(totals) or 1
531 + p.set_font("helvetica", "", 6.3)
532 + p.set_text_color(*INK3)
533 + p.set_draw_color(200, 200, 195)
534 + p.set_line_width(0.15)
535 + for g in range(5):
536 + gy = cy + ch - ch * g / 4
537 + p.line(cx, gy, cx + cw, gy)
538 + p.set_xy(x0 + 1, gy - 1.6)
539 + p.cell(10, 3, _fr(vmax * g / 4), align="R")
540 + n = len(pts)
541 + bw = max(0.8, cw / n - 0.6)
542 + p.set_draw_color(*WHITE)
543 + p.set_line_width(0.12)
544 + for i, pt in enumerate(pts):
545 + yacc = cy + ch
546 + for j, k in enumerate(keys):
547 + v = (pt.get("values") or [0] * len(keys))[j] if j < len(pt.get("values", [])) else 0
548 + if not v:
549 + continue
550 + bh = ch * (v / vmax)
551 + yacc -= bh
552 + p.set_fill_color(*self._shade(j))
553 + p.rect(cx + cw * i / n + 0.3, yacc, bw, max(bh - 0.15, 0.3), style="DF")
554 + p.set_text_color(*INK3)
555 + for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):
556 + p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)
557 + p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")
558 + p.set_y(y0 + h + 4)
559 + # légende
560 + p.set_font("helvetica", "", 6.8)
561 + lx = p.l_margin
562 + for j, k in enumerate(keys):
563 + p.set_fill_color(*self._shade(j))
564 + p.set_draw_color(*INK)
565 + p.set_line_width(0.2)
566 + p.rect(lx, p.get_y() + 0.6, 3, 3, style="DF")
567 + p.set_xy(lx + 4, p.get_y())
568 + p.set_text_color(*INK2)
569 + txt = str(k)[:22]
570 + p.cell(p.get_string_width(txt) + 3, 4, txt)
571 + lx = p.get_x() + 3
572 + if lx > 165:
573 + break
574 + p.ln(7)
575 +
576 + def _bars(self, title, items, unit=""):
577 + p = self.pdf
578 + items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]
579 + if not items:
580 + return
581 + need = 10 + len(items) * 7
582 + if p.get_y() + need > 265:
583 + p.add_page()
584 + self._chart_title(title)
585 + p.ln(1)
586 + vmax = max(it["value"] for it in items) or 1
587 + for it in items:
588 + y = p.get_y()
589 + p.set_font("helvetica", "", 7.6)
590 + p.set_text_color(*INK)
591 + p.set_x(p.l_margin)
592 + p.cell(46, 5, str(it["label"])[:34])
593 + bw = 86 * (it["value"] / vmax)
594 + p.set_fill_color(*self.accent)
595 + p.set_draw_color(*INK)
596 + p.set_line_width(0.25)
597 + p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
598 + p.set_xy(p.l_margin + 136, y)
599 + p.set_font("helvetica", "B", 7.6)
600 + p.cell(24, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
601 + if it.get("delta_pct") is not None:
602 + up = it["delta_pct"] >= 0
603 + p.set_font("helvetica", "B", 6.6)
604 + p.set_text_color(*(GREEN if up else DANGER))
605 + p.cell(14, 5, f"{'+' if up else ''}{str(round(it['delta_pct'], 1)).replace('.', ',')} %", align="R")
606 + p.ln(6.4)
607 + p.ln(3)
608 +
609 + def _donut(self, b):
610 + p = self.pdf
611 + items = [it for it in (b.get("items") or []) if it.get("value")][:8]
612 + total = sum(it["value"] for it in items)
613 + if not items or not total:
614 + return
615 + if p.get_y() > 210:
616 + p.add_page()
617 + self._chart_title(b.get("title", ""))
618 + p.ln(1)
619 + cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
620 + start = -90.0
621 + for i, it in enumerate(items):
622 + frac = it["value"] / total
623 + col = self._shade(i)
624 + steps = max(2, int(72 * frac))
625 + p.set_fill_color(*col)
626 + p.set_draw_color(*col)
627 + for st in range(steps):
628 + a0 = math.radians(start + 360 * frac * st / steps)
629 + a1 = math.radians(start + 360 * frac * (st + 1) / steps)
630 + p.polygon(
631 + [(cx, cy),
632 + (cx + r * math.cos(a0), cy + r * math.sin(a0)),
633 + (cx + r * math.cos(a1), cy + r * math.sin(a1))],
634 + style="DF",
635 + )
636 + start += 360 * frac
637 + p.set_fill_color(*WHITE)
638 + p.set_draw_color(*INK)
639 + p.set_line_width(0.4)
640 + p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
641 + p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
642 + ly = cy - 22
643 + for i, it in enumerate(items):
644 + col = self._shade(i)
645 + p.set_fill_color(*col)
646 + p.set_draw_color(*INK)
647 + p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
648 + p.set_xy(p.l_margin + 66, ly)
649 + p.set_font("helvetica", "", 7.6)
650 + p.set_text_color(*INK)
651 + pct = 100 * it["value"] / total
652 + p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))
653 + ly += 5.6
654 + p.set_y(max(cy + r, ly) + 6)
655 +
656 + def _hourly(self):
657 + hh = self.d.get("hourly") or {}
658 + cells = hh.get("cells") or []
659 + if not cells:
660 + return
661 + p = self.pdf
662 + if p.get_y() > 190:
663 + p.add_page()
664 + self._chart_title(hh.get("title", "Activité par jour et heure"))
665 + x0, y0 = p.l_margin, p.get_y()
666 + cw, chh, lx, ly = 6.4, 6.4, 12, 5
667 + vmax = max((c.get("value") or 0) for c in cells) or 1
668 + grid = {(c.get("dow"), c.get("hour")): c.get("value") or 0 for c in cells}
669 + dows = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]
670 + p.set_font("helvetica", "", 5.8)
671 + p.set_text_color(*INK3)
672 + for h in (0, 6, 12, 18, 23):
673 + p.set_xy(x0 + lx + h * cw, y0)
674 + p.cell(cw, 3, f"{h}h", align="C")
675 + for d in range(7):
676 + p.set_xy(x0, y0 + ly + d * chh + 1.5)
677 + p.cell(lx - 1, 3, dows[d], align="R")
678 + for h in range(24):
679 + v = grid.get((d, h), 0)
680 + f = 0.1 + 0.9 * (v / vmax) if v else 0.0
681 + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) if v else (235, 233, 228)
682 + p.set_fill_color(*col)
683 + p.set_draw_color(215, 213, 207)
684 + p.set_line_width(0.1)
685 + p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")
686 + p.set_y(y0 + ly + 7 * chh + 5)
687 +
688 + def _table(self, t, max_rows=200):
689 + p = self.pdf
690 + cols = t.get("columns") or []
691 + rows = t.get("rows") or []
692 + if not cols or not rows:
693 + return
694 + self._section_title(t.get("title", "Tableau"))
695 + w = 174 / len(cols)
696 + def head():
697 + p.set_font("helvetica", "B", 7.6)
698 + p.set_fill_color(*INK)
699 + p.set_text_color(*WHITE)
700 + for c in cols:
701 + p.cell(w, 6, " " + str(c)[:30], fill=True)
702 + p.ln(6)
703 + head()
704 + p.set_text_color(*INK)
705 + for i, row in enumerate(rows[:max_rows]):
706 + if p.get_y() > 262:
707 + p.add_page()
708 + head()
709 + p.set_text_color(*INK)
710 + p.set_font("helvetica", "", 7.4)
711 + p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))
712 + for cell in row:
713 + txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
714 + p.cell(w, 5.4, " " + txt[:34], fill=True)
715 + p.ln(5.4)
716 + if len(rows) > max_rows:
717 + p.set_font("helvetica", "", 7)
718 + p.set_text_color(*INK3)
719 + p.cell(0, 5, f"… {len(rows) - max_rows} lignes supplémentaires non imprimées")
720 + p.ln(6)
721 +
722 + def _records(self):
723 + recs = self.d.get("records") or []
724 + if not recs:
725 + return
726 + self._section_title("Records & faits marquants")
727 + p = self.pdf
728 + for r in recs[:14]:
729 + if p.get_y() > 258:
730 + p.add_page()
731 + y = p.get_y()
732 + self._card(p.l_margin, y, 174, 11, fill=SURFACE2)
733 + p.set_xy(p.l_margin + 4, y + 2)
734 + p.set_font("helvetica", "", 8.6)
735 + p.set_text_color(*INK2)
736 + p.cell(96, 7, str(r.get("label", ""))[:70])
737 + p.set_font("helvetica", "B", 9)
738 + p.set_text_color(*INK)
739 + p.cell(52, 7, str(r.get("value", ""))[:36], align="R")
740 + p.set_font("helvetica", "", 7.6)
741 + p.set_text_color(*INK3)
742 + p.cell(20, 7, str(r.get("date", "") or ""), align="R")
743 + p.set_y(y + 13.5)
744 + p.ln(4)
745 +
746 + def _final_page(self):
747 + p = self.pdf
748 + p.add_page()
749 + self._kicker("Groupe KA · contact")
750 + p.set_font("helvetica", "B", 15)
751 + p.set_text_color(*INK)
752 + p.cell(0, 8, "Coordonnées du Groupe KA")
753 + p.ln(12)
754 + for email, role in EMAILS:
755 + p.set_font("helvetica", "B", 10.5)
756 + p.set_text_color(*INK)
757 + p.cell(0, 6, email)
758 + p.ln(5.5)
759 + p.set_font("helvetica", "", 8.6)
760 + p.set_text_color(*INK3)
761 + p.cell(0, 5, role)
762 + p.ln(8)
763 + p.ln(2)
764 + p.set_font("helvetica", "B", 10)
765 + p.set_text_color(*GREEN)
766 + p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")
767 + p.ln(10)
768 + p.set_draw_color(*self.accent)
769 + p.set_line_width(0.8)
770 + p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())
771 + p.ln(4)
772 + p.set_font("helvetica", "", 8.6)
773 + p.set_text_color(*INK2)
774 + p.multi_cell(160, 4.6, DISCLAIMER)
775 + p.ln(4)
776 + p.set_font("helvetica", "", 7.6)
777 + p.set_text_color(*INK3)
778 + p.multi_cell(
779 + 160, 4.2,
780 + "Mentions : rapport généré automatiquement à partir des données réelles de la "
781 + "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "
782 + "de confidentialité et protection des renseignements personnels (Loi 25) : "
783 + "groupe-ka.com/conditions · /confidentialite · /loi-25.",
784 + )
785 +
786 + # ---------- groupes de sections ----------
787 + def _all_series(self, with_stats=True):
788 + for s in self.d.get("series") or []:
789 + self._line_chart(s, with_stats=with_stats)
790 + for ms in self.d.get("multiseries") or []:
791 + self._multiline(ms)
792 + for st in self.d.get("stacked") or []:
793 + self._stacked(st)
794 +
795 + def _all_breakdowns(self):
796 + for b in self.d.get("breakdowns") or []:
797 + if b.get("kind") == "donut":
798 + self._donut(b)
799 + else:
800 + self._bars(b.get("title", ""), b.get("items"))
801 + for dist in self.d.get("distributions") or []:
802 + self._vbars(dist)
803 + geo = self.d.get("geo")
804 + if geo:
805 + self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
806 + self._hourly()
807 +
808 + def build(self) -> bytes:
809 + p = self.pdf
810 + p.alias_nb_pages()
811 + self._cover()
812 + with_toc = self.mode in ("complet", "donnees")
813 + toc_page_no = None
814 + if self.mode == "synthese":
815 + p.add_page()
816 + self._kpis()
817 + self._gauges()
818 + self._records()
819 + self._final_page()
820 + elif self.mode == "tendances":
821 + p.add_page()
822 + self._kpis()
823 + self._section_title("Évolution & tendances")
824 + self._all_series(with_stats=True)
825 + self._records()
826 + self._final_page()
827 + elif self.mode == "repartitions":
828 + p.add_page()
829 + self._section_title("Répartitions, distributions & géographie")
830 + self._all_breakdowns()
831 + self._final_page()
832 + elif self.mode == "donnees":
833 + p.add_page()
834 + toc_page_no = p.page_no()
835 + for t in self.d.get("tables") or []:
836 + self._table(t, max_rows=400)
837 + self._final_page()
838 + else: # complet
839 + p.add_page()
840 + toc_page_no = p.page_no()
841 + p.add_page()
842 + self._kpis()
843 + self._gauges()
844 + if (self.d.get("series") or self.d.get("multiseries") or self.d.get("stacked")):
845 + self._section_title("Évolution & tendances")
846 + self._all_series(with_stats=True)
847 + if (self.d.get("breakdowns") or self.d.get("distributions") or self.d.get("geo") or self.d.get("hourly")):
848 + self._section_title("Répartitions, distributions & géographie")
849 + self._all_breakdowns()
850 + for t in self.d.get("tables") or []:
851 + self._table(t)
852 + self._records()
853 + self._final_page()
854 + # sommaire écrit sur la page réservée
855 + if toc_page_no is not None:
856 + last_page = p.page
857 + p.page = toc_page_no
858 + p.set_y(22)
859 + p.set_font("helvetica", "B", 15)
860 + p.set_text_color(*INK)
861 + p.cell(0, 8, "Sommaire")
862 + p.ln(12)
863 + p.set_font("helvetica", "", 9.5)
864 + for title, page_no in self.toc:
865 + p.set_text_color(*INK)
866 + p.cell(140, 6.5, title[:80])
867 + p.set_text_color(*INK3)
868 + p.cell(0, 6.5, str(page_no), align="R")
869 + p.ln(6.5)
870 + p.page = last_page
871 + return bytes(p.output())
872 +
873 +
874 +def filename(platform_id: str, period: str, mode: str = "complet") -> str:
875 + today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
876 + suffix = "" if mode in ("", "complet") else f"_{mode}"
877 + return f"groupe-ka_{platform_id}_stats_{period}{suffix}_{today}.pdf"
added frontend/src/ka/tokens.css +319 −0
@@ -0,0 +1,319 @@
1 +/* -----------------------------------------------------------------------------
2 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 + Fichier : ka-ui/tokens.css — SOURCE CANONIQUE (repo ka-ui sur spbgit)
4 + Desc. : Design system commun GROUPE KA « éditorial sharp » — tokens + socle
5 + de composants partagés par les 12 plateformes. Chaque site importe ce
6 + fichier PUIS surcharge uniquement son accent (voir accents.css).
7 +
8 + · Typo : display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
9 + · Palette: papier #f5f3ee · encre #141814 · vert profond #1c5c41 + ACCENT du site
10 + · Signature : bordures encre 1,5–2 px + ombres décalées dures, grain de film,
11 + surlignés d'accent inclinés (néo-brutalisme raffiné)
12 + · Breakpoints communs : 360 / 768 / 1024 / 1440 px (mobile-first)
13 + · Zones tactiles ≥ 44×44 px, safe-areas iOS, typographie fluide (clamp)
14 +----------------------------------------------------------------------------- */
15 +
16 +:root {
17 + /* ---- Palette fixe Groupe KA (identique sur les 12 sites) ---- */
18 + --paper: #f5f3ee;
19 + --surface: #ffffff;
20 + --surface-2: #faf9f5;
21 + --ink: #141814;
22 + --ink-2: #4d5551;
23 + --ink-3: #8b928c;
24 + --line: rgba(20, 24, 20, 0.14);
25 + --line-strong: rgba(20, 24, 20, 0.85);
26 + --green: #1c5c41;
27 + --green-deep: #123f2e;
28 + --amber: #e8a33d;
29 + --amber-soft: #fdf3e2;
30 + --danger: #b3423a;
31 + --danger-soft: #fbe9e7;
32 +
33 + /* ---- ACCENT du site — SEULES variables surchargées par marque ---- */
34 + --accent: #d9f26b; /* défaut : lime Groupe KA */
35 + --accent-soft: #f0f9d2;
36 + --accent-deep: #123f2e;
37 + --on-accent: var(--ink); /* couleur du texte posé SUR l'accent */
38 +
39 + /* alias rétro-compatibles (les satellites historiques utilisent --lime) */
40 + --lime: var(--accent);
41 + --lime-soft: var(--accent-soft);
42 +
43 + /* ---- Géométrie sharp ---- */
44 + --r-card: 10px;
45 + --r-ctl: 6px;
46 + --r-pill: 999px;
47 +
48 + /* ---- Ombres décalées — la signature du groupe ---- */
49 + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
50 + --shadow-off: 6px 6px 0 var(--ink);
51 + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
52 + --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18);
53 +
54 + /* ---- Typographie ---- */
55 + --font-display: "Space Grotesk", system-ui, sans-serif;
56 + --font-body: "Inter", system-ui, sans-serif;
57 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
58 +
59 + /* Échelle fluide (mobile-first, clamp) */
60 + --fs-h1: clamp(30px, 3.2vw + 18px, 54px);
61 + --fs-h2: clamp(23px, 1.8vw + 14px, 34px);
62 + --fs-h3: clamp(17px, 0.9vw + 12px, 22px);
63 + --fs-body: 15px;
64 + --fs-small: 13px;
65 +
66 + /* Espacements */
67 + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
68 + --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px;
69 +
70 + /* Cible tactile minimale */
71 + --touch: 44px;
72 +
73 + /* ---- Échelle z-index COMMUNE (obligatoire — aucune valeur arbitraire) ----
74 + grain de film body::before = 9999 (pointer-events:none, toujours au-dessus,
75 + purement visuel). Tout composant interactif se place sous 9999 : */
76 + --z-content: 1; /* contenu positionné ordinaire */
77 + --z-sticky: 300; /* éléments sticky de contenu (sous-nav…) */
78 + --z-header: 500; /* header du site */
79 + --z-bottombar: 600; /* tab bar / barre d'action basse */
80 + --z-dropdown: 700; /* menus déroulants (au-dessus des barres) */
81 + --z-overlay: 800; /* voile sous modale/panneau */
82 + --z-modal: 900; /* modales, panneaux de filtres, galeries */
83 + --z-toast: 950; /* notifications */
84 +
85 + /* ---- Hauteur de viewport DYNAMIQUE (barre du navigateur mobile qui se
86 + replie au scroll) : toujours var(--vh100), JAMAIS 100vh en dur. ---- */
87 + --vh100: 100vh;
88 +}
89 +@supports (height: 100dvh) { :root { --vh100: 100dvh; } }
90 +
91 +/* ---- Socle ---- */
92 +* { box-sizing: border-box; }
93 +/* overflow-x: clip sur html ET body — sur WebKit iOS, clip posé sur body seul
94 + ne bloque PAS le défilement latéral du viewport (quirk de propagation) ;
95 + clip (≠ hidden) ne casse pas position:sticky. */
96 +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; overflow-x: clip; }
97 +body {
98 + margin: 0;
99 + background: var(--paper);
100 + color: var(--ink);
101 + font-family: var(--font-body);
102 + font-size: var(--fs-body);
103 + line-height: 1.55;
104 + -webkit-font-smoothing: antialiased;
105 + padding-bottom: env(safe-area-inset-bottom);
106 + overflow-x: clip; /* jamais de débordement horizontal */
107 +}
108 +img, svg, video { max-width: 100%; height: auto; display: block; }
109 +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0 0 0.5em; }
110 +h1 { font-size: var(--fs-h1); line-height: 1.06; }
111 +h2 { font-size: var(--fs-h2); line-height: 1.15; }
112 +h3 { font-size: var(--fs-h3); line-height: 1.25; }
113 +a { color: inherit; }
114 +::selection { background: var(--accent); color: var(--on-accent); }
115 +:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
116 +
117 +/* Grain de film pleine page — ⚠️ jamais de z-index sur body > * (position:
118 + relative seulement), sinon les utilitaires z-* sont écrasés. */
119 +body::before {
120 + content: "";
121 + position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999;
122 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
123 +}
124 +body > * { position: relative; }
125 +
126 +/* ---- Conteneur commun ---- */
127 +.container { max-width: 1152px; margin: 0 auto; padding: 0 var(--sp-4); }
128 +@media (min-width: 768px) { .container { padding: 0 var(--sp-5); } }
129 +
130 +/* ---- Micro-typographie signature ---- */
131 +.kicker {
132 + display: inline-flex; align-items: center; gap: 10px;
133 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
134 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--green);
135 +}
136 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
137 +.klabel {
138 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
139 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
140 +}
141 +.hl {
142 + display: inline-block; background: var(--accent); color: var(--on-accent);
143 + border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg);
144 +}
145 +.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); }
146 +
147 +/* ---- Boutons (cible ≥ 44 px) ---- */
148 +.btn {
149 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
150 + min-height: var(--touch); padding: 10px 18px;
151 + font-family: var(--font-display); font-weight: 700; font-size: 14px;
152 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
153 + background: var(--surface); color: var(--ink);
154 + cursor: pointer; text-decoration: none;
155 + transition: transform 0.15s, box-shadow 0.15s, background 0.15s, color 0.15s;
156 +}
157 +.btn:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }
158 +.btn:active { transform: translate(0, 0); box-shadow: none; }
159 +.btn:disabled { opacity: 0.45; pointer-events: none; }
160 +.btn-primary { background: var(--ink); color: var(--accent); }
161 +.btn-primary:hover { background: var(--accent-deep); }
162 +.btn-accent { background: var(--accent); color: var(--on-accent); }
163 +.btn-ghost { background: transparent; border-color: var(--line); }
164 +.btn-ghost:hover { border-color: var(--ink); }
165 +
166 +/* ---- Cartes ---- */
167 +.card {
168 + background: var(--surface); border: 1.5px solid var(--ink);
169 + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
170 + overflow: hidden;
171 +}
172 +.card-hover { transition: transform 0.15s, box-shadow 0.15s; }
173 +.card-hover:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }
174 +
175 +/* ---- Chips / badges ---- */
176 +.chip {
177 + display: inline-flex; align-items: center; gap: 6px;
178 + padding: 4px 11px; font-family: var(--font-mono); font-size: 11px; font-weight: 700;
179 + text-transform: uppercase; letter-spacing: 0.06em;
180 + border: 1.5px solid var(--ink); border-radius: var(--r-pill);
181 + background: var(--surface); color: var(--ink);
182 +}
183 +.chip-accent { background: var(--accent); color: var(--on-accent); }
184 +.chip-soft { background: var(--accent-soft); border-color: var(--line); }
185 +
186 +/* ---- Champs ---- */
187 +.input, .select, .textarea {
188 + width: 100%; min-height: var(--touch); padding: 10px 14px;
189 + font: inherit; color: var(--ink); background: var(--surface);
190 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
191 +}
192 +.input:focus, .select:focus, .textarea:focus {
193 + outline: none; box-shadow: 3px 3px 0 var(--accent); border-color: var(--ink);
194 +}
195 +.input::placeholder { color: var(--ink-3); }
196 +
197 +/* ---- Badge « Un service Groupe KA » (header, cliquable → hub) ---- */
198 +.gk-badge {
199 + display: inline-flex; align-items: center; gap: 7px;
200 + min-height: 30px; padding: 3px 10px 4px;
201 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
202 + letter-spacing: 0.08em; text-transform: uppercase; text-decoration: none;
203 + border: 1.5px solid var(--ink); border-radius: var(--r-pill);
204 + background: var(--surface); color: var(--ink-2); white-space: nowrap;
205 +}
206 +.gk-badge b {
207 + font-family: var(--font-display); font-size: 12px; letter-spacing: -0.02em;
208 + text-transform: none; color: var(--ink);
209 +}
210 +.gk-badge b .ka {
211 + display: inline-block; background: var(--ink); color: var(--accent);
212 + border-radius: 5px; padding: 0 5px 1px; margin-left: 3px; transform: rotate(-2deg);
213 +}
214 +.gk-badge:hover .ka { transform: rotate(0); }
215 +.gk-badge--dark { background: transparent; border-color: rgba(245,243,238,0.4); color: rgba(245,243,238,0.75); }
216 +.gk-badge--dark b { color: var(--paper); }
217 +
218 +/* ---- Footer commun (fond encre — voir react/KaFooter.tsx) ---- */
219 +.ka-footer { margin-top: var(--sp-8); background: var(--ink); padding: 44px 0; font-size: 13px; color: rgba(245,243,238,0.75); }
220 +.ka-footer a { text-decoration: none; }
221 +.ka-footer .wordmark { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.04em; color: var(--paper); text-decoration: none; }
222 +.ka-footer .wordmark .ka { color: var(--accent); }
223 +.ka-footer .desc { max-width: 640px; margin-top: var(--sp-4); }
224 +.ka-footer .notice { max-width: 640px; margin-top: var(--sp-4); border-left: 2px solid var(--accent); padding-left: var(--sp-4); }
225 +.ka-footer .notice b { color: var(--paper); }
226 +.ka-footer .sites { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 26px 0 0; padding: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
227 +.ka-footer .sites a { color: rgba(245,243,238,0.65); }
228 +.ka-footer .sites a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
229 +.ka-footer .contacts { display: grid; gap: 14px 32px; border-top: 1px solid rgba(245,243,238,0.15); margin-top: 30px; padding-top: 26px; }
230 +@media (min-width: 768px) { .ka-footer .contacts { grid-template-columns: repeat(3, 1fr); } }
231 +.ka-footer .contacts a { font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: rgba(245,243,238,0.85); }
232 +.ka-footer .contacts a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
233 +.ka-footer .contacts span { display: block; margin-top: 3px; font-size: 11px; color: rgba(245,243,238,0.5); }
234 +.ka-footer .legal { margin-top: 30px; font-family: var(--font-mono); font-size: 11px; color: rgba(245,243,238,0.45); }
235 +.ka-footer .legal a { color: inherit; }
236 +.ka-footer .legal a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
237 +
238 +/* ---- Tableaux → cartes empilées sous 768 px ---- */
239 +.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
240 +@media (max-width: 767px) {
241 + .tbl-stack thead { display: none; }
242 + .tbl-stack tr { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-card); margin-bottom: var(--sp-3); background: var(--surface); }
243 + .tbl-stack td { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 12px; border: 0; }
244 + .tbl-stack td::before { content: attr(data-label); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }
245 +}
246 +
247 +/* ---- Utilitaires responsive ---- */
248 +.only-mobile { display: initial; } .only-desktop { display: none; }
249 +@media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } }
250 +
251 +/* =============================================================================
252 + SOCLE MOBILE OBLIGATOIRE (2026-08-19) — règles communes aux 13 sites.
253 + RÈGLES pour tout composant futur :
254 + · TAP, jamais :hover, pour ouvrir un menu (le survol n'existe pas au doigt) ;
255 + le :hover ne sert qu'aux effets décoratifs, sous @media (hover: hover).
256 + · var(--vh100) (dvh) et JAMAIS 100vh pour tout élément calé sur le viewport.
257 + · position:fixed exige qu'AUCUN ancêtre n'ait transform/filter/perspective/
258 + will-change/backdrop-filter — sinon monter l'élément à la racine (body).
259 + · Barre basse fixe = .ka-bottombar + classe has-bottombar sur <body>.
260 + · Menu/panneau ouvert = .ka-scroll-lock sur <html> (scroll arrière-plan gelé).
261 + · z-index : uniquement l'échelle --z-* ci-dessus.
262 + · Zones tactiles ≥ var(--touch) (44 px), champs ≥ 16 px (zoom iOS).
263 + ========================================================================== */
264 +
265 +/* Anti-zoom iOS : au doigt, aucun champ sous 16 px (le focus d'un champ <16 px
266 + déclenche un zoom automatique de page sur Safari iOS). !important assumé :
267 + c'est un filet d'accessibilité, un champ plus petit casse le zoom quoi
268 + qu'il arrive — ne s'applique qu'aux écrans tactiles. */
269 +@media (hover: none) and (pointer: coarse) {
270 + input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
271 + select, textarea { font-size: max(16px, 1em) !important; }
272 +}
273 +
274 +/* Zone tactile du badge Groupe KA étendue à ≥44 px au doigt, sans changer
275 + son rendu (débord de la zone cliquable via pseudo-élément). */
276 +@media (pointer: coarse) {
277 + .gk-badge { position: relative; }
278 + .gk-badge::after { content: ""; position: absolute; inset: -8px; }
279 +}
280 +
281 +/* Hauteurs viewport dynamiques */
282 +.h-viewport { height: 100vh; height: 100dvh; }
283 +.min-h-viewport { min-height: 100vh; min-height: 100dvh; }
284 +
285 +/* Barre fixe basse fiable : safe-area iPhone incluse, immunisée contre les
286 + transforms accidentels, et compensation d'espace via body.has-bottombar. */
287 +.ka-bottombar {
288 + position: fixed; left: 0; right: 0; bottom: 0;
289 + z-index: var(--z-bottombar);
290 + padding-bottom: env(safe-area-inset-bottom, 0px);
291 + background: var(--surface);
292 + border-top: 1.5px solid var(--ink);
293 + transform: none !important; filter: none !important;
294 +}
295 +body.has-bottombar { padding-bottom: calc(var(--bottombar-h, 64px) + env(safe-area-inset-bottom, 0px)); }
296 +
297 +/* Verrou de scroll d'arrière-plan (menu mobile, modale, panneau de filtres
298 + ouverts) : ajouter/retirer .ka-scroll-lock sur <html>. */
299 +html.ka-scroll-lock, html.ka-scroll-lock body { overflow: hidden !important; overscroll-behavior: none; }
300 +
301 +/* Menu déroulant de référence : au-dessus de tout contenu (cartes Mapbox
302 + incluses), défilement interne avec élan, jamais plus haut que l'écran. */
303 +.ka-menu {
304 + position: absolute; z-index: var(--z-dropdown);
305 + min-width: 180px;
306 + max-height: min(60vh, 420px); max-height: min(60dvh, 420px);
307 + overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
308 + background: var(--surface); border: 1.5px solid var(--ink);
309 + border-radius: var(--r-card); box-shadow: var(--shadow-off-mid);
310 +}
311 +.ka-menu a, .ka-menu button, .ka-menu label, .ka-menu [role="menuitem"], .ka-menu [role="option"] {
312 + display: flex; align-items: center; min-height: var(--touch);
313 + padding: 10px 14px; width: 100%; text-decoration: none;
314 +}
315 +/* Voile plein écran sous une modale / un panneau */
316 +.ka-overlay {
317 + position: fixed; inset: 0; z-index: var(--z-overlay);
318 + background: rgba(20, 24, 20, 0.45);
319 +}
added frontend/src/kamaps/adapter.ts +98 −0
@@ -0,0 +1,98 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Groupe Ka / Ka Maps (Rent-Ka integration)
5 +// kamaps/adapter.ts: Rent-Ka data adapter → MapProperty.
6 +// Transforms /api/listings.geojson (bbox + filters) into the framework's
7 +// canonical model — the Rent-Ka domain stays in the app, the map knows nothing.
8 +// -----------------------------------------------------------------------------
9 +import type {
10 + BoundsQuery,
11 + BoundsQueryResult,
12 + KaDataAdapter,
13 + MapProperty,
14 +} from "@groupe-ka/ka-maps";
15 +import { bboxToString } from "@groupe-ka/ka-maps";
16 +import type { ListingFilters } from "../api";
17 +
18 +interface LouKaFeatureProps {
19 + uid: string;
20 + title: string | null;
21 + address: string | null;
22 + price: number | null;
23 + price_label: string | null;
24 + unit_type: string | null;
25 + availability_date: string | null;
26 + source: string;
27 + city: string | null;
28 + sector: string | null;
29 + area_sqft: number | null;
30 + image: string | null;
31 +}
32 +
33 +interface LouKaFC {
34 + type: "FeatureCollection";
35 + features: {
36 + geometry: { coordinates: [number, number] };
37 + properties: LouKaFeatureProps;
38 + }[];
39 + totalGeocoded?: number;
40 + totalMatching?: number;
41 +}
42 +
43 +function toMapProperty(
44 + coords: [number, number],
45 + p: LouKaFeatureProps,
46 +): MapProperty {
47 + return {
48 + id: p.uid,
49 + appSource: "rent-ka",
50 + longitude: coords[0],
51 + latitude: coords[1],
52 + kind: "listing",
53 + listingType: "rent",
54 + price: p.price ?? undefined,
55 + propertyType: p.unit_type ?? undefined,
56 + address: p.address ?? p.title ?? undefined,
57 + city: p.city ?? undefined,
58 + region: p.sector ?? undefined,
59 + thumbnailUrl: p.image ?? undefined,
60 + originalUrl: `/listing/${encodeURIComponent(p.uid)}`,
61 + extra: {
62 + title: p.title,
63 + priceLabel: p.price_label,
64 + availabilityDate: p.availability_date,
65 + source: p.source,
66 + areaSqft: p.area_sqft,
67 + sector: p.sector,
68 + },
69 + };
70 +}
71 +
72 +/** Viewport→data adapter for the Rent-Ka map. */
73 +export const louKaMapAdapter: KaDataAdapter = {
74 + id: "rent-ka-listings",
75 + appSource: "rent-ka",
76 + async fetchInBounds(query: BoundsQuery): Promise<BoundsQueryResult> {
77 + const params = new URLSearchParams();
78 + const filters = (query.filters ?? {}) as Partial<ListingFilters>;
79 + for (const [k, v] of Object.entries(filters)) {
80 + if (v) params.set(k, String(v));
81 + }
82 + params.set("bbox", bboxToString(query.bbox));
83 + params.set("limit", "3000");
84 +
85 + const res = await fetch(`/api/listings.geojson?${params}`, {
86 + signal: query.signal,
87 + });
88 + if (!res.ok) throw new Error(`Map: API ${res.status}`);
89 + const data = (await res.json()) as LouKaFC;
90 +
91 + return {
92 + properties: data.features.map((f) =>
93 + toMapProperty(f.geometry.coordinates, f.properties),
94 + ),
95 + totalCount: data.totalMatching,
96 + };
97 + },
98 +};
added frontend/src/kamaps/config.ts +13 −0
@@ -0,0 +1,13 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Groupe Ka / Ka Maps (Rent-Ka integration)
5 +// kamaps/config.ts: Rent-Ka Maps Mapbox configuration.
6 +// The token is a PUBLIC Mapbox token (pk.…) — designed to be exposed
7 +// client-side; overridable at build time via VITE_MAPBOX_TOKEN. URL
8 +// restrictions are managed in the Mapbox dashboard.
9 +// -----------------------------------------------------------------------------
10 +
11 +export const MAPBOX_TOKEN: string =
12 + (import.meta.env.VITE_MAPBOX_TOKEN as string | undefined) ??
13 + "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q";
added frontend/src/kamaps/theme.ts +81 −0
@@ -0,0 +1,81 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Groupe Ka / Ka Maps (Rent-Ka integration)
5 +// kamaps/theme.ts: Rent-Ka Maps theme — cobalt / off-white / ink identity.
6 +// Premium price pills: off-white with ink text (the map breathes), COBALT is
7 +// reserved for brand moments (below market, selection, active controls).
8 +// -----------------------------------------------------------------------------
9 +import type { KaMapTheme } from "@groupe-ka/ka-maps";
10 +
11 +const INK = "#141814";
12 +const COBALT = "#2456e6";
13 +const OFFWHITE = "#fffdf8";
14 +const WHITE = "#ffffff";
15 +
16 +export const louKaMapTheme: KaMapTheme = {
17 + id: "rent-ka",
18 + productName: "Rent-Ka Maps",
19 + accent: COBALT,
20 + onAccent: WHITE,
21 + fontFamily:
22 + '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif',
23 + markers: {
24 + // Rent: off-white pill, ink text — readable without crushing the map.
25 + // Selection/hover: solid ink (white text), the absolute anchor.
26 + sale: {
27 + background: OFFWHITE,
28 + text: INK,
29 + halo: "rgba(20, 24, 20, 0.30)",
30 + selectedBackground: INK,
31 + selectedText: WHITE,
32 + },
33 + rent: {
34 + background: OFFWHITE,
35 + text: INK,
36 + halo: "rgba(20, 24, 20, 0.30)",
37 + selectedBackground: INK,
38 + selectedText: WHITE,
39 + },
40 + // "Below market": Rent-Ka cobalt — THE deal signal.
41 + highlight: {
42 + background: COBALT,
43 + text: WHITE,
44 + halo: "rgba(255, 255, 255, 0.55)",
45 + selectedBackground: INK,
46 + selectedText: WHITE,
47 + },
48 + },
49 + cluster: {
50 + background: OFFWHITE,
51 + text: INK,
52 + border: COBALT,
53 + valueText: "#1738a8",
54 + valueHalo: "rgba(255, 253, 248, 0.92)",
55 + },
56 + // Rent-Ka cartography: neutral, refined basemap — very pale grey,
57 + // desaturated blue water, subtle green parks, well-defined buildings.
58 + // Prices remain the dominant element of the map.
59 + basemapOverrides: {
60 + light: {
61 + background: "#f7f8fa",
62 + residential: "#f0f1f4",
63 + water: "#c9d8ef",
64 + waterway: "#bfd0ea",
65 + park: "#dce8d8",
66 + wood: "#d0e0ca",
67 + building: "#e6e8ee",
68 + buildingOutline: "#d0d3dd",
69 + road: "#ffffff",
70 + roadCasing: "#dcdee6",
71 + motorway: "#f4ede2",
72 + boundary: "#a9adbd",
73 + label: "#3e4560",
74 + labelHalo: "rgba(247,248,250,0.92)",
75 + cityLabel: "#0b1330",
76 + roadLabel: "#8a90a5",
77 + waterLabel: "#5c74a8",
78 + },
79 + },
80 + supportsDark: false,
81 +};
added frontend/src/live.ts +41 −0
@@ -0,0 +1,41 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// live.ts: shared "live data" hooks (home)
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +
8 +/** Animated hero counter (~0.9 s, cubic easing) — the feel of a real engine
9 + indexing. Respects prefers-reduced-motion (direct value). */
10 +export function useCountUp(target: number | null | undefined, ms = 900): string | null {
11 + const [v, setV] = useState<number | null>(null);
12 + useEffect(() => {
13 + if (target == null) return;
14 + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setV(target); return; }
15 + let raf = 0;
16 + const t0 = performance.now();
17 + const step = (t: number) => {
18 + const p = Math.min(1, (t - t0) / ms);
19 + setV(Math.round(target * (1 - Math.pow(1 - p, 3))));
20 + if (p < 1) raf = requestAnimationFrame(step);
21 + };
22 + raf = requestAnimationFrame(step);
23 + return () => cancelAnimationFrame(raf);
24 + }, [target, ms]);
25 + return v == null ? null : v.toLocaleString("en-CA");
26 +}
27 +
28 +/** Live "X s ago" — re-rendered every second while the ts exists. */
29 +export function useAgo(ts: number | null): string | null {
30 + const [, tick] = useState(0);
31 + useEffect(() => {
32 + if (ts == null) return;
33 + const id = setInterval(() => tick((x) => x + 1), 1000);
34 + return () => clearInterval(id);
35 + }, [ts]);
36 + if (ts == null) return null;
37 + const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
38 + if (s < 90) return `${s} s ago`;
39 + if (s < 5400) return `${Math.round(s / 60)} min ago`;
40 + return `${Math.round(s / 3600)} h ago`;
41 +}
added frontend/src/main.tsx +19 −0
@@ -0,0 +1,19 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// main.tsx: React entry point
5 +// -----------------------------------------------------------------------------
6 +import React from "react";
7 +import ReactDOM from "react-dom/client";
8 +import { BrowserRouter } from "react-router-dom";
9 +import App from "./App";
10 +import "./ka/tokens.css";
11 +import "./styles.css";
12 +
13 +ReactDOM.createRoot(document.getElementById("root")!).render(
14 + <React.StrictMode>
15 + <BrowserRouter>
16 + <App />
17 + </BrowserRouter>
18 + </React.StrictMode>
19 +);
added frontend/src/pages/Bienvenue.tsx +86 −0
@@ -0,0 +1,86 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Bienvenue.tsx: onboarding after account creation — role choice
5 +// "I'm looking for a rental" (tenant: saved rentals…) or "I manage rentals"
6 +// (manager: customizable page for their portfolio).
7 +// -----------------------------------------------------------------------------
8 +import { useState } from "react";
9 +import { useNavigate } from "react-router-dom";
10 +import { setRole } from "../api";
11 +import { useAccount } from "../account";
12 +import { IcoBuilding, IcoHeart, IcoHouse, IcoSliders } from "../components/Icons";
13 +
14 +export default function BienvenuePage() {
15 + const { me, loaded, refresh } = useAccount();
16 + const [busy, setBusy] = useState<"locataire" | "gestionnaire" | null>(null);
17 + const nav = useNavigate();
18 +
19 + const choose = async (role: "locataire" | "gestionnaire") => {
20 + setBusy(role);
21 + await setRole(role);
22 + refresh();
23 + nav(role === "gestionnaire" ? "/manage" : "/", { replace: true });
24 + };
25 +
26 + if (loaded && !me) {
27 + return (
28 + <div className="container profil">
29 + <span className="kicker">Welcome</span>
30 + <h1>First, create your <span className="hl">account</span>.</h1>
31 + <a className="btn btn-primary" href="/api/auth/ka/login">
32 + Sign in with KA ID
33 + </a>
34 + </div>
35 + );
36 + }
37 +
38 + return (
39 + <div className="container bienvenue">
40 + <span className="kicker">Welcome{me?.name ? `, ${me.name.split(" ")[0]}` : ""}</span>
41 + <h1>Which profile<br /><span className="hl">fits you</span>?</h1>
42 + <p className="lede">
43 + One last step: tell us how you'll use Rent-Ka.
44 + You can change your mind later in your profile.
45 + </p>
46 +
47 + <div className="role-grid">
48 + <button
49 + className="role-card"
50 + disabled={busy !== null}
51 + onClick={() => choose("locataire")}
52 + >
53 + <span className="role-ico"><IcoHouse size={30} /></span>
54 + <span className="role-title">I'm looking for a rental</span>
55 + <span className="role-desc">
56 + Prospective tenant — shop across rentals all over Canada.
57 + </span>
58 + <ul className="role-perks">
59 + <li><IcoHeart size={13} /> Save listings you love</li>
60 + <li><IcoSliders size={13} /> Filters and advanced search</li>
61 + <li>More tools coming (alerts…)</li>
62 + </ul>
63 + <span className="role-cta">{busy === "locataire" ? "One moment…" : "That's me →"}</span>
64 + </button>
65 +
66 + <button
67 + className="role-card"
68 + disabled={busy !== null}
69 + onClick={() => choose("gestionnaire")}
70 + >
71 + <span className="role-ico"><IcoBuilding size={30} /></span>
72 + <span className="role-title">I manage rentals</span>
73 + <span className="role-desc">
74 + Manager or owner whose rentals are displayed on Rent-Ka.
75 + </span>
76 + <ul className="role-perks">
77 + <li>Claim your management page</li>
78 + <li>Customize it: tagline, contact, logo</li>
79 + <li>Your inventory, linked automatically</li>
80 + </ul>
81 + <span className="role-cta">{busy === "gestionnaire" ? "One moment…" : "That's me →"}</span>
82 + </button>
83 + </div>
84 + </div>
85 + );
86 +}
added frontend/src/pages/Bot.tsx +57 −0
@@ -0,0 +1,57 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Bot.tsx: public page explaining RentKaBot to property managers
5 +// (referenced in the robot's User-Agent: +https://www.rent-ka.com/bot)
6 +// -----------------------------------------------------------------------------
7 +
8 +export default function BotPage() {
9 + return (
10 + <div className="container legal">
11 + <span className="kicker">RentKaBot</span>
12 + <h1>About our robot</h1>
13 +
14 + <section>
15 + <p>
16 + <b>RentKaBot</b> is the sync robot of Rent-Ka, an independent
17 + aggregator of rentals across Canada. If you see this User-Agent in
18 + your logs, it's us:
19 + </p>
20 + <p><code>RentKaBot/1.0 (+https://www.rent-ka.com/bot; contact@spboucher.ai)</code></p>
21 + </section>
22 +
23 + <section>
24 + <h3>What RentKaBot does</h3>
25 + <ul>
26 + <li>It reads the <b>public rental listings</b> of your site to index
27 + them on Rent-Ka, with a direct link to your original ad — every
28 + interested visitor is sent back to you.</li>
29 + <li>Limited pace: at most <b>~1 request per second</b> per site, one
30 + or two syncs per hour, with caching of already-seen pages to
31 + minimize requests.</li>
32 + <li>It respects <code>robots.txt</code> and collects <b>no personal
33 + data</b> — only the content of public listings (price, address,
34 + photos, description).</li>
35 + </ul>
36 + </section>
37 +
38 + <section>
39 + <h3>Are you a property manager?</h3>
40 + <p>
41 + Rent-Ka brings you tenants with no middleman: we display your
42 + listings under your name and send visitors back to you for the visit
43 + and the signature. If you prefer to provide an official feed (JSON,
44 + XML, iCal…), want to adjust the pace, correct information or remove
45 + your listings, write to us — we answer quickly:
46 + </p>
47 + <p><a href="mailto:contact@spboucher.ai"><b>contact@spboucher.ai</b></a></p>
48 + </section>
49 +
50 + <div className="fine">
51 + To block RentKaBot: add <code>User-agent: RentKaBot</code> /{" "}
52 + <code>Disallow: /</code> to your robots.txt — removal is automatic at
53 + the next sync.
54 + </div>
55 + </div>
56 + );
57 +}
added frontend/src/pages/Contact.tsx +64 −0
@@ -0,0 +1,64 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Contact.tsx: shared Groupe KA contact page (ecosystem.json data)
5 +// -----------------------------------------------------------------------------
6 +import { useEffect } from "react";
7 +import eco from "../ka/ecosystem.json";
8 +
9 +export default function ContactPage() {
10 + useEffect(() => {
11 + document.title = "Contact | Rent-Ka — A Groupe KA service";
12 + window.scrollTo(0, 0);
13 + }, []);
14 +
15 + const others = eco.sites.filter((s) => s.id !== "rent-ka");
16 +
17 + return (
18 + <div className="container contact-page">
19 + <span className="kicker">Contact</span>
20 + <h1>
21 + Write to <span className="hl">Groupe KA</span>
22 + </h1>
23 + <p className="lede">
24 + Rent-Ka — {eco.sites.find((s) => s.id === "rent-ka")?.tagline?.toLowerCase()} —
25 + is a service of {eco.org.name}. {eco.org.tagline} All requests go
26 + through the addresses below.
27 + </p>
28 +
29 + <div className="contact-grid">
30 + {eco.contacts.map((c) => (
31 + <div className="card contact-card" key={c.email}>
32 + <span className="klabel">{c.role}</span>
33 + <a className="mail" href={`mailto:${c.email}`}>{c.email}</a>
34 + </div>
35 + ))}
36 + </div>
37 +
38 + <p className="contact-note">
39 + <b>{eco.org.disclaimer}</b> The listings displayed on Rent-Ka come from
40 + the public websites of property managers; every listing links back to
41 + the original ad. For any question about a rental, contact the manager
42 + shown on the listing directly.
43 + </p>
44 +
45 + <div className="contact-hub">
46 + <span className="klabel">The ·Ka ecosystem</span>
47 + <ul className="contact-sites">
48 + {others.map((s) => (
49 + <li key={s.id}>
50 + <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer">
51 + {s.wordmark}
52 + </a>
53 + </li>
54 + ))}
55 + </ul>
56 + <p style={{ marginTop: 18 }}>
57 + <a className="btn btn-primary" href={eco.hub.url} target="_blank" rel="noopener noreferrer">
58 + Visit the groupe-ka.com portal →
59 + </a>
60 + </p>
61 + </div>
62 + </div>
63 + );
64 +}
added frontend/src/pages/Favoris.tsx +61 −0
@@ -0,0 +1,61 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Favoris.tsx: the rentals saved by the signed-in account
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { Link } from "react-router-dom";
8 +import { Listing, fetchFavorites } from "../api";
9 +import { useAccount } from "../account";
10 +import ListingCard from "../components/ListingCard";
11 +import { IcoHeart } from "../components/Icons";
12 +
13 +export default function FavorisPage() {
14 + const { me, loaded, favs } = useAccount();
15 + const [listings, setListings] = useState<Listing[] | null>(null);
16 +
17 + useEffect(() => {
18 + if (me) fetchFavorites().then((f) => setListings(f.listings)).catch(() => setListings([]));
19 + }, [me]);
20 +
21 + if (loaded && !me) {
22 + return (
23 + <div className="container profil">
24 + <span className="kicker">Saved rentals</span>
25 + <h1>Sign in to keep your <span className="hl">favourites</span>.</h1>
26 + <a className="btn btn-primary" href="/api/auth/ka/login">
27 + Sign in with KA ID
28 + </a>
29 + </div>
30 + );
31 + }
32 +
33 + // only show what is still saved (instant toggle without re-fetch)
34 + const visible = (listings ?? []).filter((l) => favs.has(l.uid));
35 +
36 + return (
37 + <div className="container profil">
38 + <span className="kicker">Saved rentals</span>
39 + <h1>Your <span className="hl">favourites</span>.</h1>
40 +
41 + {listings === null && me && <div className="notice">Loading…</div>}
42 +
43 + {listings !== null && visible.length === 0 && (
44 + <div className="notice">
45 + <div className="big"><IcoHeart size={40} /></div>
46 + <h2>Nothing saved yet</h2>
47 + <p>
48 + Tap the heart on a listing to find it here.
49 + </p>
50 + <Link className="btn btn-primary" to="/">Explore rentals</Link>
51 + </div>
52 + )}
53 +
54 + {visible.length > 0 && (
55 + <div className="grid" style={{ marginTop: 24 }}>
56 + {visible.map((l) => <ListingCard key={l.uid} l={l} />)}
57 + </div>
58 + )}
59 + </div>
60 + );
61 +}
added frontend/src/pages/Gestion.tsx +218 −0
@@ -0,0 +1,218 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Gestion.tsx: manager dashboard — claim YOUR page (source) then
5 +// customize it (tagline, description, contact info, logo). The matching
6 +// public page lives at /g/{source}.
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useMemo, useState } from "react";
9 +import { Link } from "react-router-dom";
10 +import {
11 + Source, claimOrg, fetchMyOrg, fetchSources, updateOrg,
12 +} from "../api";
13 +import { useAccount } from "../account";
14 +import { IcoBuilding } from "../components/Icons";
15 +
16 +interface OrgMine {
17 + source: string | null;
18 + name?: string;
19 + active_listings?: number;
20 + profile?: Record<string, string>;
21 +}
22 +
23 +export default function GestionPage() {
24 + const { me, loaded, refresh } = useAccount();
25 + const [org, setOrg] = useState<OrgMine | null>(null);
26 + const [sources, setSources] = useState<Source[]>([]);
27 + const [pick, setPick] = useState("");
28 + const [saving, setSaving] = useState(false);
29 + const [saved, setSaved] = useState(false);
30 + const [error, setError] = useState("");
31 + const [form, setForm] = useState({
32 + tagline: "", description: "", website: "", phone: "", email: "", logo_url: "",
33 + });
34 +
35 + useEffect(() => {
36 + if (me) fetchMyOrg().then((o) => {
37 + setOrg(o);
38 + const p = o.profile ?? {};
39 + setForm({
40 + tagline: p.tagline ?? "", description: p.description ?? "",
41 + website: p.website ?? "", phone: p.phone ?? "",
42 + email: p.email ?? "", logo_url: p.logo_url ?? "",
43 + });
44 + }).catch(() => setOrg({ source: null }));
45 + }, [me]);
46 +
47 + useEffect(() => {
48 + if (me && org && !org.source) {
49 + fetchSources().then((r) => setSources(r.sources)).catch(() => {});
50 + }
51 + }, [me, org]);
52 +
53 + const claimable = useMemo(
54 + () => sources
55 + .filter((s) => s.connector && s.active_listings > 0)
56 + .sort((a, b) => a.name.localeCompare(b.name, "en")),
57 + [sources]);
58 +
59 + if (loaded && !me) {
60 + return (
61 + <div className="container profil">
62 + <span className="kicker">Manager space</span>
63 + <h1>Sign in to manage <span className="hl">your page</span>.</h1>
64 + <a className="btn btn-primary" href="/api/auth/ka/login">
65 + Sign in with KA ID
66 + </a>
67 + </div>
68 + );
69 + }
70 +
71 + if (me && me.role !== "gestionnaire") {
72 + return (
73 + <div className="container profil">
74 + <span className="kicker">Manager space</span>
75 + <h1>Reserved for <span className="hl">managers</span>.</h1>
76 + <p className="lede">
77 + Your account is in tenant mode. If you manage rentals displayed on
78 + Rent-Ka, switch profiles.
79 + </p>
80 + <Link className="btn btn-primary" to="/welcome">Change profile</Link>
81 + </div>
82 + );
83 + }
84 +
85 + const doClaim = async () => {
86 + if (!pick) return;
87 + setError("");
88 + const res = await claimOrg(pick);
89 + if (!res.ok) {
90 + setError(res.status === 409
91 + ? "This page is already claimed by another account."
92 + : "Could not claim this page.");
93 + return;
94 + }
95 + refresh();
96 + const o = await fetchMyOrg();
97 + setOrg(o);
98 + };
99 +
100 + const doSave = async () => {
101 + setSaving(true);
102 + setError("");
103 + const res = await updateOrg(form);
104 + setSaving(false);
105 + if (!res.ok) { setError("Could not save — try again."); return; }
106 + setSaved(true);
107 + setTimeout(() => setSaved(false), 2000);
108 + };
109 +
110 + return (
111 + <div className="container profil">
112 + <span className="kicker">Manager space</span>
113 +
114 + {org && !org.source && (
115 + <>
116 + <h1>Claim the page of <span className="hl">your management</span>.</h1>
117 + <p className="lede">
118 + Pick your organization among the sources displayed on Rent-Ka.
119 + You'll then be able to customize its public page — your inventory
120 + is already linked automatically.
121 + </p>
122 + <div className="claim-row">
123 + <select value={pick} onChange={(e) => setPick(e.target.value)}>
124 + <option value="">— Your property management —</option>
125 + {claimable.map((s) => (
126 + <option key={s.id} value={s.id}>
127 + {s.name} ({s.active_listings} rentals)
128 + </option>
129 + ))}
130 + </select>
131 + <button className="btn btn-primary" disabled={!pick} onClick={doClaim}>
132 + Claim this page
133 + </button>
134 + </div>
135 + {error && <p className="claim-error">{error}</p>}
136 + <p className="profil-note">
137 + Can't find your organization? Write to us at{" "}
138 + <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a> and
139 + we'll add your site as a source.
140 + </p>
141 + </>
142 + )}
143 +
144 + {org && org.source && (
145 + <>
146 + <h1><span className="hl">{org.name}</span></h1>
147 + <div className="org-head">
148 + <span className="org-stat">
149 + <IcoBuilding size={15} /> <b>{org.active_listings ?? 0}</b> active linked rentals
150 + </span>
151 + <Link className="btn btn-ghost" to={`/g/${org.source}`}>
152 + See my public page ↗
153 + </Link>
154 + </div>
155 +
156 + <section className="org-form">
157 + <h3>Customize my page</h3>
158 + <label>
159 + <span>Tagline (140 characters)</span>
160 + <input
161 + value={form.tagline} maxLength={140}
162 + placeholder="E.g. Well-maintained rentals in Ottawa since 1998"
163 + onChange={(e) => setForm({ ...form, tagline: e.target.value })}
164 + />
165 + </label>
166 + <label>
167 + <span>Description</span>
168 + <textarea
169 + value={form.description} rows={5} maxLength={2000}
170 + placeholder="Introduce your management: areas, services, values…"
171 + onChange={(e) => setForm({ ...form, description: e.target.value })}
172 + />
173 + </label>
174 + <div className="org-form-row">
175 + <label>
176 + <span>Website</span>
177 + <input
178 + value={form.website} placeholder="https://…"
179 + onChange={(e) => setForm({ ...form, website: e.target.value })}
180 + />
181 + </label>
182 + <label>
183 + <span>Phone</span>
184 + <input
185 + value={form.phone} placeholder="416 555-0123"
186 + onChange={(e) => setForm({ ...form, phone: e.target.value })}
187 + />
188 + </label>
189 + </div>
190 + <div className="org-form-row">
191 + <label>
192 + <span>Public email</span>
193 + <input
194 + value={form.email} placeholder="location@…"
195 + onChange={(e) => setForm({ ...form, email: e.target.value })}
196 + />
197 + </label>
198 + <label>
199 + <span>Logo (image URL)</span>
200 + <input
201 + value={form.logo_url} placeholder="https://…/logo.png"
202 + onChange={(e) => setForm({ ...form, logo_url: e.target.value })}
203 + />
204 + </label>
205 + </div>
206 + {error && <p className="claim-error">{error}</p>}
207 + <button
208 + className={`btn btn-primary ${saved ? "pc-copy ok" : ""}`}
209 + disabled={saving} onClick={doSave}
210 + >
211 + {saved ? "✓ Saved" : saving ? "Saving…" : "Save"}
212 + </button>
213 + </section>
214 + </>
215 + )}
216 + </div>
217 + );
218 +}
added frontend/src/pages/GestionPublic.tsx +106 −0
@@ -0,0 +1,106 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/GestionPublic.tsx: public page of a property management — /g/{source}
5 +// Banner customized by the manager (tagline, description, contact, logo)
6 +// + automatically linked inventory (12/page, pagination).
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useState } from "react";
9 +import { Link, useParams } from "react-router-dom";
10 +import { Listing, OrgProfile, fetchListings, fetchOrg } from "../api";
11 +import ListingCard from "../components/ListingCard";
12 +import Pager from "../components/Pager";
13 +import { IcoCompass, IcoPin } from "../components/Icons";
14 +
15 +const PAGE_SIZE = 12;
16 +
17 +export default function GestionPublicPage() {
18 + const { sourceId = "" } = useParams();
19 + const [org, setOrg] = useState<OrgProfile | null | undefined>(undefined);
20 + const [listings, setListings] = useState<Listing[] | null>(null);
21 + const [total, setTotal] = useState(0);
22 + const [page, setPage] = useState(1);
23 +
24 + useEffect(() => {
25 + fetchOrg(sourceId).then(setOrg).catch(() => setOrg(null));
26 + setPage(1);
27 + }, [sourceId]);
28 +
29 + useEffect(() => {
30 + setListings(null);
31 + fetchListings({ source: sourceId }, PAGE_SIZE, (page - 1) * PAGE_SIZE)
32 + .then((r) => { setListings(r.listings); setTotal(r.total); })
33 + .catch(() => setListings([]));
34 + }, [sourceId, page]);
35 +
36 + if (org === undefined) {
37 + return <div className="container profil"><div className="notice">Loading…</div></div>;
38 + }
39 + if (org === null) {
40 + return (
41 + <div className="container notice">
42 + <div className="big"><IcoCompass size={40} /></div>
43 + <h2>Page not found</h2>
44 + <p>This management does not exist on Rent-Ka.</p>
45 + <Link className="btn btn-primary" to="/">Explore rentals</Link>
46 + </div>
47 + );
48 + }
49 +
50 + return (
51 + <div className="container profil orgpub">
52 + <div className="orgpub-head">
53 + {org.logo_url && (
54 + <img className="orgpub-logo" src={org.logo_url} alt="" referrerPolicy="no-referrer" />
55 + )}
56 + <div className="orgpub-id">
57 + <span className="kicker">Property management</span>
58 + <h1>{org.name}</h1>
59 + {org.tagline && <p className="orgpub-tagline">{org.tagline}</p>}
60 + <div className="orgpub-meta">
61 + <span className="stat-chip"><b>{org.active_listings}</b> active rentals</span>
62 + {org.claimed && <span className="stat-chip orgpub-verified">Page managed by the organization</span>}
63 + {org.phone && <a className="stat-chip" href={`tel:${org.phone}`}>{org.phone}</a>}
64 + {org.email && <a className="stat-chip" href={`mailto:${org.email}`}>{org.email}</a>}
65 + {org.website && (
66 + <a className="stat-chip" href={org.website} target="_blank" rel="noopener noreferrer">
67 + Website ↗
68 + </a>
69 + )}
70 + </div>
71 + </div>
72 + </div>
73 +
74 + {org.description && <p className="orgpub-desc">{org.description}</p>}
75 +
76 + <div className="results-head">
77 + <h2><IcoPin size={16} /> Available rentals</h2>
78 + <div className="results-tools">
79 + {listings && <span>{total} result{total > 1 ? "s" : ""}</span>}
80 + </div>
81 + </div>
82 +
83 + {listings === null && (
84 + <div className="grid" aria-busy="true">
85 + {Array.from({ length: 4 }).map((_, i) => (
86 + <div className="skel" key={i}>
87 + <div className="sk-img" /><div className="sk-line" /><div className="sk-line short" />
88 + </div>
89 + ))}
90 + </div>
91 + )}
92 +
93 + {listings !== null && (
94 + <>
95 + <div className="grid">
96 + {listings.map((l) => <ListingCard key={l.uid} l={l} />)}
97 + </div>
98 + <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={(p) => {
99 + setPage(p);
100 + window.scrollTo({ top: 0, behavior: "smooth" });
101 + }} />
102 + </>
103 + )}
104 + </div>
105 + );
106 +}
added frontend/src/pages/Home.tsx +675 −0
@@ -0,0 +1,675 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Home.tsx: home — hero, browse by province, advanced filters, grid
5 +// Filters: province, city, neighbourhood, size, rent min/max, availability,
6 +// pets, furnished, area, manager, search — with active-filter pills.
7 +// -----------------------------------------------------------------------------
8 +import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";
9 +import { useSearchParams } from "react-router-dom";
10 +import {
11 + Facets, Listing, ListingFilters, PROVINCE_NAMES, Stats,
12 + fetchFacets, fetchListings, fetchSources, fetchStats, isoInDays,
13 + registerSourceNames, sourceName,
14 +} from "../api";
15 +import ListingCard from "../components/ListingCard";
16 +import Pager from "../components/Pager";
17 +import { useAgo, useCountUp } from "../live";
18 +import {
19 + IcoAlert, IcoBolt, IcoList, IcoMap, IcoPaw,
20 + IcoSearch, IcoSliders, IcoSofa,
21 +} from "../components/Icons";
22 +
23 +// The synchronized list+map search (Mapbox, ~220 kB) is only loaded when the
24 +// user opens the Map view — see search/MapSearch.tsx
25 +const MapSearch = lazy(() => import("../search/MapSearch"));
26 +
27 +const PAGE_SIZE = 12; // listings per page ("list" grid)
28 +
29 +/** Debounced value: avoids one API request per keystroke in the search box. */
30 +function useDebounced<T>(value: T, delayMs = 300): T {
31 + const [debounced, setDebounced] = useState(value);
32 + useEffect(() => {
33 + const t = setTimeout(() => setDebounced(value), delayMs);
34 + return () => clearTimeout(t);
35 + }, [value, delayMs]);
36 + return debounced;
37 +}
38 +
39 +// useCountUp / useAgo: shared "live data" hooks — see ../live.ts
40 +const UNIT_TYPES = ["Studio", "1 bedroom", "2 bedrooms", "3 bedrooms", "4 bedrooms", "Loft", "Condo", "House", "Room"];
41 +const PRICE_STEPS = [800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000, 3500, 4000];
42 +const AREA_STEPS = [400, 600, 800, 1000, 1200];
43 +
44 +/** "Availability" choice → API parameter available_by (ISO date) */
45 +const DISPO_CHOICES: { key: string; label: string; days: number | null }[] = [
46 + { key: "", label: "Any", days: null },
47 + { key: "now", label: "Now", days: 0 },
48 + { key: "30", label: "Within 1 month", days: 30 },
49 + { key: "60", label: "Within 2 months", days: 60 },
50 + { key: "90", label: "Within 3 months", days: 90 },
51 +];
52 +
53 +export default function Home() {
54 + const [listings, setListings] = useState<Listing[] | null>(null);
55 + const [total, setTotal] = useState(0);
56 + const [facets, setFacets] = useState<Facets | null>(null);
57 + const [sectors, setSectors] = useState<string[]>([]);
58 + const [stats, setStats] = useState<Stats | null>(null);
59 + const [error, setError] = useState<string | null>(null);
60 +
61 + // filters (pre-filled from the URL, e.g. /?city=Toronto — Stats page links)
62 + const [params] = useSearchParams();
63 + const [q, setQ] = useState(params.get("q") ?? "");
64 + const [province, setProvince] = useState(params.get("province") ?? "");
65 + const [city, setCity] = useState(params.get("city") ?? "");
66 + const [sector, setSector] = useState(params.get("sector") ?? "");
67 + const [source, setSource] = useState(params.get("source") ?? "");
68 + const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");
69 + const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
70 + const [unitType, setUnitType] = useState(params.get("unit_type") ?? "");
71 + const [dispo, setDispo] = useState(params.get("dispo") ?? "");
72 + const [pets, setPets] = useState(params.get("pets") ?? "");
73 + const [furnished, setFurnished] = useState(params.get("furnished") ?? "");
74 + const [areaMin, setAreaMin] = useState(params.get("area_min") ?? "");
75 + const [deal, setDeal] = useState(params.get("deal") ?? ""); // fair value
76 + const [kaMin, setKaMin] = useState(params.get("kascore_min") ?? ""); // KA Score
77 + // mobile filter sheet (bottom sheet) + desktop advanced panel
78 + const [sheetOpen, setSheetOpen] = useState(false);
79 + const [advOpen, setAdvOpen] = useState(false);
80 + // sheet open: freeze background scroll + close on Escape
81 + // (same pattern as the App.tsx hamburger menu)
82 + useEffect(() => {
83 + document.body.style.overflow = sheetOpen ? "hidden" : "";
84 + if (!sheetOpen) return () => { document.body.style.overflow = ""; };
85 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };
86 + window.addEventListener("keydown", onKey);
87 + return () => {
88 + document.body.style.overflow = "";
89 + window.removeEventListener("keydown", onKey);
90 + };
91 + }, [sheetOpen]);
92 + const activeFilters = [q, province, city, sector, source, priceMin, priceMax, unitType,
93 + dispo, pets, furnished, areaMin, deal, kaMin].filter(Boolean).length;
94 + const advCount = [dispo, pets, furnished, areaMin, source, kaMin].filter(Boolean).length;
95 + // list or map view (remembered in the URL: /?view=map)
96 + const [view, setView] = useState<"liste" | "carte">(
97 + params.get("view") === "map" ? "carte" : "liste");
98 + useEffect(() => {
99 + // follow the URL when navigating via the menu ("Map" -> /?view=map)
100 + setView(params.get("view") === "map" ? "carte" : "liste");
101 + }, [params]);
102 + // map mode (Ka Map System v2): body class — the filter sheet becomes a modal
103 + // ABOVE the shell — and view remembered in the URL (reload, back from a
104 + // listing: we land back on the map, camera included).
105 + useEffect(() => {
106 + document.body.classList.toggle("ka-map-mode", view === "carte");
107 + const url = new URL(window.location.href);
108 + if (view === "carte") url.searchParams.set("view", "map");
109 + else url.searchParams.delete("view");
110 + window.history.replaceState(null, "", url);
111 + return () => document.body.classList.remove("ka-map-mode");
112 + }, [view]);
113 +
114 + const qDebounced = useDebounced(q);
115 + const filters: ListingFilters = useMemo(() => {
116 + const d = DISPO_CHOICES.find((c) => c.key === dispo);
117 + return {
118 + q: qDebounced, province, city, sector, source, unit_type: unitType,
119 + price_min: priceMin, price_max: priceMax,
120 + pets, furnished, area_min: areaMin,
121 + available_by: d?.days != null ? isoInDays(d.days) : "",
122 + deal, kascore_min: kaMin,
123 + sort: deal === "sous" ? "deal" : "", // best deals first
124 + };
125 + }, [qDebounced, province, city, sector, source, unitType, priceMin, priceMax, pets, furnished, areaMin, dispo, deal, kaMin]);
126 +
127 + // shared counter of the map view (lifted by MapSearch: list = map)
128 + const [mapTotal, setMapTotal] = useState<number | null>(null);
129 +
130 + // hero live data: last real sync (max across connectors)
131 + const [lastSync, setLastSync] = useState<number | null>(null);
132 + useEffect(() => {
133 + fetchSources().then((r) => {
134 + registerSourceNames(r.sources);
135 + const ts = Math.max(0, ...r.sources.map((s) => s.last_sync ?? 0));
136 + if (ts > 0) setLastSync(ts > 1e12 ? Math.round(ts / 1000) : ts);
137 + }).catch(() => {});
138 + fetchFacets().then(setFacets).catch(() => {});
139 + fetchStats().then(setStats).catch(() => {});
140 + }, []);
141 + const totalLive = useCountUp(stats?.total);
142 + const syncAgo = useAgo(lastSync);
143 +
144 + // Home geolocation: on arrival (bare URL, once per session), ask for the
145 + // position to directly show rentals around the user — Map view centred on
146 + // them. Shared link (filters/camera in the URL): respect the link, no
147 + // prompt. Denied: silent, the classic home stays as is.
148 + useEffect(() => {
149 + const url = new URL(window.location.href);
150 + const hasState = [...url.searchParams.keys()].length > 0;
151 + if (hasState) return;
152 + if (!("geolocation" in navigator)) return;
153 + if (sessionStorage.getItem("rentka_geo_session")) return;
154 + sessionStorage.setItem("rentka_geo_session", "1");
155 + navigator.geolocation.getCurrentPosition(
156 + (pos) => {
157 + const { latitude, longitude } = pos.coords;
158 + // outside Canada (travel, VPN): don't centre on the ocean
159 + if (latitude < 41.6 || latitude > 83.2 || longitude < -141 || longitude > -52.5) return;
160 + const u = new URL(window.location.href);
161 + u.searchParams.set("lat", latitude.toFixed(5));
162 + u.searchParams.set("lng", longitude.toFixed(5));
163 + u.searchParams.set("zoom", "13.5");
164 + u.searchParams.set("view", "map");
165 + window.history.replaceState(null, "", u);
166 + // MapSearch already mounted (map view): recentre + list via event
167 + window.dispatchEvent(new CustomEvent("rentka:geolocate", {
168 + detail: { lat: latitude, lng: longitude },
169 + }));
170 + setView("carte");
171 + },
172 + () => { /* denied or unavailable: home unchanged */ },
173 + { enableHighAccuracy: false, timeout: 8000, maximumAge: 600000 },
174 + );
175 + }, []);
176 +
177 + // neighbourhoods depend on the chosen city
178 + useEffect(() => {
179 + fetchFacets(city || undefined)
180 + .then((f) => setSectors(f.sectors))
181 + .catch(() => setSectors([]));
182 + }, [city]);
183 +
184 + // pagination — 12 listings/page, page remembered in the URL (?page=N)
185 + const [page, setPage] = useState(() => {
186 + const p = Number(params.get("page"));
187 + return Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1;
188 + });
189 + const resultsRef = useRef<HTMLDivElement | null>(null);
190 + const filtersKey = useMemo(() => JSON.stringify(filters), [filters]);
191 + const firstRun = useRef(true);
192 + useEffect(() => {
193 + // any filter change goes back to page 1 (except on initial mount,
194 + // to honour a shared link /?city=…&page=3)
195 + if (firstRun.current) { firstRun.current = false; return; }
196 + setPage(1);
197 + }, [filtersKey]);
198 + useEffect(() => {
199 + // reflect the page in the URL without a router re-render
200 + // (in map view, pagination belongs to MapSearch)
201 + if (view === "carte") return;
202 + const url = new URL(window.location.href);
203 + if (page > 1) url.searchParams.set("page", String(page));
204 + else url.searchParams.delete("page");
205 + window.history.replaceState(null, "", url);
206 + }, [page, view]);
207 +
208 + const goToPage = (p: number) => {
209 + setPage(p);
210 + resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
211 + };
212 +
213 + useEffect(() => {
214 + if (view === "carte") return; // the map view has its own unified request
215 + let cancelled = false;
216 + setListings(null);
217 + setError(null);
218 + fetchListings(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE)
219 + .then((r) => {
220 + if (!cancelled) {
221 + setListings(r.listings);
222 + setTotal(r.total);
223 + // page now out of bounds (narrowed filter): go to the last one
224 + const last = Math.max(1, Math.ceil(r.total / PAGE_SIZE));
225 + if (page > last) setPage(last);
226 + }
227 + })
228 + .catch((e) => !cancelled && setError(String(e)));
229 + return () => {
230 + cancelled = true;
231 + };
232 + }, [filters, page, view]);
233 +
234 + const availableTypes = useMemo(() => {
235 + const set = new Set(facets?.unit_types ?? []);
236 + return UNIT_TYPES.filter((t) => set.size === 0 || set.has(t));
237 + }, [facets]);
238 +
239 + // "Browse by province" cards — provinces with at least one listing
240 + const provinceCards = useMemo(() => {
241 + const provs = stats?.provinces ?? {};
242 + return Object.entries(provs)
243 + .filter(([, n]) => (n as number) > 0)
244 + .sort((a, b) => (b[1] as number) - (a[1] as number))
245 + .map(([code, n]) => ({ code, name: PROVINCE_NAMES[code] || code, n: n as number }));
246 + }, [stats]);
247 +
248 + const resetAll = () => {
249 + setQ(""); setProvince(""); setCity(""); setSector(""); setSource("");
250 + setPriceMin(""); setPriceMax(""); setUnitType("");
251 + setDispo(""); setPets(""); setFurnished(""); setAreaMin(""); setDeal("");
252 + setKaMin("");
253 + };
254 +
255 + // "active filters" pills — label + removal action
256 + const pills: { label: string; clear: () => void }[] = [];
257 + if (q) pills.push({ label: `“${q}”`, clear: () => setQ("") });
258 + if (province) pills.push({
259 + label: PROVINCE_NAMES[province] || province,
260 + clear: () => setProvince(""),
261 + });
262 + if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });
263 + if (sector) pills.push({ label: sector, clear: () => setSector("") });
264 + if (unitType) pills.push({ label: unitType, clear: () => setUnitType("") });
265 + if (priceMin) pills.push({ label: `≥ $${priceMin}`, clear: () => setPriceMin("") });
266 + if (priceMax) pills.push({ label: `≤ $${priceMax}`, clear: () => setPriceMax("") });
267 + if (dispo) pills.push({
268 + label: `Available: ${DISPO_CHOICES.find((c) => c.key === dispo)?.label ?? dispo}`,
269 + clear: () => setDispo(""),
270 + });
271 + if (pets) pills.push({ label: "Pets allowed", clear: () => setPets("") });
272 + if (furnished) pills.push({
273 + label: furnished === "1" ? "Furnished" : "Unfurnished",
274 + clear: () => setFurnished(""),
275 + });
276 + if (areaMin) pills.push({ label: `≥ ${areaMin} sq ft`, clear: () => setAreaMin("") });
277 + if (deal) pills.push({ label: "Below market", clear: () => setDeal("") });
278 + if (kaMin) pills.push({ label: `KA Score ${kaMin}+`, clear: () => setKaMin("") });
279 + if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });
280 +
281 + return (
282 + <div className="container">
283 + <section className="hero">
284 + <span className="kicker">Rentals — all of Canada, one place</span>
285 + <h1 className="hero-display">
286 + Find your next<br />
287 + <span className="brush">home.</span>
288 + </h1>
289 + <p className="lede">
290 + Apartments and homes for rent across Canada, aggregated continuously —
291 + full photos, estimated fair value, direct link to the original listing.
292 + </p>
293 + <div className="live-line" aria-label="Live data">
294 + <span className="live-flag"><span className="live-dot" /> live</span>
295 + {totalLive && (
296 + <span className="live-item"><b>{totalLive}</b> rentals indexed</span>
297 + )}
298 + {stats && stats.sources > 0 && (
299 + <span className="live-item"><b>{stats.sources}</b> sources</span>
300 + )}
301 + {syncAgo && <span className="live-item">synced {syncAgo}</span>}
302 + {stats?.avg_price != null && (
303 + <span className="live-item">average rent <b>${Math.round(stats.avg_price).toLocaleString("en-CA")}</b></span>
304 + )}
305 + </div>
306 + </section>
307 +
308 + {/* — Browse by province: signature Rent-Ka section — */}
309 + {provinceCards.length > 0 && activeFilters === 0 && (
310 + <section className="prov-zone" aria-label="Browse by province">
311 + <div className="prov-head">
312 + <h2>Browse by province</h2>
313 + <span className="prov-sub">Every listing links back to the original ad.</span>
314 + </div>
315 + <div className="prov-grid">
316 + {provinceCards.map((p) => (
317 + <button
318 + key={p.code}
319 + className="prov-card"
320 + onClick={() => {
321 + setProvince(p.code);
322 + resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
323 + }}
324 + >
325 + <span className="prov-code">{p.code}</span>
326 + <span className="prov-name">{p.name}</span>
327 + <span className="prov-n">{p.n.toLocaleString("en-CA")} rentals</span>
328 + </button>
329 + ))}
330 + </div>
331 + </section>
332 + )}
333 +
334 + {sheetOpen && (
335 + <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />
336 + )}
337 + <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Search and filters">
338 + <div className="sheet-handle" aria-hidden="true" />
339 + <div className="sheet-head">
340 + <span>Refine your search</span>
341 + <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Close filters">
342 + ✕
343 + </button>
344 + </div>
345 +
346 + {/* — search, the heart of the product: one big underlined field — */}
347 + <div className="q-big">
348 + <IcoSearch size={22} strokeWidth={2} />
349 + <input
350 + id="f-q" placeholder="Where do you want to live?" value={q}
351 + onChange={(e) => setQ(e.target.value)}
352 + aria-label="Search — city, neighbourhood or postal code"
353 + />
354 + {q && (
355 + <button className="f-clear" onClick={() => setQ("")} aria-label="Clear search">✕</button>
356 + )}
357 + </div>
358 +
359 + {/* — inline criteria, separated by rules (no boxes) — */}
360 + <div className="crit-line">
361 + <label className="crit">
362 + <span>Province</span>
363 + <select value={province} onChange={(e) => { setProvince(e.target.value); setCity(""); setSector(""); }}>
364 + <option value="">All</option>
365 + {(provinceCards.length > 0 ? provinceCards.map((p) => p.code) : Object.keys(PROVINCE_NAMES)).map((c) => (
366 + <option key={c} value={c}>{PROVINCE_NAMES[c] || c}</option>
367 + ))}
368 + </select>
369 + </label>
370 + <label className="crit">
371 + <span>City</span>
372 + <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>
373 + <option value="">All</option>
374 + {(facets?.cities ?? ["Toronto", "Ottawa"]).map((c) => (
375 + <option key={c} value={c}>{c}</option>
376 + ))}
377 + </select>
378 + </label>
379 + <label className="crit">
380 + <span>Neighbourhood</span>
381 + <select value={sector} onChange={(e) => setSector(e.target.value)}>
382 + <option value="">All</option>
383 + {sectors.map((s) => (
384 + <option key={s} value={s}>{s}</option>
385 + ))}
386 + </select>
387 + </label>
388 + <div className="crit">
389 + <span>Rent</span>
390 + <div className="range-pair">
391 + <select aria-label="Minimum rent" value={priceMin}
392 + onChange={(e) => setPriceMin(e.target.value)}>
393 + <option value="">Min</option>
394 + {PRICE_STEPS.map((p) => (
395 + <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>
396 + ${p}
397 + </option>
398 + ))}
399 + </select>
400 + <span className="range-sep">—</span>
401 + <select aria-label="Maximum rent" value={priceMax}
402 + onChange={(e) => setPriceMax(e.target.value)}>
403 + <option value="">Max</option>
404 + {PRICE_STEPS.map((p) => (
405 + <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>
406 + ${p}
407 + </option>
408 + ))}
409 + </select>
410 + </div>
411 + </div>
412 + <button
413 + className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`}
414 + onClick={() => setAdvOpen(!advOpen)}
415 + aria-expanded={advOpen}
416 + >
417 + All criteria
418 + {advCount > 0 && <span className="crit-badge">{advCount}</span>}
419 + <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" />
420 + </button>
421 + </div>
422 +
423 + {/* — advanced panel: segments & selectors — */}
424 + {(advOpen || sheetOpen) && (
425 + <div className="f-adv">
426 + <div className="f-group">
427 + <label>Availability</label>
428 + <div className="seg" role="group">
429 + {DISPO_CHOICES.map((c) => (
430 + <button key={c.key} className={dispo === c.key ? "on" : ""}
431 + onClick={() => setDispo(c.key)}>
432 + {c.label}
433 + </button>
434 + ))}
435 + </div>
436 + </div>
437 + <div className="f-group">
438 + <label>Furnished</label>
439 + <div className="seg" role="group">
440 + {[["", "Any"], ["1", "Yes"], ["0", "No"]].map(([v, l]) => (
441 + <button key={v} className={furnished === v ? "on" : ""}
442 + onClick={() => setFurnished(v)}>
443 + {l}
444 + </button>
445 + ))}
446 + </div>
447 + </div>
448 + <div className="f-group">
449 + <label>Pets</label>
450 + <div className="seg" role="group">
451 + {[["", "Any"], ["oui", "Allowed"]].map(([v, l]) => (
452 + <button key={v} className={pets === v ? "on" : ""}
453 + onClick={() => setPets(v)}>
454 + {v === "oui" && <IcoPaw size={13} />} {l}
455 + </button>
456 + ))}
457 + </div>
458 + </div>
459 + <div className="f-group">
460 + <label>Minimum area</label>
461 + <div className="seg" role="group">
462 + <button className={areaMin === "" ? "on" : ""} onClick={() => setAreaMin("")}>
463 + Any
464 + </button>
465 + {AREA_STEPS.map((a) => (
466 + <button key={a} className={areaMin === String(a) ? "on" : ""}
467 + onClick={() => setAreaMin(String(a))}>
468 + {a}+
469 + </button>
470 + ))}
471 + </div>
472 + </div>
473 + <div className="f-group">
474 + <label>Minimum KA Score</label>
475 + <div className="seg" role="group">
476 + <button className={kaMin === "" ? "on" : ""} onClick={() => setKaMin("")}>
477 + Any
478 + </button>
479 + {["60", "70", "80"].map((v) => (
480 + <button key={v} className={kaMin === v ? "on" : ""}
481 + onClick={() => setKaMin(v)}
482 + title="Rent-Ka location score (walking, transit, biking, quiet, services) — methodology at /ka-scores">
483 + {v}+
484 + </button>
485 + ))}
486 + </div>
487 + </div>
488 + <div className="f-group">
489 + <label>Manager</label>
490 + <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>
491 + <option value="">All</option>
492 + {(facets?.sources ?? []).map((s) => (
493 + <option key={s.source} value={s.source}>
494 + {sourceName(s.source)} ({s.n})
495 + </option>
496 + ))}
497 + </select>
498 + </div>
499 + <div className="f-group f-group-end">
500 + <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>
501 + Reset all{activeFilters > 0 ? ` (${activeFilters})` : ""}
502 + </button>
503 + </div>
504 + </div>
505 + )}
506 +
507 + <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
508 + {listings ? `See ${total.toLocaleString("en-CA")} rentals` : "See results"}
509 + </button>
510 + </section>
511 +
512 + {/* mobile: criteria summary — opens the sheet (search included, no FAB) */}
513 + <button className="crit-summary" onClick={() => setSheetOpen(true)}>
514 + <IcoSliders size={14} />
515 + <span className="cs-txt">
516 + {pills.length > 0
517 + ? pills.slice(0, 3).map((p) => p.label).join(" · ") + (pills.length > 3 ? ` · +${pills.length - 3}` : "")
518 + : "City, neighbourhood, budget, criteria…"}
519 + </span>
520 + <span className="f-chev" aria-hidden="true" />
521 + </button>
522 +
523 + <div className="chips" role="group" aria-label="Quick filters">
524 + {availableTypes.map((t) => (
525 + <button
526 + key={t}
527 + className={`chip ${unitType === t ? "on" : ""}`}
528 + onClick={() => setUnitType(unitType === t ? "" : t)}
529 + >
530 + {t}
531 + </button>
532 + ))}
533 + <span className="chip-sep" aria-hidden="true" />
534 + <button
535 + className={`chip chip-ico chip-deal ${deal === "sous" ? "on" : ""}`}
536 + onClick={() => setDeal(deal === "sous" ? "" : "sous")}
537 + title="Listings below estimated fair value, best deals first"
538 + >
539 + ▼ Below market
540 + </button>
541 + <button
542 + className={`chip chip-ico ${dispo === "now" ? "on" : ""}`}
543 + onClick={() => setDispo(dispo === "now" ? "" : "now")}
544 + >
545 + <IcoBolt size={13} /> Available now
546 + </button>
547 + <button
548 + className={`chip chip-ico ${pets === "oui" ? "on" : ""}`}
549 + onClick={() => setPets(pets === "oui" ? "" : "oui")}
550 + >
551 + <IcoPaw size={13} /> Pets ok
552 + </button>
553 + <button
554 + className={`chip chip-ico ${furnished === "1" ? "on" : ""}`}
555 + onClick={() => setFurnished(furnished === "1" ? "" : "1")}
556 + >
557 + <IcoSofa size={13} /> Furnished
558 + </button>
559 + </div>
560 +
561 + {pills.length > 0 && (
562 + <div className="pills" aria-label="Active filters">
563 + {pills.map((p) => (
564 + <button key={p.label} className="pill" onClick={p.clear}
565 + aria-label={`Remove filter ${p.label}`}>
566 + {p.label} <span className="pill-x">✕</span>
567 + </button>
568 + ))}
569 + <button className="pill pill-clear" onClick={resetAll}>
570 + Clear all
571 + </button>
572 + </div>
573 + )}
574 +
575 + <div className="results-bar" ref={resultsRef}>
576 + <h2 className="rb-count">
577 + {view === "carte"
578 + ? (mapTotal != null
579 + ? <><b>{mapTotal.toLocaleString("en-CA")}</b> rental{mapTotal > 1 ? "s" : ""}</>
580 + : "Rentals")
581 + : (listings
582 + ? <><b>{total.toLocaleString("en-CA")}</b> rental{total > 1 ? "s" : ""}</>
583 + : "Rentals")}
584 + </h2>
585 + <div className="rb-tools">
586 + <div className="rb-tabs" role="tablist" aria-label="Display mode">
587 + <button
588 + role="tab" aria-selected={view === "liste"}
589 + className={`rb-tab ${view === "liste" ? "on" : ""}`}
590 + onClick={() => setView("liste")}
591 + >
592 + <IcoList size={13} /> List
593 + </button>
594 + <button
595 + role="tab" aria-selected={view === "carte"}
596 + className={`rb-tab ${view === "carte" ? "on" : ""}`}
597 + onClick={() => setView("carte")}
598 + >
599 + <IcoMap size={13} /> Map
600 + </button>
601 + </div>
602 + <button className="rb-filters" onClick={() => setSheetOpen(true)}>
603 + <IcoSliders size={13} /> Filters
604 + {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>}
605 + </button>
606 + </div>
607 + </div>
608 +
609 + {error && (
610 + <div className="notice">
611 + <div className="big"><IcoAlert size={40} /></div>
612 + <h2>Could not load listings</h2>
613 + <p>{error}</p>
614 + <button className="btn btn-primary" onClick={() => window.location.reload()}>
615 + Try again
616 + </button>
617 + </div>
618 + )}
619 +
620 + {!error && view === "liste" && listings === null && (
621 + <div className="grid grid-edito" aria-busy="true">
622 + {Array.from({ length: 8 }).map((_, i) => (
623 + <div className="skel" key={i}>
624 + <div className="sk-img" />
625 + <div className="sk-line" />
626 + <div className="sk-line short" />
627 + </div>
628 + ))}
629 + </div>
630 + )}
631 +
632 + {!error && view === "liste" && listings !== null && listings.length === 0 && (
633 + <div className="notice">
634 + <div className="big"><IcoSearch size={40} /></div>
635 + <h2>No rentals match</h2>
636 + <p>
637 + Try widening your criteria
638 + {activeFilters > 0 && (
639 + <> — or <button className="link-btn" onClick={resetAll}>remove the {activeFilters} active filters</button></>
640 + )}.
641 + </p>
642 + </div>
643 + )}
644 +
645 + {!error && view === "carte" && (
646 + <div className="view-pane" key="carte">
647 + <Suspense
648 + fallback={
649 + <div className="ka-shell-fallback">Loading the map…</div>
650 + }
651 + >
652 + <MapSearch
653 + filters={filters}
654 + onTotal={setMapTotal}
655 + onExit={() => setView("liste")}
656 + onOpenFilters={() => setSheetOpen(true)}
657 + filtersCount={activeFilters}
658 + />
659 + </Suspense>
660 + </div>
661 + )}
662 +
663 + {!error && view === "liste" && listings !== null && listings.length > 0 && (
664 + <div className="view-pane" key="liste">
665 + <div className="grid grid-edito">
666 + {listings.map((l) => (
667 + <ListingCard key={l.uid} l={l} />
668 + ))}
669 + </div>
670 + <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} />
671 + </div>
672 + )}
673 + </div>
674 + );
675 +}
added frontend/src/pages/JusteValeur.tsx +92 −0
@@ -0,0 +1,92 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/JusteValeur.tsx: "How is fair value calculated?"
5 +// Transparency page on the estimation framework (rentka/fairvalue.py).
6 +// -----------------------------------------------------------------------------
7 +import { Link } from "react-router-dom";
8 +import FairValueBadge from "../components/FairValueBadge";
9 +
10 +export default function JusteValeurPage() {
11 + return (
12 + <div className="container legal">
13 + <span className="kicker">Rent-Ka price analysis</span>
14 + <h1>How is fair value calculated?</h1>
15 +
16 + <section>
17 + <p>
18 + On every listing, Rent-Ka shows an <b>estimated fair value</b> of the
19 + rent and a badge that places the asking price against the market:
20 + </p>
21 + <p className="fv-demo">
22 + <FairValueBadge verdict="sous" deviation={-0.12} />{" "}
23 + <FairValueBadge verdict="marche" deviation={0.02} />{" "}
24 + <FairValueBadge verdict="sur" deviation={0.15} />
25 + </p>
26 + </section>
27 +
28 + <section>
29 + <h3>The method, simply</h3>
30 + <ul>
31 + <li>
32 + <b>1. The market as reference.</b> We start from all the listings
33 + currently published on Rent-Ka (continuously refreshed by our
34 + syncs). For each market segment — city, neighbourhood, bedroom
35 + count — we compute the median rent and its typical range. Rooms and
36 + shared units form their own segment.
37 + </li>
38 + <li>
39 + <b>2. Features matter.</b> Heat or electricity included, parking,
40 + furnished, air conditioning, area…: the effect of each feature on
41 + the rent is measured from the listings themselves, then applied to
42 + the estimate.
43 + </li>
44 + <li>
45 + <b>3. Comparable neighbours.</b> When the listing is geolocated, we
46 + refine with the closest comparable listings (same size class,
47 + within 3 km), giving more weight to the closest and most similar.
48 + </li>
49 + <li>
50 + <b>4. Guardrails.</b> The estimate stays bounded to a plausible
51 + corridor around the segment reference. Every estimate carries a
52 + range and a confidence level. If there are not enough reliable
53 + comparables, or if the advertised price is ambiguous ("from",
54 + weekly price), <b>no badge is shown</b> rather than a misleading
55 + verdict.
56 + </li>
57 + </ul>
58 + </section>
59 +
60 + <section>
61 + <h3>The classification</h3>
62 + <p>
63 + The gap between the asking price and the fair value determines the
64 + badge: <b>below market</b> from −8 %, <b>above market</b> from +8 %,
65 + <b> at market</b> in between. These thresholds are recalibrated
66 + periodically by observing the market — notably how quickly
67 + "below market" listings find takers.
68 + </p>
69 + <p>
70 + Tip: on the home page, the <b>"▼ Below market"</b> filter shows only
71 + the good deals, best ones first.
72 + </p>
73 + </section>
74 +
75 + <section>
76 + <h3>What it is not</h3>
77 + <p>
78 + The Rent-Ka fair value is an <b>indicative statistical estimate</b>,
79 + recomputed at every sync. It is neither a professional appraisal nor
80 + a legal opinion on the rent. No data is invented: when the local
81 + market does not provide enough comparable listings, we say so instead
82 + of estimating blindly.
83 + </p>
84 + </section>
85 +
86 + <div className="fine">
87 + Questions or observations about an estimate?{" "}
88 + <Link to="/contact">Write to us</Link>.
89 + </div>
90 + </div>
91 + );
92 +}
added frontend/src/pages/KaScores.tsx +189 −0
@@ -0,0 +1,189 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/KaScores.tsx: "How are KA Scores calculated?"
5 +// Public methodology (mandatory scale transparency), live coverage of the
6 +// inventory, and personal priority settings (the sliders reweight the
7 +// global KA Score shown on listing pages).
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useMemo, useState } from "react";
10 +import {
11 + KA_DEFAULT_WEIGHTS, KaScoresStats, fetchKaScoresStats, kaWeights,
12 +} from "../api";
13 +import { KaScoreCircle } from "../components/KaScoreBadge";
14 +
15 +const SCORES: { key: string; nom: string; mesure: string; methode: string }[] = [
16 + {
17 + key: "walk", nom: "KA Walk Score", mesure: "Doing everything on foot.",
18 + methode: "Estimated walking distance to the nearest of each daily need "
19 + + "(grocery, pharmacy, park, café, school, clinic, daycare, convenience "
20 + + "store, gym, library), weighted by importance — a grocery store weighs "
21 + + "three times more than a gym. Full marks below a per-category "
22 + + "threshold (e.g. grocery ≤ 400 m), linear decay afterwards. Small "
23 + + "bonus when there is choice (3 grocery stores or cafés within 800 m).",
24 + },
25 + {
26 + key: "transit", nom: "KA Transit Score", mesure: "Public transit service.",
27 + methode: "Two parts: the walking distance to the nearest bus stop or rapid "
28 + + "transit station (OpenStreetMap), and the area's transit service level "
29 + + "measured by Statistics Canada (2021 proximity measures database, "
30 + + "Canadian percentile). An area with no stop within walking distance "
31 + + "shows \"Not served\", not a raw 0.",
32 + },
33 + {
34 + key: "bike", nom: "KA Bike Score", mesure: "Everyday cycling.",
35 + methode: "Kilometres of bike lanes (OSM paths and lanes) within 1 km, plus "
36 + + "the reachability of daily needs at biking distance. Elevation is not "
37 + + "yet taken into account (v1).",
38 + },
39 + {
40 + key: "calme", nom: "KA Quiet Score", mesure: "The estimated quietness of the area.",
41 + methode: "Decaying penalties by distance to mapped noise sources: "
42 + + "highways, main arteries, railways, airports and heliports, industrial "
43 + + "zones, bar concentrations. Bonus for a park close by. This is an "
44 + + "environment-based estimate, NOT a sound measurement.",
45 + },
46 + {
47 + key: "services", nom: "KA Services Score", mesure: "The overall richness of services.",
48 + methode: "Weighted count of points of interest by family — shops (1 km), "
49 + + "health (3 km), education (3 km), leisure (1 km) — with diminishing "
50 + + "returns: going from 0 to 3 grocery stores counts much more than from "
51 + + "12 to 15.",
52 + },
53 +];
54 +
55 +const NOMS_COURTS: Record<string, string> = {
56 + walk: "Walk", transit: "Transit", bike: "Bike",
57 + calme: "Quiet", services: "Services",
58 +};
59 +
60 +export default function KaScoresPage() {
61 + const [stats, setStats] = useState<KaScoresStats | null>(null);
62 + const [weights, setWeights] = useState<Record<string, number>>(() => kaWeights());
63 +
64 + useEffect(() => {
65 + fetchKaScoresStats().then(setStats).catch(() => {});
66 + document.title = "KA Scores — methodology | Rent-Ka";
67 + }, []);
68 +
69 + const somme = useMemo(
70 + () => Object.values(weights).reduce((a, b) => a + b, 0), [weights]);
71 +
72 + const setW = (k: string, v: number) => {
73 + const next = { ...weights, [k]: v };
74 + setWeights(next);
75 + try { localStorage.setItem("rentka_ks_weights", JSON.stringify(next)); } catch { /* private */ }
76 + };
77 + const reset = () => {
78 + setWeights(KA_DEFAULT_WEIGHTS);
79 + try { localStorage.removeItem("rentka_ks_weights"); } catch { /* private */ }
80 + };
81 +
82 + return (
83 + <div className="container page-doc">
84 + <section className="hero hero-doc">
85 + <span className="kicker">Transparency</span>
86 + <h1>How are <span className="hl">KA Scores</span> calculated?</h1>
87 + <p className="lede">
88 + Five in-house scores from 0 to 100 rate each rental's location:
89 + walking, public transit, biking, quiet and services. Here is exactly
90 + how — method, sources and limits.
91 + </p>
92 + {stats && (
93 + <div className="stat-row">
94 + <span className="stat-chip">
95 + <b>{stats.couverture_pct.toLocaleString("en-CA")} %</b> of the inventory scored
96 + ({stats.avec_score.toLocaleString("en-CA")} listings)
97 + </span>
98 + {stats.moyennes.global?.moyenne != null && (
99 + <span className="stat-chip">
100 + average global score <b>{stats.moyennes.global.moyenne}</b>
101 + </span>
102 + )}
103 + <span className="stat-chip">scale <b>{stats.version}</b></span>
104 + </div>
105 + )}
106 + </section>
107 +
108 + <div className="ka-demo-circles" aria-hidden="true">
109 + <KaScoreCircle score={92} nom="Walk" />
110 + <KaScoreCircle score={74} nom="Transit" />
111 + <KaScoreCircle score={61} nom="Bike" />
112 + <KaScoreCircle score={45} nom="Quiet" />
113 + <KaScoreCircle score={83} nom="Services" />
114 + </div>
115 +
116 + {SCORES.map((s) => (
117 + <section className="f-bloc doc-bloc" key={s.key}>
118 + <h2>{s.nom}</h2>
119 + <p><b>What it measures:</b> {s.mesure}</p>
120 + <p>{s.methode}</p>
121 + {stats?.moyennes[s.key]?.moyenne != null && (
122 + <p className="fine">
123 + Inventory average: {stats.moyennes[s.key]!.moyenne} —{" "}
124 + {stats.moyennes[s.key]!.n.toLocaleString("en-CA")} buildings rated.
125 + </p>
126 + )}
127 + </section>
128 + ))}
129 +
130 + <section className="f-bloc doc-bloc" id="priorites">
131 + <h2>Your priorities, your score</h2>
132 + <p>
133 + The global KA Score shown everywhere is a weighted average of the
134 + five scores. Set here what matters to <em>you</em> — listing pages
135 + will show your personalized score next to the standard one.
136 + These settings stay on your device.
137 + </p>
138 + <div className="ka-poids">
139 + {Object.keys(KA_DEFAULT_WEIGHTS).map((k) => (
140 + <label key={k} className="ka-poids-row">
141 + <span>{NOMS_COURTS[k]}</span>
142 + <input
143 + type="range" min={0} max={50} step={5}
144 + value={Math.round((weights[k] ?? 0) * 100)}
145 + onChange={(e) => setW(k, Number(e.target.value) / 100)}
146 + aria-label={`Importance of ${NOMS_COURTS[k]}`}
147 + />
148 + <b>{Math.round(((weights[k] ?? 0) / (somme || 1)) * 100)} %</b>
149 + </label>
150 + ))}
151 + </div>
152 + <button className="btn btn-ghost" onClick={reset}>
153 + Back to standard weights
154 + </button>
155 + </section>
156 +
157 + <section className="f-bloc doc-bloc">
158 + <h2>Sources, honesty and limits</h2>
159 + <ul className="doc-liste">
160 + <li>
161 + <b>Sources:</b> points of interest, roads, rails and bike lanes
162 + © <a href="https://www.openstreetmap.org/copyright"
163 + target="_blank" rel="noreferrer">OpenStreetMap</a> contributors
164 + (ODbL); transit service: Proximity Measures Database, Statistics
165 + Canada (2021).
166 + </li>
167 + <li>
168 + <b>Distances:</b> straight-line multiplied by 1.3 (usual network
169 + factor) — not exact pedestrian routing.
170 + </li>
171 + <li>
172 + <b>Insufficient data:</b> a poorly mapped area (rural zone) shows
173 + "Insufficient data" rather than a misleading score; an area with
174 + no public transit shows "Not served".
175 + </li>
176 + <li>
177 + <b>Consistency:</b> scores are computed per building; two rentals
178 + in the same building share exactly the same scores.
179 + </li>
180 + <li>
181 + <b>Versioning:</b> the scale is versioned; any method change
182 + recomputes the whole inventory and the computation date is shown
183 + on every listing page.
184 + </li>
185 + </ul>
186 + </section>
187 + </div>
188 + );
189 +}
added frontend/src/pages/Listing.tsx +537 −0
@@ -0,0 +1,537 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Listing.tsx: listing detail page — mobile-first
5 +// DOM order = visual order, identical on mobile AND desktop (Groupe KA
6 +// "detail page section order" standard): gallery → price + market badge +
7 +// address + chips → CTA/PDF → description → amenities → practical details →
8 +// price analysis → location → KA Scores → nearby.
9 +// Mobile: stacked columns; desktop: 2-column grid (col A | col B).
10 +// Forbidden: reordering via `order` / `column-reverse` (v2 bug where blocks
11 +// without `order` jumped ahead of the gallery on mobile).
12 +// -----------------------------------------------------------------------------
13 +import { lazy, Suspense, useEffect, useRef, useState } from "react";
14 +import { Link, useParams } from "react-router-dom";
15 +import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api";
16 +import SmartImg from "../components/SmartImg";
17 +import FairValueBadge from "../components/FairValueBadge";
18 +import PriceAnalysis from "../components/PriceAnalysis";
19 +import HistoriqueLouka from "../components/HistoriqueLouka";
20 +import ImmeubleBloc from "../components/ImmeubleBloc";
21 +import GestionnaireBloc from "../components/GestionnaireBloc";
22 +import HiverScore from "../components/HiverScore";
23 +import { IcoAlert, IcoDoc } from "../components/Icons";
24 +import AmenityIco from "../components/AmenityIco";
25 +import KaScoresBlock from "../components/KaScoresBlock";
26 +import { markSeen } from "../search/seen";
27 +
28 +// 3D mini-map (Mapbox) — lazily loaded, like the big map.
29 +const ListingMap3D = lazy(() => import("../components/ListingMap3D"));
30 +
31 +// Icons and labels of nearby amenities (rentka/poi.py category keys)
32 +const POI_META: Record<string, { icon: string; label: string }> = {
33 + epicerie: { icon: "🛒", label: "Grocery store" },
34 + depanneur: { icon: "🏪", label: "Convenience store" },
35 + pharmacie: { icon: "💊", label: "Pharmacy" },
36 + ecole: { icon: "🏫", label: "School" },
37 + garderie: { icon: "🧸", label: "Daycare" },
38 + parc: { icon: "🌳", label: "Park" },
39 + bus: { icon: "🚌", label: "Bus" },
40 + metro: { icon: "🚇", label: "Subway" },
41 + gym: { icon: "🏋️", label: "Gym" },
42 + cafe: { icon: "☕", label: "Café" },
43 + clinique: { icon: "🩺", label: "Clinic" },
44 + hopital: { icon: "🏥", label: "Hospital" },
45 + bibliotheque: { icon: "📚", label: "Library" },
46 +};
47 +
48 +// POI grouped into collapsible categories
49 +const POI_GROUPES: { titre: string; icone: string; cats: string[] }[] = [
50 + { titre: "Groceries", icone: "🛒", cats: ["epicerie", "depanneur"] },
51 + { titre: "Transit", icone: "🚌", cats: ["bus", "metro"] },
52 + { titre: "School and family", icone: "🎓", cats: ["ecole", "garderie", "bibliotheque"] },
53 + { titre: "Health", icone: "🏥", cats: ["pharmacie", "clinique", "hopital"] },
54 + { titre: "Neighbourhood life", icone: "☕", cats: ["cafe", "parc", "gym"] },
55 +];
56 +
57 +const PETS_LABEL: Record<string, string> = {
58 + oui: "Pets allowed", non: "No pets", conditions: "Pets with conditions",
59 +};
60 +
61 +const NBSP = " ";
62 +
63 +/** ≈ walking minutes (straight-line × detour factor 1.3, 4.8 km/h) */
64 +const fmtMarche = (m: number): string =>
65 + `≈${NBSP}${Math.max(1, Math.round((m * 1.3) / 80))}${NBSP}min walk`;
66 +
67 +/** Badges derived from structured details (confirmed inclusions ✓). */
68 +function badgesConfirmes(l: Listing): string[] {
69 + const d = l.details ?? {};
70 + const out: string[] = [];
71 + const inc = d.inclusions ?? {};
72 + if (inc.heating) out.push("Heat included");
73 + if (inc.electricity) out.push("Electricity included");
74 + if (inc.hot_water) out.push("Hot water included");
75 + if (inc.internet) out.push("Internet included");
76 + const app = d.appliances ?? {};
77 + if (app.dishwasher) out.push("Dishwasher");
78 + if (app.washer_dryer) out.push("Washer-dryer");
79 + if (app.fridge && app.stove) out.push("Appliances");
80 + if (d.ac) out.push("Air conditioning");
81 + if (d.elevator) out.push("Elevator");
82 + if (d.balcony) out.push("Balcony");
83 + if (d.pool) out.push("Pool");
84 + if (d.gym) out.push("Gym");
85 + if (d.laundry) out.push("Laundry");
86 + if (d.storage) out.push("Storage");
87 + if (d.parking?.available)
88 + out.push(`Parking${d.parking.type ? ` ${d.parking.type}` : ""}${d.parking.included ? " included" : ""}`);
89 + if (l.furnished) out.push("Furnished");
90 + if (d.smoking === false) out.push("Non-smoking");
91 + return out;
92 +}
93 +
94 +// --- Full-screen lightbox: swipe between photos + pinch to zoom -------------
95 +function Lightbox({ images, start, titre, onClose }:
96 + { images: string[]; start: number; titre: string; onClose: () => void }) {
97 + const [idx, setIdx] = useState(start);
98 + const [scale, setScale] = useState(1);
99 + const [tx, setTx] = useState(0);
100 + const [ty, setTy] = useState(0);
101 + const track = useRef<HTMLDivElement>(null);
102 + const pointers = useRef(new Map<number, { x: number; y: number }>());
103 + const pinch = useRef<{ d: number; scale: number } | null>(null);
104 + const lastTap = useRef(0);
105 +
106 + useEffect(() => { // initial position + page scroll lock
107 + track.current?.scrollTo({ left: start * track.current.clientWidth });
108 + document.body.style.overflow = "hidden";
109 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
110 + window.addEventListener("keydown", onKey);
111 + return () => {
112 + document.body.style.overflow = "";
113 + window.removeEventListener("keydown", onKey);
114 + };
115 + // eslint-disable-next-line react-hooks/exhaustive-deps
116 + }, []);
117 +
118 + const resetZoom = () => { setScale(1); setTx(0); setTy(0); };
119 + const onScroll = () => {
120 + const el = track.current;
121 + if (el && scale === 1) {
122 + const i = Math.round(el.scrollLeft / el.clientWidth);
123 + if (i !== idx) { setIdx(i); resetZoom(); }
124 + }
125 + };
126 + const dist = () => {
127 + const [a, b] = [...pointers.current.values()];
128 + return Math.hypot(a.x - b.x, a.y - b.y);
129 + };
130 + const onPointerDown = (e: React.PointerEvent) => {
131 + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
132 + if (pointers.current.size === 2)
133 + pinch.current = { d: dist(), scale };
134 + if (pointers.current.size === 1) { // double-tap = ×2.5 zoom / back to ×1
135 + const now = Date.now();
136 + if (now - lastTap.current < 300) {
137 + if (scale > 1) resetZoom(); else setScale(2.5);
138 + }
139 + lastTap.current = now;
140 + }
141 + };
142 + const onPointerMove = (e: React.PointerEvent) => {
143 + const prev = pointers.current.get(e.pointerId);
144 + if (!prev) return;
145 + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
146 + if (pointers.current.size === 2 && pinch.current) {
147 + const s = Math.min(4, Math.max(1, pinch.current.scale * (dist() / pinch.current.d)));
148 + setScale(s);
149 + if (s === 1) { setTx(0); setTy(0); }
150 + } else if (pointers.current.size === 1 && scale > 1) {
151 + setTx((v) => v + (e.clientX - prev.x)); // pan while zoomed
152 + setTy((v) => v + (e.clientY - prev.y));
153 + }
154 + };
155 + const onPointerUp = (e: React.PointerEvent) => {
156 + pointers.current.delete(e.pointerId);
157 + if (pointers.current.size < 2) pinch.current = null;
158 + };
159 +
160 + return (
161 + <div className="lightbox lightbox-v2" role="dialog" aria-modal="true"
162 + aria-label={`Photos — ${titre}`}>
163 + <button className="lightbox-close" aria-label="Close" onClick={onClose}>✕</button>
164 + <span className="carousel-count lightbox-count" aria-live="polite">
165 + {idx + 1}/{images.length}
166 + </span>
167 + <div
168 + className="lightbox-track" ref={track} onScroll={onScroll}
169 + style={scale > 1 ? { overflow: "hidden", touchAction: "none" } : undefined}
170 + onPointerDown={onPointerDown} onPointerMove={onPointerMove}
171 + onPointerUp={onPointerUp} onPointerCancel={onPointerUp}
172 + >
173 + {images.map((u, i) => (
174 + <div className="lightbox-cell" key={u}>
175 + <SmartImg
176 + src={u} original alt={`${titre} — photo ${i + 1} of ${images.length}`}
177 + draggable={false}
178 + style={i === idx && scale > 1
179 + ? { transform: `translate(${tx}px, ${ty}px) scale(${scale})` }
180 + : undefined}
181 + />
182 + </div>
183 + ))}
184 + </div>
185 + {scale === 1 && idx > 0 && (
186 + <button className="carousel-nav prev" aria-label="Previous photo"
187 + onClick={() => track.current?.scrollTo({
188 + left: (idx - 1) * track.current.clientWidth, behavior: "smooth" })}>‹</button>
189 + )}
190 + {scale === 1 && idx < images.length - 1 && (
191 + <button className="carousel-nav next" aria-label="Next photo"
192 + onClick={() => track.current?.scrollTo({
193 + left: (idx + 1) * track.current.clientWidth, behavior: "smooth" })}>›</button>
194 + )}
195 + </div>
196 + );
197 +}
198 +
199 +// --- Gallery with native swipe (scroll-snap) + counter + full screen --------
200 +function Galerie({ images, titre, unitType }:
201 + { images: string[]; titre: string; unitType?: string }) {
202 + const [idx, setIdx] = useState(0);
203 + const [zoom, setZoom] = useState(false);
204 + const track = useRef<HTMLDivElement>(null);
205 +
206 + const onScroll = () => {
207 + const el = track.current;
208 + if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
209 + };
210 + const goto = (i: number) =>
211 + track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
212 +
213 + if (images.length === 0)
214 + return (
215 + <div className="carousel">
216 + <SmartImg src={null} fallbackLabel={unitType}
217 + alt="No photo provided by the source" />
218 + </div>
219 + );
220 +
221 + return (
222 + <>
223 + <div className="carousel">
224 + <div className="carousel-track" ref={track} onScroll={onScroll}>
225 + {images.map((u, i) => (
226 + <SmartImg
227 + key={u} src={u} width_={800} fallbackLabel={unitType}
228 + loading={i === 0 ? "eager" : "lazy"} decoding="async"
229 + alt={`${titre} — photo ${i + 1} of ${images.length}`}
230 + onClick={() => setZoom(true)}
231 + />
232 + ))}
233 + </div>
234 + <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>
235 + {idx > 0 && (
236 + <button className="carousel-nav prev" aria-label="Previous photo" onClick={() => goto(idx - 1)}>‹</button>
237 + )}
238 + {idx < images.length - 1 && (
239 + <button className="carousel-nav next" aria-label="Next photo" onClick={() => goto(idx + 1)}>›</button>
240 + )}
241 + </div>
242 + {images.length > 1 && (
243 + <div className="thumbs">
244 + {images.map((u, i) => (
245 + <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}
246 + aria-label={`Photo ${i + 1}`}>
247 + <SmartImg src={u} width_={160} alt="" loading="lazy" decoding="async" />
248 + </button>
249 + ))}
250 + </div>
251 + )}
252 + {zoom && (
253 + <Lightbox images={images} start={idx} titre={titre}
254 + onClose={() => setZoom(false)} />
255 + )}
256 + </>
257 + );
258 +}
259 +
260 +export default function ListingPage() {
261 + const { uid } = useParams<{ uid: string }>();
262 + const [l, setL] = useState<Listing | null>(null);
263 + const [error, setError] = useState<string | null>(null);
264 +
265 + useEffect(() => {
266 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
267 + if (!uid) return;
268 + fetchListing(uid).then(setL).catch((e) => setError(String(e)));
269 + markSeen(uid); // dimmed "already seen" marker on the search map
270 + window.scrollTo(0, 0);
271 + }, [uid]);
272 +
273 + if (error)
274 + return (
275 + <div className="notice container">
276 + <div className="big"><IcoAlert size={40} /></div>
277 + <h2>Listing not found</h2>
278 + <p>{error}</p>
279 + <Link className="btn btn-primary" to="/">Back to rentals</Link>
280 + </div>
281 + );
282 +
283 + if (!l)
284 + return (
285 + <div className="container detail">
286 + <div className="fiche" aria-busy="true">
287 + <div className="skel"><div className="sk-img" /></div>
288 + <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
289 + </div>
290 + </div>
291 + );
292 +
293 + const dg = l.digest ?? null;
294 + const f = dg?.faits;
295 + const conf = dg?.confiance ?? {};
296 + const confirmes = badgesConfirmes(l);
297 + const autres = l.amenities.filter(
298 + (a) => !confirmes.some((b) => b.toLowerCase().includes(a.toLowerCase())));
299 + const inc = l.details?.inclusions ?? {};
300 + const zeroFrais = inc.heating && inc.electricity && inc.hot_water;
301 +
302 + const enLigneDepuis = l.first_seen
303 + ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null;
304 + const hist = (l.price_history ?? []).filter((h) => h.price != null);
305 + const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price
306 + ? { de: hist[1].price!, a: hist[0].price! } : null;
307 +
308 + const updated = l.updated_at
309 + ? new Date(l.updated_at * 1000).toLocaleDateString("en-CA", {
310 + day: "numeric", month: "long", year: "numeric" }) : null;
311 +
312 + // key chips (only high-confidence facts extracted from text)
313 + const chips: string[] = [];
314 + if (l.unit_type) chips.push(l.unit_type);
315 + const dispo = fmtAvailability(l.availability_date);
316 + if (dispo) chips.push(dispo === "Now" ? "Available now" : `Available ${dispo}`);
317 + if (l.furnished) chips.push("Furnished");
318 + if (l.pets) chips.push(PETS_LABEL[l.pets] ?? l.pets);
319 + if (l.area_sqft) chips.push(`${Math.round(l.area_sqft).toLocaleString("en-CA")}${NBSP}sq ft`);
320 + if (f?.nb_occupants_total && conf.nb_occupants_total !== "faible")
321 + chips.push(`${f.nb_occupants_total} occupants`);
322 + if (f?.salle_de_bain && conf.salle_de_bain !== "faible")
323 + chips.push(`${f.salle_de_bain === "commune" ? "Shared" : "Private"} bathroom`);
324 + if (l.details?.floor != null) chips.push(`Floor ${l.details.floor}`);
325 +
326 + const pois = l.poi ?? [];
327 +
328 + return (
329 + <div className="container detail">
330 + <nav className="crumbs" aria-label="Breadcrumb">
331 + <Link to="/">Rentals</Link> ›
332 + {l.city && <span>{l.city}</span>} ›
333 + <span>{l.title || l.address}</span>
334 + </nav>
335 +
336 + <div className="fiche">
337 + {/* ------- left column (desktop): gallery, price, description, -------
338 + ------- amenities, practical — DOM order IS the visual order ------ */}
339 + <div className="f-col">
340 + <section className="f-bloc f-galerie" aria-label="Photos">
341 + <Galerie images={l.images ?? []} titre={l.title || l.address}
342 + unitType={l.unit_type || undefined} />
343 + </section>
344 +
345 + <section className="f-bloc f-hero">
346 + <div className="price">
347 + {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/{NBSP}month</small>}
348 + </div>
349 + {l.fv_verdict && (
350 + <div><FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} /></div>
351 + )}
352 + <h1>{l.title || l.address}</h1>
353 + <div className="loc">
354 + {[l.address !== l.title ? l.address : "", l.sector, l.city].filter(Boolean).join(" · ")}
355 + </div>
356 + <div className="chips-scroll" role="list" aria-label="Key features">
357 + {chips.map((c) => (
358 + <span className="chip-key" role="listitem" key={c}>
359 + <AmenityIco label={c} size={14} fallback="spark" /> {c}
360 + </span>
361 + ))}
362 + </div>
363 + <nav className="ancres" aria-label="Page sections">
364 + <a href="#description">Description</a>
365 + <a href="#analyse-prix">Price</a>
366 + <a href="#inclusions">Amenities</a>
367 + {l.lat != null && l.lng != null && <a href="#emplacement">Map</a>}
368 + <a href="#proximite">Nearby</a>
369 + </nav>
370 + <a className="cta cta-desktop" href={`/gateway/${encodeURIComponent(l.uid)}`}
371 + target="_blank" rel="noopener noreferrer">
372 + See the listing at {sourceName(l.source)} ↗
373 + </a>
374 + <a className="btn btn-ghost btn-pdf"
375 + href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download>
376 + <IcoDoc size={14} /> Download the listing sheet (PDF)
377 + </a>
378 + </section>
379 +
380 + <section className="f-bloc f-desc" id="description">
381 + <h2>Description</h2>
382 + {dg ? (
383 + <>
384 + {dg.en_bref && <p className="enbref">{dg.en_bref}</p>}
385 + {dg.sections.map((s) => (
386 + <div key={s.titre} className="desc-section">
387 + <h4>{s.titre}</h4>
388 + <p>{s.texte}</p>
389 + </div>
390 + ))}
391 + <details className="texte-original">
392 + <summary>See the source's original text</summary>
393 + <p>{l.description}</p>
394 + </details>
395 + </>
396 + ) : (
397 + l.description
398 + ? <p style={{ color: "var(--ink-2)" }}>{l.description}</p>
399 + : <p className="fine">The source does not provide a description for this listing.</p>
400 + )}
401 + </section>
402 +
403 + <section className="f-bloc f-incl" id="inclusions">
404 + <h2>Amenities and inclusions</h2>
405 + {zeroFrais && <div className="deal-badge deal-good">💡 Heat, electricity and hot water included — $0 hidden costs</div>}
406 + <div className="amenity-grid">
407 + {confirmes.map((b) => (
408 + <span className="amenity-it confirmed" key={`c-${b}`}>
409 + <span className="am-ico"><AmenityIco label={b} /></span>
410 + <span className="am-txt">{b}</span>
411 + <span className="am-conf" title="Confirmed by the source's structured data">✓</span>
412 + </span>
413 + ))}
414 + {autres.map((a) => (
415 + <span className="amenity-it unconfirmed" key={a} title="Mentioned by the source, without structured confirmation">
416 + <span className="am-ico"><AmenityIco label={a} fallback="spark" /></span>
417 + <span className="am-txt">{a}</span>
418 + </span>
419 + ))}
420 + </div>
421 + {confirmes.length === 0 && autres.length === 0 && (
422 + <p className="fine">The source does not specify inclusions.</p>
423 + )}
424 + </section>
425 +
426 + <section className="f-bloc f-pratique">
427 + <h2>Practical details</h2>
428 + <div className="kv">
429 + <div className="cell"><div className="k">Manager</div><div className="v">{sourceName(l.source)}</div></div>
430 + {l.price_label && (
431 + <div className="cell"><div className="k">Advertised price</div><div className="v">{l.price_label}</div></div>
432 + )}
433 + {f?.duree_bail_minimale_mois && (
434 + <div className="cell"><div className="k">Minimum lease</div><div className="v">{f.duree_bail_minimale_mois} months</div></div>
435 + )}
436 + {enLigneDepuis != null && (
437 + <div className="cell"><div className="k">Online for</div>
438 + <div className="v">{enLigneDepuis === 0 ? "today" : `${enLigneDepuis}${NBSP}day${enLigneDepuis > 1 ? "s" : ""}`}</div></div>
439 + )}
440 + {updated && (
441 + <div className="cell"><div className="k">Synced</div><div className="v">{updated}</div></div>
442 + )}
443 + </div>
444 + {baissePrix && (
445 + <div className={`prix-histo ${baissePrix.a < baissePrix.de ? "down" : "up"}`}>
446 + {baissePrix.a < baissePrix.de ? "📉" : "📈"} Price went from{" "}
447 + {fmtPrice(baissePrix.de)} to <b>{fmtPrice(baissePrix.a)}</b>
448 + {baissePrix.a < baissePrix.de && " — negotiation leverage"}
449 + </div>
450 + )}
451 + </section>
452 + </div>
453 +
454 + {/* ------- right column (desktop): price analysis, history, ----------
455 + ------- building, manager, map, KA Scores, nearby ----------------- */}
456 + <div className="f-col">
457 + <PriceAnalysis uid={l.uid} price={l.price} />
458 +
459 + <HistoriqueLouka uid={l.uid} />
460 +
461 + {l.immeuble && <ImmeubleBloc im={l.immeuble} />}
462 +
463 + <GestionnaireBloc source={l.source} />
464 +
465 + {l.lat != null && l.lng != null && (
466 + <section className="f-bloc f-carte" id="emplacement">
467 + <h2>Location</h2>
468 + <Suspense fallback={<div className="lmap3d lmap3d-skel" aria-busy="true" />}>
469 + <ListingMap3D l={l} />
470 + </Suspense>
471 + <p className="fine">
472 + 3D view of the area — the listing's building is highlighted.
473 + Position from the geocoded address (OpenStreetMap).
474 + </p>
475 + </section>
476 + )}
477 +
478 + {l.kascores && <KaScoresBlock ks={l.kascores} />}
479 +
480 + {l.hiver && <HiverScore h={l.hiver} />}
481 +
482 + <section className="f-bloc f-poi" id="proximite">
483 + {pois.length > 0 && (
484 + <>
485 + <h2>Nearby</h2>
486 + {POI_GROUPES.map((g, gi) => {
487 + const items = pois.filter((p) => g.cats.includes(p.cat));
488 + if (items.length === 0) return null;
489 + return (
490 + <details className="poi-groupe" key={g.titre} open={gi === 0}>
491 + <summary>
492 + <span>{g.icone} {g.titre}</span>
493 + <span className="poi-resume">
494 + {items.length} · nearest at {fmtDist(items[0].dist_m)}
495 + </span>
496 + </summary>
497 + <ul className="poi-list">
498 + {items.map((p) => {
499 + const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat };
500 + return (
501 + <li key={p.cat} title={meta.label}>
502 + <span className="poi-ico" aria-hidden="true">{meta.icon}</span>
503 + <span className="poi-name">{p.name}</span>
504 + <span className="poi-dist">{fmtDist(p.dist_m)} · {fmtMarche(p.dist_m)}</span>
505 + </li>
506 + );
507 + })}
508 + </ul>
509 + </details>
510 + );
511 + })}
512 + <div className="fine">
513 + Estimated walking times (straight-line distance ×{NBSP}1.3, 4.8{NBSP}km/h) — OpenStreetMap data.
514 + </div>
515 + </>
516 + )}
517 + </section>
518 + </div>
519 + </div>
520 +
521 + <div className="fine f-foot">
522 + {updated && <>Last synced: {updated}. </>}
523 + Prices and availability are as displayed by the source — every listing
524 + links back to the original ad.
525 + </div>
526 +
527 + {/* sticky mobile CTA — always visible */}
528 + <div className="cta-sticky">
529 + <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}{l.price != null && <small>/month</small>}</span>
530 + <a className="cta" href={`/gateway/${encodeURIComponent(l.uid)}`}
531 + target="_blank" rel="noopener noreferrer">
532 + See at {sourceName(l.source)} ↗
533 + </a>
534 + </div>
535 + </div>
536 + );
537 +}
added frontend/src/pages/Passerelle.tsx +101 −0
@@ -0,0 +1,101 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Passerelle.tsx: gateway page to the original listing.
5 +// Opened in a new tab by the listing CTAs: shows Rent-Ka → manager with a
6 +// progress bar, then redirects to the manager's ad after ~2.2 s.
7 +// Security: we receive a uid (never a raw URL) — the destination URL comes
8 +// from our API, not from the parameter. No open redirect possible.
9 +// -----------------------------------------------------------------------------
10 +import { useEffect, useRef, useState } from "react";
11 +import { Link, useParams } from "react-router-dom";
12 +import { fetchListing, fetchSources, registerSourceNames, sourceName } from "../api";
13 +
14 +const DELAI_MS = 2200;
15 +
16 +export default function PasserellePage() {
17 + const { uid } = useParams<{ uid: string }>();
18 + const [dest, setDest] = useState<{ url: string; source: string } | null>(null);
19 + const [error, setError] = useState(false);
20 + const timer = useRef<number | null>(null);
21 +
22 + useEffect(() => {
23 + if (!uid) return;
24 + let cancelled = false;
25 + Promise.all([fetchSources(), fetchListing(uid)])
26 + .then(([src, l]) => {
27 + if (cancelled) return;
28 + registerSourceNames(src.sources);
29 + if (!l.url || !/^https?:\/\//.test(l.url)) { setError(true); return; }
30 + setDest({ url: l.url, source: l.source });
31 + timer.current = window.setTimeout(() => {
32 + window.location.replace(l.url);
33 + }, DELAI_MS);
34 + })
35 + .catch(() => !cancelled && setError(true));
36 + return () => {
37 + cancelled = true;
38 + if (timer.current) window.clearTimeout(timer.current);
39 + };
40 + }, [uid]);
41 +
42 + if (error)
43 + return (
44 + <div className="notice container">
45 + <div className="big">⚠️</div>
46 + <h2>Listing not found</h2>
47 + <p>Could not find the original listing.</p>
48 + <Link className="btn btn-primary" to="/">Back to rentals</Link>
49 + </div>
50 + );
51 +
52 + const nom = dest ? sourceName(dest.source) : "…";
53 +
54 + return (
55 + <div className="passerelle" role="status" aria-live="polite">
56 + <div className="pass-card">
57 + <div className="pass-brands">
58 + <span className="pass-brand">
59 + Rent<span className="ka">·Ka</span>
60 + </span>
61 + <span className="pass-trajet" aria-hidden="true">
62 + <span className="pass-dot" /><span className="pass-dot" /><span className="pass-dot" />
63 + <span className="pass-fleche">➔</span>
64 + </span>
65 + <span className="pass-gestionnaire">
66 + <span className="pass-gest-ico" aria-hidden="true">🏢</span>
67 + {nom}
68 + </span>
69 + </div>
70 +
71 + <h1>Gateway to the original listing</h1>
72 + <p className="pass-texte">
73 + {dest
74 + ? <>We are taking you to <b>{nom}</b> — prices, visits and
75 + signatures happen directly with the manager.</>
76 + : "Preparing the redirect…"}
77 + </p>
78 +
79 + <div className="pass-progress" aria-hidden="true">
80 + <span className={`pass-progress-fill ${dest ? "go" : ""}`} />
81 + </div>
82 +
83 + <div className="pass-actions">
84 + {dest && (
85 + <a className="btn btn-primary" href={dest.url} rel="noopener noreferrer">
86 + Go now ↗
87 + </a>
88 + )}
89 + <button className="btn btn-ghost" onClick={() => window.close()}>
90 + Stay on Rent-Ka
91 + </button>
92 + </div>
93 +
94 + <div className="fine">
95 + Rent-Ka is an independent aggregator: every listing links back to
96 + the ad published by the property manager.
97 + </div>
98 + </div>
99 + </div>
100 + );
101 +}
added frontend/src/pages/Privacy.tsx +80 −0
@@ -0,0 +1,80 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Privacy.tsx: privacy and cookie policy
5 +// -----------------------------------------------------------------------------
6 +
7 +export default function PrivacyPage() {
8 + const reopen = () => window.dispatchEvent(new Event("rentka:openConsent"));
9 +
10 + return (
11 + <div className="container legal">
12 + <span className="kicker">Privacy &amp; cookies</span>
13 + <h1>Your privacy, kept simple.</h1>
14 +
15 + <section>
16 + <h3>Who is responsible</h3>
17 + <p>
18 + Rent-Ka is a <b>Groupe KA</b> platform{" "}
19 + (<a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">
20 + groupe-ka.com
21 + </a>). The person responsible for the protection of personal
22 + information can be reached at{" "}
23 + <a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a>.
24 + </p>
25 + </section>
26 +
27 + <section>
28 + <h3>What Rent-Ka collects</h3>
29 + <p>
30 + Nothing that identifies you by default. Rent-Ka has no advertising
31 + pixel and sells no data. The site reads public listings from property
32 + managers and shows them to you — that's it. If you sign in with a
33 + KA ID account, only your profile and saved rentals are stored.
34 + </p>
35 + </section>
36 +
37 + <section>
38 + <h3>Cookies and local storage</h3>
39 + <p>
40 + Rent-Ka uses <b>no third-party cookies</b>. The only storage lives in
41 + your browser ("localStorage"):
42 + </p>
43 + <ul>
44 + <li><b>Essential</b> — your consent choice itself.</li>
45 + <li><b>Preferences</b> (if accepted) — your search filters and list/map view.</li>
46 + <li><b>Statistics</b> (if accepted) — possible anonymous audience
47 + measurement, with no identifier or profiling. None is active today.</li>
48 + </ul>
49 + <p>
50 + <button className="btn btn-ghost" onClick={reopen}>Change my cookie choices</button>
51 + </p>
52 + </section>
53 +
54 + <section>
55 + <h3>External services</h3>
56 + <ul>
57 + <li><b>Listing photos</b> — loaded from the property managers' servers;
58 + their policy applies to those requests.</li>
59 + <li><b>Map</b> — vector tiles (data © OpenStreetMap), loaded only if
60 + you open the map view.</li>
61 + <li><b>Nearby amenities</b> — computed server-side from OpenStreetMap;
62 + your browser does not contact those services.</li>
63 + </ul>
64 + </section>
65 +
66 + <section>
67 + <h3>Your listings, your sources</h3>
68 + <p>
69 + Every listing links back to the manager's original ad. To have content
70 + removed or for any general question:{" "}
71 + <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a> —
72 + for an information request:{" "}
73 + <a href="mailto:info@groupe-ka.com">info@groupe-ka.com</a>.
74 + </p>
75 + </section>
76 +
77 + <div className="fine">Last updated: August 2026.</div>
78 + </div>
79 + );
80 +}
added frontend/src/pages/Profile.tsx +486 −0
@@ -0,0 +1,486 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Profile.tsx: user profile — Groupe KA member card (KA-ID),
5 +// account information, actions (sign out, cookies, legal)
6 +// -----------------------------------------------------------------------------
7 +import { useRef, useEffect, useState } from "react";
8 +import { Link, useNavigate } from "react-router-dom";
9 +import {
10 + Me, Socials, deleteAvatar, fetchMe, logout, setPublicProfile,
11 + updateMyProfile, uploadAvatar,
12 +} from "../api";
13 +import {
14 + IcoBuilding, IcoCamera, IcoDoc, IcoFacebook, IcoHeart, IcoInstagram,
15 + IcoLinkedIn, IcoLock, IcoTikTok, IcoX, IcoYouTube,
16 +} from "../components/Icons";
17 +
18 +const fmtEpoch = (ts: number | null | undefined): string => {
19 + if (!ts) return "—";
20 + return new Date(ts * 1000).toLocaleDateString("en-CA", {
21 + day: "numeric", month: "long", year: "numeric",
22 + });
23 +};
24 +
25 +const SOCIAL_BASE: Record<string, string> = {
26 + instagram: "https://www.instagram.com/",
27 + facebook: "https://www.facebook.com/",
28 + x: "https://x.com/",
29 + linkedin: "https://www.linkedin.com/in/",
30 + tiktok: "https://www.tiktok.com/@",
31 + youtube: "https://www.youtube.com/@",
32 +};
33 +
34 +/** "@handle" or "handle" -> full platform URL; a URL is left as is */
35 +function socialUrl(key: string, value: string): string {
36 + const v = value.trim();
37 + if (/^https?:\/\//i.test(v)) return v;
38 + if (key === "website") return `https://${v}`;
39 + return (SOCIAL_BASE[key] ?? "https://") + v.replace(/^@/, "");
40 +}
41 +
42 +const SOCIAL_FIELDS: { key: keyof Socials; label: string; icon: JSX.Element;
43 + placeholder: string }[] = [
44 + { key: "instagram", label: "Instagram", icon: <IcoInstagram size={15} />, placeholder: "@handle or URL" },
45 + { key: "facebook", label: "Facebook", icon: <IcoFacebook size={15} />, placeholder: "profile or URL" },
46 + { key: "x", label: "X (Twitter)", icon: <IcoX size={15} />, placeholder: "@handle or URL" },
47 + { key: "linkedin", label: "LinkedIn", icon: <IcoLinkedIn size={15} />, placeholder: "profile or URL" },
48 + { key: "tiktok", label: "TikTok", icon: <IcoTikTok size={15} />, placeholder: "@handle or URL" },
49 + { key: "youtube", label: "YouTube", icon: <IcoYouTube size={15} />, placeholder: "channel or URL" },
50 +];
51 +
52 +/** Edit section: photo, display name, bio, contact info, social networks */
53 +function ProfileEditor({ me, onSaved }: { me: Me; onSaved: () => void }) {
54 + const [form, setForm] = useState({
55 + display_name: me.display_name || "",
56 + bio: me.bio || "",
57 + city: me.city || "",
58 + phone: me.phone || "",
59 + website: me.website || "",
60 + socials: { ...me.socials } as Socials,
61 + });
62 + const [saving, setSaving] = useState(false);
63 + const [saved, setSaved] = useState(false);
64 + const [error, setError] = useState("");
65 + const [avatarBusy, setAvatarBusy] = useState(false);
66 + const fileRef = useRef<HTMLInputElement | null>(null);
67 +
68 + const save = async () => {
69 + setSaving(true);
70 + setError("");
71 + const res = await updateMyProfile(form);
72 + setSaving(false);
73 + if (!res.ok) { setError("Could not save — try again."); return; }
74 + setSaved(true);
75 + setTimeout(() => setSaved(false), 2000);
76 + onSaved();
77 + };
78 +
79 + const pickPhoto = async (f: File | undefined) => {
80 + if (!f) return;
81 + setAvatarBusy(true);
82 + setError("");
83 + const url = await uploadAvatar(f);
84 + setAvatarBusy(false);
85 + if (!url) { setError("Photo rejected — JPG/PNG/WebP image, 8 MB max."); return; }
86 + onSaved();
87 + };
88 +
89 + return (
90 + <section className="org-form profil-edit">
91 + <h3>Customize my profile</h3>
92 +
93 + {/* — photo — */}
94 + <div className="pe-avatar-row">
95 + {me.picture
96 + ? <img className="pe-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" />
97 + : <span className="pe-avatar pe-avatar-empty">
98 + {(me.name || me.email).charAt(0).toUpperCase()}
99 + </span>}
100 + <div className="pe-avatar-actions">
101 + <button className="btn btn-ghost" disabled={avatarBusy}
102 + onClick={() => fileRef.current?.click()}>
103 + <IcoCamera size={14} /> {avatarBusy ? "Uploading…" : "Change photo"}
104 + </button>
105 + {me.avatar_url && (
106 + <button className="btn btn-ghost" disabled={avatarBusy}
107 + onClick={async () => { await deleteAvatar(); onSaved(); }}>
108 + Remove (back to Google)
109 + </button>
110 + )}
111 + <span className="pe-hint">JPG, PNG or WebP — cropped to a 512 px square.</span>
112 + </div>
113 + <input
114 + ref={fileRef} type="file" accept="image/*" hidden
115 + onChange={(e) => pickPhoto(e.target.files?.[0])}
116 + />
117 + </div>
118 +
119 + <div className="org-form-row">
120 + <label>
121 + <span>Display name</span>
122 + <input
123 + value={form.display_name} maxLength={80}
124 + placeholder={me.google_name || "Your name"}
125 + onChange={(e) => setForm({ ...form, display_name: e.target.value })}
126 + />
127 + </label>
128 + <label>
129 + <span>City</span>
130 + <input
131 + value={form.city} maxLength={60} placeholder="Toronto, Vancouver…"
132 + onChange={(e) => setForm({ ...form, city: e.target.value })}
133 + />
134 + </label>
135 + </div>
136 +
137 + <label>
138 + <span>Bio ({280 - form.bio.length} characters left)</span>
139 + <textarea
140 + value={form.bio} rows={3} maxLength={280}
141 + placeholder="Introduce yourself in a few words…"
142 + onChange={(e) => setForm({ ...form, bio: e.target.value })}
143 + />
144 + </label>
145 +
146 + <div className="org-form-row">
147 + <label>
148 + <span>Phone</span>
149 + <input
150 + value={form.phone} maxLength={40} placeholder="416 555-0123"
151 + onChange={(e) => setForm({ ...form, phone: e.target.value })}
152 + />
153 + </label>
154 + <label>
155 + <span>Website</span>
156 + <input
157 + value={form.website} maxLength={300} placeholder="https://…"
158 + onChange={(e) => setForm({ ...form, website: e.target.value })}
159 + />
160 + </label>
161 + </div>
162 +
163 + <h4 className="pe-socials-title">Social networks</h4>
164 + <div className="pe-socials">
165 + {SOCIAL_FIELDS.map((s) => (
166 + <label key={s.key} className="pe-social">
167 + <span className="pe-social-ico" title={s.label}>{s.icon}</span>
168 + <input
169 + value={form.socials[s.key] ?? ""} maxLength={200}
170 + placeholder={`${s.label} — ${s.placeholder}`}
171 + onChange={(e) => setForm({
172 + ...form,
173 + socials: { ...form.socials, [s.key]: e.target.value },
174 + })}
175 + />
176 + </label>
177 + ))}
178 + </div>
179 +
180 + {error && <p className="claim-error">{error}</p>}
181 + <button
182 + className={`btn btn-primary ${saved ? "pc-copy ok" : ""}`}
183 + disabled={saving} onClick={save}
184 + >
185 + {saved ? "✓ Saved" : saving ? "Saving…" : "Save my profile"}
186 + </button>
187 + </section>
188 + );
189 +}
190 +
191 +/** Profile managed at the Groupe KA HUB — READ ONLY (editing happens on
192 + * groupe-ka.com/compte, one entry for all the platforms). */
193 +function HubProfileSection({ me }: { me: Me }) {
194 + const socialLinks = SOCIAL_FIELDS.filter((s) => (me.socials?.[s.key] ?? "").trim());
195 + return (
196 + <section className="org-form profil-edit hub-profile">
197 + <h3>My Groupe KA profile</h3>
198 + {me.bio && <p className="hub-bio">{me.bio}</p>}
199 + <div className="pub-meta">
200 + {(me.job_title || me.company) && (
201 + <span className="stat-chip">
202 + {[me.job_title, me.company].filter(Boolean).join(" · ")}
203 + </span>
204 + )}
205 + {me.city && <span className="stat-chip">{me.city}</span>}
206 + {me.age != null && <span className="stat-chip">{me.age} years old</span>}
207 + {me.website && (
208 + <a className="stat-chip" href={socialUrl("website", me.website)}
209 + target="_blank" rel="noopener noreferrer">Website ↗</a>
210 + )}
211 + {socialLinks.map((s) => (
212 + <a key={s.key} className="stat-chip hub-social-chip"
213 + href={socialUrl(s.key, me.socials[s.key]!)}
214 + target="_blank" rel="noopener noreferrer" title={s.label}>
215 + {s.icon} {s.label}
216 + </a>
217 + ))}
218 + </div>
219 + <div>
220 + <a className="btn btn-primary"
221 + href="https://www.groupe-ka.com/compte"
222 + target="_blank" rel="noopener noreferrer">
223 + Edit my profile on groupe-ka.com ↗
224 + </a>
225 + </div>
226 + <p className="pe-hint">
227 + Your profile is managed at the group level: one entry, visible
228 + across all the platforms.
229 + </p>
230 + </section>
231 + );
232 +}
233 +
234 +export default function ProfilePage() {
235 + const [me, setMe] = useState<Me | null | undefined>(undefined); // undefined = loading
236 + const [copied, setCopied] = useState(false);
237 + const [isPublic, setIsPublic] = useState(false);
238 + const [linkCopied, setLinkCopied] = useState(false);
239 + const nav = useNavigate();
240 +
241 + useEffect(() => {
242 + fetchMe().then((m) => { setMe(m); setIsPublic(m?.public ?? false); });
243 + }, []);
244 +
245 + const publicUrl = me?.ka_id
246 + ? `${window.location.origin}/u/${me.ka_id}` : "";
247 +
248 + const copyKaId = async () => {
249 + if (!me?.ka_id) return;
250 + try {
251 + await navigator.clipboard.writeText(me.ka_id);
252 + setCopied(true);
253 + setTimeout(() => setCopied(false), 1800);
254 + } catch { /* clipboard unavailable: never mind */ }
255 + };
256 +
257 + if (me === undefined) {
258 + return <div className="container profil"><div className="notice">Loading…</div></div>;
259 + }
260 +
261 + if (me === null) {
262 + return (
263 + <div className="container profil">
264 + <span className="kicker">My account</span>
265 + <h1>Sign in to see <span className="hl">your profile</span>.</h1>
266 + <p className="lede">
267 + Create your account in one click with Google — you'll receive your
268 + <b>KA-ID</b> member identifier, valid across the whole Groupe KA
269 + ecosystem.
270 + </p>
271 + <a className="btn btn-primary" href="/api/auth/ka/login">
272 + Sign in with KA ID
273 + </a>
274 + </div>
275 + );
276 + }
277 +
278 + return (
279 + <div className="container profil">
280 + <span className="kicker">My account</span>
281 + <h1>
282 + {me.name ? <>Hi, <span className="hl">{me.name.split(" ")[0]}</span>.</>
283 + : <>Your <span className="hl">profile</span>.</>}
284 + </h1>
285 +
286 + {/* ——— Carte de membre Groupe KA ——— */}
287 + <div className="pc" role="img" aria-label={`Member card ${me.ka_id}`}>
288 + <div className="pc-watermark" aria-hidden="true">KA</div>
289 + <div className="pc-head">
290 + <span className="pc-brand">Groupe <span className="pc-ka">KA</span></span>
291 + <span className="pc-label">Member card · Groupe KA</span>
292 + </div>
293 + <div className="pc-id-block">
294 + <span className="pc-id-label">KA-ID</span>
295 + <span className="pc-id">{me.ka_id}</span>
296 + </div>
297 + <div className="pc-foot">
298 + <div className="pc-holder">
299 + <span className="pc-holder-name">{me.name || me.email}</span>
300 + {me.role_label && (
301 + <span className="pc-role-badge">{me.role_label}</span>
302 + )}
303 + <span className="pc-holder-since">Member since {fmtEpoch(me.created_at)}</span>
304 + </div>
305 + {me.picture && (
306 + <img className="pc-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" />
307 + )}
308 + </div>
309 + <div className="pc-strip" aria-hidden="true">
310 + {Array.from({ length: 28 }).map((_, i) => <i key={i} />)}
311 + </div>
312 + </div>
313 +
314 + <button className={`btn btn-ghost pc-copy ${copied ? "ok" : ""}`} onClick={copyKaId}>
315 + {copied ? "✓ Copied" : "Copy my KA-ID"}
316 + </button>
317 +
318 + {/* ——— Informations ——— */}
319 + <section className="profil-grid">
320 + <div className="pg-item">
321 + <span className="pg-label">Name</span>
322 + <span className="pg-value">{me.name || "—"}</span>
323 + </div>
324 + <div className="pg-item">
325 + <span className="pg-label">Email</span>
326 + <span className="pg-value">{me.email}</span>
327 + </div>
328 + <div className="pg-item">
329 + <span className="pg-label">Member ID</span>
330 + <span className="pg-value mono">{me.ka_id}</span>
331 + </div>
332 + <div className="pg-item">
333 + <span className="pg-label">Sign-in</span>
334 + <span className="pg-value">
335 + {me.provider === "ka-id" ? "KA ID (groupe-ka.com)" : "Google account"}
336 + </span>
337 + </div>
338 + {me.city && (
339 + <div className="pg-item">
340 + <span className="pg-label">City</span>
341 + <span className="pg-value">{me.city}</span>
342 + </div>
343 + )}
344 + <div className="pg-item">
345 + <span className="pg-label">Profile</span>
346 + <span className="pg-value">
347 + {me.role === "locataire" ? "Tenant — I'm looking for a rental"
348 + : me.role === "gestionnaire" ? "Manager — I manage rentals"
349 + : <Link to="/welcome" className="mono">To choose →</Link>}
350 + </span>
351 + </div>
352 + <div className="pg-item">
353 + <span className="pg-label">Member since</span>
354 + <span className="pg-value">{fmtEpoch(me.created_at)}</span>
355 + </div>
356 + <div className="pg-item">
357 + <span className="pg-label">Last sign-in</span>
358 + <span className="pg-value">{fmtEpoch(me.last_login)}</span>
359 + </div>
360 + </section>
361 +
362 + {me.profile_source === "groupe-ka" ? (
363 + <HubProfileSection me={me} />
364 + ) : (
365 + /* old account not yet linked to the hub: local editing kept */
366 + <ProfileEditor
367 + me={me}
368 + onSaved={() => fetchMe().then((m) => { setMe(m); setIsPublic(m?.public ?? false); })}
369 + />
370 + )}
371 +
372 + <p className="profil-note">
373 + Your <b>KA-ID</b> is your unique identifier in the{" "}
374 + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">
375 + Groupe KA
376 + </a>{" "}
377 + ecosystem — it follows you across all the group's platforms. Rent-Ka
378 + keeps only this profile's information (and what you choose to add);
379 + nothing else, and never resold.
380 + </p>
381 +
382 + {/* ——— Profil public ——— */}
383 + {me.profile_source === "groupe-ka" ? (
384 + <section className="profil-public">
385 + <h3>Public profile</h3>
386 + <p>
387 + The visibility of your public page is managed at the Groupe KA
388 + level — it shows your name, photo, bio, city and social networks,
389 + <b> never your email or phone</b>.
390 + </p>
391 + <div className="pub-meta">
392 + <span className="stat-chip">
393 + {me.public ? "Public" : "Private"}
394 + </span>
395 + </div>
396 + {me.public && me.public_url && (
397 + <div className="public-link">
398 + <a href={me.public_url} target="_blank" rel="noopener noreferrer"
399 + className="mono">
400 + {me.public_url.replace(/^https?:\/\//, "")}
401 + </a>
402 + </div>
403 + )}
404 + <div style={{ marginTop: 14 }}>
405 + <a className="btn btn-ghost"
406 + href="https://www.groupe-ka.com/compte"
407 + target="_blank" rel="noopener noreferrer">
408 + Manage on groupe-ka.com ↗
409 + </a>
410 + </div>
411 + </section>
412 + ) : (
413 + <section className="profil-public">
414 + <h3>Public profile</h3>
415 + <p>
416 + Enable your public page to share your member card — it shows your
417 + name, photo, bio, city and social networks, <b>never your email or
418 + phone</b>.
419 + </p>
420 + <div className="seg" role="group" aria-label="Public profile">
421 + {[[false, "Private"], [true, "Public"]].map(([v, l]) => (
422 + <button
423 + key={String(v)}
424 + className={isPublic === v ? "on" : ""}
425 + onClick={async () => {
426 + await setPublicProfile(v as boolean);
427 + setIsPublic(v as boolean);
428 + }}
429 + >
430 + {l as string}
431 + </button>
432 + ))}
433 + </div>
434 + {isPublic && (
435 + <div className="public-link">
436 + <a href={publicUrl} target="_blank" rel="noopener noreferrer" className="mono">
437 + {publicUrl.replace(/^https?:\/\//, "")}
438 + </a>
439 + <button
440 + className={`btn btn-ghost ${linkCopied ? "pc-copy ok" : ""}`}
441 + onClick={async () => {
442 + try {
443 + await navigator.clipboard.writeText(publicUrl);
444 + setLinkCopied(true);
445 + setTimeout(() => setLinkCopied(false), 1800);
446 + } catch { /* clipboard unavailable */ }
447 + }}
448 + >
449 + {linkCopied ? "✓ Copied" : "Copy the link"}
450 + </button>
451 + </div>
452 + )}
453 + </section>
454 + )}
455 +
456 + {/* ——— Actions ——— */}
457 + <div className="profil-actions">
458 + {me.role === "locataire" && (
459 + <Link className="btn btn-primary" to="/favorites">
460 + <IcoHeart size={14} /> My saved rentals
461 + </Link>
462 + )}
463 + {me.role === "gestionnaire" && (
464 + <Link className="btn btn-primary" to="/manage">
465 + <IcoBuilding size={14} /> My manager page
466 + </Link>
467 + )}
468 + <Link className="btn btn-ghost" to="/welcome">Change profile</Link>
469 + <button
470 + className="btn btn-ghost"
471 + onClick={async () => { await logout(); nav("/"); window.location.reload(); }}
472 + >
473 + Sign out
474 + </button>
475 + <button
476 + className="btn btn-ghost"
477 + onClick={() => window.dispatchEvent(new Event("rentka:openConsent"))}
478 + >
479 + Manage cookies
480 + </button>
481 + <Link className="btn btn-ghost" to="/terms"><IcoDoc size={14} /> Terms</Link>
482 + <Link className="btn btn-ghost" to="/privacy"><IcoLock size={14} /> Privacy</Link>
483 + </div>
484 + </div>
485 + );
486 +}
added frontend/src/pages/PublicProfile.tsx +166 −0
@@ -0,0 +1,166 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/PublicProfile.tsx: PUBLIC member profile — /u/{ka_id}
5 +// Shareable member card (name, avatar, seniority, KA-ID) — never the
6 +// email. Visible only if the member enabled their public profile.
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useState } from "react";
9 +import { Link, useParams } from "react-router-dom";
10 +import { PublicProfile, Socials, fetchPublicProfile } from "../api";
11 +import {
12 + IcoCompass, IcoFacebook, IcoInstagram, IcoLinkedIn, IcoTikTok, IcoX,
13 + IcoYouTube,
14 +} from "../components/Icons";
15 +
16 +const SOCIAL_META: { key: keyof Socials; label: string; icon: JSX.Element }[] = [
17 + { key: "instagram", label: "Instagram", icon: <IcoInstagram size={17} /> },
18 + { key: "facebook", label: "Facebook", icon: <IcoFacebook size={17} /> },
19 + { key: "x", label: "X (Twitter)", icon: <IcoX size={17} /> },
20 + { key: "linkedin", label: "LinkedIn", icon: <IcoLinkedIn size={17} /> },
21 + { key: "tiktok", label: "TikTok", icon: <IcoTikTok size={17} /> },
22 + { key: "youtube", label: "YouTube", icon: <IcoYouTube size={17} /> },
23 +];
24 +
25 +const SOCIAL_BASE: Record<string, string> = {
26 + instagram: "https://www.instagram.com/",
27 + facebook: "https://www.facebook.com/",
28 + x: "https://x.com/",
29 + linkedin: "https://www.linkedin.com/in/",
30 + tiktok: "https://www.tiktok.com/@",
31 + youtube: "https://www.youtube.com/@",
32 +};
33 +
34 +/** "@handle" or "handle" -> full platform URL; a URL is left as is */
35 +function socialUrl(key: string, value: string): string {
36 + const v = value.trim();
37 + if (/^https?:\/\//i.test(v)) return v;
38 + if (key === "website") return `https://${v}`;
39 + return (SOCIAL_BASE[key] ?? "https://") + v.replace(/^@/, "");
40 +}
41 +
42 +const fmtEpoch = (ts: number | null | undefined): string => {
43 + if (!ts) return "—";
44 + return new Date(ts * 1000).toLocaleDateString("en-CA", {
45 + day: "numeric", month: "long", year: "numeric",
46 + });
47 +};
48 +
49 +export default function PublicProfilePage() {
50 + const { kaId = "" } = useParams();
51 + const [profile, setProfile] = useState<PublicProfile | null | undefined>(undefined);
52 +
53 + useEffect(() => {
54 + fetchPublicProfile(kaId).then(setProfile).catch(() => setProfile(null));
55 + }, [kaId]);
56 +
57 + if (profile === undefined) {
58 + return <div className="container profil"><div className="notice">Loading…</div></div>;
59 + }
60 +
61 + if (profile === null) {
62 + return (
63 + <div className="container notice">
64 + <div className="big"><IcoCompass size={40} /></div>
65 + <h2>Profile not found</h2>
66 + <p>This member does not exist, or their profile is not public.</p>
67 + <Link className="btn btn-primary" to="/">Explore rentals</Link>
68 + </div>
69 + );
70 + }
71 +
72 + return (
73 + <div className="container profil">
74 + <span className="kicker">Member profile</span>
75 + <h1>
76 + <span className="hl">{profile.name || "Rent-Ka member"}</span>
77 + </h1>
78 +
79 + <div className="pc" role="img" aria-label={`Member card ${profile.ka_id}`}>
80 + <div className="pc-watermark" aria-hidden="true">KA</div>
81 + <div className="pc-head">
82 + <span className="pc-brand">Groupe <span className="pc-ka">KA</span></span>
83 + <span className="pc-label">Member card · Groupe KA</span>
84 + </div>
85 + <div className="pc-id-block">
86 + <span className="pc-id-label">KA-ID</span>
87 + <span className="pc-id">{profile.ka_id}</span>
88 + </div>
89 + <div className="pc-foot">
90 + <div className="pc-holder">
91 + <span className="pc-holder-name">{profile.name || "Rent-Ka member"}</span>
92 + {profile.role_label && (
93 + <span className="pc-role-badge">{profile.role_label}</span>
94 + )}
95 + <span className="pc-holder-since">Member since {fmtEpoch(profile.created_at)}</span>
96 + </div>
97 + {profile.picture && (
98 + <img className="pc-avatar" src={profile.picture} alt="" referrerPolicy="no-referrer" />
99 + )}
100 + </div>
101 + <div className="pc-strip" aria-hidden="true">
102 + {Array.from({ length: 28 }).map((_, i) => <i key={i} />)}
103 + </div>
104 + </div>
105 +
106 + {(profile.bio || profile.city || profile.role || profile.job_title
107 + || profile.company || profile.role_label) && (
108 + <section className="pub-about">
109 + {profile.bio && <p className="pub-bio">{profile.bio}</p>}
110 + <div className="pub-meta">
111 + {(profile.job_title || profile.company) && (
112 + <span className="stat-chip">
113 + {[profile.job_title, profile.company].filter(Boolean).join(" · ")}
114 + </span>
115 + )}
116 + {profile.role_label && (
117 + <span className="stat-chip">{profile.role_label}</span>
118 + )}
119 + {profile.city && <span className="stat-chip">{profile.city}</span>}
120 + {profile.age != null && (
121 + <span className="stat-chip">{profile.age} years old</span>
122 + )}
123 + {profile.role === "gestionnaire" && (
124 + <span className="stat-chip">Rental manager</span>
125 + )}
126 + {profile.role === "locataire" && (
127 + <span className="stat-chip">Looking for a rental</span>
128 + )}
129 + {profile.website && (
130 + <a className="stat-chip" href={socialUrl("website", profile.website)}
131 + target="_blank" rel="noopener noreferrer">Website ↗</a>
132 + )}
133 + </div>
134 + </section>
135 + )}
136 +
137 + {Object.keys(profile.socials || {}).length > 0 && (
138 + <div className="pub-socials">
139 + {SOCIAL_META.filter((s) => profile.socials[s.key]).map((s) => (
140 + <a
141 + key={s.key}
142 + className="pub-social"
143 + href={socialUrl(s.key, profile.socials[s.key]!)}
144 + target="_blank" rel="noopener noreferrer"
145 + aria-label={s.label} title={s.label}
146 + >
147 + {s.icon}
148 + </a>
149 + ))}
150 + </div>
151 + )}
152 +
153 + <p className="profil-note">
154 + Member of <b>Rent-Ka</b>, the rental listings aggregator of{" "}
155 + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">
156 + Groupe KA
157 + </a>.
158 + </p>
159 +
160 + <div className="profil-actions">
161 + <Link className="btn btn-primary" to="/">Explore rentals</Link>
162 + <a className="btn btn-ghost" href="https://www.groupe-ka.com/connexion" target="_blank" rel="noopener noreferrer">Create my account</a>
163 + </div>
164 + </div>
165 + );
166 +}
added frontend/src/pages/Sources.tsx +81 −0
@@ -0,0 +1,81 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Sources.tsx: registry of aggregated sources — property managers
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { Source, fetchSources } from "../api";
8 +
9 +export default function SourcesPage() {
10 + const [sources, setSources] = useState<Source[] | null>(null);
11 + const [error, setError] = useState<string | null>(null);
12 +
13 + useEffect(() => {
14 + fetchSources()
15 + .then((r) => setSources(r.sources))
16 + .catch((e) => setError(String(e)));
17 + }, []);
18 +
19 + return (
20 + <div className="container sources">
21 + <span className="kicker">Registry — aggregated sources</span>
22 + <h1>Aggregated sources</h1>
23 + <p className="sub">
24 + All the sources tracked by Rent-Ka: property managers and rental
25 + platforms across Canada (outside Québec). Every "connected" source is
26 + synced periodically by a dedicated connector.
27 + </p>
28 +
29 + {error && <div className="notice">⚠️ {error}</div>}
30 + {!sources && !error && <div className="notice">Loading…</div>}
31 +
32 + {sources && (
33 + <>
34 + <h2 style={{ marginTop: 28 }}>Monthly rentals — property managers</h2>
35 + <p className="sub">
36 + Property managers across Canada; sources without a connector are
37 + pending.
38 + </p>
39 + <div className="src-wrap">
40 + <table className="src-table">
41 + <thead>
42 + <tr>
43 + <th>Manager</th>
44 + <th>Areas</th>
45 + <th>Status</th>
46 + <th style={{ textAlign: "right" }}>Active listings</th>
47 + <th>Last sync</th>
48 + </tr>
49 + </thead>
50 + <tbody>
51 + {sources.map((s) => (
52 + <tr key={s.id}>
53 + <td>
54 + <a href={s.url} target="_blank" rel="noopener noreferrer">{s.name}</a>
55 + </td>
56 + <td style={{ color: "var(--ink-2)", maxWidth: 380 }}>{s.sectors}</td>
57 + <td>
58 + {s.connector ? (
59 + <span className="pill ok">connected</span>
60 + ) : (
61 + <span className="pill todo">{s.status}</span>
62 + )}
63 + </td>
64 + <td style={{ textAlign: "right" }}>
65 + <span className="count-pill">{s.active_listings || "—"}</span>
66 + </td>
67 + <td style={{ color: "var(--ink-3)" }}>
68 + {s.last_sync
69 + ? new Date(s.last_sync * 1000).toLocaleString("en-CA")
70 + : "—"}
71 + </td>
72 + </tr>
73 + ))}
74 + </tbody>
75 + </table>
76 + </div>
77 + </>
78 + )}
79 + </div>
80 + );
81 +}
added frontend/src/pages/Stats.tsx +223 −0
@@ -0,0 +1,223 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Stats.tsx: analytics dashboard — shared Groupe KA Stats module
5 +// v2 (contrat ../ka/stats/SPEC.md, composants ../ka/stats/kacharts.tsx).
6 +// KPI + sparklines + jauges + sélecteur de période + courbes (compare N-1,
7 +// stats de séries) + multi-courbes + barres empilées + répartitions +
8 +// distributions + géographie + calendrier & heatmap horaire + tableaux +
9 +// records + export PDF (5 rapports).
10 +// -----------------------------------------------------------------------------
11 +import { useCallback, useEffect, useState } from "react";
12 +import {
13 + BarChart, BreakItem, CalendarHeatmap, DataTable, Distribution, Donut,
14 + EmptyBlock, Fraicheur, Gauge, GaugeCard, Histogram, HourCell, HourHeatmap,
15 + Kpi, KpiCard, LineChart, MultiLineChart, MultiSerie, PdfButton,
16 + PeriodSelector, RecordCard, RecordFact, Serie, StackedBarChart,
17 + StackedSerie, StatSummary, TableSpec,
18 +} from "../ka/stats/kacharts";
19 +
20 +interface Dashboard {
21 + updated: string;
22 + period: { from: string | null; to: string | null; label: string };
23 + kpis: Kpi[];
24 + gauges?: Gauge[];
25 + series: Serie[];
26 + multiseries?: MultiSerie[];
27 + stacked?: StackedSerie[];
28 + breakdowns: { id: string; title: string; kind?: string; items: BreakItem[] }[];
29 + distributions?: Distribution[];
30 + geo?: { title: string; items: BreakItem[] };
31 + heatmap?: { title: string; cells: { date: string; value: number }[] };
32 + hourly?: { title: string; cells: HourCell[] };
33 + tables: TableSpec[];
34 + records: RecordFact[];
35 + panels?: Panel[];
36 +}
37 +
38 +interface Panel {
39 + id: string;
40 + title: string;
41 + subtitle?: string;
42 + kpis?: Kpi[];
43 + breakdowns?: { id: string; title: string; kind?: string; items: BreakItem[] }[];
44 + distributions?: Distribution[];
45 + tables?: TableSpec[];
46 +}
47 +
48 +export default function StatsPage() {
49 + const [period, setPeriod] = useState("30j");
50 + const [custom, setCustom] = useState<{ from: string; to: string }>({ from: "", to: "" });
51 + const [dash, setDash] = useState<Dashboard | null>(null);
52 + const [loading, setLoading] = useState(true);
53 + const [err, setErr] = useState<string | null>(null);
54 +
55 + const customOk = Boolean(custom.from && custom.to);
56 +
57 + const load = useCallback(() => {
58 + setLoading(true);
59 + setErr(null);
60 + const p = new URLSearchParams({ period });
61 + if (customOk) {
62 + p.set("from", custom.from);
63 + p.set("to", custom.to);
64 + }
65 + fetch(`/api/stats/dashboard?${p}`)
66 + .then((r) => {
67 + if (!r.ok) throw new Error(`API ${r.status}`);
68 + return r.json();
69 + })
70 + .then((d: Dashboard) => setDash(d))
71 + .catch((e) => setErr(String(e?.message ?? e)))
72 + .finally(() => setLoading(false));
73 + }, [period, custom.from, custom.to, customOk]);
74 +
75 + useEffect(() => {
76 + document.title = "Rental market statistics — Rent·Ka";
77 + load();
78 + }, [load]);
79 +
80 + const donuts = dash?.breakdowns?.filter((b) => b.kind === "donut") ?? [];
81 + const barBreaks = dash?.breakdowns?.filter((b) => b.kind !== "donut") ?? [];
82 +
83 + return (
84 + <div className="container" style={{ display: "grid", gap: 18, paddingBottom: 40 }}>
85 + {/* --- en-tête : titre + PDF (5 rapports) + fraîcheur ------------------ */}
86 + <header style={{ display: "flex", flexWrap: "wrap", gap: 14, alignItems: "flex-end", justifyContent: "space-between", marginTop: 8 }}>
87 + <div style={{ minWidth: 0 }}>
88 + <p className="klabel" style={{ margin: 0 }}>Groupe KA · Rent·Ka</p>
89 + <h1 style={{ margin: "4px 0 0", fontFamily: "var(--font-display)", fontSize: "clamp(24px,3.4vw,34px)", letterSpacing: "-0.02em" }}>
90 + Rental market statistics
91 + </h1>
92 + {dash && (
93 + <p className="klabel" style={{ margin: "6px 0 0" }}>
94 + Period: {dash.period.label}
95 + {dash.period.from ? ` (${dash.period.from} → ${dash.period.to})` : ""}
96 + </p>
97 + )}
98 + </div>
99 + <div style={{ display: "grid", gap: 8, justifyItems: "end" }}>
100 + <PdfButton period={period} from={customOk ? custom.from : undefined} to={customOk ? custom.to : undefined} />
101 + {dash && <Fraicheur updated={dash.updated} onRefresh={load} />}
102 + </div>
103 + </header>
104 +
105 + {err && (
106 + <div className="card" style={{ padding: 18, borderColor: "var(--danger)" }}>
107 + <b>Could not load the statistics.</b>
108 + <p className="klabel" style={{ margin: "6px 0 10px" }}>{err}</p>
109 + <button type="button" className="btn btn-primary" onClick={load}>Try again</button>
110 + </div>
111 + )}
112 +
113 + {/* --- bandeau KPI (sparklines) ---------------------------------------- */}
114 + {dash && dash.kpis.length > 0 && (
115 + <section aria-label="Key indicators" style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))", opacity: loading ? 0.55 : 1, transition: "opacity .2s" }}>
116 + {dash.kpis.map((k) => <KpiCard key={k.id} k={k} />)}
117 + </section>
118 + )}
119 + {!dash && loading && (
120 + <div className="card" style={{ padding: 24, textAlign: "center" }}>
121 + <span className="klabel">Loading statistics…</span>
122 + </div>
123 + )}
124 +
125 + {/* --- sélecteur de période -------------------------------------------- */}
126 + <section className="card" style={{ padding: "14px 16px" }} aria-label="Period">
127 + <PeriodSelector
128 + value={period}
129 + onChange={(p) => { setPeriod(p); setCustom({ from: "", to: "" }); }}
130 + custom={custom}
131 + onCustom={(from, to) => setCustom({ from, to })}
132 + />
133 + </section>
134 +
135 + {dash && (
136 + <div style={{ display: "grid", gap: 18, opacity: loading ? 0.55 : 1, transition: "opacity .2s" }}>
137 + {/* --- jauges : couverture & complétude ----------------------------- */}
138 + {dash.gauges && dash.gauges.length > 0 && (
139 + <section aria-label="Gauges" style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(min(190px, 100%), 1fr))" }}>
140 + {dash.gauges.map((g) => <GaugeCard key={g.id} g={g} />)}
141 + </section>
142 + )}
143 +
144 + {/* --- évolution : courbes / barres + stats de séries --------------- */}
145 + {dash.series?.length ? (
146 + dash.series.map((s) => (
147 + <div key={s.id} style={{ display: "grid", gap: 0 }}>
148 + <LineChart serie={s} />
149 + {s.kind !== "bar" && <StatSummary serie={s} />}
150 + </div>
151 + ))
152 + ) : (
153 + <EmptyBlock title="Daily evolution" />
154 + )}
155 +
156 + {/* --- multi-courbes (loyer médian par taille…) ---------------------- */}
157 + {dash.multiseries?.map((ms) => <MultiLineChart key={ms.id} ms={ms} />)}
158 +
159 + {/* --- barres empilées (composition par source) ---------------------- */}
160 + {dash.stacked?.map((st) => <StackedBarChart key={st.id} st={st} />)}
161 +
162 + {/* --- répartitions (anneaux + barres) ------------------------------ */}
163 + {(donuts.length > 0 || barBreaks.length > 0) && (
164 + <div style={{ display: "grid", gap: 18, gridTemplateColumns: "repeat(auto-fit, minmax(min(340px, 100%), 1fr))" }}>
165 + {donuts.map((b) => <Donut key={b.id} title={b.title} items={b.items} />)}
166 + {barBreaks.map((b) => <BarChart key={b.id} title={b.title} items={b.items} unit="listings" />)}
167 + </div>
168 + )}
169 +
170 + {/* --- distributions (histogrammes) ---------------------------------- */}
171 + {dash.distributions?.map((d) => <Histogram key={d.id} dist={d} />)}
172 +
173 + {/* --- géographie --------------------------------------------------- */}
174 + {dash.geo && <BarChart title={dash.geo.title} items={dash.geo.items} unit="listings" />}
175 +
176 + {/* --- calendriers : 26 semaines + heatmap horaire 7×24 -------------- */}
177 + {dash.heatmap && <CalendarHeatmap title={dash.heatmap.title} cells={dash.heatmap.cells} />}
178 + {dash.hourly && <HourHeatmap title={dash.hourly.title} cells={dash.hourly.cells} />}
179 +
180 + {/* --- tableaux détaillés ------------------------------------------- */}
181 + {dash.tables?.map((t) => <DataTable key={t.id} spec={t} />)}
182 +
183 + {/* --- panneaux « données du territoire » --------------------------- */}
184 + {dash.panels?.map((p) => {
185 + const pDonuts = p.breakdowns?.filter((b) => b.kind === "donut") ?? [];
186 + const pBars = p.breakdowns?.filter((b) => b.kind !== "donut") ?? [];
187 + return (
188 + <section key={p.id} aria-label={p.title} style={{ display: "grid", gap: 12, marginTop: 6 }}>
189 + <div>
190 + <h2 style={{ margin: 0, fontFamily: "var(--font-display)", fontSize: 20 }}>{p.title}</h2>
191 + {p.subtitle && <p className="klabel" style={{ margin: "4px 0 0" }}>{p.subtitle}</p>}
192 + </div>
193 + {p.kpis && p.kpis.length > 0 && (
194 + <div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))" }}>
195 + {p.kpis.map((k) => <KpiCard key={k.id} k={k} />)}
196 + </div>
197 + )}
198 + {(pDonuts.length > 0 || pBars.length > 0 || (p.distributions?.length ?? 0) > 0) && (
199 + <div style={{ display: "grid", gap: 18, gridTemplateColumns: "repeat(auto-fit, minmax(min(340px, 100%), 1fr))" }}>
200 + {pDonuts.map((b) => <Donut key={b.id} title={b.title} items={b.items} />)}
201 + {pBars.map((b) => <BarChart key={b.id} title={b.title} items={b.items} />)}
202 + {p.distributions?.map((d) => <Histogram key={d.id} dist={d} />)}
203 + </div>
204 + )}
205 + {p.tables?.map((tb) => <DataTable key={tb.id} spec={tb} />)}
206 + </section>
207 + );
208 + })}
209 +
210 + {/* --- records & faits marquants ------------------------------------ */}
211 + {dash.records?.length > 0 && (
212 + <section aria-label="Records and highlights" style={{ display: "grid", gap: 10 }}>
213 + <h2 style={{ margin: 0, fontFamily: "var(--font-display)", fontSize: 19 }}>Records & highlights</h2>
214 + <div style={{ display: "grid", gap: 10, gridTemplateColumns: "repeat(auto-fit, minmax(min(280px, 100%), 1fr))" }}>
215 + {dash.records.map((r) => <RecordCard key={r.label} r={r} />)}
216 + </div>
217 + </section>
218 + )}
219 + </div>
220 + )}
221 + </div>
222 + );
223 +}
added frontend/src/pages/Terms.tsx +81 −0
@@ -0,0 +1,81 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Terms.tsx: terms of use (Groupe KA platform)
5 +// -----------------------------------------------------------------------------
6 +import { Link } from "react-router-dom";
7 +
8 +export default function TermsPage() {
9 + return (
10 + <div className="container legal">
11 + <span className="kicker">Terms of use</span>
12 + <h1>The rules of the game, in plain words.</h1>
13 +
14 + <section>
15 + <h3>Who operates Rent-Ka</h3>
16 + <p>
17 + Rent-Ka is a <b>Groupe KA</b> platform{" "}
18 + (<a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">
19 + groupe-ka.com
20 + </a>), a Québec-based group of automated aggregators. For any general
21 + question about the service:{" "}
22 + <a href="mailto:info@groupe-ka.com">info@groupe-ka.com</a>.
23 + </p>
24 + </section>
25 +
26 + <section>
27 + <h3>Nature of the service</h3>
28 + <p>
29 + Rent-Ka is an <b>aggregator</b>: it gathers rental listings publicly
30 + published by property managers, brokers and listing platforms across
31 + Canada. Rent-Ka is neither a broker, nor a manager, nor a party to any
32 + lease — every listing links back to the original ad and the rental is
33 + concluded directly with the advertiser.
34 + </p>
35 + </section>
36 +
37 + <section>
38 + <h3>Accuracy of information</h3>
39 + <p>
40 + Listings are synced automatically and may differ from the source
41 + (changed rent, unit already rented, source or extraction error).
42 + Information is provided <b>as an indication, without warranty</b> —
43 + always verify with the advertiser before any decision. Rent-Ka cannot
44 + be held responsible for a gap between a listing page and the original
45 + ad.
46 + </p>
47 + </section>
48 +
49 + <section>
50 + <h3>Content ownership</h3>
51 + <p>
52 + Listing texts and photos belong to their authors (managers, brokers,
53 + advertisers). Rent-Ka presents them with a clear link to the source.
54 + You are the author of content and want it removed? Write to{" "}
55 + <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a> —
56 + removals are handled quickly.
57 + </p>
58 + </section>
59 +
60 + <section>
61 + <h3>Acceptable use</h3>
62 + <p>
63 + The service is offered for personal housing search. Mass scraping of
64 + the site, resale of the aggregated data or any use that degrades the
65 + service is forbidden.
66 + </p>
67 + </section>
68 +
69 + <section>
70 + <h3>Legal and privacy questions</h3>
71 + <p>
72 + Legal questions and those touching personal information are handled
73 + by <a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a>. See
74 + also the <Link to="/privacy">privacy policy</Link>.
75 + </p>
76 + </section>
77 +
78 + <div className="fine">Last updated: August 2026.</div>
79 + </div>
80 + );
81 +}
added frontend/src/pages/Ville.tsx +161 −0
@@ -0,0 +1,161 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Ville.tsx: programmatic SEO pages — /cities, /city/:ville[/:type]
5 +// The server renders the same content as HTML (rentka/seo.py); here, the
6 +// interactive version with the app's listing cards.
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useState } from "react";
9 +import { Link, useParams } from "react-router-dom";
10 +import { Listing } from "../api";
11 +import ListingCard from "../components/ListingCard";
12 +
13 +interface VilleRow {
14 + city: string;
15 + slug: string;
16 + n: number;
17 + avg_price: number | null;
18 +}
19 +
20 +interface VilleData {
21 + city: string;
22 + slug: string;
23 + unit_type: string | null;
24 + n: number;
25 + avg: number | null;
26 + med: number | null;
27 + types: { unit_type: string; slug: string; n: number }[];
28 + listings: Listing[];
29 + neighbors: VilleRow[];
30 +}
31 +
32 +const fmt = (p: number | null) =>
33 + p == null ? "—" : `$${Math.round(p).toLocaleString("en-CA")}`;
34 +
35 +async function get<T>(path: string): Promise<T> {
36 + const res = await fetch(path);
37 + if (!res.ok) throw new Error(`${res.status}`);
38 + return res.json();
39 +}
40 +
41 +export function VillesPage() {
42 + const [villes, setVilles] = useState<VilleRow[] | null>(null);
43 + const [error, setError] = useState<string | null>(null);
44 +
45 + useEffect(() => {
46 + get<{ villes: VilleRow[] }>("/api/seo/villes")
47 + .then((r) => setVilles(r.villes))
48 + .catch((e) => setError(String(e)));
49 + }, []);
50 +
51 + return (
52 + <div className="container">
53 + <span className="kicker">Directory — rentals by city</span>
54 + <h1>Rentals by city</h1>
55 + <p className="sub">
56 + Every Canadian city where Rent-Ka tracks rentals, with the number of
57 + active listings and the average rent.
58 + </p>
59 + {error && <div className="notice">⚠️ {error}</div>}
60 + {!villes && !error && <div className="notice">Loading…</div>}
61 + {villes && (
62 + <ul className="ville-list" style={{ marginTop: 24, lineHeight: 2 }}>
63 + {villes.map((v) => (
64 + <li key={v.slug}>
65 + <Link to={`/city/${v.slug}`}>{v.city}</Link>
66 + {" — "}
67 + {v.n} listing{v.n > 1 ? "s" : ""}
68 + {v.avg_price != null && <> · average rent {fmt(v.avg_price)}</>}
69 + </li>
70 + ))}
71 + </ul>
72 + )}
73 + </div>
74 + );
75 +}
76 +
77 +export default function VillePage() {
78 + const { ville = "", type } = useParams();
79 + const [data, setData] = useState<VilleData | null>(null);
80 + const [error, setError] = useState<string | null>(null);
81 +
82 + useEffect(() => {
83 + setData(null);
84 + setError(null);
85 + const qs = type ? `?type=${encodeURIComponent(type)}` : "";
86 + get<VilleData>(`/api/seo/ville/${encodeURIComponent(ville)}${qs}`)
87 + .then(setData)
88 + .catch((e) =>
89 + setError(e.message === "404" ? "Unknown city or type." : String(e)));
90 + window.scrollTo(0, 0);
91 + }, [ville, type]);
92 +
93 + if (error)
94 + return (
95 + <div className="container">
96 + <h1>Page not found</h1>
97 + <div className="notice">
98 + ⚠️ {error} <Link to="/cities">See all cities</Link>
99 + </div>
100 + </div>
101 + );
102 + if (!data)
103 + return (
104 + <div className="container">
105 + <div className="notice">Loading…</div>
106 + </div>
107 + );
108 +
109 + const what = data.unit_type ? `${data.unit_type} rentals` : "Rentals";
110 + return (
111 + <div className="container">
112 + <span className="kicker">
113 + <Link to="/cities">Cities</Link> — {data.city}
114 + </span>
115 + <h1>
116 + {what} in {data.city}
117 + </h1>
118 + <p className="sub">
119 + {data.n} active listing{data.n > 1 ? "s" : ""}
120 + {data.avg != null && <> · average rent {fmt(data.avg)}</>}
121 + {data.med != null && <> · median {fmt(data.med)}</>}
122 + </p>
123 +
124 + {!data.unit_type && data.types.length > 1 && (
125 + <p style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
126 + {data.types.map((t) => (
127 + <Link key={t.slug} className="pill" to={`/city/${data.slug}/${t.slug}`}>
128 + {t.unit_type} ({t.n})
129 + </Link>
130 + ))}
131 + </p>
132 + )}
133 + {data.unit_type && (
134 + <p>
135 + <Link to={`/city/${data.slug}`}>
136 + ← All rentals in {data.city}
137 + </Link>
138 + </p>
139 + )}
140 +
141 + <div className="grid" style={{ marginTop: 24 }}>
142 + {data.listings.map((l) => (
143 + <ListingCard key={l.uid} l={l} />
144 + ))}
145 + </div>
146 +
147 + {data.neighbors.length > 0 && (
148 + <>
149 + <h2 style={{ marginTop: 40 }}>Other cities</h2>
150 + <p style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
151 + {data.neighbors.map((v) => (
152 + <Link key={v.slug} className="pill" to={`/city/${v.slug}`}>
153 + {v.city} ({v.n})
154 + </Link>
155 + ))}
156 + </p>
157 + </>
158 + )}
159 + </div>
160 + );
161 +}
added frontend/src/search/MapSearch.tsx +840 −0
@@ -0,0 +1,840 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// search/MapSearch.tsx: the MAP MODE (Ka Map System v2) — synchronized
5 +// list ↔ map search, full viewport.
6 +//
7 +// UNE SEULE VÉRITÉ PARTAGÉE — filtres + zone de carte + tri + page vivent
8 +// dans un état unique ; chaque changement déclenche UNE requête /api/search
9 +// qui renvoie à la fois la page de liste et tous les points carte, triés
10 +// pareil : le compteur de la liste est par construction égal au nombre de
11 +// marqueurs, et l'index d'un point donne directement sa page de liste.
12 +//
13 +// v2 (KaMapShell) : la carte n'est plus une vignette dans la page — c'est
14 +// une prise de contrôle du viewport.
15 +// · mobile : carte edge-to-edge, barre supérieure compacte, résultats en
16 +// bottom sheet à 3 crans, fiche contextuelle glissable entre voisines ;
17 +// · desktop : split résultats|carte redimensionnable (ratio persisté),
18 +// carte quasi plein écran d'un clic, survols miroirs liste ↔ carte ;
19 +// · contrôles regroupés dans UNE toolbar (zoom, 3D, dessin, localisation),
20 +// « Rechercher dans cette zone » contextuel, mode dessin temporaire avec
21 +// CTA « N logements dans cette zone ».
22 +//
23 +// Ce composant reste le modèle réutilisable Groupe-KA (immo-ka, resto-ka,
24 +// sorti-ka) : voir ~/apps/ka-maps/docs/SEARCH-SYNC.md.
25 +// -----------------------------------------------------------------------------
26 +import {
27 + useCallback, useEffect, useMemo, useRef, useState,
28 +} from "react";
29 +import { useNavigate } from "react-router-dom";
30 +import "mapbox-gl/dist/mapbox-gl.css";
31 +import "@groupe-ka/ka-maps/styles.css";
32 +import type { MapProperty } from "@groupe-ka/ka-maps";
33 +import {
34 + bboxToString, cameraFromParams, cameraToParams, formatCompactPrice,
35 +} from "@groupe-ka/ka-maps";
36 +import type { KaMap } from "@groupe-ka/ka-maps";
37 +import {
38 + KaBrandBadge, KaDrawAreaMode, KaMapShell, KaMapToolbar, KaMapView,
39 + KaPropertyPreview, KaToolbar3D, KaToolbarDraw, KaToolbarGroup,
40 + KaToolbarLocate, KaToolbarZoom, useKaMap, useKaShell,
41 +} from "@groupe-ka/ka-maps/react";
42 +import {
43 + Listing, ListingFilters, SearchPoint, SearchResponse, SearchSort,
44 + fetchListing, fetchSearch, fmtPrice, sourceName, thumb,
45 +} from "../api";
46 +import { useAccount } from "../account";
47 +import ListingCard from "../components/ListingCard";
48 +import Pager from "../components/Pager";
49 +import FairValueBadge from "../components/FairValueBadge";
50 +import { IcoHeart, IcoSearch, IcoSliders } from "../components/Icons";
51 +import { louKaMapTheme } from "../kamaps/theme";
52 +import { MAPBOX_TOKEN } from "../kamaps/config";
53 +import { seenUids } from "./seen";
54 +
55 +const PAGE_SIZE = 20; // listings per page (map view)
56 +const CANADA = { lat: 47.5, lng: -84.5, zoom: 4.4 };
57 +
58 +const SORT_CHOICES: { key: SearchSort; label: string }[] = [
59 + { key: "recent", label: "Most recent" },
60 + { key: "prix", label: "Price: low to high" },
61 + { key: "prix_desc", label: "Price: high to low" },
62 + { key: "deal", label: "Best deals" },
63 + { key: "ka", label: "Best KA Score" },
64 +];
65 +
66 +/** Points compacts → modèle canonique Ka Maps, avec écartement léger des
67 + * coordonnées EXACTEMENT identiques (unités d'un même immeuble) pour que
68 + * chaque pastille reste cliquable au zoom maximal (spiderfy discret). */
69 +function pointsToProperties(points: SearchPoint[]): MapProperty[] {
70 + const byCoord = new Map<string, number>();
71 + const out: MapProperty[] = [];
72 + for (const [uid, lng, lat, price, fv] of points) {
73 + const key = `${lng},${lat}`;
74 + const n = byCoord.get(key) ?? 0;
75 + byCoord.set(key, n + 1);
76 + let lng2 = lng, lat2 = lat;
77 + if (n > 0) {
78 + // cercle de ~9 m autour du point d'origine, déterministe
79 + const angle = n * 2.4; // angle d'or : pas d'alignement
80 + lng2 = lng + Math.cos(angle) * 0.00011;
81 + lat2 = lat + Math.sin(angle) * 0.00008;
82 + }
83 + out.push({
84 + id: uid,
85 + appSource: "rent-ka",
86 + longitude: lng2,
87 + latitude: lat2,
88 + kind: "listing",
89 + listingType: "rent",
90 + price: price ?? undefined,
91 + highlight: fv === "s", // below market
92 + originalUrl: `/listing/${encodeURIComponent(uid)}`,
93 + });
94 + }
95 + return out;
96 +}
97 +
98 +/** Sérialisation du polygone pour l'URL et l'API : lng,lat;lng,lat;… */
99 +const polyToString = (poly: [number, number][]): string =>
100 + poly.map(([lng, lat]) => `${lng.toFixed(6)},${lat.toFixed(6)}`).join(";");
101 +
102 +function polyFromString(text: string | null): [number, number][] | null {
103 + if (!text) return null;
104 + const pts: [number, number][] = [];
105 + for (const part of text.split(";")) {
106 + const [lng, lat] = part.split(",").map(Number);
107 + if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
108 + pts.push([lng as number, lat as number]);
109 + }
110 + return pts.length >= 3 ? pts : null;
111 +}
112 +
113 +/** Pont : expose le moteur KaMap au composant parent (hors du canevas). */
114 +function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) {
115 + const map = useKaMap();
116 + useEffect(() => {
117 + onEngine(map);
118 + return () => onEngine(null);
119 + }, [map, onEngine]);
120 + return null;
121 +}
122 +
123 +/** Pont déclaratif : reflète la sélection venue de l'app vers le moteur. */
124 +function SelectionMirror({ selectedUid }: { selectedUid: string | null }) {
125 + const map = useKaMap();
126 + useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);
127 + return null;
128 +}
129 +
130 +/** Mobile : la sélection d'un marqueur replie le sheet en mini pour laisser
131 + * toute la place à la carte + fiche contextuelle. */
132 +function SheetAutoCollapse({ selectedUid }: { selectedUid: string | null }) {
133 + const shell = useKaShell();
134 + const shellRef = useRef(shell);
135 + shellRef.current = shell;
136 + useEffect(() => {
137 + const s = shellRef.current;
138 + if (selectedUid && s?.isMobile) s.setSheet("mini");
139 + }, [selectedUid]);
140 + return null;
141 +}
142 +
143 +/** Fiche contextuelle : les points carte sont volontairement compacts, le
144 + * détail (photo, titre, dispo) est chargé à la sélection (rapide, mis en
145 + * cache) — ka-prevcard structure of the Ka Map System, Rent-Ka content. */
146 +const previewCache = new Map<string, Listing>();
147 +
148 +function LazyPreview({ p }: { p: MapProperty }) {
149 + const navigate = useNavigate();
150 + const { favs, toggleFav } = useAccount();
151 + const [l, setL] = useState<Listing | null>(previewCache.get(p.id) ?? null);
152 + useEffect(() => {
153 + if (previewCache.has(p.id)) { setL(previewCache.get(p.id)!); return; }
154 + let dead = false;
155 + setL(null);
156 + fetchListing(p.id)
157 + .then((r) => {
158 + previewCache.set(p.id, r);
159 + if (previewCache.size > 80) {
160 + const first = previewCache.keys().next().value;
161 + if (first) previewCache.delete(first);
162 + }
163 + if (!dead) setL(r);
164 + })
165 + .catch(() => {});
166 + return () => { dead = true; };
167 + }, [p.id]);
168 +
169 + const img = l?.images?.[0] ?? null;
170 + const fav = favs.has(p.id);
171 + const fiche = `/listing/${encodeURIComponent(p.id)}`;
172 + return (
173 + <>
174 + <div className="ka-prevcard-media">
175 + {img ? (
176 + <img src={thumb(img, 480)} alt="" loading="lazy" />
177 + ) : (
178 + <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div>
179 + )}
180 + </div>
181 + <div className="ka-prevcard-body">
182 + <div className="ka-prevcard-price">
183 + {l ? fmtPrice(l.price, l.price_label) : p.price != null ? `$${p.price.toLocaleString("en-CA")}` : "…"}
184 + {(l?.price ?? p.price) != null && <small>/month</small>}
185 + {l && <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />}
186 + </div>
187 + <div className="ka-prevcard-addr">{l ? (l.title || l.address) : "Loading…"}</div>
188 + <div className="ka-prevcard-meta">
189 + {[
190 + l?.unit_type,
191 + l?.area_sqft ? `${Math.round(l.area_sqft)} sq ft` : "",
192 + l?.availability,
193 + l ? sourceName(l.source) : "",
194 + ].filter(Boolean).join(" · ")}
195 + </div>
196 + <div className="ka-prevcard-actions">
197 + <a
198 + className="ka-prevcard-cta"
199 + href={fiche}
200 + onClick={(e) => { e.preventDefault(); navigate(fiche); }}
201 + >
202 + See the listing →
203 + </a>
204 + <button
205 + type="button"
206 + className={`ka-prevcard-fav${fav ? " on" : ""}`}
207 + onClick={() => toggleFav(p.id)}
208 + aria-label={fav ? "Remove from saved" : "Save this rental"}
209 + aria-pressed={fav}
210 + >
211 + <IcoHeart size={16} filled={fav} />
212 + </button>
213 + </div>
214 + </div>
215 + </>
216 + );
217 +}
218 +
219 +export interface MapSearchProps {
220 + filters: ListingFilters;
221 + /** Compteur partagé remonté à l'entête de résultats de la page. */
222 + onTotal?: (total: number | null) => void;
223 + /** Quitter le mode carte (retour à la vue liste). */
224 + onExit?: () => void;
225 + /** Ouvrir la feuille de filtres de la page (le shell n'a qu'un bouton). */
226 + onOpenFilters?: () => void;
227 + /** Nombre de filtres actifs (badge du bouton Filtres). */
228 + filtersCount?: number;
229 +}
230 +
231 +interface Query {
232 + bboxStr: string | null; // zone visible (gestes utilisateur uniquement)
233 + poly: string | null; // zone dessinée (prime sur la zone visible)
234 + sort: SearchSort;
235 + page: number;
236 +}
237 +
238 +export default function MapSearch({
239 + filters, onTotal, onExit, onOpenFilters, filtersCount = 0,
240 +}: MapSearchProps) {
241 + const initialParams = useMemo(
242 + () => new URLSearchParams(window.location.search), []);
243 + const initialCamera = useMemo(
244 + () => cameraFromParams(initialParams), [initialParams]);
245 +
246 + // --- état de recherche partagé (la « seule vérité ») ---
247 + const [qy, setQy] = useState<Query>(() => {
248 + const sort = (initialParams.get("sort") as SearchSort | null) ?? "recent";
249 + const p = Number(initialParams.get("page"));
250 + return {
251 + bboxStr: null, // posé au premier geste ou « zone » restaurée
252 + poly: initialParams.get("area"),
253 + sort: SORT_CHOICES.some((s) => s.key === sort) ? sort : "recent",
254 + page: Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1,
255 + };
256 + });
257 + const [searchOnMove, setSearchOnMove] = useState(
258 + initialParams.get("move") !== "0");
259 + const [dirty, setDirty] = useState(false); // zone divergée (mode manuel)
260 + const [userLocked, setUserLocked] = useState(initialCamera !== null);
261 + const [drawing, setDrawing] = useState(false);
262 +
263 + // URL avec caméra partagée : attendre la carte pour connaître la zone
264 + // visible AVANT la première requête (la liste doit refléter cette zone).
265 + const [bootstrapped, setBootstrapped] = useState(initialCamera === null);
266 + const bootstrappedRef = useRef(bootstrapped);
267 +
268 + const [res, setRes] = useState<SearchResponse | null>(null);
269 + const [points, setPoints] = useState<SearchPoint[]>([]);
270 + const [loading, setLoading] = useState(false);
271 + const [error, setError] = useState<string | null>(null);
272 +
273 + const [engine, setEngine] = useState<KaMap | null>(null);
274 + const [selectedUid, setSelectedUid] = useState<string | null>(null);
275 + const [mapHoverUid, setMapHoverUid] = useState<string | null>(null);
276 + const [hoverOffscreen, setHoverOffscreen] = useState<"up" | "down" | null>(null);
277 + const [clusterTip, setClusterTip] = useState<
278 + { count: number; min: number | null; max: number | null; x: number; y: number } | null
279 + >(null);
280 + const [isMobile, setIsMobile] = useState(
281 + () => window.matchMedia("(max-width: 780px)").matches);
282 +
283 + // --- refs de pilotage (valeurs à jour dans les callbacks carte) ---
284 + const engineRef = useRef<KaMap | null>(null);
285 + const qyRef = useRef(qy); qyRef.current = qy;
286 + const searchOnMoveRef = useRef(searchOnMove); searchOnMoveRef.current = searchOnMove;
287 + const userLockedRef = useRef(userLocked); userLockedRef.current = userLocked;
288 + const seqRef = useRef(0);
289 + const ctrlRef = useRef<AbortController | null>(null);
290 + const debounceMsRef = useRef(0);
291 + const pointsKeyRef = useRef<string>("");
292 + const fitAfterRef = useRef(initialCamera === null); // recadrer au 1er résultat
293 + const pendingScrollUidRef = useRef<string | null>(null);
294 + const listRef = useRef<HTMLDivElement | null>(null);
295 +
296 + const filtersClean = useMemo(() => {
297 + const f: Record<string, string> = {};
298 + for (const [k, v] of Object.entries(filters)) {
299 + if (v && k !== "sort") f[k] = String(v);
300 + }
301 + return f;
302 + }, [filters]);
303 + const filtersKey = useMemo(() => JSON.stringify(filtersClean), [filtersClean]);
304 +
305 + useEffect(() => {
306 + const mq = window.matchMedia("(max-width: 780px)");
307 + const update = () => setIsMobile(mq.matches);
308 + mq.addEventListener("change", update);
309 + return () => mq.removeEventListener("change", update);
310 + }, []);
311 +
312 + // --- filtres modifiés : page 1, recadrage si l'utilisateur n'a pas la main
313 + const firstFiltersRun = useRef(true);
314 + useEffect(() => {
315 + if (firstFiltersRun.current) { firstFiltersRun.current = false; return; }
316 + if (!userLockedRef.current) {
317 + fitAfterRef.current = true;
318 + setQy((q) => ({ ...q, page: 1, bboxStr: null }));
319 + } else {
320 + setQy((q) => ({ ...q, page: 1 }));
321 + }
322 + }, [filtersKey]);
323 +
324 + // --- LA requête unifiée : une réponse = page de liste + points carte ---
325 + const qyKey = useMemo(() => JSON.stringify(qy), [qy]);
326 + useEffect(() => {
327 + if (!bootstrapped) return;
328 + const { bboxStr, poly, sort, page } = qyRef.current;
329 + const pKey = `${filtersKey}|${poly ?? bboxStr ?? ""}|${sort}`;
330 + const wait = debounceMsRef.current;
331 + debounceMsRef.current = 0;
332 +
333 + const timer = window.setTimeout(() => {
334 + ctrlRef.current?.abort();
335 + const ctrl = new AbortController();
336 + ctrlRef.current = ctrl;
337 + const mySeq = ++seqRef.current;
338 + setLoading(true);
339 + setError(null);
340 + fetchSearch(
341 + {
342 + ...filtersClean,
343 + bbox: poly ? undefined : bboxStr ?? undefined,
344 + poly: poly ?? undefined,
345 + sort, page, page_size: PAGE_SIZE,
346 + include: pKey === pointsKeyRef.current ? "liste" : "tout",
347 + },
348 + ctrl.signal,
349 + )
350 + .then((r) => {
351 + if (mySeq !== seqRef.current) return; // réponse périmée : ignorée
352 + setLoading(false);
353 + setRes(r);
354 + if (r.points) {
355 + setPoints(r.points);
356 + pointsKeyRef.current = pKey;
357 + }
358 + if (r.page !== qyRef.current.page) {
359 + // page ramenée dans les bornes par le serveur (filtre resserré)
360 + setQy((q) => ({ ...q, page: r.page }));
361 + }
362 + // recadrage sur les résultats — si la carte n'est pas encore
363 + // prête (premier chargement), l'effet données→carte le fera
364 + if (fitAfterRef.current && engineRef.current && r.points?.length) {
365 + fitAfterRef.current = false;
366 + engineRef.current.fitToProperties(pointsToProperties(r.points), {
367 + padding: 72, maxZoom: 16,
368 + });
369 + }
370 + })
371 + .catch((e) => {
372 + if (ctrl.signal.aborted || mySeq !== seqRef.current) return;
373 + setLoading(false);
374 + setError(String(e));
375 + });
376 + }, wait);
377 + return () => window.clearTimeout(timer);
378 + }, [filtersKey, qyKey, filtersClean, bootstrapped]);
379 +
380 + // compteur partagé remonté à la page
381 + useEffect(() => {
382 + onTotal?.(res?.total ?? null);
383 + return () => onTotal?.(null);
384 + }, [res?.total, onTotal]);
385 +
386 + // --- URL : tri + page + zone + mode (la caméra est gérée au moveend) ---
387 + useEffect(() => {
388 + const url = new URL(window.location.href);
389 + const set = (k: string, v: string | null) =>
390 + v === null ? url.searchParams.delete(k) : url.searchParams.set(k, v);
391 + set("sort", qy.sort === "recent" ? null : qy.sort);
392 + set("page", qy.page > 1 ? String(qy.page) : null);
393 + set("area", qy.poly);
394 + set("move", searchOnMove ? null : "0");
395 + window.history.replaceState(null, "", url);
396 + }, [qy.sort, qy.page, qy.poly, searchOnMove]);
397 +
398 + // --- moteur prêt : polygone restauré, annonces « vues » ---
399 + const handleEngine = useCallback((m: KaMap | null) => {
400 + engineRef.current = m;
401 + setEngine(m);
402 + if (!m) return;
403 + m.setSeenIds(seenUids());
404 + const zone = polyFromString(qyRef.current.poly);
405 + if (zone) m.setDrawnPolygon(zone);
406 + // Caméra restaurée depuis l'URL : la zone visible devient la contrainte
407 + // spatiale de la première requête (lien partagé = même recherche).
408 + if (!bootstrappedRef.current) {
409 + bootstrappedRef.current = true;
410 + const b = m.currentBBox();
411 + if (b && !zone) setQy((q) => ({ ...q, bboxStr: bboxToString(b) }));
412 + setBootstrapped(true);
413 + }
414 + }, []);
415 +
416 + // --- données → carte (poussées, pas d'adaptateur : une seule requête) ---
417 + const mapProperties = useMemo(() => pointsToProperties(points), [points]);
418 + useEffect(() => {
419 + if (!engine) return;
420 + engine.setProperties(mapProperties);
421 + engine.setSeenIds(seenUids());
422 + // premier chargement : la réponse est souvent arrivée avant la carte —
423 + // le recadrage en attente s'applique dès que les deux sont prêts
424 + if (fitAfterRef.current && mapProperties.length > 0) {
425 + fitAfterRef.current = false;
426 + engine.fitToProperties(mapProperties, { padding: 72, maxZoom: 16 });
427 + }
428 + }, [engine, mapProperties]);
429 +
430 + // index uid → position dans le tri (= page de liste, sans requête)
431 + const uidIndex = useMemo(() => {
432 + const m = new Map<string, number>();
433 + points.forEach((p, i) => m.set(p[0], i));
434 + return m;
435 + }, [points]);
436 +
437 + // --- géolocalisation d'accueil / « Me localiser » : recentrer sur la
438 + // position et synchroniser la liste sur cette zone ---
439 + const syncListToViewport = useCallback((delayMs: number) => {
440 + window.setTimeout(() => {
441 + const b = engineRef.current?.currentBBox();
442 + if (b) {
443 + setDirty(false);
444 + setQy((q) => ({ ...q, page: 1, poly: null, bboxStr: bboxToString(b) }));
445 + }
446 + }, delayMs);
447 + }, []);
448 +
449 + useEffect(() => {
450 + const handler = (e: Event) => {
451 + const { lat, lng } = (e as CustomEvent<{ lat: number; lng: number }>).detail;
452 + const m = engineRef.current;
453 + if (!m) return;
454 + setUserLocked(true);
455 + userLockedRef.current = true;
456 + m.flyTo({ lng, lat }, { zoom: 13.5, duration: 900 });
457 + syncListToViewport(950);
458 + };
459 + window.addEventListener("rentka:geolocate", handler);
460 + return () => window.removeEventListener("rentka:geolocate", handler);
461 + }, [syncListToViewport]);
462 +
463 + // --- geste sur la carte ---
464 + const onMoveEnd = useCallback(
465 + (center: { lat: number; lng: number }, zoom: number, byUser: boolean) => {
466 + const url = new URL(window.location.href);
467 + url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();
468 + window.history.replaceState(null, "", url);
469 + if (!byUser) return; // fitBounds/recentrages : rien
470 + setUserLocked(true);
471 + userLockedRef.current = true;
472 + if (qyRef.current.poly) return; // la zone dessinée prime
473 + const engineNow = engineRef.current;
474 + const b = engineNow?.currentBBox();
475 + if (!b) return;
476 + const bboxStr = bboxToString(b);
477 + if (searchOnMoveRef.current) {
478 + debounceMsRef.current = 280; // fin de geste + petite marge
479 + setDirty(false);
480 + setQy((q) => ({ ...q, page: 1, bboxStr }));
481 + } else {
482 + setDirty(true);
483 + }
484 + }, []);
485 +
486 + const searchThisArea = useCallback(() => {
487 + const b = engineRef.current?.currentBBox();
488 + if (!b) return;
489 + setDirty(false);
490 + setQy((q) => ({ ...q, page: 1, bboxStr: bboxToString(b) }));
491 + }, []);
492 +
493 + const refitToResults = useCallback(() => {
494 + setUserLocked(false);
495 + userLockedRef.current = false;
496 + setDirty(false);
497 + if (qyRef.current.bboxStr === null) {
498 + // requête déjà « tous les résultats » : recadrage immédiat
499 + engineRef.current?.fitToProperties(undefined, { padding: 72, maxZoom: 16 });
500 + } else {
501 + fitAfterRef.current = true;
502 + setQy((q) => ({ ...q, page: 1, bboxStr: null }));
503 + }
504 + }, []);
505 +
506 + const widenArea = useCallback(() => {
507 + const m = engineRef.current;
508 + if (!m) return;
509 + // « Élargir la zone » : dézoome, puis recherche élargie explicite.
510 + m.flyTo({ lng: m.map.getCenter().lng, lat: m.map.getCenter().lat }, {
511 + zoom: Math.max(4, m.map.getZoom() - 1.6), duration: 650,
512 + });
513 + syncListToViewport(700);
514 + }, [syncListToViewport]);
515 +
516 + // --- polygone dessiné ---
517 + const onDraw = useCallback((polygon: [number, number][] | null, isDrawing: boolean) => {
518 + setDrawing(isDrawing);
519 + if (isDrawing) return;
520 + const polyStr = polygon ? polyToString(polygon) : null;
521 + if (polyStr === qyRef.current.poly) return;
522 + if (polyStr) {
523 + fitAfterRef.current = true;
524 + setDirty(false);
525 + setQy((q) => ({ ...q, page: 1, poly: polyStr, bboxStr: null }));
526 + } else {
527 + const b = engineRef.current?.currentBBox();
528 + setQy((q) => ({
529 + ...q, page: 1, poly: null,
530 + bboxStr: userLockedRef.current && b ? bboxToString(b) : null,
531 + }));
532 + }
533 + }, []);
534 +
535 + const clearZone = useCallback(() => {
536 + engineRef.current?.clearDrawnPolygon(); // onDraw fera le reste
537 + }, []);
538 +
539 + // --- sélection : marqueur ↔ annonce, toujours, quelle que soit la page ---
540 + const scrollToCard = useCallback((uid: string, pulse: boolean) => {
541 + const sel = `[data-uid="${CSS.escape(uid)}"]`;
542 + const card = listRef.current?.querySelector<HTMLElement>(sel);
543 + if (card) {
544 + card.scrollIntoView({ behavior: "smooth", block: "nearest" });
545 + if (pulse) {
546 + card.classList.remove("map-card-pulse");
547 + void card.offsetWidth; // relance l'animation
548 + card.classList.add("map-card-pulse");
549 + }
550 + }
551 + }, []);
552 +
553 + const onSelect = useCallback((p: MapProperty | null, origin: "map" | "app") => {
554 + const uid = p?.id ?? null;
555 + setSelectedUid(uid);
556 + if (!uid) return;
557 + const idx = uidIndex.get(uid);
558 + if (idx === undefined) return;
559 + const targetPage = Math.floor(idx / PAGE_SIZE) + 1;
560 + if (targetPage !== qyRef.current.page) {
561 + pendingScrollUidRef.current = uid;
562 + setQy((q) => ({ ...q, page: targetPage }));
563 + } else {
564 + scrollToCard(uid, origin === "map");
565 + }
566 + }, [uidIndex, scrollToCard]);
567 +
568 + // défilement différé (la page vient de changer pour révéler l'annonce)
569 + useEffect(() => {
570 + const uid = pendingScrollUidRef.current;
571 + if (!uid || !res) return;
572 + if (res.listings.some((l) => l.uid === uid)) {
573 + pendingScrollUidRef.current = null;
574 + requestAnimationFrame(() => scrollToCard(uid, true));
575 + }
576 + }, [res, scrollToCard]);
577 +
578 + // --- survol carte → liste : surlignage + indicateur hors écran ---
579 + const onHover = useCallback((p: MapProperty | null) => {
580 + setMapHoverUid(p?.id ?? null);
581 + }, []);
582 + useEffect(() => {
583 + if (!mapHoverUid || !listRef.current) { setHoverOffscreen(null); return; }
584 + const card = listRef.current.querySelector<HTMLElement>(
585 + `[data-uid="${CSS.escape(mapHoverUid)}"]`);
586 + if (!card) { setHoverOffscreen(null); return; }
587 + const list = listRef.current.getBoundingClientRect();
588 + const rect = card.getBoundingClientRect();
589 + if (rect.bottom < list.top + 8) setHoverOffscreen("up");
590 + else if (rect.top > list.bottom - 8) setHoverOffscreen("down");
591 + else setHoverOffscreen(null);
592 + }, [mapHoverUid]);
593 +
594 + // --- clic sur une carte d'annonce : recentre + fiche (1er clic),
595 + // navigation vers la fiche (2e clic ou Cmd/Ctrl-clic) ---
596 + const onCardClick = useCallback((e: React.MouseEvent, uid: string) => {
597 + if (e.metaKey || e.ctrlKey || e.button !== 0) return; // nouvel onglet
598 + if (selectedUid === uid) return; // 2e clic : fiche
599 + e.preventDefault();
600 + engineRef.current?.select(uid, "app");
601 + }, [selectedUid]);
602 +
603 + const listings = res?.listings ?? [];
604 + const total = res?.total ?? null;
605 + const unpositioned = res?.unpositioned ?? 0;
606 + const hasZone = qy.poly !== null;
607 +
608 + /* ------------------------- volets du shell ------------------------- */
609 +
610 + const listHeader = (
611 + <div className="ms2-head">
612 + <div className="ms2-count" role="status" aria-live="polite">
613 + {total === null ? "…" : (
614 + <>
615 + <b>{total.toLocaleString("en-CA")}</b>
616 + {" "}rental{total !== null && total > 1 ? "s" : ""}
617 + {hasZone ? " · drawn area" : ""}
618 + </>
619 + )}
620 + {unpositioned > 0 && (
621 + <span
622 + className="ms-nopos"
623 + title="Listings matching the filters but without a geocoded position — visible in List view only"
624 + >
625 + +{unpositioned} unpositioned
626 + </span>
627 + )}
628 + </div>
629 + <label className="ms2-sort">
630 + <select
631 + value={qy.sort}
632 + onChange={(e) => setQy((q) => ({
633 + ...q, page: 1, sort: e.target.value as SearchSort,
634 + }))}
635 + aria-label="Sort the results"
636 + >
637 + {SORT_CHOICES.map((s) => (
638 + <option key={s.key} value={s.key}>{s.label}</option>
639 + ))}
640 + </select>
641 + </label>
642 + </div>
643 + );
644 +
645 + const listPane = (
646 + <div className="ms2-list" aria-label="Results list" ref={listRef}>
647 + {userLocked && total !== null && total > 0 && !hasZone && (
648 + <button className="ms-refit" onClick={refitToResults}>
649 + ⌖ Refit to results
650 + </button>
651 + )}
652 +
653 + {loading && <div className="ms-progress" aria-hidden="true" />}
654 +
655 + {error && (
656 + <div className="ms-empty">
657 + <h3>Could not load the results</h3>
658 + <p>{error}</p>
659 + </div>
660 + )}
661 +
662 + {!error && total === 0 && (
663 + <div className="ms-empty" role="status">
664 + <div className="big"><IcoSearch size={34} /></div>
665 + <h3>No rentals in this area</h3>
666 + <p>Widen the map or change your filters.</p>
667 + <div className="ms-empty-actions">
668 + <button className="btn btn-primary" onClick={widenArea}>
669 + Widen the area
670 + </button>
671 + {hasZone && (
672 + <button className="btn btn-ghost" onClick={clearZone}>
673 + Clear the drawn area
674 + </button>
675 + )}
676 + </div>
677 + </div>
678 + )}
679 +
680 + {!error && listings.map((l) => (
681 + <div
682 + key={l.uid}
683 + data-uid={l.uid}
684 + className={
685 + "map-card" +
686 + (selectedUid === l.uid ? " map-card-sel" : "") +
687 + (mapHoverUid === l.uid ? " map-card-hover" : "")
688 + }
689 + onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")}
690 + onMouseLeave={() => engineRef.current?.setHovered(null, "app")}
691 + onFocus={() => engineRef.current?.setHovered(l.uid, "app")}
692 + onBlur={() => engineRef.current?.setHovered(null, "app")}
693 + onClickCapture={(e) => onCardClick(e, l.uid)}
694 + >
695 + <ListingCard l={l} />
696 + </div>
697 + ))}
698 +
699 + {!error && total !== null && total > 0 && (
700 + <Pager
701 + page={qy.page} pageSize={PAGE_SIZE} total={total}
702 + onPage={(p) => {
703 + setQy((q) => ({ ...q, page: p }));
704 + // le défileur est le parent (ka-pane-body / ka-sheet-body)
705 + listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });
706 + }}
707 + />
708 + )}
709 +
710 + {hoverOffscreen && (
711 + <div className={`ms-hover-hint ${hoverOffscreen}`} aria-hidden="true">
712 + {hoverOffscreen === "up" ? "▲ Listing higher up" : "▼ Listing further down"}
713 + </div>
714 + )}
715 + </div>
716 + );
717 +
718 + return (
719 + <KaMapShell
720 + className="ms2"
721 + brand={<span className="ms2-brand"><b>Rent·Ka</b><span>Map</span></span>}
722 + onExit={onExit}
723 + exitLabel="List"
724 + storageKey="rentka-map-split"
725 + listHeader={listHeader}
726 + list={listPane}
727 + topExtras={
728 + onOpenFilters ? (
729 + <button className="ka-top-btn" onClick={onOpenFilters}>
730 + <IcoSliders size={13} /> Filters
731 + {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>}
732 + </button>
733 + ) : null
734 + }
735 + >
736 + <KaMapView
737 + theme={louKaMapTheme}
738 + mapboxToken={MAPBOX_TOKEN}
739 + center={initialCamera ?? CANADA}
740 + zoom={(initialCamera ?? CANADA).zoom}
741 + pitch={50}
742 + basemap={{ theme: "default", showLandmarks: true }}
743 + cluster={{ maxZoom: 15, valueClamp: [250, 8000] }}
744 + navControl={false}
745 + onMoveEnd={onMoveEnd}
746 + onSelect={onSelect}
747 + onHover={onHover}
748 + onDraw={onDraw}
749 + onClusterHover={setClusterTip}
750 + >
751 + <EngineBridge onEngine={handleEngine} />
752 + <SelectionMirror selectedUid={selectedUid} />
753 + <SheetAutoCollapse selectedUid={selectedUid} />
754 + <KaBrandBadge />
755 +
756 + {/* LA grappe de contrôles — zoom (desktop), 3D, dessin, localisation */}
757 + <KaMapToolbar>
758 + {!isMobile && (
759 + <KaToolbarGroup><KaToolbarZoom /></KaToolbarGroup>
760 + )}
761 + <KaToolbarGroup>
762 + <KaToolbar3D />
763 + <KaToolbarDraw />
764 + <KaToolbarLocate
765 + onLocated={() => {
766 + setUserLocked(true);
767 + userLockedRef.current = true;
768 + syncListToViewport(750);
769 + }}
770 + />
771 + </KaToolbarGroup>
772 + </KaMapToolbar>
773 +
774 + {/* mode dessin : bandeau temporaire + CTA « N logements » */}
775 + <KaDrawAreaMode
776 + count={hasZone ? total : null}
777 + formatCount={(n) => `${n.toLocaleString("en-CA")} rental${n > 1 ? "s" : ""}`}
778 + onClear={clearZone}
779 + />
780 +
781 + {/* « Rechercher quand je déplace la carte » + zone divergée */}
782 + {!hasZone && !drawing && (
783 + <div className="ka-search-area" role="group" aria-label="Geographic search">
784 + {dirty && !searchOnMove && (
785 + <button type="button" className="ka-search-area-btn"
786 + onClick={searchThisArea} disabled={loading}>
787 + {loading ? "Searching…" : "Search this area"}
788 + </button>
789 + )}
790 + <label className="ka-search-area-auto">
791 + <input
792 + type="checkbox"
793 + checked={searchOnMove}
794 + onChange={() => {
795 + const next = !searchOnMove;
796 + setSearchOnMove(next);
797 + if (next && dirty) searchThisArea();
798 + }}
799 + />
800 + <span>Search as I move the map</span>
801 + </label>
802 + </div>
803 + )}
804 +
805 + {loading && (
806 + <div className="ka-loading" role="status" aria-live="polite">
807 + <span className="ka-loading-dot" aria-hidden="true" />
808 + Updating rentals…
809 + </div>
810 + )}
811 +
812 + {total === 0 && !loading && (
813 + <div className="ka-empty" role="status">
814 + <strong>No rentals in this area.</strong>
815 + <span>Widen the map or change your filters.</span>
816 + </div>
817 + )}
818 +
819 + {clusterTip && !isMobile && (
820 + <div
821 + className="ka-cluster-tip"
822 + style={{ left: clusterTip.x, top: clusterTip.y }}
823 + >
824 + {clusterTip.count.toLocaleString("en-CA")} rentals
825 + {clusterTip.min != null && clusterTip.max != null && (
826 + <small>
827 + {clusterTip.min === clusterTip.max
828 + ? formatCompactPrice(clusterTip.min)
829 + : `${formatCompactPrice(clusterTip.min)} – ${formatCompactPrice(clusterTip.max)}`}
830 + {" "}/month
831 + </small>
832 + )}
833 + </div>
834 + )}
835 +
836 + <KaPropertyPreview render={(p) => <LazyPreview p={p} />} />
837 + </KaMapView>
838 + </KaMapShell>
839 + );
840 +}
added frontend/src/search/seen.ts +29 −0
@@ -0,0 +1,29 @@
1 +// -----------------------------------------------------------------------------
2 +// Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// search/seen.ts : annonces déjà consultées (état « vu » des marqueurs).
5 +// Persisté en localStorage, plafonné aux 400 visites les plus récentes.
6 +// -----------------------------------------------------------------------------
7 +
8 +const KEY = "rentka_seen";
9 +const MAX = 400;
10 +
11 +export function seenUids(): Set<string> {
12 + try {
13 + const raw = localStorage.getItem(KEY);
14 + return new Set(raw ? (JSON.parse(raw) as string[]) : []);
15 + } catch {
16 + return new Set();
17 + }
18 +}
19 +
20 +export function markSeen(uid: string): void {
21 + try {
22 + const raw = localStorage.getItem(KEY);
23 + const list = raw ? (JSON.parse(raw) as string[]) : [];
24 + const next = [uid, ...list.filter((u) => u !== uid)].slice(0, MAX);
25 + localStorage.setItem(KEY, JSON.stringify(next));
26 + } catch {
27 + // stockage indisponible (navigation privée) : l'état « vu » est optionnel
28 + }
29 +}
added frontend/src/styles.css +907 −0
@@ -0,0 +1,2905 @@
1 +/* -----------------------------------------------------------------------------
2 + Rent-Ka — Rental listings aggregator (Canada, outside Québec)
3 + Author: Simon-Pierre Boucher — contact@spboucher.ai
4 + styles.css: Rent-Ka LOCAL styles, laid ON TOP of the shared Groupe KA
5 + "editorial sharp" design system (frontend/src/ka/tokens.css, imported
6 + BEFORE this file in main.tsx).
7 + · Shared palette: paper #f5f3ee · ink #141814 · deep green #1c5c41
8 + · Rent-Ka ACCENT: cobalt #2456E6 (+ display font, only tokens.css overrides)
9 + · Type: display Bricolage Grotesque / text Inter / micro-labels JetBrains Mono
10 + · Signature: 1.5 px ink borders + hard offset shadows, radii 10/6 px
11 + · Baseline: "Apartments and homes for rent across Canada."
12 + · 100 % mobile adaptive (installable PWA, iOS safe-areas)
13 +----------------------------------------------------------------------------- */
14 +:root {
15 + /* ---- Rent-Ka ACCENT + display font (official ka-ui token overrides) ---- */
16 + --accent: #2456e6;
17 + --accent-soft: #e9efff;
18 + --accent-deep: #1738a8;
19 + --on-accent: #ffffff;
20 + --lime: var(--accent);
21 + --lime-soft: var(--accent-soft);
22 + --font-display: "Bricolage Grotesque", "Inter", system-ui, sans-serif;
23 +
24 + /* ---- Aliases inherited from the old navy/blue identity — remapped onto
25 + the shared tokens (the background goes back to paper via tokens.css) ---- */
26 + --navy: var(--ink);
27 + --navy-soft: var(--surface-2);
28 + --blue: var(--green);
29 + --blue-soft: #e8f0ea;
30 + --orange: var(--accent);
31 + --orange-soft: var(--accent-soft);
32 + --orange-deep: var(--accent-deep);
33 + --grey: var(--surface-2);
34 + --white: #ffffff;
35 +}
36 +
37 +#root { position: relative; z-index: 1; }
38 +a { text-decoration: none; }
39 +button { font-family: inherit; }
40 +
41 +.mono { font-family: var(--font-mono); }
42 +
43 +/* Garde-fou : jamais de défilement horizontal de la page, même transitoire
44 + pendant le chargement (bandeau défilant, polices) — clip ne casse pas
45 + position: sticky, contrairement à hidden. */
46 +html, body { overflow-x: clip; }
47 +
48 +/* ================= Header ================= */
49 +.header {
50 + position: sticky; top: 0; z-index: var(--z-header, 500);
51 + background: var(--paper);
52 + border-bottom: 1px solid var(--line);
53 +}
54 +.header-inner { display: flex; align-items: center; gap: 20px; height: 64px; }
55 +.brand {
56 + font-family: var(--font-display); font-weight: 700; font-size: 25px;
57 + letter-spacing: -0.03em; display: flex; align-items: center; line-height: 1;
58 + color: var(--navy);
59 +}
60 +.brand .logo-ico { margin-right: 12px; flex: none; } /* ≈ 40 % de la largeur de l'icône */
61 +.brand .ka { color: var(--orange); }
62 +.brand-tag {
63 + font-size: 11px; font-weight: 500; color: var(--ink-3);
64 + letter-spacing: 0.02em; margin-left: 12px;
65 +}
66 +@media (max-width: 900px) { .brand-tag { display: none; } }
67 +.nav { margin-left: auto; display: flex; gap: 4px; }
68 +.nav a {
69 + padding: 9px 16px; border-radius: var(--r-pill); font-weight: 600; font-size: 14px;
70 + color: var(--navy); transition: all 0.15s ease; min-height: 40px;
71 + display: inline-flex; align-items: center;
72 +}
73 +.nav a:hover { color: var(--orange); background: var(--orange-soft); }
74 +.nav a.active { background: var(--navy); color: var(--white); }
75 +
76 +/* ================= Ticker ================= */
77 +.ticker {
78 + background: var(--navy); color: rgba(255, 255, 255, 0.85); overflow: hidden;
79 + font-size: 11.5px; font-weight: 500; letter-spacing: 0.08em;
80 + text-transform: uppercase; padding: 7px 0; white-space: nowrap;
81 +}
82 +.ticker-track { display: inline-flex; gap: 0; animation: ticker 40s linear infinite; will-change: transform; }
83 +.ticker span { padding: 0 26px; position: relative; }
84 +.ticker span::after { content: "◆"; position: absolute; right: -6px; color: var(--orange); opacity: 0.8; font-size: 8px; top: 3px; }
85 +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
86 +@media (prefers-reduced-motion: reduce) {
87 + .ticker-track { animation: none; }
88 + * { transition-duration: 0.01ms !important; }
89 +}
90 +
91 +/* ================= Hero ================= */
92 +.hero { position: relative; padding: 64px 0 26px; }
93 +@media (max-width: 640px) { .hero { padding: 36px 0 14px; } }
94 +/* halo d'accent + trame de points en fond — purement décoratif, sous le texte */
95 +.hero::before {
96 + content: ""; position: absolute; z-index: -1; top: -60px; right: -120px;
97 + width: 560px; height: 460px; pointer-events: none;
98 + background: radial-gradient(closest-side, rgba(255, 106, 0, 0.13), transparent 72%);
99 +}
100 +.hero::after {
101 + content: ""; position: absolute; z-index: -1; inset: -20px -40px 0 0; pointer-events: none;
102 + background-image: radial-gradient(rgba(20, 24, 20, 0.16) 1px, transparent 1.5px);
103 + background-size: 24px 24px;
104 + -webkit-mask-image: linear-gradient(112deg, transparent 58%, rgba(0, 0, 0, 0.85));
105 + mask-image: linear-gradient(112deg, transparent 58%, rgba(0, 0, 0, 0.85));
106 +}
107 +.hero h1 {
108 + font-size: clamp(36px, 5.4vw, 56px); font-weight: 700; line-height: 1.06;
109 + letter-spacing: -0.03em; max-width: 900px; margin-top: 14px; color: var(--navy);
110 +}
111 +.hero h1 .outline { color: var(--navy); }
112 +.hero p.lede { color: var(--ink-2); font-size: 17px; max-width: 640px; margin: 18px 0 0; }
113 +
114 +.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; }
115 +.stat-chip {
116 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-pill);
117 + padding: 8px 16px; font-size: 12.5px; font-weight: 500;
118 + color: var(--ink-2); display: flex; gap: 8px; align-items: center;
119 + box-shadow: var(--shadow-flat);
120 + transition: transform 0.13s ease, box-shadow 0.13s ease;
121 +}
122 +@media (hover: hover) {
123 + .stat-chip:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); }
124 +}
125 +.stat-chip b { color: var(--navy); font-weight: 700; font-variant-numeric: tabular-nums; }
126 +.stat-chip .pulse {
127 + width: 8px; height: 8px; border-radius: 50%; background: var(--orange);
128 + box-shadow: 0 0 0 4px var(--orange-soft); animation: pulse 2.4s ease infinite;
129 +}
130 +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(255, 106, 0, 0.18); } }
131 +
132 +/* ================= Rangée d'avantages ================= */
133 +.benefits {
134 + display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
135 + gap: 14px; margin-top: 30px;
136 +}
137 +.benefit {
138 + display: flex; align-items: center; gap: 12px;
139 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
140 + padding: 11px 14px; box-shadow: var(--shadow-flat);
141 + transition: transform 0.15s ease, box-shadow 0.15s ease;
142 +}
143 +@media (hover: hover) {
144 + .benefit:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }
145 +}
146 +.benefit-ico {
147 + width: 42px; height: 42px; border-radius: var(--r-pill); flex: none;
148 + display: flex; align-items: center; justify-content: center;
149 + background: var(--orange-soft); color: var(--orange);
150 + border: 1px solid rgba(255, 106, 0, 0.25);
151 +}
152 +.benefit-ico.blue { background: var(--blue-soft); color: var(--blue); border-color: rgba(28, 92, 65, 0.25); }
153 +.benefit-label {
154 + font-size: 11.5px; font-weight: 700; text-transform: uppercase;
155 + letter-spacing: 0.08em; color: var(--navy);
156 +}
157 +@media (max-width: 640px) {
158 + .benefits { grid-template-columns: 1fr 1fr; gap: 12px; }
159 +}
160 +
161 +/* ================= Filter bar ================= */
162 +.filterbar {
163 + background: var(--surface); border: 2px solid var(--ink); border-radius: 16px;
164 + box-shadow: var(--shadow-off-soft); padding: 14px; margin: 34px 0 6px;
165 + display: flex; flex-direction: column; gap: 0; min-width: 0;
166 +}
167 +
168 +/* — rangée principale — */
169 +.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; }
170 +.f-search {
171 + flex: 1 1 240px; min-width: 0; max-width: 460px;
172 + display: flex; align-items: center; gap: 9px;
173 + border: 1.5px solid var(--ink); border-radius: var(--r-pill); background: var(--grey);
174 + padding: 0 16px; min-height: 52px; color: var(--ink-2);
175 + transition: box-shadow 0.15s ease, border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
176 +}
177 +.f-search:focus-within {
178 + background: var(--white); border-color: var(--ink);
179 + box-shadow: 3px 3px 0 var(--accent); color: var(--accent-deep);
180 +}
181 +.f-search input {
182 + border: none; background: none; outline: none; flex: 1; min-width: 0;
183 + font-size: 14.5px; color: var(--ink); font-family: inherit;
184 +}
185 +.f-search input::placeholder { color: var(--ink-3); }
186 +.f-clear {
187 + border: none; background: var(--line); color: var(--ink-2); border-radius: 50%;
188 + width: 22px; height: 22px; font-size: 10px; cursor: pointer; flex: none;
189 + display: grid; place-items: center; transition: background 0.12s ease, color 0.12s ease;
190 +}
191 +.f-clear:hover { background: var(--ink); color: var(--white); }
192 +.f-ctl {
193 + flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center;
194 + gap: 2px; border: 1.5px solid var(--ink); border-radius: 14px; background: var(--surface);
195 + padding: 7px 14px 6px; min-height: 52px; cursor: pointer;
196 + transition: box-shadow 0.15s ease, background 0.15s ease;
197 +}
198 +@media (hover: hover) { .f-ctl:hover { background: var(--grey); } }
199 +.f-ctl:focus-within { background: var(--white); box-shadow: 3px 3px 0 var(--accent); }
200 +.f-ctl > span {
201 + font-size: 9.5px; font-weight: 700;
202 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
203 +}
204 +.f-ctl select {
205 + border: none; background: transparent; outline: none; font-family: var(--font-display);
206 + font-weight: 700; font-size: 14.5px; color: var(--navy); cursor: pointer;
207 + appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 170px;
208 + text-overflow: ellipsis;
209 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%23141814'/%3E%3C/svg%3E");
210 + background-repeat: no-repeat; background-position: right center;
211 +}
212 +.range-pair { display: flex; align-items: center; gap: 4px; }
213 +.range-pair select { max-width: 86px; }
214 +.range-sep { color: var(--ink-3); font-size: 11px; }
215 +.f-more {
216 + flex: none; align-self: stretch; display: inline-flex; align-items: center; gap: 8px;
217 + border: 1.5px solid var(--ink); border-radius: var(--r-pill);
218 + background: var(--surface); color: var(--navy); padding: 0 20px; cursor: pointer;
219 + font-family: var(--font-display); font-weight: 700; font-size: 14px; min-height: 52px;
220 + transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.13s ease, color 0.13s ease;
221 +}
222 +.f-more:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); }
223 +.f-more:active { transform: none; box-shadow: none; }
224 +.f-more.on { background: var(--ink); border-color: var(--ink); color: var(--white); box-shadow: 3px 3px 0 var(--accent); }
225 +.f-more.on:hover { transform: none; }
226 +.f-more-badge {
227 + display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px;
228 + border-radius: var(--r-pill); background: var(--accent); color: var(--on-accent);
229 + font-size: 11.5px; font-weight: 700; font-variant-numeric: tabular-nums;
230 +}
231 +.f-chev {
232 + width: 8px; height: 8px; flex: none;
233 + border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
234 + transform: rotate(45deg); margin-top: -4px; transition: transform 0.18s ease, margin 0.18s ease;
235 +}
236 +.f-chev.up { transform: rotate(-135deg); margin-top: 4px; }
237 +
238 +/* — panneau avancé — */
239 +.f-adv {
240 + display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 18px 26px;
241 + border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 16px; min-width: 0;
242 + animation: adv-in 0.22s cubic-bezier(0.2, 0.9, 0.3, 1);
243 +}
244 +@keyframes adv-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
245 +.f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
246 +.f-group > label {
247 + font-size: 10.5px; font-weight: 700;
248 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
249 +}
250 +.f-group-end { justify-content: flex-end; }
251 +.f-group .btn:disabled { opacity: 0.4; cursor: default; }
252 +.f-native {
253 + border: 1.5px solid var(--ink); background: var(--grey); border-radius: var(--r-ctl);
254 + padding: 10px 30px 10px 14px; font-size: 14px; color: var(--ink); outline: none;
255 + font-family: inherit; min-height: 42px; width: 100%; min-width: 0;
256 + appearance: none; -webkit-appearance: none;
257 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E");
258 + background-repeat: no-repeat; background-position: right 12px center;
259 +}
260 +.f-native:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--accent); background: var(--white); }
261 +
262 +/* segments (pilules soudées) */
263 +.seg { display: inline-flex; flex-wrap: wrap; row-gap: 6px; }
264 +.seg button {
265 + border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink-2);
266 + padding: 8px 14px; font-family: var(--font-display); font-weight: 600; font-size: 13px;
267 + cursor: pointer; margin-left: -1.5px; white-space: nowrap; min-height: 40px;
268 + transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
269 +}
270 +.seg button:first-child { border-radius: var(--r-pill) 0 0 var(--r-pill); margin-left: 0; padding-left: 16px; }
271 +.seg button:last-child { border-radius: 0 var(--r-pill) var(--r-pill) 0; padding-right: 16px; }
272 +.seg button:hover { background: var(--orange-soft); color: var(--orange-deep); }
273 +.seg button.on {
274 + background: var(--ink); border-color: var(--ink); color: var(--white);
275 + position: relative; z-index: 1; box-shadow: inset 0 -2.5px 0 var(--accent);
276 +}
277 +
278 +/* pastilles de filtres actifs — survol = intention de retrait (danger) */
279 +.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 2px; }
280 +.pills .pill {
281 + display: inline-flex; align-items: center; gap: 7px;
282 + border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink);
283 + border-radius: var(--r-pill); padding: 6px 14px; font-size: 12.5px; font-weight: 600;
284 + text-transform: none; letter-spacing: normal;
285 + cursor: pointer; transition: all 0.13s ease; box-shadow: 2px 2px 0 var(--orange-soft);
286 + animation: pill-in 0.18s ease;
287 +}
288 +@keyframes pill-in { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: none; } }
289 +.pills .pill:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); box-shadow: none; }
290 +.pills .pill:hover .pill-x { opacity: 1; }
291 +.pill-x { font-size: 10px; opacity: 0.6; }
292 +.pills .pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); box-shadow: none; }
293 +.pills .pill-clear:hover { background: var(--danger); color: #fff; }
294 +.chip-sep { width: 1px; align-self: stretch; background: var(--line); margin: 4px 4px; flex: none; }
295 +.link-btn {
296 + background: none; border: none; padding: 0; color: var(--blue); font: inherit;
297 + text-decoration: underline; cursor: pointer;
298 +}
299 +.field { display: flex; flex-direction: column; gap: 5px; }
300 +.field label {
301 + font-size: 10.5px; font-weight: 700;
302 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); padding-left: 2px;
303 +}
304 +.field input, .field select {
305 + border: 1.5px solid var(--ink); background: var(--grey); border-radius: var(--r-ctl);
306 + padding: 11px 14px; font-size: 15px; color: var(--ink); outline: none; font-family: inherit;
307 + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; min-height: 44px;
308 + appearance: none; -webkit-appearance: none;
309 +}
310 +.field select {
311 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E");
312 + background-repeat: no-repeat; background-position: right 12px center; padding-right: 30px;
313 +}
314 +.field input:focus, .field select:focus {
315 + border-color: var(--blue); background: var(--white);
316 + box-shadow: 0 0 0 3px rgba(28, 92, 65, 0.15);
317 +}
318 +/* .btn / .btn-primary / .btn-ghost : fournis par tokens.css (ka-ui). */
319 +
320 +/* ================= Chips ================= */
321 +.chips { display: flex; gap: 8px; margin: 18px 0 4px; overflow-x: auto; padding: 2px 0 8px; scrollbar-width: none; }
322 +.chips::-webkit-scrollbar { display: none; }
323 +.chip {
324 + display: inline-flex; align-items: center; gap: 7px;
325 + border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink);
326 + border-radius: var(--r-pill); padding: 9px 18px; font-size: 13px; font-weight: 700; cursor: pointer;
327 + font-family: var(--font-display); white-space: nowrap; min-height: 42px;
328 + transition: transform 0.13s, box-shadow 0.13s, background 0.13s, color 0.13s;
329 +}
330 +.chip:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); }
331 +.chip:active { transform: none; box-shadow: none; }
332 +.chip.on { background: var(--ink); border-color: var(--ink); color: var(--accent); box-shadow: 3px 3px 0 var(--accent); }
333 +
334 +/* ================= Results ================= */
335 +.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }
336 +.results-head h2 { font-size: 22px; letter-spacing: -0.02em; color: var(--navy); }
337 +.results-head span { color: var(--ink-3); font-size: 12.5px; font-weight: 500; }
338 +.grid {
339 + display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
340 + gap: 22px; padding-bottom: 70px;
341 +}
342 +@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 48px; } }
343 +
344 +.card {
345 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
346 + overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-flat);
347 + transition: transform 0.16s ease, box-shadow 0.16s ease;
348 +}
349 +.card:hover { transform: translateY(-4px); box-shadow: var(--shadow-off); }
350 +.card:focus-visible { outline: 3px solid rgba(28, 92, 65, 0.4); outline-offset: 2px; }
351 +.card-img { position: relative; aspect-ratio: 16/10.5; background: var(--grey); overflow: hidden; }
352 +.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
353 +.card:hover .card-img img { transform: scale(1.05); }
354 +.card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; }
355 +.badge {
356 + position: absolute; top: 12px; left: 12px; border-radius: var(--r-pill); padding: 4px 12px;
357 + font-size: 11px; font-weight: 700; letter-spacing: 0.04em;
358 + background: rgba(255, 255, 255, 0.94); color: var(--navy);
359 + backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
360 +}
361 +.badge.type { background: var(--orange); color: var(--white); }
362 +.badge.right { left: auto; right: 12px; background: rgba(255, 255, 255, 0.92); }
363 +.card-body { padding: 16px 18px 16px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
364 +.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.02em; color: var(--navy); }
365 +.card-price small { font-weight: 500; color: var(--ink-3); font-size: 11.5px; }
366 +.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); }
367 +.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
368 +.card-meta .sep { width: 4px; height: 4px; background: var(--orange); border-radius: 50%; }
369 +.card-foot { margin-top: auto; padding-top: 11px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
370 +.source-tag {
371 + font-size: 10px; font-weight: 700; text-transform: uppercase;
372 + letter-spacing: 0.06em; color: var(--blue); background: var(--blue-soft);
373 + border-radius: var(--r-pill); padding: 3px 10px;
374 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%;
375 +}
376 +.avail { font-size: 11.5px; color: var(--ink-2); font-weight: 500; text-align: right; }
377 +
378 +/* ================= Skeletons ================= */
379 +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
380 +.skel { border-radius: var(--r-card); border: 1.5px solid var(--ink); overflow: hidden; background: var(--surface); }
381 +.skel .sk-img, .skel .sk-line {
382 + background: linear-gradient(90deg, #ececef 25%, #f7f7f9 50%, #ececef 75%);
383 + background-size: 800px 100%; animation: shimmer 1.4s infinite linear;
384 +}
385 +.skel .sk-img { aspect-ratio: 16/10.5; }
386 +.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; }
387 +.skel .sk-line.short { width: 45%; }
388 +
389 +/* ================= Empty / error ================= */
390 +.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); }
391 +.notice .big { font-size: 44px; margin-bottom: 10px; }
392 +.notice h2 { color: var(--navy); }
393 +
394 +/* ================= Detail page ================= */
395 +.detail { padding: 30px 0 90px; }
396 +.crumbs {
397 + font-size: 11.5px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
398 + color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
399 +}
400 +.crumbs a { border-bottom: 1.5px solid transparent; }
401 +.crumbs a:hover { color: var(--blue); border-color: var(--blue); }
402 +.detail-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
403 +@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } }
404 +/* les enfants de grille ont min-width:auto par défaut : un contenu large
405 + (grille du quartier) déborderait de l'écran mobile sans ceci */
406 +.detail-grid > * { min-width: 0; }
407 +
408 +.gallery { display: flex; flex-direction: column; gap: 10px; }
409 +.gallery-main {
410 + border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--ink);
411 + aspect-ratio: 16/10; background: var(--grey); cursor: zoom-in; box-shadow: var(--shadow-off-soft);
412 +}
413 +.gallery-main img { width: 100%; height: 100%; object-fit: cover; }
414 +.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; }
415 +.thumbs button {
416 + border: 2px solid var(--line); border-radius: 12px; overflow: hidden; padding: 0; cursor: pointer;
417 + aspect-ratio: 4/3; background: var(--grey); transition: border-color 0.12s ease, transform 0.12s ease;
418 +}
419 +.thumbs button:hover { transform: translateY(-2px); }
420 +.thumbs button.on { border-color: var(--orange); }
421 +.thumbs img { width: 100%; height: 100%; object-fit: cover; }
422 +
423 +.panel {
424 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
425 + box-shadow: var(--shadow-off-soft); padding: 26px; position: sticky; top: 120px;
426 +}
427 +@media (max-width: 900px) { .panel { position: static; } }
428 +.panel .price { font-family: var(--font-display); font-size: 34px; font-weight: 700; letter-spacing: -0.03em; color: var(--navy); }
429 +.panel .price small { font-size: 12.5px; color: var(--ink-3); font-weight: 500; }
430 +.panel h1 { font-size: 22px; margin: 8px 0 2px; }
431 +.panel .loc { color: var(--ink-2); font-size: 14px; }
432 +.kv { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
433 +@media (max-width: 380px) { .kv { grid-template-columns: 1fr; } }
434 +.kv .cell { background: var(--grey); border-radius: var(--r-ctl); padding: 10px 14px; }
435 +.kv .cell .k { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 700; }
436 +.kv .cell .v { font-weight: 700; font-size: 14.5px; margin-top: 2px; font-family: var(--font-display); color: var(--navy); }
437 +/* .klabel : fourni par tokens.css (ka-ui). */
438 +.panel .klabel, .f-bloc .klabel { display: block; margin-bottom: 8px; }
439 +.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 20px; }
440 +.amenity {
441 + background: var(--blue-soft); color: var(--blue); font-size: 12px; font-weight: 600;
442 + border: 1px solid rgba(28, 92, 65, 0.25); border-radius: var(--r-pill); padding: 5px 13px;
443 +}
444 +.cta {
445 + display: block; text-align: center; background: var(--orange); color: var(--white);
446 + font-weight: 600; font-family: var(--font-display); border-radius: var(--r-pill);
447 + padding: 15px; min-height: 48px;
448 + box-shadow: 0 8px 22px rgba(255, 106, 0, 0.34); transition: all 0.14s ease;
449 +}
450 +.cta:hover { background: var(--orange-deep); }
451 +.cta:active { transform: translateY(1px); }
452 +.panel .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; }
453 +
454 +/* ================= Lightbox ================= */
455 +.lightbox {
456 + position: fixed; inset: 0; background: rgba(20, 24, 20, 0.94); z-index: var(--z-modal, 900);
457 + display: flex; align-items: center; justify-content: center; cursor: zoom-out;
458 + padding: max(16px, env(safe-area-inset-top)) 16px;
459 +}
460 +.lightbox img { max-width: 94vw; max-height: 90vh; border-radius: 12px; }
461 +
462 +/* Lightbox v2 : balayage entre photos (scroll-snap) + pincement pour zoomer */
463 +.lightbox-v2 { padding: 0; cursor: default; display: block; }
464 +.lightbox-track {
465 + display: flex; overflow-x: auto; scroll-snap-type: x mandatory; height: 100%;
466 + -webkit-overflow-scrolling: touch; scrollbar-width: none;
467 + touch-action: pan-x pinch-zoom;
468 +}
469 +.lightbox-track::-webkit-scrollbar { display: none; }
470 +.lightbox-cell {
471 + flex: 0 0 100%; width: 100%; height: 100%; scroll-snap-align: center;
472 + display: flex; align-items: center; justify-content: center;
473 + padding: max(52px, env(safe-area-inset-top)) 10px max(24px, env(safe-area-inset-bottom));
474 + overflow: hidden;
475 +}
476 +.lightbox-cell img {
477 + max-width: 100%; max-height: 100%; border-radius: 10px;
478 + user-select: none; -webkit-user-drag: none; will-change: transform;
479 +}
480 +.lightbox-close {
481 + position: fixed; top: max(12px, env(safe-area-inset-top)); right: 12px; z-index: 3;
482 + width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid rgba(255,255,255,0.4);
483 + background: rgba(20, 24, 20, 0.6); color: #fff; font-size: 18px; cursor: pointer;
484 + display: flex; align-items: center; justify-content: center;
485 + backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
486 +}
487 +.lightbox-count {
488 + position: fixed; left: 50%; transform: translateX(-50%); right: auto;
489 + top: max(22px, env(safe-area-inset-top)); bottom: auto; z-index: 3;
490 +}
491 +.lightbox-v2 .carousel-nav { position: fixed; z-index: 3; }
492 +
493 +/* visuel de secours (SmartImg) directement sous .carousel (galerie vide) */
494 +.carousel > img { display: block; width: 100%; aspect-ratio: 16/11; object-fit: cover; }
495 +
496 +/* ================= Sources page ================= */
497 +.sources { padding: 44px 0 90px; }
498 +.sources h1 { font-size: clamp(28px, 4vw, 40px); margin: 10px 0 6px; color: var(--navy); }
499 +.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 700px; }
500 +.src-wrap { overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); background: var(--surface); }
501 +.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 720px; }
502 +.src-table th {
503 + text-align: left; font-size: 10.5px; text-transform: uppercase;
504 + letter-spacing: 0.1em; color: var(--ink-3); padding: 14px 18px;
505 + border-bottom: 1px solid var(--line-strong); background: var(--grey);
506 +}
507 +.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; }
508 +.src-table tr:last-child td { border-bottom: none; }
509 +.src-table tr:hover td { background: var(--navy-soft); }
510 +.src-table a { color: var(--blue); font-weight: 600; border-bottom: 1.5px solid rgba(28, 92, 65, 0.35); }
511 +.pill { display: inline-block; border-radius: var(--r-pill); padding: 3px 10px; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
512 +.pill.ok { background: var(--blue-soft); color: var(--blue); }
513 +.pill.todo { background: var(--orange-soft); color: var(--orange-deep); }
514 +.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; color: var(--navy); }
515 +
516 +/* ================= Page Statistiques ================= */
517 +.stats-page { padding: 44px 0 90px; }
518 +.stats-title { font-size: clamp(30px, 4.6vw, 46px); margin: 10px 0 6px; color: var(--navy); }
519 +.stats-page .sub { color: var(--ink-2); max-width: 640px; margin-bottom: 30px; }
520 +
521 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }
522 +.tile {
523 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
524 + padding: 18px 20px; box-shadow: var(--shadow-flat);
525 +}
526 +.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(24px, 3vw, 34px); letter-spacing: -0.03em; line-height: 1.05; color: var(--navy); }
527 +.tile-k { font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; }
528 +.hero-tile { background: var(--navy); color: var(--white); border-color: var(--navy); }
529 +.hero-tile .tile-v { color: var(--white); }
530 +.hero-tile .tile-k { color: rgba(255, 255, 255, 0.6); }
531 +
532 +.viz-card {
533 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
534 + box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px;
535 +}
536 +.viz-card h2 { font-size: 19px; letter-spacing: -0.01em; color: var(--navy); }
537 +.viz-sub { font-size: 11.5px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; }
538 +.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
539 +@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } }
540 +
541 +/* Histogramme (mono-série, bleu vif) */
542 +.histo { display: flex; align-items: stretch; gap: 2px; height: 240px; }
543 +.histo-col { flex: 1; display: flex; flex-direction: column; min-width: 0; cursor: default; }
544 +.histo-bar-zone { flex: 1; display: flex; align-items: flex-end; border-bottom: 1px solid var(--line-strong); }
545 +.histo-bar {
546 + width: 100%; background: var(--blue); border-radius: 6px 6px 0 0;
547 + min-height: 2px; transition: background 0.12s ease;
548 +}
549 +.histo-col:hover .histo-bar { background: var(--orange); }
550 +.histo-x { font-size: 9.5px; color: var(--ink-3); text-align: left; height: 18px; padding-top: 5px; overflow: visible; white-space: nowrap; }
551 +
552 +/* Barres horizontales (mono-série) */
553 +.hbars { display: flex; flex-direction: column; gap: 7px; }
554 +.hbar-row {
555 + display: grid; grid-template-columns: minmax(96px, 170px) 1fr auto;
556 + gap: 12px; align-items: center; min-height: 26px; cursor: default;
557 +}
558 +.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
559 +.hbar-label a { border-bottom: 1.5px solid rgba(28, 92, 65, 0.35); }
560 +.hbar-label a:hover { color: var(--blue); }
561 +.hbar-track { background: var(--grey); border-radius: var(--r-pill); height: 18px; overflow: hidden; }
562 +.hbar-fill {
563 + display: block; height: 100%; background: var(--blue); border-radius: var(--r-pill);
564 + min-width: 2px; transition: background 0.12s ease;
565 +}
566 +.hbar-row:hover .hbar-fill { background: var(--orange); }
567 +.hbar-value { font-size: 11.5px; font-weight: 700; white-space: nowrap; color: var(--navy); }
568 +.hbar-value em { font-style: normal; font-weight: 500; color: var(--ink-3); }
569 +@media (max-width: 640px) {
570 + .hbar-row { grid-template-columns: minmax(80px, 110px) 1fr auto; gap: 8px; }
571 + .hbar-value em { display: none; }
572 + .viz-card { padding: 20px 16px 14px; }
573 + .histo { height: 170px; }
574 +}
575 +
576 +/* Infobulle */
577 +.viz-tip {
578 + position: fixed; z-index: var(--z-toast, 950); pointer-events: none; max-width: 180px;
579 + background: var(--navy); color: var(--white); border-radius: 12px;
580 + padding: 9px 12px; font-size: 12px; line-height: 1.5;
581 + box-shadow: 0 8px 24px rgba(20, 24, 20, 0.35);
582 +}
583 +.viz-tip-title { font-family: var(--font-display); font-weight: 700; color: var(--orange); margin-bottom: 2px; }
584 +
585 +/* Vue tableau repliable (accessibilité / relief) */
586 +.viz-table { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 10px; }
587 +.viz-table summary {
588 + cursor: pointer; font-size: 11.5px; font-weight: 700;
589 + text-transform: uppercase; letter-spacing: 0.08em; color: var(--blue);
590 +}
591 +.viz-table table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 13px; }
592 +.viz-table th { text-align: left; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1px solid var(--line-strong); }
593 +.viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
594 +
595 +.stats-foot { font-size: 11.5px; color: var(--ink-3); letter-spacing: 0.02em; margin-top: 6px; }
596 +
597 +/* ================= Mobile : feuille de filtres + FAB ================= */
598 +.sheet-head { display: none; }
599 +.sheet-handle { display: none; }
600 +.sheet-apply { display: none; }
601 +.sheet-backdrop { display: none; }
602 +.fab { display: none; }
603 +.fab-badge {
604 + display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px;
605 + border-radius: var(--r-pill); background: var(--white); color: var(--accent-deep);
606 + font-size: 11.5px; font-weight: 700; font-variant-numeric: tabular-nums; margin-left: 2px;
607 +}
608 +
609 +@media (max-width: 640px) {
610 + /* En-tête compact */
611 + .header-inner { height: 56px; }
612 + .brand { font-size: 21px; }
613 + .nav a { padding: 8px 13px; font-size: 13.5px; }
614 + .ticker { font-size: 10.5px; padding: 6px 0; }
615 +
616 + /* Héro resserré + stats en rangée défilante */
617 + .hero::before { width: 320px; height: 260px; top: -30px; right: -90px; }
618 + .hero h1 { font-size: clamp(30px, 8.6vw, 42px); }
619 + .hero p.lede { font-size: 15px; }
620 + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }
621 + .stat-row::-webkit-scrollbar { display: none; }
622 + .stat-chip { flex: 0 0 auto; white-space: nowrap; }
623 +
624 + /* La barre de filtres devient une feuille coulissante (bottom sheet) */
625 + .filterbar { display: none; }
626 + .filterbar.open .f-primary { flex-direction: column; }
627 + .filterbar.open .f-search { align-self: stretch; max-width: none; min-height: 46px; }
628 + .filterbar.open .f-search input { font-size: 16px; } /* anti-zoom iOS */
629 + .filterbar.open .f-ctl select { max-width: none; width: 100%; }
630 + .filterbar.open .f-more { display: none; }
631 + .filterbar.open .f-adv { margin-top: 4px; }
632 + .filterbar.open {
633 + display: flex; flex-direction: column; gap: 12px;
634 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
635 + margin: 0; border-radius: 24px 24px 0 0; border: none;
636 + max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
637 + padding: 10px 18px calc(18px + env(safe-area-inset-bottom));
638 + box-shadow: 0 -16px 48px rgba(20, 24, 20, 0.28);
639 + animation: sheet-up 0.24s cubic-bezier(0.2, 0.9, 0.3, 1);
640 + }
641 + @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }
642 + .filterbar.open .sheet-handle {
643 + display: block; flex: none; width: 44px; height: 5px; border-radius: var(--r-pill);
644 + background: var(--line); margin: 0 auto 2px;
645 + }
646 + .filterbar.open .sheet-head {
647 + display: flex; justify-content: space-between; align-items: center;
648 + font-family: var(--font-display); font-weight: 700; font-size: 17px;
649 + letter-spacing: -0.01em; color: var(--navy);
650 + position: sticky; top: -10px; background: var(--surface); padding: 6px 0 8px;
651 + border-bottom: 1px solid var(--line); margin-bottom: 2px; z-index: 1;
652 + }
653 + .sheet-close {
654 + border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%;
655 + width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; color: var(--navy);
656 + }
657 + .filterbar.open .sheet-apply {
658 + display: block; width: 100%; min-height: 50px;
659 + position: sticky; bottom: 0; z-index: 1;
660 + box-shadow: 0 -14px 18px -12px rgba(20, 24, 20, 0.35), 5px 5px 0 var(--accent);
661 + }
662 + .sheet-backdrop {
663 + display: block; position: fixed; inset: 0; z-index: var(--z-overlay, 800);
664 + background: rgba(20, 24, 20, 0.45); backdrop-filter: blur(2px);
665 + }
666 +
667 + /* Bouton flottant */
668 + .fab {
669 + display: flex; align-items: center; gap: 6px;
670 + position: fixed; left: 50%; transform: translateX(-50%);
671 + bottom: calc(74px + env(safe-area-inset-bottom) + var(--consent-h, 0px)); z-index: 80;
672 + background: var(--orange); color: var(--white); border: none;
673 + border-radius: var(--r-pill); padding: 13px 24px; font-family: var(--font-display);
674 + font-weight: 600; font-size: 15px; cursor: pointer;
675 + box-shadow: 0 10px 28px rgba(255, 106, 0, 0.4);
676 + }
677 + .fab:active { transform: translateX(-50%) scale(0.97); }
678 +
679 + /* Fiche logement : vignettes en bande défilante + panneau non collant */
680 + .thumbs { display: flex; overflow-x: auto; scrollbar-width: none; padding-bottom: 4px; }
681 + .thumbs::-webkit-scrollbar { display: none; }
682 + .thumbs button { flex: 0 0 96px; }
683 + .detail { padding-top: 20px; }
684 + .panel { padding: 20px; }
685 + .panel .price { font-size: 28px; }
686 + .results-head { margin-top: 20px; }
687 + .chips { margin-right: -16px; padding-right: 16px; }
688 + .notice { padding: 48px 16px; }
689 +}
690 +
691 +/* ================= Barre de navigation inférieure (mobile) ================= */
692 +.tabbar { display: none; }
693 +@media (max-width: 760px) {
694 + .tabbar {
695 + display: grid; grid-template-columns: repeat(4, 1fr);
696 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-bottombar, 600);
697 + background: rgba(255, 255, 255, 0.92);
698 + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
699 + border-top: 1px solid var(--line);
700 + padding: 6px 4px calc(6px + env(safe-area-inset-bottom));
701 + }
702 + .tabbar a {
703 + display: flex; flex-direction: column; align-items: center; gap: 3px;
704 + padding: 5px 2px; min-height: 48px; justify-content: center;
705 + font-size: 10.5px; font-weight: 600; color: var(--ink-3);
706 + border-radius: var(--r-ctl); transition: color 0.12s ease;
707 + }
708 + .tabbar a.active { color: var(--orange); }
709 + .tabbar a svg { display: block; }
710 + body { padding-bottom: calc(64px + env(safe-area-inset-bottom)); }
711 + .footer { padding-bottom: calc(72px + env(safe-area-inset-bottom)); }
712 +}
713 +
714 +/* Anti-zoom iOS : les champs doivent faire >= 16px
715 + (filet global : tokens.css force aussi 16 px au doigt) */
716 +@media (max-width: 900px) {
717 + .field input, .field select,
718 + .f-search input, .f-native, .f-ctl select, .ms-sort select { font-size: 16px; }
719 +}
720 +
721 +/* Cible tactile ≥44 px du logo (124×30 rendus) : zone étendue invisible */
722 +@media (pointer: coarse) {
723 + .brand { position: relative; }
724 + .brand::after { content: ""; position: absolute; inset: -8px; }
725 +}
726 +
727 +/* ================= Footer ================= */
728 +.footer { background: var(--navy); color: rgba(255, 255, 255, 0.72); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; }
729 +.footer .fbrand { font-family: var(--font-display); font-weight: 700; font-size: 32px; color: var(--white); letter-spacing: -0.03em; margin-bottom: 4px; display: flex; align-items: center; }
730 +.footer .fbrand .logo-ico { margin-right: 13px; }
731 +.footer .fbrand .ka { color: var(--orange); }
732 +.footer .fbaseline { color: rgba(255, 255, 255, 0.65); font-size: 14px; margin-bottom: 16px; }
733 +.footer .fbaseline .hl { color: var(--orange); font-weight: 600; }
734 +.footer b { color: var(--white); }
735 +.footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 720px; }
736 +.footer .fmono { font-size: 11.5px; letter-spacing: 0.04em; color: rgba(255, 255, 255, 0.42); margin-top: 18px; }
737 +
738 +/* --- Carte interactive (mission 4) --------------------------------------- */
739 +.results-tools { display: flex; align-items: center; gap: 14px; }
740 +.view-toggle {
741 + display: inline-flex; border: 1.5px solid var(--ink);
742 + border-radius: var(--r-pill); overflow: hidden; background: var(--surface);
743 + box-shadow: var(--shadow-flat);
744 +}
745 +.view-toggle button {
746 + border: 0; background: transparent; padding: 8px 16px; cursor: pointer;
747 + font-size: 12.5px; font-weight: 700; color: var(--ink-2);
748 + font-family: var(--font-display); transition: background 0.12s ease, color 0.12s ease;
749 +}
750 +.view-toggle button:hover { background: var(--grey); color: var(--ink); }
751 +/* zone tactile >= 44 px au doigt (coarse pointer = mobile/tablette) */
752 +@media (pointer: coarse) {
753 + .view-toggle button { min-height: 44px; padding: 10px 18px; }
754 +}
755 +.view-toggle button + button { border-left: 1px solid var(--line); }
756 +.view-toggle button.on { background: var(--ink); color: var(--white); box-shadow: inset 0 -2.5px 0 var(--accent); }
757 +
758 +.map-split {
759 + display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 18px;
760 + height: calc(100dvh - 170px); min-height: 420px;
761 +}
762 +.map-list {
763 + overflow-y: auto; display: flex; flex-direction: column; gap: 14px;
764 + padding-right: 4px; scrollbar-width: thin;
765 +}
766 +.map-list .card { margin: 0; flex: 0 0 auto; }
767 +.mapview {
768 + width: 100%; height: 100%; border: 1.5px solid var(--ink);
769 + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
770 + overflow: hidden; background: var(--grey);
771 +}
772 +.map-loading {
773 + display: flex; align-items: center; justify-content: center;
774 + color: var(--ink-3); font-size: 13px;
775 +}
776 +
777 +/* mobile d'abord : carte plein écran, liste masquée (bascule via l'onglet) */
778 +@media (max-width: 780px) {
779 + .map-split { grid-template-columns: 1fr; height: calc(100dvh - 210px); }
780 + .map-list { display: none; }
781 + .results-tools { width: 100%; justify-content: space-between; }
782 +}
783 +
784 +/* MapLibre popup in the Rent-Ka style */
785 +.maplibregl-popup-content {
786 + padding: 0; border-radius: var(--r-card); overflow: hidden;
787 + border: 1.5px solid var(--ink); box-shadow: var(--shadow-off);
788 + font-family: var(--font-body);
789 +}
790 +.maplibregl-popup-close-button {
791 + font-size: 18px; padding: 2px 8px; color: var(--white); z-index: 2;
792 + text-shadow: 0 0 4px rgba(20, 24, 20, 0.8);
793 +}
794 +.mv-pop img, .mv-noimg { width: 100%; height: 130px; object-fit: cover; }
795 +.mv-noimg { display: flex; align-items: center; justify-content: center;
796 + font-size: 34px; background: var(--grey); }
797 +.mv-pop-body { padding: 10px 12px 12px; }
798 +.mv-pop-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; color: var(--navy); }
799 +.mv-pop-price small { font-weight: 400; color: var(--ink-3); font-size: 12px; }
800 +.mv-pop-title { font-size: 13px; color: var(--ink-2); margin-top: 2px;
801 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
802 +.mv-pop-meta { font-size: 11.5px; color: var(--ink-2); margin-top: 6px; }
803 +.mv-pop-src { font-size: 10px; color: var(--ink-3);
804 + text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
805 +.mv-pop-cta {
806 + display: block; margin-top: 10px; padding: 9px 10px; text-align: center;
807 + background: var(--orange); color: var(--white); border-radius: var(--r-pill);
808 + font-weight: 600; font-size: 13px;
809 +}
810 +.mv-pop-cta:hover { background: var(--orange-deep); }
811 +
812 +/* --- Commodités de proximité (fiche) -------------------------------------- */
813 +.poi-list {
814 + list-style: none; margin: 0; padding: 0;
815 + display: flex; flex-direction: column; gap: 2px;
816 +}
817 +.poi-list li {
818 + display: flex; align-items: center; gap: 9px;
819 + padding: 5px 2px; border-bottom: 1px solid var(--line);
820 + font-size: 13px;
821 +}
822 +.poi-list li:last-child { border-bottom: 0; }
823 +.poi-ico { width: 20px; text-align: center; flex: 0 0 auto; }
824 +.poi-name { flex: 1; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
825 +.poi-dist { font-size: 11.5px; color: var(--navy); font-weight: 600; flex: 0 0 auto; }
826 +
827 +/* --- Menu déroulant mobile ------------------------------------------------- */
828 +.menu-btn {
829 + display: none; position: relative; z-index: 60;
830 + width: 44px; height: 44px; margin-left: auto;
831 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
832 + background: var(--surface); cursor: pointer; padding: 0;
833 + flex-direction: column; align-items: center; justify-content: center; gap: 5px;
834 + box-shadow: var(--shadow-flat);
835 + transition: box-shadow 0.15s ease, transform 0.15s ease;
836 +}
837 +.menu-btn:active { transform: scale(0.96); }
838 +.menu-btn span {
839 + display: block; width: 18px; height: 2px; background: var(--navy);
840 + border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease;
841 +}
842 +.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
843 +.menu-btn.open span:nth-child(2) { opacity: 0; }
844 +.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
845 +
846 +/* KA Nav v2 (2026-08-25) : panneau PLEIN ECRAN fixed inset:0 - visible peu
847 + importe le scroll (l'ancien dropdown absolute 480px clippait/se coupait). */
848 +.mobile-menu {
849 + display: none; position: fixed; inset: 0;
850 + background: var(--paper);
851 + padding: 76px 16px calc(24px + env(safe-area-inset-bottom));
852 + overflow-y: auto; overscroll-behavior: contain;
853 +}
854 +.mobile-menu.open { display: block; animation: mm-in 0.16s ease; }
855 +@keyframes mm-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
856 +/* burger masque a l'ouverture : la fermeture passe par le X fixe du panneau */
857 +.header:has(.mobile-menu.open) .menu-btn { visibility: hidden; }
858 +/* bouton de fermeture du panneau : fixe en haut a droite du viewport */
859 +.mm-close {
860 + position: fixed; top: 10px; right: 14px; z-index: 2;
861 + width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;
862 + border: 1.5px solid var(--ink); border-radius: 999px;
863 + background: var(--ink); color: var(--paper);
864 + font-size: 17px; font-weight: 700; line-height: 1; cursor: pointer; padding: 0;
865 +}
866 +/* verrou du scroll d'arriere-plan quand le menu est ouvert (CSS pur) */
867 +html:has(.mobile-menu.open) { overflow: hidden; }
868 +/* Le drawer vit DANS le header : quand il est ouvert, le header monte au niveau
869 + modal pour peindre au-dessus du .mm-backdrop (frère du header, overlay 800). */
870 +.header:has(.mobile-menu.open) { z-index: var(--z-modal, 900); }
871 +.mm-link {
872 + display: flex; align-items: center; gap: 12px;
873 + padding: 15px 10px; min-height: 52px;
874 + font-family: var(--font-display); font-weight: 700; font-size: 19px;
875 + letter-spacing: -0.02em; border-bottom: 1px solid var(--line);
876 + color: var(--navy);
877 + opacity: 0; transform: translateY(-8px);
878 + transition: opacity 0.25s ease, transform 0.25s ease;
879 +}
880 +.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); }
881 +.mm-link.active { color: var(--orange); }
882 +.mm-link.active .mm-arrow { opacity: 1; color: var(--orange); }
883 +.mm-ico { width: 26px; text-align: center; font-size: 17px; }
884 +.mm-arrow { margin-left: auto; opacity: 0.25; color: var(--orange); transition: opacity 0.15s ease; }
885 +.mm-link:active { background: var(--orange-soft); border-radius: var(--r-ctl); }
886 +.mm-foot {
887 + padding: 12px 10px 4px;
888 + font-size: 11px; color: var(--ink-3); letter-spacing: 0.02em;
889 +}
890 +.mm-backdrop {
891 + position: fixed; inset: 0; z-index: var(--z-overlay, 800);
892 + background: rgba(20, 24, 20, 0.35); backdrop-filter: blur(2px);
893 +}
894 +@media (max-width: 760px) {
895 + .menu-btn { display: flex; }
896 + .nav { display: none; }
897 +}
898 +
899 +/* --- Bandeau de consentement (témoins) ------------------------------------- */
900 +.cookie-banner {
901 + position: fixed; left: 12px; right: 12px;
902 + bottom: calc(12px + env(safe-area-inset-bottom)); z-index: 80;
903 + animation: cookie-in 0.35s ease;
904 +}
905 +@keyframes cookie-in { from { transform: translateY(24px); opacity: 0; } }
906 +.cookie-inner {
907 + max-width: 720px; margin: 0 auto; background: var(--surface);

Diff truncated — file too large.