SPB Git forge

spb/home-ka

Public
10commits 1branches 0releases
793.0 KBsize
maindefault branch
20 days agolast push
Python 49.6% TypeScript 25.5% CSS 24.1%

Home-Ka v1 — US real-estate aggregator (Immo-Ka architecture): RESO-aligned schema, PROPERTY/LISTING separation, connector families (reso/rets/xml/json/jsonld/csv/sftp/arcgis), brokerage discovery pipeline (630 seeds), admin sources/connectors/brokerages, EN/USD frontend (forest green), deployed M4M36:8099 + ngrok www.home-ka.com

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

60 changed files +20,001 −0

added .env.example +17 −0
@@ -0,0 +1,17 @@
1 +# Home-Ka — credentials (copy to .env; NEVER commit .env)
2 +# Admin API token (optional — /api/admin/* is open when unset)
3 +HOMEKA_ADMIN_TOKEN=
4 +
5 +# Anti-bot fallback chain (optional, LAST RESORT — see connectors/_resilient.py)
6 +SCRAPFLY_KEY=
7 +FIRECRAWL_API_KEY=
8 +OXYLABS_PROXY=
9 +OXYLABS_PROXY_USER=
10 +OXYLABS_PROXY_PASS=
11 +BRIGHTDATA_API_KEY=
12 +BRIGHTDATA_ZONE=
13 +
14 +# RESO / RETS feed credentials, referenced from source configs as $ENV:NAME
15 +# MLSGRID_TOKEN=
16 +# TRESTLE_CLIENT_ID=
17 +# TRESTLE_CLIENT_SECRET=
added .gitignore +9 −0
@@ -0,0 +1,9 @@
1 +__pycache__/
2 +*.pyc
3 +.venv/
4 +node_modules/
5 +frontend/dist/
6 +frontend/*.tsbuildinfo
7 +data/homeka.db*
8 +.env
9 +.DS_Store
added README.md +78 −0
@@ -0,0 +1,78 @@
1 +<h1 align="center">Home·Ka</h1>
2 +
3 +<p align="center"><b>US real-estate aggregator — every home for sale, from direct feeds first.</b><br>
4 +<a href="https://www.home-ka.com">www.home-ka.com</a> · Groupe KA · US counterpart of <a href="https://www.immo-ka.com">Immo·Ka</a> (Canada)</p>
5 +
6 +**Home-Ka** reuses the Immo-Ka architecture end to end — connectors, normalization, ingestion, property matching, deduplication, quality gate, search API, Ka Maps frontend — adapted to the US market: **RESO Data Dictionary** as the schema reference, USD/sq ft/USPS states, US Census geocoder, and a **brokerage partnership pipeline** designed to convert sources into direct feeds instead of crawling.
7 +
8 +## Pipeline (same philosophy as Immo-Ka)
9 +
10 +```
11 +SOURCE → connector → raw ingestion → normalization → property matching
12 + → deduplication → canonical listing → search index → Home-Ka
13 +```
14 +
15 +- **PROPERTY ≠ LISTING** — a `Listing` is one publication by one source; a `Property` (`properties` table, keyed by APN or normalized address) is the physical asset and keeps existing after the listing goes off-market. Every listing is matched on ingest (`homeka/propertymatch.py`).
16 +- **Change detection** — content hash per listing, price history (`price_log`), grace period (2 syncs) before deactivation, drift detection (volume/price collapse suspends removals).
17 +- **Dedup** — precomputed (`dup_hidden`/`dup_of`): same MLS number across sources, or same property at ±1% price; most authoritative source wins (`sources.authority`: direct feed < partner API < site < classifieds).
18 +- **Quality gate** — completeness score, quarantine below threshold (`published=0`).
19 +- **Geocoding** — US Census batch geocoder (free, 5k/req) + budgeted Nominatim fallback, persistent cache.
20 +
21 +## Connector families (`homeka/connectors/`)
22 +
23 +| Family | Module | Use |
24 +|---|---|---|
25 +| `reso` | `reso/webapi.py` | **RESO Web API (OData)** — MLS Grid, Trestle, Bridge, Spark… the preferred feed |
26 +| `rets` | `rets/client.py` | legacy RETS (login + DMQL2, COMPACT-DECODED) |
27 +| `xml` | `xml/feed.py` | XML exports / syndication feeds |
28 +| `json` | `json/feed.py` | JSON partner APIs, wp-json, internal search APIs |
29 +| `jsonld` | `json/jsonld_site.py` | schema.org JSON-LD site crawler (bootstrap only) |
30 +| `csv` | `csv/feed.py` | CSV exports |
31 +| `sftp` | `sftp/drop.py` | SFTP drops (csv/xml), newest file by mtime |
32 +| `arcgis` | `public_data/arcgis.py` | county assessor/GIS parcels → feeds **properties**, not listings |
33 +| custom | `custom_broker/*.py` | one class per site when a family can't express it |
34 +
35 +A new source of a known family = **one row in the `sources` table** (`config` JSON: URL, field mapping, `$ENV:NAME` credentials), zero code. Custom connectors auto-register via `source_id`, exactly like Immo-Ka. Anti-bot policy: direct requests first; auto-escalation chain (Oxylabs → Scrapfly ASP → Bright Data, `connectors/_resilient.py`) only when blocked.
36 +
37 +## Brokerage pipeline (`/admin/brokerages`)
38 +
39 +```
40 +discover brokerage → inspect website → detect IDX/feed/provider
41 +→ find public contacts → classify connector → score → admin
42 +```
43 +
44 +- Seed: **630 real US brokerages** (`data/brokerages_seed.json`, `python run.py seed`).
45 +- `homeka/discovery.py` inspects each website (direct GET): IDX platform fingerprints (kvCORE, iHomefinder, IDX Broker, Sierra, REW, Delta Media, Ylopo…), RESO/RETS hints, MLS affiliations, contact/partnership emails; then scores `feed_probability_score` and `priority_score` (size × feed probability × coverage).
46 +- Partnership statuses: prospect → to-contact → contacted → in-discussion → feed-received → **live** (linked to a `sources` row).
47 +
48 +## Run
49 +
50 +```bash
51 +python run.py serve 8099 # API + frontend
52 +python run.py sync [source ...] # one-shot ingestion
53 +python run.py watch 60 # sync loop + geocode + img audit + discovery
54 +python run.py seed # load brokerage seed
55 +python run.py discover 25 # inspect next prospects
56 +python run.py list | geocode | quality | dedup | rescore
57 +```
58 +
59 +Admin: `/admin` (overview), `/admin/sources`, `/admin/connectors`, `/admin/brokerages` — optional `HOMEKA_ADMIN_TOKEN` (.env) required as `X-Admin-Token`.
60 +
61 +## Deployment
62 +
63 +- Node **M4M36** (`~/apps/home-ka`), port **8099**, PM2 `home-ka-web` + `home-ka-sync` + `home-ka-ngrok` (`--url=www.home-ka.com`).
64 +- Frontend: Vite build (`frontend/`, ka-maps vendored in `vendor/ka-maps`).
65 +- Git: `origin` = spbgit (`gitsrv:srv/git/home-ka.git`).
66 +
67 +## Adding a RESO feed (example)
68 +
69 +```python
70 +from homeka import db
71 +con = db.connect()
72 +db.upsert_source(con, "mlsgrid_actris", "MLS Grid — ACTRIS", "reso", config={
73 + "base_url": "https://api.mlsgrid.com/v2",
74 + "token": "$ENV:MLSGRID_TOKEN",
75 + "filter": "StandardStatus eq 'Active'",
76 + "expand": "Media", "originating_system": "ACTRIS",
77 +}, authority=0, states=["TX"])
78 +```
added data/brokerages_seed.json +10059 −0
@@ -0,0 +1,10059 @@
1 +{
2 + "updated": "2026-08-28",
3 + "count": 630,
4 + "brokerages": [
5 + {
6 + "name": "Compass",
7 + "website": "www.compass.com",
8 + "states": [
9 + "NY",
10 + "CA",
11 + "FL",
12 + "TX",
13 + "MA",
14 + "DC",
15 + "WA",
16 + "IL",
17 + "CO",
18 + "NJ",
19 + "CT",
20 + "PA",
21 + "GA",
22 + "AZ",
23 + "NV",
24 + "TN",
25 + "NC",
26 + "MD",
27 + "VA",
28 + "HI"
29 + ],
30 + "cities": [
31 + "New York",
32 + "Los Angeles",
33 + "Miami",
34 + "San Francisco",
35 + "Washington"
36 + ],
37 + "estimated_agents": 28000,
38 + "estimated_listings": 45000,
39 + "mls_affiliations": []
40 + },
41 + {
42 + "name": "eXp Realty",
43 + "website": "www.exprealty.com",
44 + "states": [],
45 + "cities": [],
46 + "estimated_agents": 82000,
47 + "estimated_listings": 120000,
48 + "mls_affiliations": []
49 + },
50 + {
51 + "name": "Redfin",
52 + "website": "www.redfin.com",
53 + "states": [
54 + "WA",
55 + "CA",
56 + "TX",
57 + "FL",
58 + "IL",
59 + "CO",
60 + "GA",
61 + "AZ",
62 + "MD",
63 + "VA",
64 + "OR",
65 + "NC"
66 + ],
67 + "cities": [
68 + "Seattle"
69 + ],
70 + "estimated_agents": 1900,
71 + "estimated_listings": 8000,
72 + "mls_affiliations": []
73 + },
74 + {
75 + "name": "HomeSmart",
76 + "website": "www.homesmart.com",
77 + "states": [
78 + "AZ",
79 + "CA",
80 + "CO",
81 + "FL",
82 + "TX",
83 + "NV",
84 + "GA",
85 + "WA",
86 + "NM",
87 + "UT"
88 + ],
89 + "cities": [
90 + "Phoenix",
91 + "Scottsdale"
92 + ],
93 + "estimated_agents": 25000,
94 + "estimated_listings": 30000,
95 + "mls_affiliations": [
96 + "ARMLS"
97 + ]
98 + },
99 + {
100 + "name": "Realty ONE Group",
101 + "website": "www.realtyonegroup.com",
102 + "states": [
103 + "NV",
104 + "AZ",
105 + "CA",
106 + "FL",
107 + "TX",
108 + "CO",
109 + "WA",
110 + "NC",
111 + "TN"
112 + ],
113 + "cities": [
114 + "Las Vegas",
115 + "Irvine"
116 + ],
117 + "estimated_agents": 19000,
118 + "estimated_listings": 26000,
119 + "mls_affiliations": []
120 + },
121 + {
122 + "name": "Fathom Realty",
123 + "website": "www.fathomrealty.com",
124 + "states": [
125 + "TX",
126 + "NC",
127 + "VA",
128 + "GA",
129 + "FL",
130 + "AZ",
131 + "CO",
132 + "WA",
133 + "TN",
134 + "MO"
135 + ],
136 + "cities": [],
137 + "estimated_agents": 11000,
138 + "estimated_listings": 15000,
139 + "mls_affiliations": []
140 + },
141 + {
142 + "name": "United Real Estate",
143 + "website": "www.unitedrealestate.com",
144 + "states": [
145 + "TX",
146 + "KS",
147 + "MO",
148 + "IL",
149 + "PA",
150 + "NC",
151 + "FL",
152 + "GA",
153 + "OH",
154 + "NJ"
155 + ],
156 + "cities": [
157 + "Dallas",
158 + "Kansas City",
159 + "Chicago",
160 + "Philadelphia"
161 + ],
162 + "estimated_agents": 21000,
163 + "estimated_listings": 24000,
164 + "mls_affiliations": []
165 + },
166 + {
167 + "name": "The Real Brokerage",
168 + "website": "www.onereal.com",
169 + "states": [],
170 + "cities": [],
171 + "estimated_agents": 24000,
172 + "estimated_listings": 30000,
173 + "mls_affiliations": []
174 + },
175 + {
176 + "name": "Weichert Realtors",
177 + "website": "www.weichert.com",
178 + "states": [
179 + "NJ",
180 + "NY",
181 + "PA",
182 + "CT",
183 + "MD",
184 + "VA",
185 + "DC",
186 + "DE",
187 + "FL",
188 + "SC",
189 + "NC",
190 + "GA",
191 + "TX"
192 + ],
193 + "cities": [
194 + "Morris Plains"
195 + ],
196 + "estimated_agents": 10000,
197 + "estimated_listings": 15000,
198 + "mls_affiliations": []
199 + },
200 + {
201 + "name": "Douglas Elliman",
202 + "website": "www.elliman.com",
203 + "states": [
204 + "NY",
205 + "FL",
206 + "CA",
207 + "CT",
208 + "NJ",
209 + "MA",
210 + "CO",
211 + "TX",
212 + "NV"
213 + ],
214 + "cities": [
215 + "New York",
216 + "Miami",
217 + "Los Angeles"
218 + ],
219 + "estimated_agents": 6600,
220 + "estimated_listings": 12000,
221 + "mls_affiliations": [
222 + "OneKey",
223 + "MiamiMLS"
224 + ]
225 + },
226 + {
227 + "name": "Corcoran",
228 + "website": "www.corcoran.com",
229 + "states": [
230 + "NY",
231 + "FL",
232 + "CA",
233 + "MA",
234 + "NJ",
235 + "CT",
236 + "SC",
237 + "GA"
238 + ],
239 + "cities": [
240 + "New York",
241 + "Palm Beach"
242 + ],
243 + "estimated_agents": 5100,
244 + "estimated_listings": 9000,
245 + "mls_affiliations": [
246 + "OneKey"
247 + ]
248 + },
249 + {
250 + "name": "Coldwell Banker Realty",
251 + "website": "www.coldwellbankerhomes.com",
252 + "states": [
253 + "CA",
254 + "FL",
255 + "TX",
256 + "IL",
257 + "MN",
258 + "MA",
259 + "CT",
260 + "NJ",
261 + "NY",
262 + "MD",
263 + "VA",
264 + "GA",
265 + "CO",
266 + "AZ",
267 + "WA",
268 + "OH",
269 + "MI",
270 + "WI",
271 + "PA",
272 + "NC",
273 + "SC",
274 + "UT",
275 + "NV"
276 + ],
277 + "cities": [],
278 + "estimated_agents": 52000,
279 + "estimated_listings": 90000,
280 + "mls_affiliations": []
281 + },
282 + {
283 + "name": "Berkshire Hathaway HomeServices (network)",
284 + "website": "www.bhhs.com",
285 + "states": [],
286 + "cities": [],
287 + "estimated_agents": 50000,
288 + "estimated_listings": 80000,
289 + "mls_affiliations": []
290 + },
291 + {
292 + "name": "Sotheby's International Realty",
293 + "website": "www.sothebysrealty.com",
294 + "states": [
295 + "NY",
296 + "CA",
297 + "FL",
298 + "CO",
299 + "TX",
300 + "MA",
301 + "AZ",
302 + "NV",
303 + "HI",
304 + "WA",
305 + "CT",
306 + "NJ"
307 + ],
308 + "cities": [],
309 + "estimated_agents": 26000,
310 + "estimated_listings": 40000,
311 + "mls_affiliations": []
312 + },
313 + {
314 + "name": "Christie's International Real Estate",
315 + "website": "www.christiesrealestate.com",
316 + "states": [
317 + "NY",
318 + "CA",
319 + "FL",
320 + "IL",
321 + "TX",
322 + "NJ"
323 + ],
324 + "cities": [],
325 + "estimated_agents": 10000,
326 + "estimated_listings": 15000,
327 + "mls_affiliations": []
328 + },
329 + {
330 + "name": "Engel & Völkers Americas",
331 + "website": "www.evrealestate.com",
332 + "states": [
333 + "FL",
334 + "NY",
335 + "CA",
336 + "TX",
337 + "CO",
338 + "GA",
339 + "MN",
340 + "AZ",
341 + "SC",
342 + "NV"
343 + ],
344 + "cities": [],
345 + "estimated_agents": 6500,
346 + "estimated_listings": 10000,
347 + "mls_affiliations": []
348 + },
349 + {
350 + "name": "Keller Williams Realty International",
351 + "website": "www.kw.com",
352 + "states": [],
353 + "cities": [
354 + "Austin"
355 + ],
356 + "estimated_agents": 160000,
357 + "estimated_listings": 200000,
358 + "mls_affiliations": []
359 + },
360 + {
361 + "name": "RE/MAX",
362 + "website": "www.remax.com",
363 + "states": [],
364 + "cities": [
365 + "Denver"
366 + ],
367 + "estimated_agents": 60000,
368 + "estimated_listings": 110000,
369 + "mls_affiliations": []
370 + },
371 + {
372 + "name": "Century 21 Real Estate",
373 + "website": "www.century21.com",
374 + "states": [],
375 + "cities": [],
376 + "estimated_agents": 55000,
377 + "estimated_listings": 90000,
378 + "mls_affiliations": []
379 + },
380 + {
381 + "name": "ERA Real Estate",
382 + "website": "www.era.com",
383 + "states": [],
384 + "cities": [],
385 + "estimated_agents": 15000,
386 + "estimated_listings": 25000,
387 + "mls_affiliations": []
388 + },
389 + {
390 + "name": "Better Homes and Gardens Real Estate",
391 + "website": "www.bhgre.com",
392 + "states": [],
393 + "cities": [],
394 + "estimated_agents": 13000,
395 + "estimated_listings": 20000,
396 + "mls_affiliations": []
397 + },
398 + {
399 + "name": "EXIT Realty Corp International",
400 + "website": "www.exitrealty.com",
401 + "states": [],
402 + "cities": [],
403 + "estimated_agents": 14000,
404 + "estimated_listings": 20000,
405 + "mls_affiliations": []
406 + },
407 + {
408 + "name": "NextHome",
409 + "website": "www.nexthome.com",
410 + "states": [],
411 + "cities": [],
412 + "estimated_agents": 5600,
413 + "estimated_listings": 9000,
414 + "mls_affiliations": []
415 + },
416 + {
417 + "name": "HomeServices of America",
418 + "website": "www.homeservices.com",
419 + "states": [
420 + "MN",
421 + "CA",
422 + "TX",
423 + "GA",
424 + "NC",
425 + "VA",
426 + "PA",
427 + "NJ",
428 + "DE",
429 + "IA",
430 + "NE",
431 + "KS",
432 + "MO",
433 + "AZ",
434 + "OR",
435 + "WA"
436 + ],
437 + "cities": [
438 + "Minneapolis"
439 + ],
440 + "estimated_agents": 40000,
441 + "estimated_listings": 70000,
442 + "mls_affiliations": []
443 + },
444 + {
445 + "name": "Howard Hanna Real Estate Services",
446 + "website": "www.howardhanna.com",
447 + "states": [
448 + "PA",
449 + "OH",
450 + "NY",
451 + "VA",
452 + "NC",
453 + "MI",
454 + "WV",
455 + "MD"
456 + ],
457 + "cities": [
458 + "Pittsburgh",
459 + "Cleveland"
460 + ],
461 + "estimated_agents": 11000,
462 + "estimated_listings": 22000,
463 + "mls_affiliations": []
464 + },
465 + {
466 + "name": "William Raveis Real Estate",
467 + "website": "www.raveis.com",
468 + "states": [
469 + "CT",
470 + "MA",
471 + "NY",
472 + "FL",
473 + "RI",
474 + "NH",
475 + "ME",
476 + "VT",
477 + "NJ",
478 + "SC"
479 + ],
480 + "cities": [
481 + "Shelton",
482 + "Boston"
483 + ],
484 + "estimated_agents": 4500,
485 + "estimated_listings": 9000,
486 + "mls_affiliations": [
487 + "MLSPIN"
488 + ]
489 + },
490 + {
491 + "name": "@properties Christie's International",
492 + "website": "www.atproperties.com",
493 + "states": [
494 + "IL",
495 + "IN",
496 + "WI",
497 + "MI"
498 + ],
499 + "cities": [
500 + "Chicago"
501 + ],
502 + "estimated_agents": 4000,
503 + "estimated_listings": 8000,
504 + "mls_affiliations": [
505 + "MRED"
506 + ]
507 + },
508 + {
509 + "name": "John L. Scott Real Estate",
510 + "website": "www.johnlscott.com",
511 + "states": [
512 + "WA",
513 + "OR",
514 + "ID",
515 + "CA"
516 + ],
517 + "cities": [
518 + "Seattle",
519 + "Bellevue",
520 + "Portland"
521 + ],
522 + "estimated_agents": 3000,
523 + "estimated_listings": 7000,
524 + "mls_affiliations": [
525 + "NWMLS",
526 + "RMLS"
527 + ]
528 + },
529 + {
530 + "name": "Windermere Real Estate",
531 + "website": "www.windermere.com",
532 + "states": [
533 + "WA",
534 + "OR",
535 + "CA",
536 + "AZ",
537 + "CO",
538 + "ID",
539 + "MT",
540 + "NV",
541 + "UT",
542 + "HI"
543 + ],
544 + "cities": [
545 + "Seattle"
546 + ],
547 + "estimated_agents": 6500,
548 + "estimated_listings": 12000,
549 + "mls_affiliations": [
550 + "NWMLS"
551 + ]
552 + },
553 + {
554 + "name": "Long & Foster Real Estate",
555 + "website": "www.longandfoster.com",
556 + "states": [
557 + "VA",
558 + "MD",
559 + "DC",
560 + "PA",
561 + "NJ",
562 + "DE",
563 + "WV",
564 + "NC"
565 + ],
566 + "cities": [
567 + "Chantilly"
568 + ],
569 + "estimated_agents": 9000,
570 + "estimated_listings": 16000,
571 + "mls_affiliations": [
572 + "BRIGHT"
573 + ]
574 + },
575 + {
576 + "name": "Baird & Warner",
577 + "website": "www.bairdwarner.com",
578 + "states": [
579 + "IL"
580 + ],
581 + "cities": [
582 + "Chicago"
583 + ],
584 + "estimated_agents": 2400,
585 + "estimated_listings": 5000,
586 + "mls_affiliations": [
587 + "MRED"
588 + ]
589 + },
590 + {
591 + "name": "Edina Realty",
592 + "website": "www.edinarealty.com",
593 + "states": [
594 + "MN",
595 + "WI"
596 + ],
597 + "cities": [
598 + "Minneapolis",
599 + "Saint Paul"
600 + ],
601 + "estimated_agents": 2300,
602 + "estimated_listings": 5500,
603 + "mls_affiliations": [
604 + "NorthstarMLS"
605 + ]
606 + },
607 + {
608 + "name": "Ebby Halliday Realtors",
609 + "website": "www.ebby.com",
610 + "states": [
611 + "TX"
612 + ],
613 + "cities": [
614 + "Dallas",
615 + "Plano",
616 + "Frisco"
617 + ],
618 + "estimated_agents": 1800,
619 + "estimated_listings": 4000,
620 + "mls_affiliations": [
621 + "NTREIS"
622 + ]
623 + },
624 + {
625 + "name": "Allen Tate Realtors",
626 + "website": "www.allentate.com",
627 + "states": [
628 + "NC",
629 + "SC"
630 + ],
631 + "cities": [
632 + "Charlotte",
633 + "Raleigh",
634 + "Greensboro"
635 + ],
636 + "estimated_agents": 1800,
637 + "estimated_listings": 4200,
638 + "mls_affiliations": [
639 + "Canopy"
640 + ]
641 + },
642 + {
643 + "name": "Crye-Leike Real Estate Services",
644 + "website": "www.crye-leike.com",
645 + "states": [
646 + "TN",
647 + "AR",
648 + "MS",
649 + "GA",
650 + "AL",
651 + "KY",
652 + "FL"
653 + ],
654 + "cities": [
655 + "Memphis",
656 + "Nashville"
657 + ],
658 + "estimated_agents": 3200,
659 + "estimated_listings": 7000,
660 + "mls_affiliations": [
661 + "Realtracs"
662 + ]
663 + },
664 + {
665 + "name": "Michael Saunders & Company",
666 + "website": "www.michaelsaunders.com",
667 + "states": [
668 + "FL"
669 + ],
670 + "cities": [
671 + "Sarasota"
672 + ],
673 + "estimated_agents": 700,
674 + "estimated_listings": 2000,
675 + "mls_affiliations": [
676 + "Stellar"
677 + ]
678 + },
679 + {
680 + "name": "Watson Realty Corp",
681 + "website": "www.watsonrealtycorp.com",
682 + "states": [
683 + "FL",
684 + "GA"
685 + ],
686 + "cities": [
687 + "Jacksonville",
688 + "Orlando"
689 + ],
690 + "estimated_agents": 1400,
691 + "estimated_listings": 3200,
692 + "mls_affiliations": [
693 + "Stellar"
694 + ]
695 + },
696 + {
697 + "name": "BHHS Fox & Roach Realtors",
698 + "website": "www.foxroach.com",
699 + "states": [
700 + "PA",
701 + "NJ",
702 + "DE"
703 + ],
704 + "cities": [
705 + "Philadelphia"
706 + ],
707 + "estimated_agents": 5500,
708 + "estimated_listings": 9000,
709 + "mls_affiliations": [
710 + "BRIGHT"
711 + ]
712 + },
713 + {
714 + "name": "Houlihan Lawrence",
715 + "website": "www.houlihanlawrence.com",
716 + "states": [
717 + "NY",
718 + "CT"
719 + ],
720 + "cities": [
721 + "Rye",
722 + "Greenwich"
723 + ],
724 + "estimated_agents": 1300,
725 + "estimated_listings": 2800,
726 + "mls_affiliations": [
727 + "OneKey"
728 + ]
729 + },
730 + {
731 + "name": "Daniel Gale Sotheby's International Realty",
732 + "website": "www.danielgale.com",
733 + "states": [
734 + "NY"
735 + ],
736 + "cities": [
737 + "Long Island"
738 + ],
739 + "estimated_agents": 950,
740 + "estimated_listings": 2200,
741 + "mls_affiliations": [
742 + "OneKey"
743 + ]
744 + },
745 + {
746 + "name": "Brown Harris Stevens",
747 + "website": "www.bhsusa.com",
748 + "states": [
749 + "NY",
750 + "FL",
751 + "CT",
752 + "NJ"
753 + ],
754 + "cities": [
755 + "New York",
756 + "Palm Beach"
757 + ],
758 + "estimated_agents": 2500,
759 + "estimated_listings": 5000,
760 + "mls_affiliations": []
761 + },
762 + {
763 + "name": "SERHANT.",
764 + "website": "www.serhant.com",
765 + "states": [
766 + "NY",
767 + "NJ",
768 + "CT",
769 + "FL"
770 + ],
771 + "cities": [
772 + "New York"
773 + ],
774 + "estimated_agents": 500,
775 + "estimated_listings": 1500,
776 + "mls_affiliations": []
777 + },
778 + {
779 + "name": "First Team Real Estate",
780 + "website": "www.firstteam.com",
781 + "states": [
782 + "CA"
783 + ],
784 + "cities": [
785 + "Irvine",
786 + "Newport Beach"
787 + ],
788 + "estimated_agents": 2000,
789 + "estimated_listings": 3500,
790 + "mls_affiliations": [
791 + "CRMLS"
792 + ]
793 + },
794 + {
795 + "name": "HomeSmart Evergreen Realty",
796 + "website": "www.homesmartevergreen.com",
797 + "states": [
798 + "CA"
799 + ],
800 + "cities": [
801 + "Irvine"
802 + ],
803 + "estimated_agents": 1500,
804 + "estimated_listings": 2200,
805 + "mls_affiliations": [
806 + "CRMLS"
807 + ]
808 + },
809 + {
810 + "name": "Anywhere Real Estate (Coldwell Banker parent)",
811 + "website": "www.anywhere.re",
812 + "states": [],
813 + "cities": [],
814 + "estimated_agents": 180000,
815 + "estimated_listings": 250000,
816 + "mls_affiliations": []
817 + },
818 + {
819 + "name": "Weichert, Realtors - Capital Properties",
820 + "website": "www.weichertcp.com",
821 + "states": [
822 + "DC",
823 + "MD",
824 + "VA"
825 + ],
826 + "cities": [
827 + "Washington"
828 + ],
829 + "estimated_agents": 300,
830 + "estimated_listings": 700,
831 + "mls_affiliations": [
832 + "BRIGHT"
833 + ]
834 + },
835 + {
836 + "name": "RedKey Realty Leaders",
837 + "website": "www.redkeystl.com",
838 + "states": [
839 + "MO"
840 + ],
841 + "cities": [
842 + "Saint Louis"
843 + ],
844 + "estimated_agents": 150,
845 + "estimated_listings": 400,
846 + "mls_affiliations": [
847 + "MARIS"
848 + ]
849 + },
850 + {
851 + "name": "JB Goodwin Realtors",
852 + "website": "www.jbgoodwin.com",
853 + "states": [
854 + "TX"
855 + ],
856 + "cities": [
857 + "Austin",
858 + "San Antonio"
859 + ],
860 + "estimated_agents": 700,
861 + "estimated_listings": 1500,
862 + "mls_affiliations": [
863 + "ACTRIS",
864 + "SABOR"
865 + ]
866 + },
867 + {
868 + "name": "Kuper Sotheby's International Realty",
869 + "website": "www.kuperrealty.com",
870 + "states": [
871 + "TX"
872 + ],
873 + "cities": [
874 + "San Antonio",
875 + "Austin"
876 + ],
877 + "estimated_agents": 425,
878 + "estimated_listings": 1100,
879 + "mls_affiliations": [
880 + "SABOR",
881 + "ACTRIS"
882 + ]
883 + },
884 + {
885 + "name": "Phyllis Browning Company",
886 + "website": "www.phyllisbrowning.com",
887 + "states": [
888 + "TX"
889 + ],
890 + "cities": [
891 + "San Antonio"
892 + ],
893 + "estimated_agents": 200,
894 + "estimated_listings": 500,
895 + "mls_affiliations": [
896 + "SABOR"
897 + ]
898 + },
899 + {
900 + "name": "Better Homes and Gardens RE Gary Greene",
901 + "website": "www.garygreene.com",
902 + "states": [
903 + "TX"
904 + ],
905 + "cities": [
906 + "Houston",
907 + "The Woodlands"
908 + ],
909 + "estimated_agents": 1100,
910 + "estimated_listings": 2600,
911 + "mls_affiliations": [
912 + "HAR"
913 + ]
914 + },
915 + {
916 + "name": "Greenwood King Properties",
917 + "website": "www.greenwoodking.com",
918 + "states": [
919 + "TX"
920 + ],
921 + "cities": [
922 + "Houston"
923 + ],
924 + "estimated_agents": 180,
925 + "estimated_listings": 450,
926 + "mls_affiliations": [
927 + "HAR"
928 + ]
929 + },
930 + {
931 + "name": "Martha Turner Sotheby's International Realty",
932 + "website": "www.marthaturner.com",
933 + "states": [
934 + "TX"
935 + ],
936 + "cities": [
937 + "Houston"
938 + ],
939 + "estimated_agents": 300,
940 + "estimated_listings": 700,
941 + "mls_affiliations": [
942 + "HAR"
943 + ]
944 + },
945 + {
946 + "name": "John Daugherty Realtors",
947 + "website": "www.johndaugherty.com",
948 + "states": [
949 + "TX"
950 + ],
951 + "cities": [
952 + "Houston"
953 + ],
954 + "estimated_agents": 120,
955 + "estimated_listings": 300,
956 + "mls_affiliations": [
957 + "HAR"
958 + ]
959 + },
960 + {
961 + "name": "Heritage Texas Properties",
962 + "website": "www.heritagetexas.com",
963 + "states": [
964 + "TX"
965 + ],
966 + "cities": [
967 + "Houston"
968 + ],
969 + "estimated_agents": 200,
970 + "estimated_listings": 450,
971 + "mls_affiliations": [
972 + "HAR"
973 + ]
974 + },
975 + {
976 + "name": "Compass RE Texas",
977 + "website": "www.compass.com/tx",
978 + "states": [
979 + "TX"
980 + ],
981 + "cities": [
982 + "Houston",
983 + "Dallas",
984 + "Austin"
985 + ],
986 + "estimated_agents": 2200,
987 + "estimated_listings": 5000,
988 + "mls_affiliations": [
989 + "HAR",
990 + "NTREIS",
991 + "ACTRIS"
992 + ]
993 + },
994 + {
995 + "name": "Briggs Freeman Sotheby's International Realty",
996 + "website": "www.briggsfreeman.com",
997 + "states": [
998 + "TX"
999 + ],
1000 + "cities": [
1001 + "Dallas",
1002 + "Fort Worth"
1003 + ],
1004 + "estimated_agents": 380,
1005 + "estimated_listings": 900,
1006 + "mls_affiliations": [
1007 + "NTREIS"
1008 + ]
1009 + },
1010 + {
1011 + "name": "Allie Beth Allman & Associates",
1012 + "website": "www.alliebeth.com",
1013 + "states": [
1014 + "TX"
1015 + ],
1016 + "cities": [
1017 + "Dallas"
1018 + ],
1019 + "estimated_agents": 400,
1020 + "estimated_listings": 900,
1021 + "mls_affiliations": [
1022 + "NTREIS"
1023 + ]
1024 + },
1025 + {
1026 + "name": "Dave Perry-Miller Real Estate",
1027 + "website": "www.daveperrymiller.com",
1028 + "states": [
1029 + "TX"
1030 + ],
1031 + "cities": [
1032 + "Dallas"
1033 + ],
1034 + "estimated_agents": 350,
1035 + "estimated_listings": 800,
1036 + "mls_affiliations": [
1037 + "NTREIS"
1038 + ]
1039 + },
1040 + {
1041 + "name": "Williams Trew Real Estate",
1042 + "website": "www.williamstrew.com",
1043 + "states": [
1044 + "TX"
1045 + ],
1046 + "cities": [
1047 + "Fort Worth"
1048 + ],
1049 + "estimated_agents": 200,
1050 + "estimated_listings": 500,
1051 + "mls_affiliations": [
1052 + "NTREIS"
1053 + ]
1054 + },
1055 + {
1056 + "name": "Coldwell Banker Apex Realtors",
1057 + "website": "www.cbapex.com",
1058 + "states": [
1059 + "TX"
1060 + ],
1061 + "cities": [
1062 + "Dallas",
1063 + "Rockwall"
1064 + ],
1065 + "estimated_agents": 900,
1066 + "estimated_listings": 2000,
1067 + "mls_affiliations": [
1068 + "NTREIS"
1069 + ]
1070 + },
1071 + {
1072 + "name": "Monument Realty",
1073 + "website": "www.monumentrealty.com",
1074 + "states": [
1075 + "TX"
1076 + ],
1077 + "cities": [
1078 + "Frisco",
1079 + "Dallas"
1080 + ],
1081 + "estimated_agents": 700,
1082 + "estimated_listings": 1400,
1083 + "mls_affiliations": [
1084 + "NTREIS"
1085 + ]
1086 + },
1087 + {
1088 + "name": "Keller Williams Realty Austin",
1089 + "website": "www.kwaustin.com",
1090 + "states": [
1091 + "TX"
1092 + ],
1093 + "cities": [
1094 + "Austin"
1095 + ],
1096 + "estimated_agents": 2200,
1097 + "estimated_listings": 3500,
1098 + "mls_affiliations": [
1099 + "ACTRIS"
1100 + ]
1101 + },
1102 + {
1103 + "name": "Moreland Properties",
1104 + "website": "www.moreland.com",
1105 + "states": [
1106 + "TX"
1107 + ],
1108 + "cities": [
1109 + "Austin"
1110 + ],
1111 + "estimated_agents": 110,
1112 + "estimated_listings": 300,
1113 + "mls_affiliations": [
1114 + "ACTRIS"
1115 + ]
1116 + },
1117 + {
1118 + "name": "Realty Austin (Compass)",
1119 + "website": "www.realtyaustin.com",
1120 + "states": [
1121 + "TX"
1122 + ],
1123 + "cities": [
1124 + "Austin"
1125 + ],
1126 + "estimated_agents": 600,
1127 + "estimated_listings": 1400,
1128 + "mls_affiliations": [
1129 + "ACTRIS"
1130 + ]
1131 + },
1132 + {
1133 + "name": "All City Real Estate",
1134 + "website": "www.allcityagents.com",
1135 + "states": [
1136 + "TX"
1137 + ],
1138 + "cities": [
1139 + "Austin"
1140 + ],
1141 + "estimated_agents": 900,
1142 + "estimated_listings": 1500,
1143 + "mls_affiliations": [
1144 + "ACTRIS"
1145 + ]
1146 + },
1147 + {
1148 + "name": "Spyglass Realty",
1149 + "website": "www.spyglassrealty.com",
1150 + "states": [
1151 + "TX"
1152 + ],
1153 + "cities": [
1154 + "Austin"
1155 + ],
1156 + "estimated_agents": 130,
1157 + "estimated_listings": 350,
1158 + "mls_affiliations": [
1159 + "ACTRIS"
1160 + ]
1161 + },
1162 + {
1163 + "name": "Orchard",
1164 + "website": "www.orchard.com",
1165 + "states": [
1166 + "TX",
1167 + "CO",
1168 + "GA",
1169 + "NC"
1170 + ],
1171 + "cities": [
1172 + "Austin",
1173 + "Denver",
1174 + "Atlanta"
1175 + ],
1176 + "estimated_agents": 500,
1177 + "estimated_listings": 1200,
1178 + "mls_affiliations": []
1179 + },
1180 + {
1181 + "name": "Redfin Texas",
1182 + "website": "www.redfin.com/state/texas",
1183 + "states": [
1184 + "TX"
1185 + ],
1186 + "cities": [],
1187 + "estimated_agents": 250,
1188 + "estimated_listings": 1500,
1189 + "mls_affiliations": [
1190 + "NTREIS",
1191 + "HAR",
1192 + "ACTRIS"
1193 + ]
1194 + },
1195 + {
1196 + "name": "RE/MAX DFW Associates",
1197 + "website": "www.rmdfw.com",
1198 + "states": [
1199 + "TX"
1200 + ],
1201 + "cities": [
1202 + "Dallas",
1203 + "Coppell"
1204 + ],
1205 + "estimated_agents": 400,
1206 + "estimated_listings": 900,
1207 + "mls_affiliations": [
1208 + "NTREIS"
1209 + ]
1210 + },
1211 + {
1212 + "name": "Ultima Real Estate",
1213 + "website": "www.ultimarealestate.com",
1214 + "states": [
1215 + "TX"
1216 + ],
1217 + "cities": [
1218 + "Dallas"
1219 + ],
1220 + "estimated_agents": 600,
1221 + "estimated_listings": 1000,
1222 + "mls_affiliations": [
1223 + "NTREIS"
1224 + ]
1225 + },
1226 + {
1227 + "name": "Central Metro Realty",
1228 + "website": "www.centralmetrorealty.com",
1229 + "states": [
1230 + "TX"
1231 + ],
1232 + "cities": [
1233 + "Austin",
1234 + "San Antonio",
1235 + "Houston"
1236 + ],
1237 + "estimated_agents": 1300,
1238 + "estimated_listings": 1800,
1239 + "mls_affiliations": [
1240 + "ACTRIS"
1241 + ]
1242 + },
1243 + {
1244 + "name": "Texas United Realty",
1245 + "website": "www.texasunitedrealty.com",
1246 + "states": [
1247 + "TX"
1248 + ],
1249 + "cities": [
1250 + "Houston"
1251 + ],
1252 + "estimated_agents": 800,
1253 + "estimated_listings": 1200,
1254 + "mls_affiliations": [
1255 + "HAR"
1256 + ]
1257 + },
1258 + {
1259 + "name": "Norhill Realty",
1260 + "website": "www.norhillrealty.com",
1261 + "states": [
1262 + "TX"
1263 + ],
1264 + "cities": [
1265 + "Houston"
1266 + ],
1267 + "estimated_agents": 60,
1268 + "estimated_listings": 180,
1269 + "mls_affiliations": [
1270 + "HAR"
1271 + ]
1272 + },
1273 + {
1274 + "name": "Fathom Realty Texas",
1275 + "website": "www.fathomrealty.com/tx",
1276 + "states": [
1277 + "TX"
1278 + ],
1279 + "cities": [],
1280 + "estimated_agents": 2500,
1281 + "estimated_listings": 3500,
1282 + "mls_affiliations": [
1283 + "NTREIS",
1284 + "HAR"
1285 + ]
1286 + },
1287 + {
1288 + "name": "Rogers Healy and Associates",
1289 + "website": "www.rogershealy.com",
1290 + "states": [
1291 + "TX"
1292 + ],
1293 + "cities": [
1294 + "Dallas"
1295 + ],
1296 + "estimated_agents": 700,
1297 + "estimated_listings": 1300,
1298 + "mls_affiliations": [
1299 + "NTREIS"
1300 + ]
1301 + },
1302 + {
1303 + "name": "The Agency Texas",
1304 + "website": "www.theagencyre.com/tx",
1305 + "states": [
1306 + "TX"
1307 + ],
1308 + "cities": [
1309 + "Austin",
1310 + "Dallas"
1311 + ],
1312 + "estimated_agents": 150,
1313 + "estimated_listings": 400,
1314 + "mls_affiliations": []
1315 + },
1316 + {
1317 + "name": "The Agency",
1318 + "website": "www.theagencyre.com",
1319 + "states": [
1320 + "CA",
1321 + "NY",
1322 + "FL",
1323 + "CO",
1324 + "AZ",
1325 + "TX",
1326 + "NV",
1327 + "WA"
1328 + ],
1329 + "cities": [
1330 + "Beverly Hills",
1331 + "Los Angeles"
1332 + ],
1333 + "estimated_agents": 2100,
1334 + "estimated_listings": 4500,
1335 + "mls_affiliations": [
1336 + "CRMLS"
1337 + ]
1338 + },
1339 + {
1340 + "name": "Hilton & Hyland",
1341 + "website": "www.hiltonhyland.com",
1342 + "states": [
1343 + "CA"
1344 + ],
1345 + "cities": [
1346 + "Beverly Hills"
1347 + ],
1348 + "estimated_agents": 120,
1349 + "estimated_listings": 300,
1350 + "mls_affiliations": [
1351 + "CRMLS"
1352 + ]
1353 + },
1354 + {
1355 + "name": "Carolwood Estates",
1356 + "website": "www.carolwoodre.com",
1357 + "states": [
1358 + "CA"
1359 + ],
1360 + "cities": [
1361 + "Beverly Hills"
1362 + ],
1363 + "estimated_agents": 90,
1364 + "estimated_listings": 220,
1365 + "mls_affiliations": [
1366 + "CRMLS"
1367 + ]
1368 + },
1369 + {
1370 + "name": "Westside Estate Agency",
1371 + "website": "www.weahomes.com",
1372 + "states": [
1373 + "CA"
1374 + ],
1375 + "cities": [
1376 + "Beverly Hills"
1377 + ],
1378 + "estimated_agents": 60,
1379 + "estimated_listings": 150,
1380 + "mls_affiliations": [
1381 + "CRMLS"
1382 + ]
1383 + },
1384 + {
1385 + "name": "Rodeo Realty",
1386 + "website": "www.rodeore.com",
1387 + "states": [
1388 + "CA"
1389 + ],
1390 + "cities": [
1391 + "Los Angeles",
1392 + "Beverly Hills"
1393 + ],
1394 + "estimated_agents": 1100,
1395 + "estimated_listings": 2200,
1396 + "mls_affiliations": [
1397 + "CRMLS"
1398 + ]
1399 + },
1400 + {
1401 + "name": "John Aaroe Group / Pacific Union LA",
1402 + "website": "www.aaroe.com",
1403 + "states": [
1404 + "CA"
1405 + ],
1406 + "cities": [
1407 + "Los Angeles"
1408 + ],
1409 + "estimated_agents": 300,
1410 + "estimated_listings": 600,
1411 + "mls_affiliations": [
1412 + "CRMLS"
1413 + ]
1414 + },
1415 + {
1416 + "name": "Vanguard Properties",
1417 + "website": "www.vanguardproperties.com",
1418 + "states": [
1419 + "CA"
1420 + ],
1421 + "cities": [
1422 + "San Francisco",
1423 + "Sonoma"
1424 + ],
1425 + "estimated_agents": 450,
1426 + "estimated_listings": 900,
1427 + "mls_affiliations": [
1428 + "SFAR"
1429 + ]
1430 + },
1431 + {
1432 + "name": "Zephyr Real Estate (Corcoran Icon)",
1433 + "website": "www.corcoranicon.com",
1434 + "states": [
1435 + "CA"
1436 + ],
1437 + "cities": [
1438 + "San Francisco",
1439 + "Oakland"
1440 + ],
1441 + "estimated_agents": 900,
1442 + "estimated_listings": 1600,
1443 + "mls_affiliations": [
1444 + "SFAR"
1445 + ]
1446 + },
1447 + {
1448 + "name": "Golden Gate Sotheby's International Realty",
1449 + "website": "www.goldengatesir.com",
1450 + "states": [
1451 + "CA"
1452 + ],
1453 + "cities": [
1454 + "San Francisco",
1455 + "Marin"
1456 + ],
1457 + "estimated_agents": 500,
1458 + "estimated_listings": 1100,
1459 + "mls_affiliations": [
1460 + "SFAR",
1461 + "BAREIS"
1462 + ]
1463 + },
1464 + {
1465 + "name": "Intero Real Estate Services",
1466 + "website": "www.intero.com",
1467 + "states": [
1468 + "CA"
1469 + ],
1470 + "cities": [
1471 + "San Jose",
1472 + "Cupertino"
1473 + ],
1474 + "estimated_agents": 1600,
1475 + "estimated_listings": 2600,
1476 + "mls_affiliations": [
1477 + "MLSListings"
1478 + ]
1479 + },
1480 + {
1481 + "name": "Sereno (Christie's)",
1482 + "website": "www.sereno.com",
1483 + "states": [
1484 + "CA"
1485 + ],
1486 + "cities": [
1487 + "Los Gatos",
1488 + "Palo Alto"
1489 + ],
1490 + "estimated_agents": 600,
1491 + "estimated_listings": 1100,
1492 + "mls_affiliations": [
1493 + "MLSListings"
1494 + ]
1495 + },
1496 + {
1497 + "name": "Compass California",
1498 + "website": "www.compass.com/ca",
1499 + "states": [
1500 + "CA"
1501 + ],
1502 + "cities": [
1503 + "San Francisco",
1504 + "Los Angeles",
1505 + "San Diego"
1506 + ],
1507 + "estimated_agents": 6000,
1508 + "estimated_listings": 10000,
1509 + "mls_affiliations": [
1510 + "CRMLS",
1511 + "SFAR",
1512 + "MLSListings",
1513 + "SDMLS"
1514 + ]
1515 + },
1516 + {
1517 + "name": "Berkshire Hathaway HomeServices California Properties",
1518 + "website": "www.bhhscalifornia.com",
1519 + "states": [
1520 + "CA"
1521 + ],
1522 + "cities": [
1523 + "San Diego",
1524 + "Los Angeles",
1525 + "Santa Barbara"
1526 + ],
1527 + "estimated_agents": 3000,
1528 + "estimated_listings": 5500,
1529 + "mls_affiliations": [
1530 + "CRMLS",
1531 + "SDMLS"
1532 + ]
1533 + },
1534 + {
1535 + "name": "Willis Allen Real Estate",
1536 + "website": "www.willisallen.com",
1537 + "states": [
1538 + "CA"
1539 + ],
1540 + "cities": [
1541 + "La Jolla",
1542 + "San Diego"
1543 + ],
1544 + "estimated_agents": 200,
1545 + "estimated_listings": 450,
1546 + "mls_affiliations": [
1547 + "SDMLS"
1548 + ]
1549 + },
1550 + {
1551 + "name": "Pacific Sotheby's International Realty",
1552 + "website": "www.pacificsir.com",
1553 + "states": [
1554 + "CA"
1555 + ],
1556 + "cities": [
1557 + "San Diego"
1558 + ],
1559 + "estimated_agents": 750,
1560 + "estimated_listings": 1500,
1561 + "mls_affiliations": [
1562 + "SDMLS"
1563 + ]
1564 + },
1565 + {
1566 + "name": "Big Block Realty",
1567 + "website": "www.bigblockrealty.com",
1568 + "states": [
1569 + "CA"
1570 + ],
1571 + "cities": [
1572 + "San Diego"
1573 + ],
1574 + "estimated_agents": 1200,
1575 + "estimated_listings": 1800,
1576 + "mls_affiliations": [
1577 + "SDMLS"
1578 + ]
1579 + },
1580 + {
1581 + "name": "eXp Realty of California",
1582 + "website": "www.exprealty.com/ca",
1583 + "states": [
1584 + "CA"
1585 + ],
1586 + "cities": [],
1587 + "estimated_agents": 8000,
1588 + "estimated_listings": 11000,
1589 + "mls_affiliations": [
1590 + "CRMLS"
1591 + ]
1592 + },
1593 + {
1594 + "name": "Realty Masters & Associates",
1595 + "website": "www.realtymasters.com",
1596 + "states": [
1597 + "CA"
1598 + ],
1599 + "cities": [
1600 + "Riverside",
1601 + "Chino"
1602 + ],
1603 + "estimated_agents": 1800,
1604 + "estimated_listings": 2500,
1605 + "mls_affiliations": [
1606 + "CRMLS"
1607 + ]
1608 + },
1609 + {
1610 + "name": "KW Advisors Bay Area",
1611 + "website": "www.kwadvisorsbayarea.com",
1612 + "states": [
1613 + "CA"
1614 + ],
1615 + "cities": [
1616 + "San Francisco",
1617 + "Oakland"
1618 + ],
1619 + "estimated_agents": 900,
1620 + "estimated_listings": 1400,
1621 + "mls_affiliations": [
1622 + "SFAR"
1623 + ]
1624 + },
1625 + {
1626 + "name": "Lyon Real Estate",
1627 + "website": "www.golyon.com",
1628 + "states": [
1629 + "CA"
1630 + ],
1631 + "cities": [
1632 + "Sacramento"
1633 + ],
1634 + "estimated_agents": 800,
1635 + "estimated_listings": 1600,
1636 + "mls_affiliations": [
1637 + "Metrolist"
1638 + ]
1639 + },
1640 + {
1641 + "name": "Nick Sadek Sotheby's International Realty",
1642 + "website": "www.nicksadeksir.com",
1643 + "states": [
1644 + "CA"
1645 + ],
1646 + "cities": [
1647 + "Sacramento",
1648 + "Roseville"
1649 + ],
1650 + "estimated_agents": 250,
1651 + "estimated_listings": 500,
1652 + "mls_affiliations": [
1653 + "Metrolist"
1654 + ]
1655 + },
1656 + {
1657 + "name": "Coldwell Banker Grass Roots Realty",
1658 + "website": "www.cbgrassroots.com",
1659 + "states": [
1660 + "CA"
1661 + ],
1662 + "cities": [
1663 + "Grass Valley"
1664 + ],
1665 + "estimated_agents": 90,
1666 + "estimated_listings": 220,
1667 + "mls_affiliations": [
1668 + "Metrolist"
1669 + ]
1670 + },
1671 + {
1672 + "name": "Village Properties",
1673 + "website": "www.villagesite.com",
1674 + "states": [
1675 + "CA"
1676 + ],
1677 + "cities": [
1678 + "Santa Barbara",
1679 + "Montecito"
1680 + ],
1681 + "estimated_agents": 200,
1682 + "estimated_listings": 400,
1683 + "mls_affiliations": [
1684 + "SBMLS"
1685 + ]
1686 + },
1687 + {
1688 + "name": "Bennion Deville Homes",
1689 + "website": "www.bdhomes.com",
1690 + "states": [
1691 + "CA"
1692 + ],
1693 + "cities": [
1694 + "Palm Springs",
1695 + "Rancho Mirage"
1696 + ],
1697 + "estimated_agents": 900,
1698 + "estimated_listings": 1600,
1699 + "mls_affiliations": [
1700 + "CRMLS"
1701 + ]
1702 + },
1703 + {
1704 + "name": "Monterey Coast Realty",
1705 + "website": "www.montereycoastrealty.com",
1706 + "states": [
1707 + "CA"
1708 + ],
1709 + "cities": [
1710 + "Carmel"
1711 + ],
1712 + "estimated_agents": 70,
1713 + "estimated_listings": 180,
1714 + "mls_affiliations": [
1715 + "MLSListings"
1716 + ]
1717 + },
1718 + {
1719 + "name": "Alain Pinel / Golden Gate (legacy)",
1720 + "website": "www.apr.com",
1721 + "states": [
1722 + "CA"
1723 + ],
1724 + "cities": [
1725 + "Palo Alto"
1726 + ],
1727 + "estimated_agents": 300,
1728 + "estimated_listings": 600,
1729 + "mls_affiliations": [
1730 + "MLSListings"
1731 + ]
1732 + },
1733 + {
1734 + "name": "Keller Williams Larchmont LA",
1735 + "website": "www.kwlarchmont.com",
1736 + "states": [
1737 + "CA"
1738 + ],
1739 + "cities": [
1740 + "Los Angeles"
1741 + ],
1742 + "estimated_agents": 600,
1743 + "estimated_listings": 900,
1744 + "mls_affiliations": [
1745 + "CRMLS"
1746 + ]
1747 + },
1748 + {
1749 + "name": "Harcourts Auctions USA",
1750 + "website": "www.harcourtsusa.com",
1751 + "states": [
1752 + "CA"
1753 + ],
1754 + "cities": [
1755 + "San Diego",
1756 + "Orange County"
1757 + ],
1758 + "estimated_agents": 500,
1759 + "estimated_listings": 900,
1760 + "mls_affiliations": [
1761 + "CRMLS"
1762 + ]
1763 + },
1764 + {
1765 + "name": "Realty World Northern California & Nevada",
1766 + "website": "www.realtyworld-nca.com",
1767 + "states": [
1768 + "CA",
1769 + "NV"
1770 + ],
1771 + "cities": [
1772 + "Sacramento"
1773 + ],
1774 + "estimated_agents": 1200,
1775 + "estimated_listings": 1900,
1776 + "mls_affiliations": [
1777 + "Metrolist"
1778 + ]
1779 + },
1780 + {
1781 + "name": "PLG Estates",
1782 + "website": "www.plgestates.com",
1783 + "states": [
1784 + "CA"
1785 + ],
1786 + "cities": [
1787 + "Beverly Hills"
1788 + ],
1789 + "estimated_agents": 250,
1790 + "estimated_listings": 400,
1791 + "mls_affiliations": [
1792 + "CRMLS"
1793 + ]
1794 + },
1795 + {
1796 + "name": "The Keyes Company",
1797 + "website": "www.keyes.com",
1798 + "states": [
1799 + "FL"
1800 + ],
1801 + "cities": [
1802 + "Miami",
1803 + "Fort Lauderdale",
1804 + "West Palm Beach"
1805 + ],
1806 + "estimated_agents": 3800,
1807 + "estimated_listings": 7000,
1808 + "mls_affiliations": [
1809 + "MiamiMLS",
1810 + "BeachesMLS"
1811 + ]
1812 + },
1813 + {
1814 + "name": "Coldwell Banker Florida (Realty)",
1815 + "website": "www.coldwellbankerhomes.com/fl",
1816 + "states": [
1817 + "FL"
1818 + ],
1819 + "cities": [
1820 + "Orlando",
1821 + "Tampa",
1822 + "Miami"
1823 + ],
1824 + "estimated_agents": 6000,
1825 + "estimated_listings": 11000,
1826 + "mls_affiliations": [
1827 + "Stellar",
1828 + "MiamiMLS"
1829 + ]
1830 + },
1831 + {
1832 + "name": "Lang Realty",
1833 + "website": "www.langrealty.com",
1834 + "states": [
1835 + "FL"
1836 + ],
1837 + "cities": [
1838 + "Boca Raton",
1839 + "Palm Beach Gardens"
1840 + ],
1841 + "estimated_agents": 450,
1842 + "estimated_listings": 1000,
1843 + "mls_affiliations": [
1844 + "BeachesMLS"
1845 + ]
1846 + },
1847 + {
1848 + "name": "Illustrated Properties",
1849 + "website": "www.ipre.com",
1850 + "states": [
1851 + "FL"
1852 + ],
1853 + "cities": [
1854 + "Palm Beach Gardens"
1855 + ],
1856 + "estimated_agents": 600,
1857 + "estimated_listings": 1300,
1858 + "mls_affiliations": [
1859 + "BeachesMLS"
1860 + ]
1861 + },
1862 + {
1863 + "name": "The Corcoran Group Florida",
1864 + "website": "www.corcoran.com/fl",
1865 + "states": [
1866 + "FL"
1867 + ],
1868 + "cities": [
1869 + "Palm Beach",
1870 + "Miami"
1871 + ],
1872 + "estimated_agents": 700,
1873 + "estimated_listings": 1400,
1874 + "mls_affiliations": [
1875 + "MiamiMLS",
1876 + "BeachesMLS"
1877 + ]
1878 + },
1879 + {
1880 + "name": "ONE Sotheby's International Realty",
1881 + "website": "www.onesothebysrealty.com",
1882 + "states": [
1883 + "FL"
1884 + ],
1885 + "cities": [
1886 + "Miami",
1887 + "Fort Lauderdale"
1888 + ],
1889 + "estimated_agents": 1300,
1890 + "estimated_listings": 2800,
1891 + "mls_affiliations": [
1892 + "MiamiMLS"
1893 + ]
1894 + },
1895 + {
1896 + "name": "Premier Sotheby's International Realty",
1897 + "website": "www.premiersothebysrealty.com",
1898 + "states": [
1899 + "FL",
1900 + "NC"
1901 + ],
1902 + "cities": [
1903 + "Naples",
1904 + "Sarasota"
1905 + ],
1906 + "estimated_agents": 1400,
1907 + "estimated_listings": 3200,
1908 + "mls_affiliations": [
1909 + "Stellar",
1910 + "NABOR"
1911 + ]
1912 + },
1913 + {
1914 + "name": "John R. Wood Christie's",
1915 + "website": "www.johnrwood.com",
1916 + "states": [
1917 + "FL"
1918 + ],
1919 + "cities": [
1920 + "Naples",
1921 + "Fort Myers"
1922 + ],
1923 + "estimated_agents": 800,
1924 + "estimated_listings": 1900,
1925 + "mls_affiliations": [
1926 + "NABOR"
1927 + ]
1928 + },
1929 + {
1930 + "name": "Royal Shell Real Estate",
1931 + "website": "www.royalshellrealestate.com",
1932 + "states": [
1933 + "FL",
1934 + "NC"
1935 + ],
1936 + "cities": [
1937 + "Fort Myers",
1938 + "Sanibel"
1939 + ],
1940 + "estimated_agents": 500,
1941 + "estimated_listings": 1100,
1942 + "mls_affiliations": [
1943 + "NABOR"
1944 + ]
1945 + },
1946 + {
1947 + "name": "Smith & Associates Real Estate",
1948 + "website": "www.smithandassociates.com",
1949 + "states": [
1950 + "FL"
1951 + ],
1952 + "cities": [
1953 + "Tampa",
1954 + "Saint Petersburg"
1955 + ],
1956 + "estimated_agents": 300,
1957 + "estimated_listings": 800,
1958 + "mls_affiliations": [
1959 + "Stellar"
1960 + ]
1961 + },
1962 + {
1963 + "name": "Berkshire Hathaway HomeServices Florida Properties Group",
1964 + "website": "www.bhhsfloridaproperties.com",
1965 + "states": [
1966 + "FL"
1967 + ],
1968 + "cities": [
1969 + "Tampa"
1970 + ],
1971 + "estimated_agents": 650,
1972 + "estimated_listings": 1400,
1973 + "mls_affiliations": [
1974 + "Stellar"
1975 + ]
1976 + },
1977 + {
1978 + "name": "Charles Rutenberg Realty Orlando",
1979 + "website": "www.orlandocrr.com",
1980 + "states": [
1981 + "FL"
1982 + ],
1983 + "cities": [
1984 + "Orlando"
1985 + ],
1986 + "estimated_agents": 2000,
1987 + "estimated_listings": 2800,
1988 + "mls_affiliations": [
1989 + "Stellar"
1990 + ]
1991 + },
1992 + {
1993 + "name": "Charles Rutenberg Realty Clearwater",
1994 + "website": "www.crrfl.com",
1995 + "states": [
1996 + "FL"
1997 + ],
1998 + "cities": [
1999 + "Clearwater",
2000 + "Tampa"
2001 + ],
2002 + "estimated_agents": 2200,
2003 + "estimated_listings": 3200,
2004 + "mls_affiliations": [
2005 + "Stellar"
2006 + ]
2007 + },
2008 + {
2009 + "name": "La Rosa Realty",
2010 + "website": "www.larosarealty.com",
2011 + "states": [
2012 + "FL"
2013 + ],
2014 + "cities": [
2015 + "Orlando",
2016 + "Kissimmee"
2017 + ],
2018 + "estimated_agents": 2500,
2019 + "estimated_listings": 3500,
2020 + "mls_affiliations": [
2021 + "Stellar"
2022 + ]
2023 + },
2024 + {
2025 + "name": "Olympus Executive Realty",
2026 + "website": "www.olympusexecutiverealty.com",
2027 + "states": [
2028 + "FL"
2029 + ],
2030 + "cities": [
2031 + "Orlando"
2032 + ],
2033 + "estimated_agents": 800,
2034 + "estimated_listings": 1200,
2035 + "mls_affiliations": [
2036 + "Stellar"
2037 + ]
2038 + },
2039 + {
2040 + "name": "Dalton Wade Real Estate Group",
2041 + "website": "www.daltonwade.com",
2042 + "states": [
2043 + "FL"
2044 + ],
2045 + "cities": [
2046 + "Saint Petersburg"
2047 + ],
2048 + "estimated_agents": 2200,
2049 + "estimated_listings": 3000,
2050 + "mls_affiliations": [
2051 + "Stellar"
2052 + ]
2053 + },
2054 + {
2055 + "name": "LPT Realty",
2056 + "website": "www.lptrealty.com",
2057 + "states": [
2058 + "FL",
2059 + "TX",
2060 + "GA",
2061 + "NC",
2062 + "SC",
2063 + "TN",
2064 + "AL",
2065 + "CO",
2066 + "AZ",
2067 + "NV"
2068 + ],
2069 + "cities": [
2070 + "Lake Mary"
2071 + ],
2072 + "estimated_agents": 12000,
2073 + "estimated_listings": 16000,
2074 + "mls_affiliations": [
2075 + "Stellar"
2076 + ]
2077 + },
2078 + {
2079 + "name": "Robert Slack LLC",
2080 + "website": "www.robertslack.com",
2081 + "states": [
2082 + "FL"
2083 + ],
2084 + "cities": [
2085 + "Ocala",
2086 + "Orlando"
2087 + ],
2088 + "estimated_agents": 600,
2089 + "estimated_listings": 1400,
2090 + "mls_affiliations": [
2091 + "Stellar"
2092 + ]
2093 + },
2094 + {
2095 + "name": "Florida Homes Realty & Mortgage",
2096 + "website": "www.floridahomesrealty.com",
2097 + "states": [
2098 + "FL"
2099 + ],
2100 + "cities": [
2101 + "Jacksonville"
2102 + ],
2103 + "estimated_agents": 1500,
2104 + "estimated_listings": 2200,
2105 + "mls_affiliations": [
2106 + "realMLS"
2107 + ]
2108 + },
2109 + {
2110 + "name": "Berkshire Hathaway HomeServices Florida Network Realty",
2111 + "website": "www.floridanetworkrealty.com",
2112 + "states": [
2113 + "FL"
2114 + ],
2115 + "cities": [
2116 + "Jacksonville"
2117 + ],
2118 + "estimated_agents": 500,
2119 + "estimated_listings": 1000,
2120 + "mls_affiliations": [
2121 + "realMLS"
2122 + ]
2123 + },
2124 + {
2125 + "name": "Ponte Vedra Club Realty",
2126 + "website": "www.pvclubrealty.com",
2127 + "states": [
2128 + "FL"
2129 + ],
2130 + "cities": [
2131 + "Ponte Vedra Beach"
2132 + ],
2133 + "estimated_agents": 90,
2134 + "estimated_listings": 220,
2135 + "mls_affiliations": [
2136 + "realMLS"
2137 + ]
2138 + },
2139 + {
2140 + "name": "Berkshire Hathaway HomeServices EWM Realty",
2141 + "website": "www.ewm.com",
2142 + "states": [
2143 + "FL"
2144 + ],
2145 + "cities": [
2146 + "Miami",
2147 + "Coral Gables"
2148 + ],
2149 + "estimated_agents": 800,
2150 + "estimated_listings": 1600,
2151 + "mls_affiliations": [
2152 + "MiamiMLS"
2153 + ]
2154 + },
2155 + {
2156 + "name": "Fortune International Realty",
2157 + "website": "www.fir.com",
2158 + "states": [
2159 + "FL"
2160 + ],
2161 + "cities": [
2162 + "Miami"
2163 + ],
2164 + "estimated_agents": 1000,
2165 + "estimated_listings": 1800,
2166 + "mls_affiliations": [
2167 + "MiamiMLS"
2168 + ]
2169 + },
2170 + {
2171 + "name": "Cervera Real Estate",
2172 + "website": "www.cervera.com",
2173 + "states": [
2174 + "FL"
2175 + ],
2176 + "cities": [
2177 + "Miami"
2178 + ],
2179 + "estimated_agents": 300,
2180 + "estimated_listings": 700,
2181 + "mls_affiliations": [
2182 + "MiamiMLS"
2183 + ]
2184 + },
2185 + {
2186 + "name": "Related ISG Realty",
2187 + "website": "www.relatedisg.com",
2188 + "states": [
2189 + "FL"
2190 + ],
2191 + "cities": [
2192 + "Miami",
2193 + "Aventura"
2194 + ],
2195 + "estimated_agents": 900,
2196 + "estimated_listings": 1500,
2197 + "mls_affiliations": [
2198 + "MiamiMLS"
2199 + ]
2200 + },
2201 + {
2202 + "name": "Luxe Properties",
2203 + "website": "www.luxeknows.com",
2204 + "states": [
2205 + "FL"
2206 + ],
2207 + "cities": [
2208 + "Miami"
2209 + ],
2210 + "estimated_agents": 500,
2211 + "estimated_listings": 900,
2212 + "mls_affiliations": [
2213 + "MiamiMLS"
2214 + ]
2215 + },
2216 + {
2217 + "name": "Avanti Way Realty",
2218 + "website": "www.avantiway.com",
2219 + "states": [
2220 + "FL"
2221 + ],
2222 + "cities": [
2223 + "Miami"
2224 + ],
2225 + "estimated_agents": 1200,
2226 + "estimated_listings": 1800,
2227 + "mls_affiliations": [
2228 + "MiamiMLS"
2229 + ]
2230 + },
2231 + {
2232 + "name": "United Realty Group",
2233 + "website": "www.urgfl.com",
2234 + "states": [
2235 + "FL"
2236 + ],
2237 + "cities": [
2238 + "Fort Lauderdale"
2239 + ],
2240 + "estimated_agents": 2500,
2241 + "estimated_listings": 3400,
2242 + "mls_affiliations": [
2243 + "BeachesMLS"
2244 + ]
2245 + },
2246 + {
2247 + "name": "RE/MAX Fine Properties Florida",
2248 + "website": "www.remaxfineproperties.com",
2249 + "states": [
2250 + "FL"
2251 + ],
2252 + "cities": [
2253 + "Sarasota"
2254 + ],
2255 + "estimated_agents": 200,
2256 + "estimated_listings": 500,
2257 + "mls_affiliations": [
2258 + "Stellar"
2259 + ]
2260 + },
2261 + {
2262 + "name": "Michael Saunders Rentals & New Homes",
2263 + "website": "www.msandc.com",
2264 + "states": [
2265 + "FL"
2266 + ],
2267 + "cities": [
2268 + "Sarasota"
2269 + ],
2270 + "estimated_agents": 150,
2271 + "estimated_listings": 350,
2272 + "mls_affiliations": [
2273 + "Stellar"
2274 + ]
2275 + },
2276 + {
2277 + "name": "Coastal Properties Group (Christie's)",
2278 + "website": "www.coastalpgi.com",
2279 + "states": [
2280 + "FL"
2281 + ],
2282 + "cities": [
2283 + "Tampa",
2284 + "Clearwater"
2285 + ],
2286 + "estimated_agents": 400,
2287 + "estimated_listings": 900,
2288 + "mls_affiliations": [
2289 + "Stellar"
2290 + ]
2291 + },
2292 + {
2293 + "name": "Engel & Völkers Florida",
2294 + "website": "www.florida.evrealestate.com",
2295 + "states": [
2296 + "FL"
2297 + ],
2298 + "cities": [
2299 + "Naples",
2300 + "Miami"
2301 + ],
2302 + "estimated_agents": 700,
2303 + "estimated_listings": 1400,
2304 + "mls_affiliations": [
2305 + "NABOR",
2306 + "MiamiMLS"
2307 + ]
2308 + },
2309 + {
2310 + "name": "Coldwell Banker Warburg",
2311 + "website": "www.cbwarburg.com",
2312 + "states": [
2313 + "NY"
2314 + ],
2315 + "cities": [
2316 + "New York"
2317 + ],
2318 + "estimated_agents": 130,
2319 + "estimated_listings": 300,
2320 + "mls_affiliations": []
2321 + },
2322 + {
2323 + "name": "Halstead / BHS merged",
2324 + "website": "www.halstead.com",
2325 + "states": [
2326 + "NY",
2327 + "NJ",
2328 + "CT"
2329 + ],
2330 + "cities": [
2331 + "New York"
2332 + ],
2333 + "estimated_agents": 1200,
2334 + "estimated_listings": 2400,
2335 + "mls_affiliations": []
2336 + },
2337 + {
2338 + "name": "Bond New York",
2339 + "website": "www.bondnewyork.com",
2340 + "states": [
2341 + "NY"
2342 + ],
2343 + "cities": [
2344 + "New York"
2345 + ],
2346 + "estimated_agents": 500,
2347 + "estimated_listings": 900,
2348 + "mls_affiliations": []
2349 + },
2350 + {
2351 + "name": "Level Group",
2352 + "website": "www.levelgroup.com",
2353 + "states": [
2354 + "NY"
2355 + ],
2356 + "cities": [
2357 + "New York"
2358 + ],
2359 + "estimated_agents": 900,
2360 + "estimated_listings": 1300,
2361 + "mls_affiliations": []
2362 + },
2363 + {
2364 + "name": "Nest Seekers International",
2365 + "website": "www.nestseekers.com",
2366 + "states": [
2367 + "NY",
2368 + "NJ",
2369 + "FL",
2370 + "CA"
2371 + ],
2372 + "cities": [
2373 + "New York",
2374 + "Hamptons"
2375 + ],
2376 + "estimated_agents": 1400,
2377 + "estimated_listings": 2500,
2378 + "mls_affiliations": []
2379 + },
2380 + {
2381 + "name": "The Modlin Group",
2382 + "website": "www.modlingroup.com",
2383 + "states": [
2384 + "NY"
2385 + ],
2386 + "cities": [
2387 + "New York"
2388 + ],
2389 + "estimated_agents": 40,
2390 + "estimated_listings": 100,
2391 + "mls_affiliations": []
2392 + },
2393 + {
2394 + "name": "Saunders & Associates",
2395 + "website": "www.saunders.com",
2396 + "states": [
2397 + "NY"
2398 + ],
2399 + "cities": [
2400 + "Bridgehampton",
2401 + "Southampton"
2402 + ],
2403 + "estimated_agents": 120,
2404 + "estimated_listings": 400,
2405 + "mls_affiliations": []
2406 + },
2407 + {
2408 + "name": "Town & Country Real Estate Hamptons",
2409 + "website": "www.townandcountryhamptons.com",
2410 + "states": [
2411 + "NY"
2412 + ],
2413 + "cities": [
2414 + "East Hampton"
2415 + ],
2416 + "estimated_agents": 90,
2417 + "estimated_listings": 260,
2418 + "mls_affiliations": []
2419 + },
2420 + {
2421 + "name": "Julia B. Fee Sotheby's International Realty",
2422 + "website": "www.juliabfee.com",
2423 + "states": [
2424 + "NY"
2425 + ],
2426 + "cities": [
2427 + "Rye",
2428 + "Scarsdale"
2429 + ],
2430 + "estimated_agents": 200,
2431 + "estimated_listings": 450,
2432 + "mls_affiliations": [
2433 + "OneKey"
2434 + ]
2435 + },
2436 + {
2437 + "name": "William Pitt Sotheby's International Realty",
2438 + "website": "www.williampitt.com",
2439 + "states": [
2440 + "CT",
2441 + "NY",
2442 + "MA"
2443 + ],
2444 + "cities": [
2445 + "Stamford"
2446 + ],
2447 + "estimated_agents": 1100,
2448 + "estimated_listings": 2300,
2449 + "mls_affiliations": [
2450 + "SmartMLS"
2451 + ]
2452 + },
2453 + {
2454 + "name": "Berkshire Hathaway HomeServices New England Properties",
2455 + "website": "www.bhhsneproperties.com",
2456 + "states": [
2457 + "CT",
2458 + "RI",
2459 + "NY"
2460 + ],
2461 + "cities": [
2462 + "Hartford"
2463 + ],
2464 + "estimated_agents": 1500,
2465 + "estimated_listings": 2900,
2466 + "mls_affiliations": [
2467 + "SmartMLS"
2468 + ]
2469 + },
2470 + {
2471 + "name": "The Riverside Realty Group",
2472 + "website": "www.riversiderealtyct.com",
2473 + "states": [
2474 + "CT"
2475 + ],
2476 + "cities": [
2477 + "Westport"
2478 + ],
2479 + "estimated_agents": 60,
2480 + "estimated_listings": 160,
2481 + "mls_affiliations": [
2482 + "SmartMLS"
2483 + ]
2484 + },
2485 + {
2486 + "name": "Higgins Group Private Brokerage",
2487 + "website": "www.higginsgroup.com",
2488 + "states": [
2489 + "CT"
2490 + ],
2491 + "cities": [
2492 + "Fairfield"
2493 + ],
2494 + "estimated_agents": 400,
2495 + "estimated_listings": 800,
2496 + "mls_affiliations": [
2497 + "SmartMLS"
2498 + ]
2499 + },
2500 + {
2501 + "name": "Gibson Sotheby's International Realty",
2502 + "website": "www.gibsonsothebysrealty.com",
2503 + "states": [
2504 + "MA"
2505 + ],
2506 + "cities": [
2507 + "Boston"
2508 + ],
2509 + "estimated_agents": 400,
2510 + "estimated_listings": 900,
2511 + "mls_affiliations": [
2512 + "MLSPIN"
2513 + ]
2514 + },
2515 + {
2516 + "name": "Jack Conway & Company",
2517 + "website": "www.jackconway.com",
2518 + "states": [
2519 + "MA"
2520 + ],
2521 + "cities": [
2522 + "Norwell",
2523 + "Boston South Shore"
2524 + ],
2525 + "estimated_agents": 700,
2526 + "estimated_listings": 1300,
2527 + "mls_affiliations": [
2528 + "MLSPIN"
2529 + ]
2530 + },
2531 + {
2532 + "name": "Lamacchia Realty",
2533 + "website": "www.lamacchiarealty.com",
2534 + "states": [
2535 + "MA",
2536 + "NH",
2537 + "CT",
2538 + "RI",
2539 + "FL"
2540 + ],
2541 + "cities": [
2542 + "Waltham"
2543 + ],
2544 + "estimated_agents": 500,
2545 + "estimated_listings": 1100,
2546 + "mls_affiliations": [
2547 + "MLSPIN"
2548 + ]
2549 + },
2550 + {
2551 + "name": "William Raveis Boston",
2552 + "website": "www.raveis.com/ma",
2553 + "states": [
2554 + "MA"
2555 + ],
2556 + "cities": [
2557 + "Boston"
2558 + ],
2559 + "estimated_agents": 700,
2560 + "estimated_listings": 1300,
2561 + "mls_affiliations": [
2562 + "MLSPIN"
2563 + ]
2564 + },
2565 + {
2566 + "name": "Compass Massachusetts",
2567 + "website": "www.compass.com/ma",
2568 + "states": [
2569 + "MA"
2570 + ],
2571 + "cities": [
2572 + "Boston",
2573 + "Cambridge"
2574 + ],
2575 + "estimated_agents": 1300,
2576 + "estimated_listings": 2300,
2577 + "mls_affiliations": [
2578 + "MLSPIN"
2579 + ]
2580 + },
2581 + {
2582 + "name": "Berkshire Hathaway HomeServices Commonwealth Real Estate",
2583 + "website": "www.commonmoves.com",
2584 + "states": [
2585 + "MA"
2586 + ],
2587 + "cities": [
2588 + "Natick"
2589 + ],
2590 + "estimated_agents": 900,
2591 + "estimated_listings": 1700,
2592 + "mls_affiliations": [
2593 + "MLSPIN"
2594 + ]
2595 + },
2596 + {
2597 + "name": "Barrett Sotheby's International Realty",
2598 + "website": "www.barrettsothebysrealty.com",
2599 + "states": [
2600 + "MA"
2601 + ],
2602 + "cities": [
2603 + "Concord",
2604 + "Lexington"
2605 + ],
2606 + "estimated_agents": 150,
2607 + "estimated_listings": 350,
2608 + "mls_affiliations": [
2609 + "MLSPIN"
2610 + ]
2611 + },
2612 + {
2613 + "name": "Residential Properties Ltd",
2614 + "website": "www.residentialproperties.com",
2615 + "states": [
2616 + "RI"
2617 + ],
2618 + "cities": [
2619 + "Providence",
2620 + "Barrington"
2621 + ],
2622 + "estimated_agents": 200,
2623 + "estimated_listings": 500,
2624 + "mls_affiliations": [
2625 + "RIMLS"
2626 + ]
2627 + },
2628 + {
2629 + "name": "Mott & Chace Sotheby's International Realty",
2630 + "website": "www.mottandchace.com",
2631 + "states": [
2632 + "RI"
2633 + ],
2634 + "cities": [
2635 + "Providence",
2636 + "Newport"
2637 + ],
2638 + "estimated_agents": 130,
2639 + "estimated_listings": 320,
2640 + "mls_affiliations": [
2641 + "RIMLS"
2642 + ]
2643 + },
2644 + {
2645 + "name": "Lila Delman Compass",
2646 + "website": "www.liladelman.com",
2647 + "states": [
2648 + "RI"
2649 + ],
2650 + "cities": [
2651 + "Newport"
2652 + ],
2653 + "estimated_agents": 100,
2654 + "estimated_listings": 260,
2655 + "mls_affiliations": [
2656 + "RIMLS"
2657 + ]
2658 + },
2659 + {
2660 + "name": "Badger Peabody & Smith Realty",
2661 + "website": "www.peabodysmith.com",
2662 + "states": [
2663 + "NH"
2664 + ],
2665 + "cities": [
2666 + "Franconia",
2667 + "Plymouth"
2668 + ],
2669 + "estimated_agents": 90,
2670 + "estimated_listings": 260,
2671 + "mls_affiliations": [
2672 + "PrimeMLS"
2673 + ]
2674 + },
2675 + {
2676 + "name": "Bean Group",
2677 + "website": "www.beangroup.com",
2678 + "states": [
2679 + "NH",
2680 + "ME",
2681 + "MA",
2682 + "VT"
2683 + ],
2684 + "cities": [
2685 + "Portsmouth"
2686 + ],
2687 + "estimated_agents": 300,
2688 + "estimated_listings": 700,
2689 + "mls_affiliations": [
2690 + "PrimeMLS"
2691 + ]
2692 + },
2693 + {
2694 + "name": "KW Coastal and Lakes & Mountains Realty",
2695 + "website": "www.kwlakesandmountains.com",
2696 + "states": [
2697 + "NH"
2698 + ],
2699 + "cities": [
2700 + "Portsmouth",
2701 + "Wolfeboro"
2702 + ],
2703 + "estimated_agents": 500,
2704 + "estimated_listings": 900,
2705 + "mls_affiliations": [
2706 + "PrimeMLS"
2707 + ]
2708 + },
2709 + {
2710 + "name": "Legacy Properties Sotheby's International Realty",
2711 + "website": "www.legacysir.com",
2712 + "states": [
2713 + "ME"
2714 + ],
2715 + "cities": [
2716 + "Portland",
2717 + "Kennebunk"
2718 + ],
2719 + "estimated_agents": 130,
2720 + "estimated_listings": 350,
2721 + "mls_affiliations": [
2722 + "MREIS"
2723 + ]
2724 + },
2725 + {
2726 + "name": "Portside Real Estate Group",
2727 + "website": "www.portsiderealestategroup.com",
2728 + "states": [
2729 + "ME"
2730 + ],
2731 + "cities": [
2732 + "Portland"
2733 + ],
2734 + "estimated_agents": 90,
2735 + "estimated_listings": 240,
2736 + "mls_affiliations": [
2737 + "MREIS"
2738 + ]
2739 + },
2740 + {
2741 + "name": "Camden Real Estate Company",
2742 + "website": "www.camdenre.com",
2743 + "states": [
2744 + "ME"
2745 + ],
2746 + "cities": [
2747 + "Camden"
2748 + ],
2749 + "estimated_agents": 30,
2750 + "estimated_listings": 90,
2751 + "mls_affiliations": [
2752 + "MREIS"
2753 + ]
2754 + },
2755 + {
2756 + "name": "Four Seasons Sotheby's International Realty",
2757 + "website": "www.fourseasonssir.com",
2758 + "states": [
2759 + "VT",
2760 + "NH"
2761 + ],
2762 + "cities": [
2763 + "Stowe",
2764 + "Manchester"
2765 + ],
2766 + "estimated_agents": 250,
2767 + "estimated_listings": 600,
2768 + "mls_affiliations": [
2769 + "PrimeMLS"
2770 + ]
2771 + },
2772 + {
2773 + "name": "Coldwell Banker Hickok & Boardman",
2774 + "website": "www.hickokandboardman.com",
2775 + "states": [
2776 + "VT"
2777 + ],
2778 + "cities": [
2779 + "Burlington"
2780 + ],
2781 + "estimated_agents": 90,
2782 + "estimated_listings": 240,
2783 + "mls_affiliations": [
2784 + "NEREN"
2785 + ]
2786 + },
2787 + {
2788 + "name": "McEnearney Associates",
2789 + "website": "www.mcenearney.com",
2790 + "states": [
2791 + "VA",
2792 + "DC",
2793 + "MD"
2794 + ],
2795 + "cities": [
2796 + "Alexandria",
2797 + "McLean"
2798 + ],
2799 + "estimated_agents": 400,
2800 + "estimated_listings": 900,
2801 + "mls_affiliations": [
2802 + "BRIGHT"
2803 + ]
2804 + },
2805 + {
2806 + "name": "TTR Sotheby's International Realty",
2807 + "website": "www.ttrsir.com",
2808 + "states": [
2809 + "DC",
2810 + "VA",
2811 + "MD"
2812 + ],
2813 + "cities": [
2814 + "Washington",
2815 + "Georgetown"
2816 + ],
2817 + "estimated_agents": 500,
2818 + "estimated_listings": 1100,
2819 + "mls_affiliations": [
2820 + "BRIGHT"
2821 + ]
2822 + },
2823 + {
2824 + "name": "Washington Fine Properties",
2825 + "website": "www.wfp.com",
2826 + "states": [
2827 + "DC",
2828 + "VA",
2829 + "MD"
2830 + ],
2831 + "cities": [
2832 + "Washington"
2833 + ],
2834 + "estimated_agents": 200,
2835 + "estimated_listings": 450,
2836 + "mls_affiliations": [
2837 + "BRIGHT"
2838 + ]
2839 + },
2840 + {
2841 + "name": "Samson Properties",
2842 + "website": "www.samsonproperties.net",
2843 + "states": [
2844 + "VA",
2845 + "MD",
2846 + "DC",
2847 + "WV",
2848 + "NC"
2849 + ],
2850 + "cities": [
2851 + "Chantilly"
2852 + ],
2853 + "estimated_agents": 5500,
2854 + "estimated_listings": 7500,
2855 + "mls_affiliations": [
2856 + "BRIGHT"
2857 + ]
2858 + },
2859 + {
2860 + "name": "Pearson Smith Realty",
2861 + "website": "www.pearsonsmithrealty.com",
2862 + "states": [
2863 + "VA",
2864 + "MD",
2865 + "DC",
2866 + "WV"
2867 + ],
2868 + "cities": [
2869 + "Ashburn"
2870 + ],
2871 + "estimated_agents": 1200,
2872 + "estimated_listings": 2000,
2873 + "mls_affiliations": [
2874 + "BRIGHT"
2875 + ]
2876 + },
2877 + {
2878 + "name": "KW Metro Center",
2879 + "website": "www.kwmetrocenter.com",
2880 + "states": [
2881 + "VA",
2882 + "DC"
2883 + ],
2884 + "cities": [
2885 + "Alexandria",
2886 + "Arlington"
2887 + ],
2888 + "estimated_agents": 900,
2889 + "estimated_listings": 1400,
2890 + "mls_affiliations": [
2891 + "BRIGHT"
2892 + ]
2893 + },
2894 + {
2895 + "name": "Joyner Fine Properties",
2896 + "website": "www.joynerfineproperties.com",
2897 + "states": [
2898 + "VA"
2899 + ],
2900 + "cities": [
2901 + "Richmond"
2902 + ],
2903 + "estimated_agents": 200,
2904 + "estimated_listings": 500,
2905 + "mls_affiliations": [
2906 + "CVRMLS"
2907 + ]
2908 + },
2909 + {
2910 + "name": "Shaheen, Ruth, Martin & Fonville Real Estate",
2911 + "website": "www.srmfre.com",
2912 + "states": [
2913 + "VA"
2914 + ],
2915 + "cities": [
2916 + "Richmond"
2917 + ],
2918 + "estimated_agents": 300,
2919 + "estimated_listings": 700,
2920 + "mls_affiliations": [
2921 + "CVRMLS"
2922 + ]
2923 + },
2924 + {
2925 + "name": "Howard Hanna William E. Wood",
2926 + "website": "www.howardhanna.com/va",
2927 + "states": [
2928 + "VA",
2929 + "NC"
2930 + ],
2931 + "cities": [
2932 + "Virginia Beach",
2933 + "Norfolk"
2934 + ],
2935 + "estimated_agents": 900,
2936 + "estimated_listings": 2000,
2937 + "mls_affiliations": [
2938 + "REIN"
2939 + ]
2940 + },
2941 + {
2942 + "name": "BHHS RW Towne Realty",
2943 + "website": "www.rwtowne.com",
2944 + "states": [
2945 + "VA",
2946 + "NC"
2947 + ],
2948 + "cities": [
2949 + "Virginia Beach"
2950 + ],
2951 + "estimated_agents": 500,
2952 + "estimated_listings": 1100,
2953 + "mls_affiliations": [
2954 + "REIN"
2955 + ]
2956 + },
2957 + {
2958 + "name": "Cummings & Co Realtors",
2959 + "website": "www.cummingsrealtors.com",
2960 + "states": [
2961 + "MD"
2962 + ],
2963 + "cities": [
2964 + "Baltimore",
2965 + "Towson"
2966 + ],
2967 + "estimated_agents": 800,
2968 + "estimated_listings": 1400,
2969 + "mls_affiliations": [
2970 + "BRIGHT"
2971 + ]
2972 + },
2973 + {
2974 + "name": "Northrop Realty",
2975 + "website": "www.northroprealty.com",
2976 + "states": [
2977 + "MD",
2978 + "DE"
2979 + ],
2980 + "cities": [
2981 + "Columbia",
2982 + "Annapolis"
2983 + ],
2984 + "estimated_agents": 400,
2985 + "estimated_listings": 900,
2986 + "mls_affiliations": [
2987 + "BRIGHT"
2988 + ]
2989 + },
2990 + {
2991 + "name": "Monument Sotheby's International Realty",
2992 + "website": "www.monumentsothebysrealty.com",
2993 + "states": [
2994 + "MD",
2995 + "DE"
2996 + ],
2997 + "cities": [
2998 + "Baltimore",
2999 + "Annapolis"
3000 + ],
3001 + "estimated_agents": 300,
3002 + "estimated_listings": 700,
3003 + "mls_affiliations": [
3004 + "BRIGHT"
3005 + ]
3006 + },
3007 + {
3008 + "name": "Patterson-Schwartz Real Estate",
3009 + "website": "www.pattersonschwartz.com",
3010 + "states": [
3011 + "DE",
3012 + "PA",
3013 + "MD"
3014 + ],
3015 + "cities": [
3016 + "Wilmington",
3017 + "Hockessin"
3018 + ],
3019 + "estimated_agents": 400,
3020 + "estimated_listings": 900,
3021 + "mls_affiliations": [
3022 + "BRIGHT"
3023 + ]
3024 + },
3025 + {
3026 + "name": "Jack Lingo Realtor",
3027 + "website": "www.jacklingo.com",
3028 + "states": [
3029 + "DE"
3030 + ],
3031 + "cities": [
3032 + "Rehoboth Beach",
3033 + "Lewes"
3034 + ],
3035 + "estimated_agents": 150,
3036 + "estimated_listings": 450,
3037 + "mls_affiliations": [
3038 + "BRIGHT"
3039 + ]
3040 + },
3041 + {
3042 + "name": "Berkshire Hathaway HomeServices Homesale Realty",
3043 + "website": "www.homesale.com",
3044 + "states": [
3045 + "PA",
3046 + "MD"
3047 + ],
3048 + "cities": [
3049 + "Lancaster",
3050 + "York"
3051 + ],
3052 + "estimated_agents": 1300,
3053 + "estimated_listings": 2600,
3054 + "mls_affiliations": [
3055 + "BRIGHT"
3056 + ]
3057 + },
3058 + {
3059 + "name": "RE/MAX of Reading",
3060 + "website": "www.gopennsylvania.com",
3061 + "states": [
3062 + "PA"
3063 + ],
3064 + "cities": [
3065 + "Reading",
3066 + "Wyomissing"
3067 + ],
3068 + "estimated_agents": 130,
3069 + "estimated_listings": 350,
3070 + "mls_affiliations": [
3071 + "BRIGHT"
3072 + ]
3073 + },
3074 + {
3075 + "name": "Kurfiss Sotheby's International Realty",
3076 + "website": "www.kurfiss.com",
3077 + "states": [
3078 + "PA",
3079 + "NJ"
3080 + ],
3081 + "cities": [
3082 + "Philadelphia",
3083 + "New Hope"
3084 + ],
3085 + "estimated_agents": 200,
3086 + "estimated_listings": 450,
3087 + "mls_affiliations": [
3088 + "BRIGHT"
3089 + ]
3090 + },
3091 + {
3092 + "name": "Elfant Wissahickon Realtors",
3093 + "website": "www.elfantwissahickon.com",
3094 + "states": [
3095 + "PA"
3096 + ],
3097 + "cities": [
3098 + "Philadelphia"
3099 + ],
3100 + "estimated_agents": 250,
3101 + "estimated_listings": 500,
3102 + "mls_affiliations": [
3103 + "BRIGHT"
3104 + ]
3105 + },
3106 + {
3107 + "name": "Compass Pennsylvania",
3108 + "website": "www.compass.com/pa",
3109 + "states": [
3110 + "PA"
3111 + ],
3112 + "cities": [
3113 + "Philadelphia"
3114 + ],
3115 + "estimated_agents": 700,
3116 + "estimated_listings": 1300,
3117 + "mls_affiliations": [
3118 + "BRIGHT"
3119 + ]
3120 + },
3121 + {
3122 + "name": "RE/MAX Main Line",
3123 + "website": "www.remaxmainline.com",
3124 + "states": [
3125 + "PA"
3126 + ],
3127 + "cities": [
3128 + "Paoli",
3129 + "Wayne"
3130 + ],
3131 + "estimated_agents": 200,
3132 + "estimated_listings": 500,
3133 + "mls_affiliations": [
3134 + "BRIGHT"
3135 + ]
3136 + },
3137 + {
3138 + "name": "Piatt Sotheby's International Realty",
3139 + "website": "www.piattsothebysrealty.com",
3140 + "states": [
3141 + "PA"
3142 + ],
3143 + "cities": [
3144 + "Pittsburgh"
3145 + ],
3146 + "estimated_agents": 150,
3147 + "estimated_listings": 400,
3148 + "mls_affiliations": [
3149 + "WestPennMLS"
3150 + ]
3151 + },
3152 + {
3153 + "name": "Realty ONE Group Gold Standard",
3154 + "website": "www.roggoldstandard.com",
3155 + "states": [
3156 + "PA"
3157 + ],
3158 + "cities": [
3159 + "Pittsburgh"
3160 + ],
3161 + "estimated_agents": 250,
3162 + "estimated_listings": 450,
3163 + "mls_affiliations": [
3164 + "WestPennMLS"
3165 + ]
3166 + },
3167 + {
3168 + "name": "Coldwell Banker Realty Pittsburgh",
3169 + "website": "www.coldwellbankerhomes.com/pa/pittsburgh",
3170 + "states": [
3171 + "PA"
3172 + ],
3173 + "cities": [
3174 + "Pittsburgh"
3175 + ],
3176 + "estimated_agents": 700,
3177 + "estimated_listings": 1500,
3178 + "mls_affiliations": [
3179 + "WestPennMLS"
3180 + ]
3181 + },
3182 + {
3183 + "name": "Prominent Properties Sotheby's International Realty",
3184 + "website": "www.prominentproperties.com",
3185 + "states": [
3186 + "NJ"
3187 + ],
3188 + "cities": [
3189 + "Tenafly",
3190 + "Ridgewood"
3191 + ],
3192 + "estimated_agents": 700,
3193 + "estimated_listings": 1500,
3194 + "mls_affiliations": [
3195 + "NJMLS"
3196 + ]
3197 + },
3198 + {
3199 + "name": "Christie's International Real Estate Northern New Jersey",
3200 + "website": "www.specialproperties.com",
3201 + "states": [
3202 + "NJ"
3203 + ],
3204 + "cities": [
3205 + "Saddle River"
3206 + ],
3207 + "estimated_agents": 300,
3208 + "estimated_listings": 700,
3209 + "mls_affiliations": [
3210 + "NJMLS"
3211 + ]
3212 + },
3213 + {
3214 + "name": "Coldwell Banker Realty New Jersey",
3215 + "website": "www.coldwellbankerhomes.com/nj",
3216 + "states": [
3217 + "NJ"
3218 + ],
3219 + "cities": [
3220 + "Madison",
3221 + "Princeton"
3222 + ],
3223 + "estimated_agents": 3000,
3224 + "estimated_listings": 5500,
3225 + "mls_affiliations": [
3226 + "GSMLS",
3227 + "BRIGHT"
3228 + ]
3229 + },
3230 + {
3231 + "name": "Keller Williams NJ Metro Group",
3232 + "website": "www.kwnjmetro.com",
3233 + "states": [
3234 + "NJ"
3235 + ],
3236 + "cities": [
3237 + "Montclair"
3238 + ],
3239 + "estimated_agents": 400,
3240 + "estimated_listings": 800,
3241 + "mls_affiliations": [
3242 + "GSMLS"
3243 + ]
3244 + },
3245 + {
3246 + "name": "Halo Realty",
3247 + "website": "www.halorealtynj.com",
3248 + "states": [
3249 + "NJ"
3250 + ],
3251 + "cities": [
3252 + "Edison"
3253 + ],
3254 + "estimated_agents": 400,
3255 + "estimated_listings": 700,
3256 + "mls_affiliations": [
3257 + "GSMLS"
3258 + ]
3259 + },
3260 + {
3261 + "name": "Diane Turton, Realtors",
3262 + "website": "www.dianeturton.com",
3263 + "states": [
3264 + "NJ"
3265 + ],
3266 + "cities": [
3267 + "Point Pleasant",
3268 + "Jersey Shore"
3269 + ],
3270 + "estimated_agents": 400,
3271 + "estimated_listings": 900,
3272 + "mls_affiliations": [
3273 + "MOMLS"
3274 + ]
3275 + },
3276 + {
3277 + "name": "Berkshire Hathaway HomeServices Fox & Roach NJ Shore",
3278 + "website": "www.foxroach.com/nj",
3279 + "states": [
3280 + "NJ"
3281 + ],
3282 + "cities": [
3283 + "Avalon",
3284 + "Margate"
3285 + ],
3286 + "estimated_agents": 700,
3287 + "estimated_listings": 1500,
3288 + "mls_affiliations": [
3289 + "SJSRMLS"
3290 + ]
3291 + },
3292 + {
3293 + "name": "Harry Norman Realtors",
3294 + "website": "www.harrynorman.com",
3295 + "states": [
3296 + "GA"
3297 + ],
3298 + "cities": [
3299 + "Atlanta",
3300 + "Buckhead"
3301 + ],
3302 + "estimated_agents": 1000,
3303 + "estimated_listings": 2100,
3304 + "mls_affiliations": [
3305 + "FMLS",
3306 + "GAMLS"
3307 + ]
3308 + },
3309 + {
3310 + "name": "Atlanta Fine Homes Sotheby's International Realty",
3311 + "website": "www.atlantafinehomes.com",
3312 + "states": [
3313 + "GA"
3314 + ],
3315 + "cities": [
3316 + "Atlanta"
3317 + ],
3318 + "estimated_agents": 500,
3319 + "estimated_listings": 1100,
3320 + "mls_affiliations": [
3321 + "FMLS"
3322 + ]
3323 + },
3324 + {
3325 + "name": "Ansley Real Estate Christie's",
3326 + "website": "www.ansleyre.com",
3327 + "states": [
3328 + "GA"
3329 + ],
3330 + "cities": [
3331 + "Atlanta",
3332 + "Alpharetta"
3333 + ],
3334 + "estimated_agents": 500,
3335 + "estimated_listings": 1100,
3336 + "mls_affiliations": [
3337 + "FMLS"
3338 + ]
3339 + },
3340 + {
3341 + "name": "Dorsey Alston Realtors",
3342 + "website": "www.dorseyalston.com",
3343 + "states": [
3344 + "GA"
3345 + ],
3346 + "cities": [
3347 + "Atlanta"
3348 + ],
3349 + "estimated_agents": 250,
3350 + "estimated_listings": 550,
3351 + "mls_affiliations": [
3352 + "FMLS"
3353 + ]
3354 + },
3355 + {
3356 + "name": "Palmer House Properties",
3357 + "website": "www.palmerhouseproperties.com",
3358 + "states": [
3359 + "GA"
3360 + ],
3361 + "cities": [
3362 + "Atlanta"
3363 + ],
3364 + "estimated_agents": 1800,
3365 + "estimated_listings": 2500,
3366 + "mls_affiliations": [
3367 + "FMLS",
3368 + "GAMLS"
3369 + ]
3370 + },
3371 + {
3372 + "name": "Maximum One Realty",
3373 + "website": "www.maximumonerealty.com",
3374 + "states": [
3375 + "GA"
3376 + ],
3377 + "cities": [
3378 + "Atlanta",
3379 + "Marietta"
3380 + ],
3381 + "estimated_agents": 3000,
3382 + "estimated_listings": 4200,
3383 + "mls_affiliations": [
3384 + "GAMLS"
3385 + ]
3386 + },
3387 + {
3388 + "name": "Virtual Properties Realty",
3389 + "website": "www.virtualpropertiesrealty.com",
3390 + "states": [
3391 + "GA"
3392 + ],
3393 + "cities": [
3394 + "Duluth"
3395 + ],
3396 + "estimated_agents": 3500,
3397 + "estimated_listings": 5000,
3398 + "mls_affiliations": [
3399 + "GAMLS"
3400 + ]
3401 + },
3402 + {
3403 + "name": "BHGRE Metro Brokers Atlanta",
3404 + "website": "www.metrobrokers.com",
3405 + "states": [
3406 + "GA"
3407 + ],
3408 + "cities": [
3409 + "Atlanta"
3410 + ],
3411 + "estimated_agents": 2200,
3412 + "estimated_listings": 3400,
3413 + "mls_affiliations": [
3414 + "GAMLS",
3415 + "FMLS"
3416 + ]
3417 + },
3418 + {
3419 + "name": "Seabolt Real Estate",
3420 + "website": "www.seaboltrealestate.com",
3421 + "states": [
3422 + "GA"
3423 + ],
3424 + "cities": [
3425 + "Savannah"
3426 + ],
3427 + "estimated_agents": 80,
3428 + "estimated_listings": 220,
3429 + "mls_affiliations": [
3430 + "SavannahMLS"
3431 + ]
3432 + },
3433 + {
3434 + "name": "Daniel Ravenel Sotheby's International Realty",
3435 + "website": "www.danielravenelsir.com",
3436 + "states": [
3437 + "SC",
3438 + "GA"
3439 + ],
3440 + "cities": [
3441 + "Charleston",
3442 + "Savannah"
3443 + ],
3444 + "estimated_agents": 120,
3445 + "estimated_listings": 320,
3446 + "mls_affiliations": [
3447 + "CharlestonMLS"
3448 + ]
3449 + },
3450 + {
3451 + "name": "Carolina One Real Estate",
3452 + "website": "www.carolinaone.com",
3453 + "states": [
3454 + "SC"
3455 + ],
3456 + "cities": [
3457 + "Charleston"
3458 + ],
3459 + "estimated_agents": 900,
3460 + "estimated_listings": 2000,
3461 + "mls_affiliations": [
3462 + "CharlestonMLS"
3463 + ]
3464 + },
3465 + {
3466 + "name": "The Boulevard Company",
3467 + "website": "www.theboulevardcompany.com",
3468 + "states": [
3469 + "SC"
3470 + ],
3471 + "cities": [
3472 + "Charleston"
3473 + ],
3474 + "estimated_agents": 200,
3475 + "estimated_listings": 450,
3476 + "mls_affiliations": [
3477 + "CharlestonMLS"
3478 + ]
3479 + },
3480 + {
3481 + "name": "Dunes Properties of Charleston",
3482 + "website": "www.dunesproperties.com",
3483 + "states": [
3484 + "SC"
3485 + ],
3486 + "cities": [
3487 + "Charleston",
3488 + "Isle of Palms"
3489 + ],
3490 + "estimated_agents": 100,
3491 + "estimated_listings": 280,
3492 + "mls_affiliations": [
3493 + "CharlestonMLS"
3494 + ]
3495 + },
3496 + {
3497 + "name": "ERA Wilder Realty",
3498 + "website": "www.erawilder.com",
3499 + "states": [
3500 + "SC"
3501 + ],
3502 + "cities": [
3503 + "Columbia"
3504 + ],
3505 + "estimated_agents": 300,
3506 + "estimated_listings": 700,
3507 + "mls_affiliations": [
3508 + "ColumbiaMLS"
3509 + ]
3510 + },
3511 + {
3512 + "name": "Coldwell Banker Caine",
3513 + "website": "www.cbcaine.com",
3514 + "states": [
3515 + "SC"
3516 + ],
3517 + "cities": [
3518 + "Greenville"
3519 + ],
3520 + "estimated_agents": 400,
3521 + "estimated_listings": 850,
3522 + "mls_affiliations": [
3523 + "GreenvilleMLS"
3524 + ]
3525 + },
3526 + {
3527 + "name": "Berkshire Hathaway HomeServices C Dan Joyner Realtors",
3528 + "website": "www.cdanjoyner.com",
3529 + "states": [
3530 + "SC"
3531 + ],
3532 + "cities": [
3533 + "Greenville"
3534 + ],
3535 + "estimated_agents": 450,
3536 + "estimated_listings": 950,
3537 + "mls_affiliations": [
3538 + "GreenvilleMLS"
3539 + ]
3540 + },
3541 + {
3542 + "name": "CENTURY 21 Boling & Associates",
3543 + "website": "www.century21boling.com",
3544 + "states": [
3545 + "SC"
3546 + ],
3547 + "cities": [
3548 + "Myrtle Beach"
3549 + ],
3550 + "estimated_agents": 150,
3551 + "estimated_listings": 420,
3552 + "mls_affiliations": [
3553 + "CCARMLS"
3554 + ]
3555 + },
3556 + {
3557 + "name": "Howard Perry & Walston (Corcoran HPW)",
3558 + "website": "www.hpw.com",
3559 + "states": [
3560 + "NC"
3561 + ],
3562 + "cities": [
3563 + "Raleigh",
3564 + "Durham"
3565 + ],
3566 + "estimated_agents": 900,
3567 + "estimated_listings": 1900,
3568 + "mls_affiliations": [
3569 + "TriangleMLS"
3570 + ]
3571 + },
3572 + {
3573 + "name": "Fonville Morisey Realty",
3574 + "website": "www.fmrealty.com",
3575 + "states": [
3576 + "NC"
3577 + ],
3578 + "cities": [
3579 + "Raleigh"
3580 + ],
3581 + "estimated_agents": 700,
3582 + "estimated_listings": 1400,
3583 + "mls_affiliations": [
3584 + "TriangleMLS"
3585 + ]
3586 + },
3587 + {
3588 + "name": "Hodge & Kittrell Sotheby's International Realty",
3589 + "website": "www.hodgekittrell.com",
3590 + "states": [
3591 + "NC"
3592 + ],
3593 + "cities": [
3594 + "Raleigh"
3595 + ],
3596 + "estimated_agents": 130,
3597 + "estimated_listings": 320,
3598 + "mls_affiliations": [
3599 + "TriangleMLS"
3600 + ]
3601 + },
3602 + {
3603 + "name": "Dickens Mitchener",
3604 + "website": "www.dickensmitchener.com",
3605 + "states": [
3606 + "NC"
3607 + ],
3608 + "cities": [
3609 + "Charlotte"
3610 + ],
3611 + "estimated_agents": 130,
3612 + "estimated_listings": 320,
3613 + "mls_affiliations": [
3614 + "Canopy"
3615 + ]
3616 + },
3617 + {
3618 + "name": "Cottingham Chalk",
3619 + "website": "www.cottinghamchalk.com",
3620 + "states": [
3621 + "NC"
3622 + ],
3623 + "cities": [
3624 + "Charlotte"
3625 + ],
3626 + "estimated_agents": 70,
3627 + "estimated_listings": 180,
3628 + "mls_affiliations": [
3629 + "Canopy"
3630 + ]
3631 + },
3632 + {
3633 + "name": "Helen Adams Realty",
3634 + "website": "www.helenadamsrealty.com",
3635 + "states": [
3636 + "NC",
3637 + "SC"
3638 + ],
3639 + "cities": [
3640 + "Charlotte"
3641 + ],
3642 + "estimated_agents": 250,
3643 + "estimated_listings": 550,
3644 + "mls_affiliations": [
3645 + "Canopy"
3646 + ]
3647 + },
3648 + {
3649 + "name": "Premier South (KW)",
3650 + "website": "www.premiersouth.com",
3651 + "states": [
3652 + "NC",
3653 + "SC"
3654 + ],
3655 + "cities": [
3656 + "Charlotte"
3657 + ],
3658 + "estimated_agents": 400,
3659 + "estimated_listings": 800,
3660 + "mls_affiliations": [
3661 + "Canopy"
3662 + ]
3663 + },
3664 + {
3665 + "name": "Intracoastal Realty",
3666 + "website": "www.intracoastalrealty.com",
3667 + "states": [
3668 + "NC"
3669 + ],
3670 + "cities": [
3671 + "Wilmington",
3672 + "Wrightsville Beach"
3673 + ],
3674 + "estimated_agents": 400,
3675 + "estimated_listings": 950,
3676 + "mls_affiliations": [
3677 + "CapeFearMLS"
3678 + ]
3679 + },
3680 + {
3681 + "name": "Landmark Sotheby's International Realty",
3682 + "website": "www.landmarksir.com",
3683 + "states": [
3684 + "NC"
3685 + ],
3686 + "cities": [
3687 + "Wilmington"
3688 + ],
3689 + "estimated_agents": 90,
3690 + "estimated_listings": 240,
3691 + "mls_affiliations": [
3692 + "CapeFearMLS"
3693 + ]
3694 + },
3695 + {
3696 + "name": "Premier Sotheby's International Realty NC Mountains",
3697 + "website": "www.premiersir.com/nc",
3698 + "states": [
3699 + "NC"
3700 + ],
3701 + "cities": [
3702 + "Asheville"
3703 + ],
3704 + "estimated_agents": 200,
3705 + "estimated_listings": 500,
3706 + "mls_affiliations": [
3707 + "CanopyMLS"
3708 + ]
3709 + },
3710 + {
3711 + "name": "Beverly-Hanks Allen Tate Realtors",
3712 + "website": "www.beverly-hanks.com",
3713 + "states": [
3714 + "NC"
3715 + ],
3716 + "cities": [
3717 + "Asheville"
3718 + ],
3719 + "estimated_agents": 400,
3720 + "estimated_listings": 900,
3721 + "mls_affiliations": [
3722 + "CanopyMLS"
3723 + ]
3724 + },
3725 + {
3726 + "name": "RE/MAX Executive Carolinas",
3727 + "website": "www.remaxexec.com",
3728 + "states": [
3729 + "NC",
3730 + "SC"
3731 + ],
3732 + "cities": [
3733 + "Charlotte"
3734 + ],
3735 + "estimated_agents": 500,
3736 + "estimated_listings": 1000,
3737 + "mls_affiliations": [
3738 + "Canopy"
3739 + ]
3740 + },
3741 + {
3742 + "name": "Village Realty Outer Banks",
3743 + "website": "www.villagerealtyobx.com",
3744 + "states": [
3745 + "NC"
3746 + ],
3747 + "cities": [
3748 + "Nags Head"
3749 + ],
3750 + "estimated_agents": 60,
3751 + "estimated_listings": 200,
3752 + "mls_affiliations": [
3753 + "OBXMLS"
3754 + ]
3755 + },
3756 + {
3757 + "name": "Parks Compass Nashville",
3758 + "website": "www.parksathome.com",
3759 + "states": [
3760 + "TN"
3761 + ],
3762 + "cities": [
3763 + "Nashville"
3764 + ],
3765 + "estimated_agents": 1400,
3766 + "estimated_listings": 2600,
3767 + "mls_affiliations": [
3768 + "Realtracs"
3769 + ]
3770 + },
3771 + {
3772 + "name": "Benchmark Realty",
3773 + "website": "www.benchmarkrealtytn.com",
3774 + "states": [
3775 + "TN"
3776 + ],
3777 + "cities": [
3778 + "Nashville",
3779 + "Franklin"
3780 + ],
3781 + "estimated_agents": 1600,
3782 + "estimated_listings": 2600,
3783 + "mls_affiliations": [
3784 + "Realtracs"
3785 + ]
3786 + },
3787 + {
3788 + "name": "Zeitlin Sotheby's International Realty",
3789 + "website": "www.zeitlin.com",
3790 + "states": [
3791 + "TN"
3792 + ],
3793 + "cities": [
3794 + "Nashville"
3795 + ],
3796 + "estimated_agents": 200,
3797 + "estimated_listings": 450,
3798 + "mls_affiliations": [
3799 + "Realtracs"
3800 + ]
3801 + },
3802 + {
3803 + "name": "Fridrich & Clark Realty",
3804 + "website": "www.fridrichandclark.com",
3805 + "states": [
3806 + "TN"
3807 + ],
3808 + "cities": [
3809 + "Nashville"
3810 + ],
3811 + "estimated_agents": 200,
3812 + "estimated_listings": 450,
3813 + "mls_affiliations": [
3814 + "Realtracs"
3815 + ]
3816 + },
3817 + {
3818 + "name": "simpliHOM",
3819 + "website": "www.simplihom.com",
3820 + "states": [
3821 + "TN"
3822 + ],
3823 + "cities": [
3824 + "Nashville"
3825 + ],
3826 + "estimated_agents": 700,
3827 + "estimated_listings": 1100,
3828 + "mls_affiliations": [
3829 + "Realtracs"
3830 + ]
3831 + },
3832 + {
3833 + "name": "Marx-Bensdorf Realtors",
3834 + "website": "www.marx-bensdorf.com",
3835 + "states": [
3836 + "TN"
3837 + ],
3838 + "cities": [
3839 + "Memphis"
3840 + ],
3841 + "estimated_agents": 70,
3842 + "estimated_listings": 180,
3843 + "mls_affiliations": [
3844 + "MAARMLS"
3845 + ]
3846 + },
3847 + {
3848 + "name": "Alliance Sotheby's International Realty",
3849 + "website": "www.alliancesir.com",
3850 + "states": [
3851 + "TN"
3852 + ],
3853 + "cities": [
3854 + "Knoxville"
3855 + ],
3856 + "estimated_agents": 90,
3857 + "estimated_listings": 240,
3858 + "mls_affiliations": [
3859 + "KnoxvilleMLS"
3860 + ]
3861 + },
3862 + {
3863 + "name": "Wallace Real Estate",
3864 + "website": "www.wallacetn.com",
3865 + "states": [
3866 + "TN"
3867 + ],
3868 + "cities": [
3869 + "Knoxville"
3870 + ],
3871 + "estimated_agents": 400,
3872 + "estimated_listings": 900,
3873 + "mls_affiliations": [
3874 + "KnoxvilleMLS"
3875 + ]
3876 + },
3877 + {
3878 + "name": "RE/MAX Advantage Alabama",
3879 + "website": "www.remaxadvantagesouth.com",
3880 + "states": [
3881 + "AL"
3882 + ],
3883 + "cities": [
3884 + "Birmingham"
3885 + ],
3886 + "estimated_agents": 200,
3887 + "estimated_listings": 500,
3888 + "mls_affiliations": [
3889 + "GALMLS"
3890 + ]
3891 + },
3892 + {
3893 + "name": "ARC Realty",
3894 + "website": "www.arcrealtyco.com",
3895 + "states": [
3896 + "AL"
3897 + ],
3898 + "cities": [
3899 + "Birmingham"
3900 + ],
3901 + "estimated_agents": 400,
3902 + "estimated_listings": 900,
3903 + "mls_affiliations": [
3904 + "GALMLS"
3905 + ]
3906 + },
3907 + {
3908 + "name": "RealtySouth",
3909 + "website": "www.realtysouth.com",
3910 + "states": [
3911 + "AL"
3912 + ],
3913 + "cities": [
3914 + "Birmingham"
3915 + ],
3916 + "estimated_agents": 800,
3917 + "estimated_listings": 1700,
3918 + "mls_affiliations": [
3919 + "GALMLS"
3920 + ]
3921 + },
3922 + {
3923 + "name": "LAH Sotheby's International Realty",
3924 + "website": "www.lahsothebysrealty.com",
3925 + "states": [
3926 + "AL"
3927 + ],
3928 + "cities": [
3929 + "Birmingham"
3930 + ],
3931 + "estimated_agents": 150,
3932 + "estimated_listings": 380,
3933 + "mls_affiliations": [
3934 + "GALMLS"
3935 + ]
3936 + },
3937 + {
3938 + "name": "Bellator Real Estate & Development",
3939 + "website": "www.gobellator.com",
3940 + "states": [
3941 + "AL"
3942 + ],
3943 + "cities": [
3944 + "Mobile",
3945 + "Gulf Shores"
3946 + ],
3947 + "estimated_agents": 250,
3948 + "estimated_listings": 600,
3949 + "mls_affiliations": [
3950 + "BaldwinMLS"
3951 + ]
3952 + },
3953 + {
3954 + "name": "Semonin Realtors",
3955 + "website": "www.semonin.com",
3956 + "states": [
3957 + "KY",
3958 + "IN"
3959 + ],
3960 + "cities": [
3961 + "Louisville"
3962 + ],
3963 + "estimated_agents": 500,
3964 + "estimated_listings": 1100,
3965 + "mls_affiliations": [
3966 + "GLARMLS"
3967 + ]
3968 + },
3969 + {
3970 + "name": "Lenihan Sotheby's International Realty",
3971 + "website": "www.lsir.com",
3972 + "states": [
3973 + "KY"
3974 + ],
3975 + "cities": [
3976 + "Louisville"
3977 + ],
3978 + "estimated_agents": 90,
3979 + "estimated_listings": 240,
3980 + "mls_affiliations": [
3981 + "GLARMLS"
3982 + ]
3983 + },
3984 + {
3985 + "name": "Kentucky Select Properties",
3986 + "website": "www.kyselectproperties.com",
3987 + "states": [
3988 + "KY"
3989 + ],
3990 + "cities": [
3991 + "Louisville"
3992 + ],
3993 + "estimated_agents": 90,
3994 + "estimated_listings": 230,
3995 + "mls_affiliations": [
3996 + "GLARMLS"
3997 + ]
3998 + },
3999 + {
4000 + "name": "The Brokerage / Bluegrass Sotheby's",
4001 + "website": "www.bluegrasssir.com",
4002 + "states": [
4003 + "KY"
4004 + ],
4005 + "cities": [
4006 + "Lexington"
4007 + ],
4008 + "estimated_agents": 100,
4009 + "estimated_listings": 260,
4010 + "mls_affiliations": [
4011 + "LBARMLS"
4012 + ]
4013 + },
4014 + {
4015 + "name": "Latter & Blum (Compass)",
4016 + "website": "www.latter-blum.com",
4017 + "states": [
4018 + "LA",
4019 + "MS",
4020 + "TX"
4021 + ],
4022 + "cities": [
4023 + "New Orleans",
4024 + "Baton Rouge"
4025 + ],
4026 + "estimated_agents": 2500,
4027 + "estimated_listings": 5000,
4028 + "mls_affiliations": [
4029 + "GSREIN"
4030 + ]
4031 + },
4032 + {
4033 + "name": "Gardner Realtors",
4034 + "website": "www.gardnerrealtors.com",
4035 + "states": [
4036 + "LA",
4037 + "MS"
4038 + ],
4039 + "cities": [
4040 + "New Orleans",
4041 + "Metairie"
4042 + ],
4043 + "estimated_agents": 700,
4044 + "estimated_listings": 1500,
4045 + "mls_affiliations": [
4046 + "GSREIN"
4047 + ]
4048 + },
4049 + {
4050 + "name": "Keller Williams Realty New Orleans",
4051 + "website": "www.kwneworleans.com",
4052 + "states": [
4053 + "LA"
4054 + ],
4055 + "cities": [
4056 + "New Orleans"
4057 + ],
4058 + "estimated_agents": 400,
4059 + "estimated_listings": 800,
4060 + "mls_affiliations": [
4061 + "GSREIN"
4062 + ]
4063 + },
4064 + {
4065 + "name": "Burns & Co. Realtors",
4066 + "website": "www.burnsandco.com",
4067 + "states": [
4068 + "MS"
4069 + ],
4070 + "cities": [
4071 + "Jackson",
4072 + "Ridgeland"
4073 + ],
4074 + "estimated_agents": 60,
4075 + "estimated_listings": 160,
4076 + "mls_affiliations": [
4077 + "MGCMLS"
4078 + ]
4079 + },
4080 + {
4081 + "name": "Coldwell Banker Smith Homes Realtors",
4082 + "website": "www.cbsmithhomes.com",
4083 + "states": [
4084 + "MS"
4085 + ],
4086 + "cities": [
4087 + "Gulfport",
4088 + "Biloxi"
4089 + ],
4090 + "estimated_agents": 90,
4091 + "estimated_listings": 240,
4092 + "mls_affiliations": [
4093 + "MGCMLS"
4094 + ]
4095 + },
4096 + {
4097 + "name": "Jameson Sotheby's International Realty",
4098 + "website": "www.jamesonsir.com",
4099 + "states": [
4100 + "IL"
4101 + ],
4102 + "cities": [
4103 + "Chicago"
4104 + ],
4105 + "estimated_agents": 500,
4106 + "estimated_listings": 1100,
4107 + "mls_affiliations": [
4108 + "MRED"
4109 + ]
4110 + },
4111 + {
4112 + "name": "Dream Town Real Estate",
4113 + "website": "www.dreamtown.com",
4114 + "states": [
4115 + "IL"
4116 + ],
4117 + "cities": [
4118 + "Chicago"
4119 + ],
4120 + "estimated_agents": 500,
4121 + "estimated_listings": 1000,
4122 + "mls_affiliations": [
4123 + "MRED"
4124 + ]
4125 + },
4126 + {
4127 + "name": "Berkshire Hathaway HomeServices Chicago",
4128 + "website": "www.bhhschicago.com",
4129 + "states": [
4130 + "IL",
4131 + "WI",
4132 + "IN",
4133 + "MI"
4134 + ],
4135 + "cities": [
4136 + "Chicago"
4137 + ],
4138 + "estimated_agents": 1500,
4139 + "estimated_listings": 3000,
4140 + "mls_affiliations": [
4141 + "MRED"
4142 + ]
4143 + },
4144 + {
4145 + "name": "Redco Realty (Chicago)",
4146 + "website": "www.redcorealty.com",
4147 + "states": [
4148 + "IL"
4149 + ],
4150 + "cities": [
4151 + "Chicago"
4152 + ],
4153 + "estimated_agents": 150,
4154 + "estimated_listings": 300,
4155 + "mls_affiliations": [
4156 + "MRED"
4157 + ]
4158 + },
4159 + {
4160 + "name": "John Greene Realtor",
4161 + "website": "www.johngreenerealtor.com",
4162 + "states": [
4163 + "IL"
4164 + ],
4165 + "cities": [
4166 + "Naperville"
4167 + ],
4168 + "estimated_agents": 200,
4169 + "estimated_listings": 480,
4170 + "mls_affiliations": [
4171 + "MRED"
4172 + ]
4173 + },
4174 + {
4175 + "name": "Charles Rutenberg Realty of Illinois",
4176 + "website": "www.crrealtyil.com",
4177 + "states": [
4178 + "IL"
4179 + ],
4180 + "cities": [
4181 + "Naperville",
4182 + "Chicago"
4183 + ],
4184 + "estimated_agents": 1300,
4185 + "estimated_listings": 1900,
4186 + "mls_affiliations": [
4187 + "MRED"
4188 + ]
4189 + },
4190 + {
4191 + "name": "RE/MAX Suburban Chicago",
4192 + "website": "www.remaxsuburban.com",
4193 + "states": [
4194 + "IL"
4195 + ],
4196 + "cities": [
4197 + "Arlington Heights"
4198 + ],
4199 + "estimated_agents": 200,
4200 + "estimated_listings": 480,
4201 + "mls_affiliations": [
4202 + "MRED"
4203 + ]
4204 + },
4205 + {
4206 + "name": "F.C. Tucker Company",
4207 + "website": "www.talktotucker.com",
4208 + "states": [
4209 + "IN"
4210 + ],
4211 + "cities": [
4212 + "Indianapolis"
4213 + ],
4214 + "estimated_agents": 1500,
4215 + "estimated_listings": 3200,
4216 + "mls_affiliations": [
4217 + "MIBOR"
4218 + ]
4219 + },
4220 + {
4221 + "name": "Carpenter Realtors",
4222 + "website": "www.callcarpenter.com",
4223 + "states": [
4224 + "IN"
4225 + ],
4226 + "cities": [
4227 + "Indianapolis"
4228 + ],
4229 + "estimated_agents": 600,
4230 + "estimated_listings": 1300,
4231 + "mls_affiliations": [
4232 + "MIBOR"
4233 + ]
4234 + },
4235 + {
4236 + "name": "Encore Sotheby's International Realty",
4237 + "website": "www.encoresothebysrealty.com",
4238 + "states": [
4239 + "IN"
4240 + ],
4241 + "cities": [
4242 + "Indianapolis",
4243 + "Carmel"
4244 + ],
4245 + "estimated_agents": 130,
4246 + "estimated_listings": 320,
4247 + "mls_affiliations": [
4248 + "MIBOR"
4249 + ]
4250 + },
4251 + {
4252 + "name": "Berkshire Hathaway HomeServices Indiana Realty",
4253 + "website": "www.bhhsin.com",
4254 + "states": [
4255 + "IN"
4256 + ],
4257 + "cities": [
4258 + "Indianapolis",
4259 + "Bloomington"
4260 + ],
4261 + "estimated_agents": 500,
4262 + "estimated_listings": 1100,
4263 + "mls_affiliations": [
4264 + "MIBOR"
4265 + ]
4266 + },
4267 + {
4268 + "name": "Sibcy Cline Realtors",
4269 + "website": "www.sibcycline.com",
4270 + "states": [
4271 + "OH",
4272 + "KY",
4273 + "IN"
4274 + ],
4275 + "cities": [
4276 + "Cincinnati"
4277 + ],
4278 + "estimated_agents": 1100,
4279 + "estimated_listings": 2300,
4280 + "mls_affiliations": [
4281 + "CincyMLS"
4282 + ]
4283 + },
4284 + {
4285 + "name": "Comey & Shepherd Realtors",
4286 + "website": "www.comey.com",
4287 + "states": [
4288 + "OH",
4289 + "KY"
4290 + ],
4291 + "cities": [
4292 + "Cincinnati"
4293 + ],
4294 + "estimated_agents": 400,
4295 + "estimated_listings": 850,
4296 + "mls_affiliations": [
4297 + "CincyMLS"
4298 + ]
4299 + },
4300 + {
4301 + "name": "Cutler Real Estate",
4302 + "website": "www.cutlerhomes.com",
4303 + "states": [
4304 + "OH"
4305 + ],
4306 + "cities": [
4307 + "Canton",
4308 + "Columbus"
4309 + ],
4310 + "estimated_agents": 500,
4311 + "estimated_listings": 1100,
4312 + "mls_affiliations": [
4313 + "ColumbusMLS",
4314 + "YesMLS"
4315 + ]
4316 + },
4317 + {
4318 + "name": "Berkshire Hathaway HomeServices Professional Realty",
4319 + "website": "www.bhhspro.com",
4320 + "states": [
4321 + "OH",
4322 + "KY",
4323 + "IN"
4324 + ],
4325 + "cities": [
4326 + "Columbus",
4327 + "Dayton"
4328 + ],
4329 + "estimated_agents": 700,
4330 + "estimated_listings": 1500,
4331 + "mls_affiliations": [
4332 + "ColumbusMLS"
4333 + ]
4334 + },
4335 + {
4336 + "name": "Coldwell Banker Realty Columbus",
4337 + "website": "www.coldwellbankerhomes.com/oh/columbus",
4338 + "states": [
4339 + "OH"
4340 + ],
4341 + "cities": [
4342 + "Columbus"
4343 + ],
4344 + "estimated_agents": 600,
4345 + "estimated_listings": 1300,
4346 + "mls_affiliations": [
4347 + "ColumbusMLS"
4348 + ]
4349 + },
4350 + {
4351 + "name": "Street Sotheby's International Realty",
4352 + "website": "www.streetsir.com",
4353 + "states": [
4354 + "OH"
4355 + ],
4356 + "cities": [
4357 + "Columbus",
4358 + "New Albany"
4359 + ],
4360 + "estimated_agents": 100,
4361 + "estimated_listings": 260,
4362 + "mls_affiliations": [
4363 + "ColumbusMLS"
4364 + ]
4365 + },
4366 + {
4367 + "name": "Keller Williams Greater Metropolitan Cleveland",
4368 + "website": "www.clevelandrealestateexperts.com",
4369 + "states": [
4370 + "OH"
4371 + ],
4372 + "cities": [
4373 + "Cleveland"
4374 + ],
4375 + "estimated_agents": 400,
4376 + "estimated_listings": 800,
4377 + "mls_affiliations": [
4378 + "YesMLS"
4379 + ]
4380 + },
4381 + {
4382 + "name": "Chestnut Hill Realty (Cleveland)",
4383 + "website": "www.chestnuthillrealty.com",
4384 + "states": [
4385 + "OH"
4386 + ],
4387 + "cities": [
4388 + "Cleveland"
4389 + ],
4390 + "estimated_agents": 60,
4391 + "estimated_listings": 150,
4392 + "mls_affiliations": [
4393 + "YesMLS"
4394 + ]
4395 + },
4396 + {
4397 + "name": "Real Estate One",
4398 + "website": "www.realestateone.com",
4399 + "states": [
4400 + "MI"
4401 + ],
4402 + "cities": [
4403 + "Detroit",
4404 + "Southfield"
4405 + ],
4406 + "estimated_agents": 2000,
4407 + "estimated_listings": 4200,
4408 + "mls_affiliations": [
4409 + "Realcomp"
4410 + ]
4411 + },
4412 + {
4413 + "name": "Max Broock Realtors",
4414 + "website": "www.maxbroock.com",
4415 + "states": [
4416 + "MI"
4417 + ],
4418 + "cities": [
4419 + "Birmingham",
4420 + "Detroit"
4421 + ],
4422 + "estimated_agents": 600,
4423 + "estimated_listings": 1300,
4424 + "mls_affiliations": [
4425 + "Realcomp"
4426 + ]
4427 + },
4428 + {
4429 + "name": "The Charles Reinhart Company",
4430 + "website": "www.reinhartrealtors.com",
4431 + "states": [
4432 + "MI"
4433 + ],
4434 + "cities": [
4435 + "Ann Arbor"
4436 + ],
4437 + "estimated_agents": 200,
4438 + "estimated_listings": 500,
4439 + "mls_affiliations": [
4440 + "AnnArborMLS"
4441 + ]
4442 + },
4443 + {
4444 + "name": "@properties Detroit (Christie's)",
4445 + "website": "www.atproperties.com/detroit",
4446 + "states": [
4447 + "MI"
4448 + ],
4449 + "cities": [
4450 + "Detroit",
4451 + "Birmingham"
4452 + ],
4453 + "estimated_agents": 300,
4454 + "estimated_listings": 600,
4455 + "mls_affiliations": [
4456 + "Realcomp"
4457 + ]
4458 + },
4459 + {
4460 + "name": "Coldwell Banker Schmidt Family of Companies",
4461 + "website": "www.cbgreatlakes.com",
4462 + "states": [
4463 + "MI",
4464 + "OH",
4465 + "FL"
4466 + ],
4467 + "cities": [
4468 + "Traverse City"
4469 + ],
4470 + "estimated_agents": 2000,
4471 + "estimated_listings": 4500,
4472 + "mls_affiliations": [
4473 + "NGLRMLS"
4474 + ]
4475 + },
4476 + {
4477 + "name": "Shorewest Realtors",
4478 + "website": "www.shorewest.com",
4479 + "states": [
4480 + "WI"
4481 + ],
4482 + "cities": [
4483 + "Milwaukee",
4484 + "Brookfield"
4485 + ],
4486 + "estimated_agents": 1200,
4487 + "estimated_listings": 2600,
4488 + "mls_affiliations": [
4489 + "Metro MLS"
4490 + ]
4491 + },
4492 + {
4493 + "name": "First Weber Realtors",
4494 + "website": "www.firstweber.com",
4495 + "states": [
4496 + "WI"
4497 + ],
4498 + "cities": [
4499 + "Madison",
4500 + "Milwaukee"
4501 + ],
4502 + "estimated_agents": 1400,
4503 + "estimated_listings": 3000,
4504 + "mls_affiliations": [
4505 + "SCWMLS"
4506 + ]
4507 + },
4508 + {
4509 + "name": "Stark Company Realtors",
4510 + "website": "www.starkhomes.com",
4511 + "states": [
4512 + "WI"
4513 + ],
4514 + "cities": [
4515 + "Madison"
4516 + ],
4517 + "estimated_agents": 450,
4518 + "estimated_listings": 950,
4519 + "mls_affiliations": [
4520 + "SCWMLS"
4521 + ]
4522 + },
4523 + {
4524 + "name": "Mahler Sotheby's International Realty",
4525 + "website": "www.mahlersir.com",
4526 + "states": [
4527 + "WI"
4528 + ],
4529 + "cities": [
4530 + "Milwaukee"
4531 + ],
4532 + "estimated_agents": 80,
4533 + "estimated_listings": 210,
4534 + "mls_affiliations": [
4535 + "Metro MLS"
4536 + ]
4537 + },
4538 + {
4539 + "name": "Lakes Sotheby's International Realty",
4540 + "website": "www.lakessir.com",
4541 + "states": [
4542 + "MN"
4543 + ],
4544 + "cities": [
4545 + "Edina",
4546 + "Minneapolis"
4547 + ],
4548 + "estimated_agents": 150,
4549 + "estimated_listings": 380,
4550 + "mls_affiliations": [
4551 + "NorthstarMLS"
4552 + ]
4553 + },
4554 + {
4555 + "name": "Coldwell Banker Realty Minneapolis",
4556 + "website": "www.coldwellbankerhomes.com/mn",
4557 + "states": [
4558 + "MN"
4559 + ],
4560 + "cities": [
4561 + "Minneapolis"
4562 + ],
4563 + "estimated_agents": 1500,
4564 + "estimated_listings": 3200,
4565 + "mls_affiliations": [
4566 + "NorthstarMLS"
4567 + ]
4568 + },
4569 + {
4570 + "name": "RE/MAX Results Minnesota",
4571 + "website": "www.results.net",
4572 + "states": [
4573 + "MN",
4574 + "WI"
4575 + ],
4576 + "cities": [
4577 + "Eden Prairie",
4578 + "Minneapolis"
4579 + ],
4580 + "estimated_agents": 1200,
4581 + "estimated_listings": 2600,
4582 + "mls_affiliations": [
4583 + "NorthstarMLS"
4584 + ]
4585 + },
4586 + {
4587 + "name": "Kris Lindahl Real Estate",
4588 + "website": "www.krislindahl.com",
4589 + "states": [
4590 + "MN",
4591 + "WI"
4592 + ],
4593 + "cities": [
4594 + "Blaine",
4595 + "Minneapolis"
4596 + ],
4597 + "estimated_agents": 200,
4598 + "estimated_listings": 550,
4599 + "mls_affiliations": [
4600 + "NorthstarMLS"
4601 + ]
4602 + },
4603 + {
4604 + "name": "Reece Nichols Real Estate",
4605 + "website": "www.reecenichols.com",
4606 + "states": [
4607 + "KS",
4608 + "MO"
4609 + ],
4610 + "cities": [
4611 + "Kansas City",
4612 + "Overland Park"
4613 + ],
4614 + "estimated_agents": 2600,
4615 + "estimated_listings": 5200,
4616 + "mls_affiliations": [
4617 + "Heartland"
4618 + ]
4619 + },
4620 + {
4621 + "name": "Keller Williams Realty Partners KC",
4622 + "website": "www.kwkc.com",
4623 + "states": [
4624 + "KS",
4625 + "MO"
4626 + ],
4627 + "cities": [
4628 + "Overland Park"
4629 + ],
4630 + "estimated_agents": 700,
4631 + "estimated_listings": 1400,
4632 + "mls_affiliations": [
4633 + "Heartland"
4634 + ]
4635 + },
4636 + {
4637 + "name": "Compass Realty Group KC",
4638 + "website": "www.compassrealtygroupkc.com",
4639 + "states": [
4640 + "MO",
4641 + "KS"
4642 + ],
4643 + "cities": [
4644 + "Kansas City"
4645 + ],
4646 + "estimated_agents": 300,
4647 + "estimated_listings": 650,
4648 + "mls_affiliations": [
4649 + "Heartland"
4650 + ]
4651 + },
4652 + {
4653 + "name": "Berkshire Hathaway HomeServices Kansas City Realty",
4654 + "website": "www.bhhskcrealty.com",
4655 + "states": [
4656 + "KS",
4657 + "MO"
4658 + ],
4659 + "cities": [
4660 + "Kansas City"
4661 + ],
4662 + "estimated_agents": 300,
4663 + "estimated_listings": 650,
4664 + "mls_affiliations": [
4665 + "Heartland"
4666 + ]
4667 + },
4668 + {
4669 + "name": "Coldwell Banker Gundaker (Realty STL)",
4670 + "website": "www.cbgundaker.com",
4671 + "states": [
4672 + "MO",
4673 + "IL"
4674 + ],
4675 + "cities": [
4676 + "Saint Louis"
4677 + ],
4678 + "estimated_agents": 2000,
4679 + "estimated_listings": 3800,
4680 + "mls_affiliations": [
4681 + "MARIS"
4682 + ]
4683 + },
4684 + {
4685 + "name": "Janet McAfee Real Estate",
4686 + "website": "www.janetmcafee.com",
4687 + "states": [
4688 + "MO"
4689 + ],
4690 + "cities": [
4691 + "Saint Louis"
4692 + ],
4693 + "estimated_agents": 130,
4694 + "estimated_listings": 320,
4695 + "mls_affiliations": [
4696 + "MARIS"
4697 + ]
4698 + },
4699 + {
4700 + "name": "Dielmann Sotheby's International Realty",
4701 + "website": "www.dielmannsothebysrealty.com",
4702 + "states": [
4703 + "MO"
4704 + ],
4705 + "cities": [
4706 + "Saint Louis"
4707 + ],
4708 + "estimated_agents": 100,
4709 + "estimated_listings": 260,
4710 + "mls_affiliations": [
4711 + "MARIS"
4712 + ]
4713 + },
4714 + {
4715 + "name": "Murney Associates Realtors",
4716 + "website": "www.murney.com",
4717 + "states": [
4718 + "MO"
4719 + ],
4720 + "cities": [
4721 + "Springfield"
4722 + ],
4723 + "estimated_agents": 700,
4724 + "estimated_listings": 1500,
4725 + "mls_affiliations": [
4726 + "SOMOMLS"
4727 + ]
4728 + },
4729 + {
4730 + "name": "Berkshire Hathaway HomeServices Ambassador Real Estate",
4731 + "website": "www.bhhsamb.com",
4732 + "states": [
4733 + "NE",
4734 + "IA"
4735 + ],
4736 + "cities": [
4737 + "Omaha"
4738 + ],
4739 + "estimated_agents": 800,
4740 + "estimated_listings": 1600,
4741 + "mls_affiliations": [
4742 + "GPRMLS"
4743 + ]
4744 + },
4745 + {
4746 + "name": "NP Dodge Real Estate",
4747 + "website": "www.npdodge.com",
4748 + "states": [
4749 + "NE",
4750 + "IA"
4751 + ],
4752 + "cities": [
4753 + "Omaha"
4754 + ],
4755 + "estimated_agents": 800,
4756 + "estimated_listings": 1700,
4757 + "mls_affiliations": [
4758 + "GPRMLS"
4759 + ]
4760 + },
4761 + {
4762 + "name": "Nebraska Realty",
4763 + "website": "www.nebraskarealty.com",
4764 + "states": [
4765 + "NE",
4766 + "IA"
4767 + ],
4768 + "cities": [
4769 + "Omaha",
4770 + "Lincoln"
4771 + ],
4772 + "estimated_agents": 1000,
4773 + "estimated_listings": 1900,
4774 + "mls_affiliations": [
4775 + "GPRMLS"
4776 + ]
4777 + },
4778 + {
4779 + "name": "Iowa Realty",
4780 + "website": "www.iowarealty.com",
4781 + "states": [
4782 + "IA"
4783 + ],
4784 + "cities": [
4785 + "Des Moines",
4786 + "West Des Moines"
4787 + ],
4788 + "estimated_agents": 900,
4789 + "estimated_listings": 2000,
4790 + "mls_affiliations": [
4791 + "DMAAR"
4792 + ]
4793 + },
4794 + {
4795 + "name": "RE/MAX Concepts Iowa",
4796 + "website": "www.remax-concepts.com",
4797 + "states": [
4798 + "IA"
4799 + ],
4800 + "cities": [
4801 + "Des Moines"
4802 + ],
4803 + "estimated_agents": 300,
4804 + "estimated_listings": 700,
4805 + "mls_affiliations": [
4806 + "DMAAR"
4807 + ]
4808 + },
4809 + {
4810 + "name": "Ruhl & Ruhl Realtors",
4811 + "website": "www.ruhlhomes.com",
4812 + "states": [
4813 + "IA",
4814 + "IL",
4815 + "WI"
4816 + ],
4817 + "cities": [
4818 + "Davenport",
4819 + "Quad Cities"
4820 + ],
4821 + "estimated_agents": 400,
4822 + "estimated_listings": 950,
4823 + "mls_affiliations": [
4824 + "QCARMLS"
4825 + ]
4826 + },
4827 + {
4828 + "name": "Skogman Realty",
4829 + "website": "www.skogman.com",
4830 + "states": [
4831 + "IA"
4832 + ],
4833 + "cities": [
4834 + "Cedar Rapids"
4835 + ],
4836 + "estimated_agents": 200,
4837 + "estimated_listings": 500,
4838 + "mls_affiliations": [
4839 + "CRMLS-IA"
4840 + ]
4841 + },
4842 + {
4843 + "name": "Fischer Rounds Real Estate (SD)",
4844 + "website": "www.fischerrounds.com",
4845 + "states": [
4846 + "SD"
4847 + ],
4848 + "cities": [
4849 + "Pierre",
4850 + "Rapid City"
4851 + ],
4852 + "estimated_agents": 40,
4853 + "estimated_listings": 120,
4854 + "mls_affiliations": [
4855 + "MtPlainsMLS"
4856 + ]
4857 + },
4858 + {
4859 + "name": "Hegg Realtors",
4860 + "website": "www.hegg.com",
4861 + "states": [
4862 + "SD"
4863 + ],
4864 + "cities": [
4865 + "Sioux Falls"
4866 + ],
4867 + "estimated_agents": 200,
4868 + "estimated_listings": 450,
4869 + "mls_affiliations": [
4870 + "RASE MLS"
4871 + ]
4872 + },
4873 + {
4874 + "name": "Century 21 Morrison Realty",
4875 + "website": "www.century21morrison.com",
4876 + "states": [
4877 + "ND"
4878 + ],
4879 + "cities": [
4880 + "Bismarck"
4881 + ],
4882 + "estimated_agents": 90,
4883 + "estimated_listings": 240,
4884 + "mls_affiliations": [
4885 + "BisManMLS"
4886 + ]
4887 + },
4888 + {
4889 + "name": "Park Co. Realtors",
4890 + "website": "www.parkcompany.com",
4891 + "states": [
4892 + "ND",
4893 + "MN"
4894 + ],
4895 + "cities": [
4896 + "Fargo"
4897 + ],
4898 + "estimated_agents": 130,
4899 + "estimated_listings": 320,
4900 + "mls_affiliations": [
4901 + "FMAAR"
4902 + ]
4903 + },
4904 + {
4905 + "name": "Berkshire Hathaway HomeServices First Realty Montana",
4906 + "website": "www.bhhsmt.com",
4907 + "states": [
4908 + "MT"
4909 + ],
4910 + "cities": [
4911 + "Bozeman",
4912 + "Billings"
4913 + ],
4914 + "estimated_agents": 300,
4915 + "estimated_listings": 700,
4916 + "mls_affiliations": [
4917 + "MontanaMLS"
4918 + ]
4919 + },
4920 + {
4921 + "name": "PureWest Christie's International Real Estate",
4922 + "website": "www.purewestrealestate.com",
4923 + "states": [
4924 + "MT"
4925 + ],
4926 + "cities": [
4927 + "Whitefish",
4928 + "Bozeman"
4929 + ],
4930 + "estimated_agents": 400,
4931 + "estimated_listings": 1000,
4932 + "mls_affiliations": [
4933 + "MontanaMLS"
4934 + ]
4935 + },
4936 + {
4937 + "name": "ERA Landmark Real Estate",
4938 + "website": "www.eralandmark.com",
4939 + "states": [
4940 + "MT"
4941 + ],
4942 + "cities": [
4943 + "Bozeman"
4944 + ],
4945 + "estimated_agents": 90,
4946 + "estimated_listings": 260,
4947 + "mls_affiliations": [
4948 + "GallatinMLS"
4949 + ]
4950 + },
4951 + {
4952 + "name": "Jackson Hole Sotheby's International Realty",
4953 + "website": "www.jhsir.com",
4954 + "states": [
4955 + "WY",
4956 + "ID"
4957 + ],
4958 + "cities": [
4959 + "Jackson"
4960 + ],
4961 + "estimated_agents": 130,
4962 + "estimated_listings": 350,
4963 + "mls_affiliations": [
4964 + "TetonMLS"
4965 + ]
4966 + },
4967 + {
4968 + "name": "Live Water Properties",
4969 + "website": "www.livewaterproperties.com",
4970 + "states": [
4971 + "WY",
4972 + "MT",
4973 + "ID"
4974 + ],
4975 + "cities": [
4976 + "Jackson"
4977 + ],
4978 + "estimated_agents": 40,
4979 + "estimated_listings": 140,
4980 + "mls_affiliations": [
4981 + "TetonMLS"
4982 + ]
4983 + },
4984 + {
4985 + "name": "Coldwell Banker Antlers Realty",
4986 + "website": "www.codyrealestate.info",
4987 + "states": [
4988 + "WY"
4989 + ],
4990 + "cities": [
4991 + "Cody"
4992 + ],
4993 + "estimated_agents": 20,
4994 + "estimated_listings": 70,
4995 + "mls_affiliations": [
4996 + "ParkCountyMLS"
4997 + ]
4998 + },
4999 + {
5000 + "name": "The Group Real Estate (Idaho Falls)",
5001 + "website": "www.thegrouprealestate.com",
5002 + "states": [
5003 + "ID"
5004 + ],
5005 + "cities": [
5006 + "Idaho Falls"
5007 + ],
5008 + "estimated_agents": 90,
5009 + "estimated_listings": 240,
5010 + "mls_affiliations": [
5011 + "SnakeRiverMLS"
5012 + ]
5013 + },
5014 + {
5015 + "name": "Amherst Madison Real Estate Advisors",
5016 + "website": "www.amherstmadison.com",
5017 + "states": [
5018 + "ID"
5019 + ],
5020 + "cities": [
5021 + "Boise"
5022 + ],
5023 + "estimated_agents": 500,
5024 + "estimated_listings": 950,
5025 + "mls_affiliations": [
5026 + "IntermountainMLS"
5027 + ]
5028 + },
5029 + {
5030 + "name": "Boise Premier Real Estate",
5031 + "website": "www.boisepremier.com",
5032 + "states": [
5033 + "ID"
5034 + ],
5035 + "cities": [
5036 + "Boise"
5037 + ],
5038 + "estimated_agents": 300,
5039 + "estimated_listings": 550,
5040 + "mls_affiliations": [
5041 + "IntermountainMLS"
5042 + ]
5043 + },
5044 + {
5045 + "name": "Silvercreek Realty Group",
5046 + "website": "www.silvercreekrealty.com",
5047 + "states": [
5048 + "ID"
5049 + ],
5050 + "cities": [
5051 + "Boise",
5052 + "Meridian"
5053 + ],
5054 + "estimated_agents": 1800,
5055 + "estimated_listings": 2800,
5056 + "mls_affiliations": [
5057 + "IntermountainMLS"
5058 + ]
5059 + },
5060 + {
5061 + "name": "Windermere Coeur d'Alene Realty",
5062 + "website": "www.cdarealty.com",
5063 + "states": [
5064 + "ID"
5065 + ],
5066 + "cities": [
5067 + "Coeur d'Alene"
5068 + ],
5069 + "estimated_agents": 130,
5070 + "estimated_listings": 350,
5071 + "mls_affiliations": [
5072 + "CDAMLS"
5073 + ]
5074 + },
5075 + {
5076 + "name": "LIV Sotheby's International Realty",
5077 + "website": "www.livsothebysrealty.com",
5078 + "states": [
5079 + "CO"
5080 + ],
5081 + "cities": [
5082 + "Denver",
5083 + "Vail",
5084 + "Boulder"
5085 + ],
5086 + "estimated_agents": 800,
5087 + "estimated_listings": 1800,
5088 + "mls_affiliations": [
5089 + "REcolorado"
5090 + ]
5091 + },
5092 + {
5093 + "name": "Kentwood Real Estate",
5094 + "website": "www.denverrealestate.com",
5095 + "states": [
5096 + "CO"
5097 + ],
5098 + "cities": [
5099 + "Denver"
5100 + ],
5101 + "estimated_agents": 200,
5102 + "estimated_listings": 500,
5103 + "mls_affiliations": [
5104 + "REcolorado"
5105 + ]
5106 + },
5107 + {
5108 + "name": "Milehimodern",
5109 + "website": "www.milehimodern.com",
5110 + "states": [
5111 + "CO"
5112 + ],
5113 + "cities": [
5114 + "Denver",
5115 + "Boulder"
5116 + ],
5117 + "estimated_agents": 250,
5118 + "estimated_listings": 550,
5119 + "mls_affiliations": [
5120 + "REcolorado"
5121 + ]
5122 + },
5123 + {
5124 + "name": "RE/MAX Alliance Colorado",
5125 + "website": "www.homesincolorado.com",
5126 + "states": [
5127 + "CO"
5128 + ],
5129 + "cities": [
5130 + "Denver",
5131 + "Boulder",
5132 + "Fort Collins"
5133 + ],
5134 + "estimated_agents": 900,
5135 + "estimated_listings": 1900,
5136 + "mls_affiliations": [
5137 + "REcolorado",
5138 + "IRES"
5139 + ]
5140 + },
5141 + {
5142 + "name": "The Group Inc Real Estate",
5143 + "website": "www.thegroupinc.com",
5144 + "states": [
5145 + "CO"
5146 + ],
5147 + "cities": [
5148 + "Fort Collins",
5149 + "Loveland"
5150 + ],
5151 + "estimated_agents": 200,
5152 + "estimated_listings": 500,
5153 + "mls_affiliations": [
5154 + "IRES"
5155 + ]
5156 + },
5157 + {
5158 + "name": "Slifer Smith & Frampton Real Estate",
5159 + "website": "www.slifersmithandframpton.com",
5160 + "states": [
5161 + "CO"
5162 + ],
5163 + "cities": [
5164 + "Vail",
5165 + "Beaver Creek"
5166 + ],
5167 + "estimated_agents": 300,
5168 + "estimated_listings": 700,
5169 + "mls_affiliations": [
5170 + "VailMLS"
5171 + ]
5172 + },
5173 + {
5174 + "name": "Aspen Snowmass Sotheby's International Realty",
5175 + "website": "www.aspensnowmasssir.com",
5176 + "states": [
5177 + "CO"
5178 + ],
5179 + "cities": [
5180 + "Aspen"
5181 + ],
5182 + "estimated_agents": 200,
5183 + "estimated_listings": 500,
5184 + "mls_affiliations": [
5185 + "AspenGlenwoodMLS"
5186 + ]
5187 + },
5188 + {
5189 + "name": "Douglas Elliman Colorado",
5190 + "website": "www.elliman.com/colorado",
5191 + "states": [
5192 + "CO"
5193 + ],
5194 + "cities": [
5195 + "Aspen",
5196 + "Denver"
5197 + ],
5198 + "estimated_agents": 130,
5199 + "estimated_listings": 320,
5200 + "mls_affiliations": [
5201 + "AspenGlenwoodMLS"
5202 + ]
5203 + },
5204 + {
5205 + "name": "ERA Shields Real Estate",
5206 + "website": "www.erashields.com",
5207 + "states": [
5208 + "CO"
5209 + ],
5210 + "cities": [
5211 + "Colorado Springs"
5212 + ],
5213 + "estimated_agents": 200,
5214 + "estimated_listings": 480,
5215 + "mls_affiliations": [
5216 + "PPMLS"
5217 + ]
5218 + },
5219 + {
5220 + "name": "The Platinum Group Realtors",
5221 + "website": "www.platinumgrouprealtors.com",
5222 + "states": [
5223 + "CO"
5224 + ],
5225 + "cities": [
5226 + "Colorado Springs"
5227 + ],
5228 + "estimated_agents": 130,
5229 + "estimated_listings": 340,
5230 + "mls_affiliations": [
5231 + "PPMLS"
5232 + ]
5233 + },
5234 + {
5235 + "name": "Summit Sotheby's International Realty",
5236 + "website": "www.summitsothebysrealty.com",
5237 + "states": [
5238 + "UT"
5239 + ],
5240 + "cities": [
5241 + "Park City",
5242 + "Salt Lake City"
5243 + ],
5244 + "estimated_agents": 350,
5245 + "estimated_listings": 850,
5246 + "mls_affiliations": [
5247 + "ParkCityMLS",
5248 + "UtahRealEstate"
5249 + ]
5250 + },
5251 + {
5252 + "name": "Windermere Real Estate Utah",
5253 + "website": "www.windermereutah.com",
5254 + "states": [
5255 + "UT"
5256 + ],
5257 + "cities": [
5258 + "Salt Lake City"
5259 + ],
5260 + "estimated_agents": 300,
5261 + "estimated_listings": 650,
5262 + "mls_affiliations": [
5263 + "UtahRealEstate"
5264 + ]
5265 + },
5266 + {
5267 + "name": "Presidio Real Estate",
5268 + "website": "www.presidiorealestate.com",
5269 + "states": [
5270 + "UT"
5271 + ],
5272 + "cities": [
5273 + "South Jordan"
5274 + ],
5275 + "estimated_agents": 900,
5276 + "estimated_listings": 1400,
5277 + "mls_affiliations": [
5278 + "UtahRealEstate"
5279 + ]
5280 + },
5281 + {
5282 + "name": "Equity Real Estate Utah",
5283 + "website": "www.equityrealestate.com",
5284 + "states": [
5285 + "UT"
5286 + ],
5287 + "cities": [
5288 + "Murray"
5289 + ],
5290 + "estimated_agents": 1500,
5291 + "estimated_listings": 2200,
5292 + "mls_affiliations": [
5293 + "UtahRealEstate"
5294 + ]
5295 + },
5296 + {
5297 + "name": "ERA Brokers Consolidated",
5298 + "website": "www.erabrokers.com",
5299 + "states": [
5300 + "UT",
5301 + "NV",
5302 + "AZ",
5303 + "ID"
5304 + ],
5305 + "cities": [
5306 + "Saint George"
5307 + ],
5308 + "estimated_agents": 400,
5309 + "estimated_listings": 950,
5310 + "mls_affiliations": [
5311 + "WCBRMLS"
5312 + ]
5313 + },
5314 + {
5315 + "name": "Russ Lyon Sotheby's International Realty",
5316 + "website": "www.russlyon.com",
5317 + "states": [
5318 + "AZ"
5319 + ],
5320 + "cities": [
5321 + "Scottsdale",
5322 + "Phoenix",
5323 + "Sedona"
5324 + ],
5325 + "estimated_agents": 900,
5326 + "estimated_listings": 2100,
5327 + "mls_affiliations": [
5328 + "ARMLS"
5329 + ]
5330 + },
5331 + {
5332 + "name": "Launch Real Estate",
5333 + "website": "www.launchrealestate.com",
5334 + "states": [
5335 + "AZ"
5336 + ],
5337 + "cities": [
5338 + "Scottsdale"
5339 + ],
5340 + "estimated_agents": 400,
5341 + "estimated_listings": 850,
5342 + "mls_affiliations": [
5343 + "ARMLS"
5344 + ]
5345 + },
5346 + {
5347 + "name": "The Brokery",
5348 + "website": "www.thebrokery.com",
5349 + "states": [
5350 + "AZ"
5351 + ],
5352 + "cities": [
5353 + "Phoenix"
5354 + ],
5355 + "estimated_agents": 90,
5356 + "estimated_listings": 240,
5357 + "mls_affiliations": [
5358 + "ARMLS"
5359 + ]
5360 + },
5361 + {
5362 + "name": "My Home Group",
5363 + "website": "www.myhomegroup.com",
5364 + "states": [
5365 + "AZ",
5366 + "WA"
5367 + ],
5368 + "cities": [
5369 + "Scottsdale",
5370 + "Phoenix"
5371 + ],
5372 + "estimated_agents": 3200,
5373 + "estimated_listings": 4600,
5374 + "mls_affiliations": [
5375 + "ARMLS"
5376 + ]
5377 + },
5378 + {
5379 + "name": "West USA Realty",
5380 + "website": "www.westusa.com",
5381 + "states": [
5382 + "AZ"
5383 + ],
5384 + "cities": [
5385 + "Phoenix"
5386 + ],
5387 + "estimated_agents": 2900,
5388 + "estimated_listings": 4200,
5389 + "mls_affiliations": [
5390 + "ARMLS"
5391 + ]
5392 + },
5393 + {
5394 + "name": "Realty Executives Arizona Territory",
5395 + "website": "www.realtyexecutivesaz.com",
5396 + "states": [
5397 + "AZ"
5398 + ],
5399 + "cities": [
5400 + "Tucson"
5401 + ],
5402 + "estimated_agents": 500,
5403 + "estimated_listings": 1100,
5404 + "mls_affiliations": [
5405 + "MLSSAZ"
5406 + ]
5407 + },
5408 + {
5409 + "name": "Long Realty Company",
5410 + "website": "www.longrealty.com",
5411 + "states": [
5412 + "AZ"
5413 + ],
5414 + "cities": [
5415 + "Tucson"
5416 + ],
5417 + "estimated_agents": 1200,
5418 + "estimated_listings": 2600,
5419 + "mls_affiliations": [
5420 + "MLSSAZ"
5421 + ]
5422 + },
5423 + {
5424 + "name": "Tierra Antigua Realty",
5425 + "website": "www.tierraantigua.com",
5426 + "states": [
5427 + "AZ"
5428 + ],
5429 + "cities": [
5430 + "Tucson"
5431 + ],
5432 + "estimated_agents": 1400,
5433 + "estimated_listings": 2200,
5434 + "mls_affiliations": [
5435 + "MLSSAZ"
5436 + ]
5437 + },
5438 + {
5439 + "name": "Berkshire Hathaway HomeServices Arizona Properties",
5440 + "website": "www.bhhsaz.com",
5441 + "states": [
5442 + "AZ"
5443 + ],
5444 + "cities": [
5445 + "Scottsdale"
5446 + ],
5447 + "estimated_agents": 700,
5448 + "estimated_listings": 1500,
5449 + "mls_affiliations": [
5450 + "ARMLS"
5451 + ]
5452 + },
5453 + {
5454 + "name": "HomeSmart Arizona",
5455 + "website": "www.homesmart.com/az",
5456 + "states": [
5457 + "AZ"
5458 + ],
5459 + "cities": [
5460 + "Phoenix"
5461 + ],
5462 + "estimated_agents": 7000,
5463 + "estimated_listings": 9000,
5464 + "mls_affiliations": [
5465 + "ARMLS"
5466 + ]
5467 + },
5468 + {
5469 + "name": "Coldwell Banker Legacy",
5470 + "website": "www.cblegacy.com",
5471 + "states": [
5472 + "NM"
5473 + ],
5474 + "cities": [
5475 + "Albuquerque"
5476 + ],
5477 + "estimated_agents": 400,
5478 + "estimated_listings": 950,
5479 + "mls_affiliations": [
5480 + "SWMLS"
5481 + ]
5482 + },
5483 + {
5484 + "name": "Berkshire Hathaway HomeServices New Mexico Properties",
5485 + "website": "www.bhhsnm.com",
5486 + "states": [
5487 + "NM"
5488 + ],
5489 + "cities": [
5490 + "Albuquerque"
5491 + ],
5492 + "estimated_agents": 300,
5493 + "estimated_listings": 700,
5494 + "mls_affiliations": [
5495 + "SWMLS"
5496 + ]
5497 + },
5498 + {
5499 + "name": "Sotheby's International Realty Santa Fe",
5500 + "website": "www.santafesothebysrealty.com",
5501 + "states": [
5502 + "NM"
5503 + ],
5504 + "cities": [
5505 + "Santa Fe"
5506 + ],
5507 + "estimated_agents": 200,
5508 + "estimated_listings": 550,
5509 + "mls_affiliations": [
5510 + "SantaFeMLS"
5511 + ]
5512 + },
5513 + {
5514 + "name": "Barker Realty (Santa Fe)",
5515 + "website": "www.santaferealestate.com",
5516 + "states": [
5517 + "NM"
5518 + ],
5519 + "cities": [
5520 + "Santa Fe"
5521 + ],
5522 + "estimated_agents": 90,
5523 + "estimated_listings": 260,
5524 + "mls_affiliations": [
5525 + "SantaFeMLS"
5526 + ]
5527 + },
5528 + {
5529 + "name": "Berkshire Hathaway HomeServices Nevada Properties",
5530 + "website": "www.bhhsnv.com",
5531 + "states": [
5532 + "NV"
5533 + ],
5534 + "cities": [
5535 + "Las Vegas"
5536 + ],
5537 + "estimated_agents": 2600,
5538 + "estimated_listings": 3900,
5539 + "mls_affiliations": [
5540 + "LVR"
5541 + ]
5542 + },
5543 + {
5544 + "name": "Simply Vegas Real Estate",
5545 + "website": "www.simplyvegas.com",
5546 + "states": [
5547 + "NV"
5548 + ],
5549 + "cities": [
5550 + "Las Vegas"
5551 + ],
5552 + "estimated_agents": 500,
5553 + "estimated_listings": 1000,
5554 + "mls_affiliations": [
5555 + "LVR"
5556 + ]
5557 + },
5558 + {
5559 + "name": "Urban Nest Realty",
5560 + "website": "www.urbannestlv.com",
5561 + "states": [
5562 + "NV"
5563 + ],
5564 + "cities": [
5565 + "Las Vegas"
5566 + ],
5567 + "estimated_agents": 400,
5568 + "estimated_listings": 800,
5569 + "mls_affiliations": [
5570 + "LVR"
5571 + ]
5572 + },
5573 + {
5574 + "name": "Chase International",
5575 + "website": "www.chaseinternational.com",
5576 + "states": [
5577 + "NV",
5578 + "CA"
5579 + ],
5580 + "cities": [
5581 + "Lake Tahoe",
5582 + "Reno"
5583 + ],
5584 + "estimated_agents": 350,
5585 + "estimated_listings": 800,
5586 + "mls_affiliations": [
5587 + "NNRMLS"
5588 + ]
5589 + },
5590 + {
5591 + "name": "Dickson Realty",
5592 + "website": "www.dicksonrealty.com",
5593 + "states": [
5594 + "NV",
5595 + "CA"
5596 + ],
5597 + "cities": [
5598 + "Reno"
5599 + ],
5600 + "estimated_agents": 300,
5601 + "estimated_listings": 700,
5602 + "mls_affiliations": [
5603 + "NNRMLS"
5604 + ]
5605 + },
5606 + {
5607 + "name": "Ferrari-Lund Real Estate",
5608 + "website": "www.ferrari-lund.com",
5609 + "states": [
5610 + "NV"
5611 + ],
5612 + "cities": [
5613 + "Reno"
5614 + ],
5615 + "estimated_agents": 200,
5616 + "estimated_listings": 450,
5617 + "mls_affiliations": [
5618 + "NNRMLS"
5619 + ]
5620 + },
5621 + {
5622 + "name": "Realogics Sotheby's International Realty",
5623 + "website": "www.rsir.com",
5624 + "states": [
5625 + "WA"
5626 + ],
5627 + "cities": [
5628 + "Seattle",
5629 + "Bellevue"
5630 + ],
5631 + "estimated_agents": 300,
5632 + "estimated_listings": 700,
5633 + "mls_affiliations": [
5634 + "NWMLS"
5635 + ]
5636 + },
5637 + {
5638 + "name": "COMPASS Washington",
5639 + "website": "www.compass.com/wa",
5640 + "states": [
5641 + "WA"
5642 + ],
5643 + "cities": [
5644 + "Seattle"
5645 + ],
5646 + "estimated_agents": 1300,
5647 + "estimated_listings": 2400,
5648 + "mls_affiliations": [
5649 + "NWMLS"
5650 + ]
5651 + },
5652 + {
5653 + "name": "Coldwell Banker Bain",
5654 + "website": "www.coldwellbankerbain.com",
5655 + "states": [
5656 + "WA",
5657 + "OR"
5658 + ],
5659 + "cities": [
5660 + "Seattle",
5661 + "Bellevue",
5662 + "Portland"
5663 + ],
5664 + "estimated_agents": 1100,
5665 + "estimated_listings": 2400,
5666 + "mls_affiliations": [
5667 + "NWMLS",
5668 + "RMLS"
5669 + ]
5670 + },
5671 + {
5672 + "name": "RE/MAX Northwest Realtors",
5673 + "website": "www.remaxnorthwest.com",
5674 + "states": [
5675 + "WA"
5676 + ],
5677 + "cities": [
5678 + "Seattle"
5679 + ],
5680 + "estimated_agents": 300,
5681 + "estimated_listings": 650,
5682 + "mls_affiliations": [
5683 + "NWMLS"
5684 + ]
5685 + },
5686 + {
5687 + "name": "Skyline Properties",
5688 + "website": "www.skylineproperties.com",
5689 + "states": [
5690 + "WA"
5691 + ],
5692 + "cities": [
5693 + "Bellevue"
5694 + ],
5695 + "estimated_agents": 900,
5696 + "estimated_listings": 1400,
5697 + "mls_affiliations": [
5698 + "NWMLS"
5699 + ]
5700 + },
5701 + {
5702 + "name": "Marketplace Sotheby's International Realty",
5703 + "website": "www.marketplacesir.com",
5704 + "states": [
5705 + "WA"
5706 + ],
5707 + "cities": [
5708 + "Redmond"
5709 + ],
5710 + "estimated_agents": 90,
5711 + "estimated_listings": 240,
5712 + "mls_affiliations": [
5713 + "NWMLS"
5714 + ]
5715 + },
5716 + {
5717 + "name": "Lake & Company Real Estate",
5718 + "website": "www.lakere.com",
5719 + "states": [
5720 + "WA"
5721 + ],
5722 + "cities": [
5723 + "Seattle"
5724 + ],
5725 + "estimated_agents": 90,
5726 + "estimated_listings": 220,
5727 + "mls_affiliations": [
5728 + "NWMLS"
5729 + ]
5730 + },
5731 + {
5732 + "name": "Berkshire Hathaway HomeServices Northwest Real Estate",
5733 + "website": "www.bhhsnw.com",
5734 + "states": [
5735 + "WA",
5736 + "OR"
5737 + ],
5738 + "cities": [
5739 + "Portland",
5740 + "Tacoma"
5741 + ],
5742 + "estimated_agents": 800,
5743 + "estimated_listings": 1700,
5744 + "mls_affiliations": [
5745 + "RMLS",
5746 + "NWMLS"
5747 + ]
5748 + },
5749 + {
5750 + "name": "Where Real Estate (Portland)",
5751 + "website": "www.wherepdx.com",
5752 + "states": [
5753 + "OR"
5754 + ],
5755 + "cities": [
5756 + "Portland"
5757 + ],
5758 + "estimated_agents": 90,
5759 + "estimated_listings": 220,
5760 + "mls_affiliations": [
5761 + "RMLS"
5762 + ]
5763 + },
5764 + {
5765 + "name": "Living Room Realty",
5766 + "website": "www.livingroomre.com",
5767 + "states": [
5768 + "OR",
5769 + "WA"
5770 + ],
5771 + "cities": [
5772 + "Portland"
5773 + ],
5774 + "estimated_agents": 200,
5775 + "estimated_listings": 480,
5776 + "mls_affiliations": [
5777 + "RMLS"
5778 + ]
5779 + },
5780 + {
5781 + "name": "Cascade Hasson Sotheby's International Realty",
5782 + "website": "www.cascadehasson.com",
5783 + "states": [
5784 + "OR",
5785 + "WA"
5786 + ],
5787 + "cities": [
5788 + "Portland",
5789 + "Lake Oswego"
5790 + ],
5791 + "estimated_agents": 500,
5792 + "estimated_listings": 1200,
5793 + "mls_affiliations": [
5794 + "RMLS"
5795 + ]
5796 + },
5797 + {
5798 + "name": "Duke Warner Realty",
5799 + "website": "www.dukewarner.com",
5800 + "states": [
5801 + "OR"
5802 + ],
5803 + "cities": [
5804 + "Bend"
5805 + ],
5806 + "estimated_agents": 60,
5807 + "estimated_listings": 170,
5808 + "mls_affiliations": [
5809 + "COAR"
5810 + ]
5811 + },
5812 + {
5813 + "name": "Cascade Hasson SIR Bend",
5814 + "website": "www.cascadehasson.com/bend",
5815 + "states": [
5816 + "OR"
5817 + ],
5818 + "cities": [
5819 + "Bend"
5820 + ],
5821 + "estimated_agents": 90,
5822 + "estimated_listings": 260,
5823 + "mls_affiliations": [
5824 + "COAR"
5825 + ]
5826 + },
5827 + {
5828 + "name": "Windermere Central Oregon Real Estate",
5829 + "website": "www.windermerecentraloregon.com",
5830 + "states": [
5831 + "OR"
5832 + ],
5833 + "cities": [
5834 + "Bend"
5835 + ],
5836 + "estimated_agents": 90,
5837 + "estimated_listings": 250,
5838 + "mls_affiliations": [
5839 + "COAR"
5840 + ]
5841 + },
5842 + {
5843 + "name": "John L Scott Medford",
5844 + "website": "www.johnlscott.com/medford",
5845 + "states": [
5846 + "OR"
5847 + ],
5848 + "cities": [
5849 + "Medford"
5850 + ],
5851 + "estimated_agents": 90,
5852 + "estimated_listings": 240,
5853 + "mls_affiliations": [
5854 + "SOMLS"
5855 + ]
5856 + },
5857 + {
5858 + "name": "RE/MAX Dynamic Properties",
5859 + "website": "www.dynamicak.com",
5860 + "states": [
5861 + "AK"
5862 + ],
5863 + "cities": [
5864 + "Anchorage"
5865 + ],
5866 + "estimated_agents": 200,
5867 + "estimated_listings": 480,
5868 + "mls_affiliations": [
5869 + "AlaskaMLS"
5870 + ]
5871 + },
5872 + {
5873 + "name": "Keller Williams Realty Alaska Group",
5874 + "website": "www.kwalaska.com",
5875 + "states": [
5876 + "AK"
5877 + ],
5878 + "cities": [
5879 + "Anchorage"
5880 + ],
5881 + "estimated_agents": 200,
5882 + "estimated_listings": 420,
5883 + "mls_affiliations": [
5884 + "AlaskaMLS"
5885 + ]
5886 + },
5887 + {
5888 + "name": "Jack White Real Estate",
5889 + "website": "www.jackwhite.com",
5890 + "states": [
5891 + "AK"
5892 + ],
5893 + "cities": [
5894 + "Anchorage",
5895 + "Wasilla"
5896 + ],
5897 + "estimated_agents": 300,
5898 + "estimated_listings": 650,
5899 + "mls_affiliations": [
5900 + "AlaskaMLS"
5901 + ]
5902 + },
5903 + {
5904 + "name": "Hawaii Life Real Estate Brokers",
5905 + "website": "www.hawaiilife.com",
5906 + "states": [
5907 + "HI"
5908 + ],
5909 + "cities": [
5910 + "Honolulu",
5911 + "Kailua-Kona",
5912 + "Princeville"
5913 + ],
5914 + "estimated_agents": 350,
5915 + "estimated_listings": 900,
5916 + "mls_affiliations": [
5917 + "HiCentralMLS"
5918 + ]
5919 + },
5920 + {
5921 + "name": "Locations Hawaii",
5922 + "website": "www.locationshawaii.com",
5923 + "states": [
5924 + "HI"
5925 + ],
5926 + "cities": [
5927 + "Honolulu"
5928 + ],
5929 + "estimated_agents": 300,
5930 + "estimated_listings": 700,
5931 + "mls_affiliations": [
5932 + "HiCentralMLS"
5933 + ]
5934 + },
5935 + {
5936 + "name": "List Sotheby's International Realty Hawaii",
5937 + "website": "www.listsothebysrealty.com",
5938 + "states": [
5939 + "HI"
5940 + ],
5941 + "cities": [
5942 + "Honolulu"
5943 + ],
5944 + "estimated_agents": 200,
5945 + "estimated_listings": 480,
5946 + "mls_affiliations": [
5947 + "HiCentralMLS"
5948 + ]
5949 + },
5950 + {
5951 + "name": "Coldwell Banker Island Properties",
5952 + "website": "www.cbislandproperties.com",
5953 + "states": [
5954 + "HI"
5955 + ],
5956 + "cities": [
5957 + "Maui",
5958 + "Wailea"
5959 + ],
5960 + "estimated_agents": 200,
5961 + "estimated_listings": 500,
5962 + "mls_affiliations": [
5963 + "REALTORS ACM"
5964 + ]
5965 + },
5966 + {
5967 + "name": "Elite Pacific Properties (Compass Hawaii)",
5968 + "website": "www.elitepacific.com",
5969 + "states": [
5970 + "HI"
5971 + ],
5972 + "cities": [
5973 + "Honolulu",
5974 + "Maui"
5975 + ],
5976 + "estimated_agents": 200,
5977 + "estimated_listings": 450,
5978 + "mls_affiliations": [
5979 + "HiCentralMLS"
5980 + ]
5981 + },
5982 + {
5983 + "name": "Chinowth & Cohen Realtors",
5984 + "website": "www.ccoklahoma.com",
5985 + "states": [
5986 + "OK"
5987 + ],
5988 + "cities": [
5989 + "Tulsa",
5990 + "Oklahoma City"
5991 + ],
5992 + "estimated_agents": 900,
5993 + "estimated_listings": 1900,
5994 + "mls_affiliations": [
5995 + "MLSOK",
5996 + "NORES"
5997 + ]
5998 + },
5999 + {
6000 + "name": "McGraw Realtors",
6001 + "website": "www.mcgrawrealtors.com",
6002 + "states": [
6003 + "OK"
6004 + ],
6005 + "cities": [
6006 + "Tulsa"
6007 + ],
6008 + "estimated_agents": 700,
6009 + "estimated_listings": 1500,
6010 + "mls_affiliations": [
6011 + "NORES"
6012 + ]
6013 + },
6014 + {
6015 + "name": "Keller Williams Realty Elite OKC",
6016 + "website": "www.kwokc.com",
6017 + "states": [
6018 + "OK"
6019 + ],
6020 + "cities": [
6021 + "Oklahoma City"
6022 + ],
6023 + "estimated_agents": 500,
6024 + "estimated_listings": 1000,
6025 + "mls_affiliations": [
6026 + "MLSOK"
6027 + ]
6028 + },
6029 + {
6030 + "name": "Sage Sotheby's International Realty",
6031 + "website": "www.sagesir.com",
6032 + "states": [
6033 + "OK"
6034 + ],
6035 + "cities": [
6036 + "Oklahoma City"
6037 + ],
6038 + "estimated_agents": 60,
6039 + "estimated_listings": 160,
6040 + "mls_affiliations": [
6041 + "MLSOK"
6042 + ]
6043 + },
6044 + {
6045 + "name": "Verbode",
6046 + "website": "www.verbode.com",
6047 + "states": [
6048 + "OK"
6049 + ],
6050 + "cities": [
6051 + "Oklahoma City"
6052 + ],
6053 + "estimated_agents": 40,
6054 + "estimated_listings": 110,
6055 + "mls_affiliations": [
6056 + "MLSOK"
6057 + ]
6058 + },
6059 + {
6060 + "name": "Crye-Leike Arkansas",
6061 + "website": "www.crye-leike.com/ar",
6062 + "states": [
6063 + "AR"
6064 + ],
6065 + "cities": [
6066 + "Little Rock"
6067 + ],
6068 + "estimated_agents": 400,
6069 + "estimated_listings": 900,
6070 + "mls_affiliations": [
6071 + "CARMLS"
6072 + ]
6073 + },
6074 + {
6075 + "name": "The Charlotte John Company",
6076 + "website": "www.charlottejohn.com",
6077 + "states": [
6078 + "AR"
6079 + ],
6080 + "cities": [
6081 + "Little Rock"
6082 + ],
6083 + "estimated_agents": 90,
6084 + "estimated_listings": 230,
6085 + "mls_affiliations": [
6086 + "CARMLS"
6087 + ]
6088 + },
6089 + {
6090 + "name": "The Janet Jones Company",
6091 + "website": "www.janetjones.com",
6092 + "states": [
6093 + "AR"
6094 + ],
6095 + "cities": [
6096 + "Little Rock"
6097 + ],
6098 + "estimated_agents": 90,
6099 + "estimated_listings": 230,
6100 + "mls_affiliations": [
6101 + "CARMLS"
6102 + ]
6103 + },
6104 + {
6105 + "name": "Lindsey & Associates",
6106 + "website": "www.lindsey.com",
6107 + "states": [
6108 + "AR"
6109 + ],
6110 + "cities": [
6111 + "Fayetteville",
6112 + "Rogers"
6113 + ],
6114 + "estimated_agents": 500,
6115 + "estimated_listings": 1100,
6116 + "mls_affiliations": [
6117 + "NWAMLS"
6118 + ]
6119 + },
6120 + {
6121 + "name": "Collier & Associates NWA",
6122 + "website": "www.collierandassociates.com",
6123 + "states": [
6124 + "AR"
6125 + ],
6126 + "cities": [
6127 + "Bentonville"
6128 + ],
6129 + "estimated_agents": 130,
6130 + "estimated_listings": 340,
6131 + "mls_affiliations": [
6132 + "NWAMLS"
6133 + ]
6134 + },
6135 + {
6136 + "name": "Old Colony Realtors",
6137 + "website": "www.oldcolony.com",
6138 + "states": [
6139 + "WV"
6140 + ],
6141 + "cities": [
6142 + "Charleston"
6143 + ],
6144 + "estimated_agents": 200,
6145 + "estimated_listings": 480,
6146 + "mls_affiliations": [
6147 + "KVBRMLS"
6148 + ]
6149 + },
6150 + {
6151 + "name": "Real Estate Central WV",
6152 + "website": "www.recentralwv.com",
6153 + "states": [
6154 + "WV"
6155 + ],
6156 + "cities": [
6157 + "Morgantown"
6158 + ],
6159 + "estimated_agents": 60,
6160 + "estimated_listings": 160,
6161 + "mls_affiliations": [
6162 + "NCWVMLS"
6163 + ]
6164 + },
6165 + {
6166 + "name": "Ben Kinney Real Estate Team (PLACE)",
6167 + "website": "www.benkinneyteam.com",
6168 + "states": [
6169 + "WA"
6170 + ],
6171 + "cities": [
6172 + "Bellingham",
6173 + "Seattle"
6174 + ],
6175 + "estimated_agents": 100,
6176 + "estimated_listings": 400,
6177 + "mls_affiliations": [
6178 + "NWMLS"
6179 + ]
6180 + },
6181 + {
6182 + "name": "Mark Spain Real Estate",
6183 + "website": "www.markspain.com",
6184 + "states": [
6185 + "GA",
6186 + "NC",
6187 + "TN",
6188 + "FL",
6189 + "TX",
6190 + "AL",
6191 + "SC"
6192 + ],
6193 + "cities": [
6194 + "Atlanta"
6195 + ],
6196 + "estimated_agents": 400,
6197 + "estimated_listings": 1200,
6198 + "mls_affiliations": [
6199 + "FMLS"
6200 + ]
6201 + },
6202 + {
6203 + "name": "The Loken Group",
6204 + "website": "www.thelokengroup.com",
6205 + "states": [
6206 + "TX"
6207 + ],
6208 + "cities": [
6209 + "Houston"
6210 + ],
6211 + "estimated_agents": 150,
6212 + "estimated_listings": 500,
6213 + "mls_affiliations": [
6214 + "HAR"
6215 + ]
6216 + },
6217 + {
6218 + "name": "JPAR Real Estate",
6219 + "website": "www.jpar.com",
6220 + "states": [
6221 + "TX",
6222 + "FL",
6223 + "GA",
6224 + "NC",
6225 + "SC",
6226 + "AZ",
6227 + "CO",
6228 + "LA",
6229 + "OK",
6230 + "TN",
6231 + "MD",
6232 + "NJ",
6233 + "NM"
6234 + ],
6235 + "cities": [
6236 + "Plano"
6237 + ],
6238 + "estimated_agents": 4000,
6239 + "estimated_listings": 6500,
6240 + "mls_affiliations": [
6241 + "NTREIS"
6242 + ]
6243 + },
6244 + {
6245 + "name": "Side Inc (platform brokerages)",
6246 + "website": "www.side.com",
6247 + "states": [
6248 + "CA",
6249 + "TX",
6250 + "FL",
6251 + "NY",
6252 + "WA",
6253 + "AZ",
6254 + "CO",
6255 + "GA"
6256 + ],
6257 + "cities": [
6258 + "San Francisco"
6259 + ],
6260 + "estimated_agents": 3000,
6261 + "estimated_listings": 6000,
6262 + "mls_affiliations": []
6263 + },
6264 + {
6265 + "name": "Radius Agent Realty",
6266 + "website": "www.radiusagent.com",
6267 + "states": [
6268 + "CA",
6269 + "TX",
6270 + "FL",
6271 + "CO",
6272 + "GA",
6273 + "WA",
6274 + "OR",
6275 + "AZ"
6276 + ],
6277 + "cities": [
6278 + "San Francisco"
6279 + ],
6280 + "estimated_agents": 900,
6281 + "estimated_listings": 1500,
6282 + "mls_affiliations": []
6283 + },
6284 + {
6285 + "name": "Homie",
6286 + "website": "www.homie.com",
6287 + "states": [
6288 + "UT",
6289 + "AZ",
6290 + "NV",
6291 + "ID",
6292 + "CO"
6293 + ],
6294 + "cities": [
6295 + "Salt Lake City"
6296 + ],
6297 + "estimated_agents": 150,
6298 + "estimated_listings": 450,
6299 + "mls_affiliations": [
6300 + "UtahRealEstate"
6301 + ]
6302 + },
6303 + {
6304 + "name": "Houwzer (rebranded)",
6305 + "website": "www.houwzer.com",
6306 + "states": [
6307 + "PA",
6308 + "NJ",
6309 + "MD",
6310 + "DC",
6311 + "VA",
6312 + "FL"
6313 + ],
6314 + "cities": [
6315 + "Philadelphia"
6316 + ],
6317 + "estimated_agents": 150,
6318 + "estimated_listings": 400,
6319 + "mls_affiliations": [
6320 + "BRIGHT"
6321 + ]
6322 + },
6323 + {
6324 + "name": "Prevu Real Estate",
6325 + "website": "www.prevu.com",
6326 + "states": [
6327 + "NY",
6328 + "CT",
6329 + "MA",
6330 + "PA",
6331 + "CA",
6332 + "CO",
6333 + "WA",
6334 + "TX",
6335 + "FL"
6336 + ],
6337 + "cities": [
6338 + "New York"
6339 + ],
6340 + "estimated_agents": 100,
6341 + "estimated_listings": 300,
6342 + "mls_affiliations": []
6343 + },
6344 + {
6345 + "name": "Homecoin",
6346 + "website": "www.homecoin.com",
6347 + "states": [
6348 + "CA",
6349 + "FL",
6350 + "GA",
6351 + "MI",
6352 + "MO",
6353 + "NY",
6354 + "NC",
6355 + "SC",
6356 + "TN",
6357 + "TX",
6358 + "VA",
6359 + "WA",
6360 + "WI"
6361 + ],
6362 + "cities": [
6363 + "San Diego"
6364 + ],
6365 + "estimated_agents": 20,
6366 + "estimated_listings": 900,
6367 + "mls_affiliations": []
6368 + },
6369 + {
6370 + "name": "beycome",
6371 + "website": "www.beycome.com",
6372 + "states": [
6373 + "FL",
6374 + "GA",
6375 + "AL",
6376 + "TX",
6377 + "CA",
6378 + "IL",
6379 + "NC",
6380 + "SC",
6381 + "MN"
6382 + ],
6383 + "cities": [
6384 + "Miami"
6385 + ],
6386 + "estimated_agents": 30,
6387 + "estimated_listings": 1200,
6388 + "mls_affiliations": []
6389 + },
6390 + {
6391 + "name": "HomeLister",
6392 + "website": "www.homelister.com",
6393 + "states": [
6394 + "CA",
6395 + "WA",
6396 + "OR",
6397 + "CO",
6398 + "TX",
6399 + "FL",
6400 + "GA",
6401 + "NC",
6402 + "SC",
6403 + "PA",
6404 + "NJ",
6405 + "NY",
6406 + "MD",
6407 + "VA",
6408 + "MI",
6409 + "MN",
6410 + "MO",
6411 + "TN"
6412 + ],
6413 + "cities": [
6414 + "Los Angeles"
6415 + ],
6416 + "estimated_agents": 25,
6417 + "estimated_listings": 700,
6418 + "mls_affiliations": []
6419 + },
6420 + {
6421 + "name": "Flat Fee Group",
6422 + "website": "www.flatfeegroup.com",
6423 + "states": [],
6424 + "cities": [],
6425 + "estimated_agents": 50,
6426 + "estimated_listings": 900,
6427 + "mls_affiliations": []
6428 + },
6429 + {
6430 + "name": "ISoldMyHouse.com",
6431 + "website": "www.isoldmyhouse.com",
6432 + "states": [],
6433 + "cities": [],
6434 + "estimated_agents": 20,
6435 + "estimated_listings": 600,
6436 + "mls_affiliations": []
6437 + },
6438 + {
6439 + "name": "Redefy Real Estate",
6440 + "website": "www.redefy.com",
6441 + "states": [
6442 + "CO",
6443 + "TX",
6444 + "FL",
6445 + "GA",
6446 + "NC",
6447 + "SC",
6448 + "TN",
6449 + "VA"
6450 + ],
6451 + "cities": [
6452 + "Denver"
6453 + ],
6454 + "estimated_agents": 100,
6455 + "estimated_listings": 300,
6456 + "mls_affiliations": [
6457 + "REcolorado"
6458 + ]
6459 + },
6460 + {
6461 + "name": "Door.com",
6462 + "website": "www.door.com",
6463 + "states": [
6464 + "TX"
6465 + ],
6466 + "cities": [
6467 + "Dallas"
6468 + ],
6469 + "estimated_agents": 60,
6470 + "estimated_listings": 200,
6471 + "mls_affiliations": [
6472 + "NTREIS"
6473 + ]
6474 + },
6475 + {
6476 + "name": "Central Texas flat-fee: Creekview Realty",
6477 + "website": "www.creekviewrealty.com",
6478 + "states": [
6479 + "TX"
6480 + ],
6481 + "cities": [
6482 + "Dallas",
6483 + "Austin",
6484 + "Houston"
6485 + ],
6486 + "estimated_agents": 10,
6487 + "estimated_listings": 400,
6488 + "mls_affiliations": [
6489 + "NTREIS"
6490 + ]
6491 + },
6492 + {
6493 + "name": "Coldwell Banker Howard Perry and Walston Builder Services",
6494 + "website": "www.hpwnewhomes.com",
6495 + "states": [
6496 + "NC"
6497 + ],
6498 + "cities": [
6499 + "Raleigh"
6500 + ],
6501 + "estimated_agents": 100,
6502 + "estimated_listings": 300,
6503 + "mls_affiliations": [
6504 + "TriangleMLS"
6505 + ]
6506 + },
6507 + {
6508 + "name": "Coach House Realty (Long Island)",
6509 + "website": "www.coachrealtors.com",
6510 + "states": [
6511 + "NY"
6512 + ],
6513 + "cities": [
6514 + "Northport",
6515 + "Long Island"
6516 + ],
6517 + "estimated_agents": 600,
6518 + "estimated_listings": 1300,
6519 + "mls_affiliations": [
6520 + "OneKey"
6521 + ]
6522 + },
6523 + {
6524 + "name": "Signature Premier Properties",
6525 + "website": "www.signaturepremier.com",
6526 + "states": [
6527 + "NY"
6528 + ],
6529 + "cities": [
6530 + "Long Island"
6531 + ],
6532 + "estimated_agents": 1300,
6533 + "estimated_listings": 2400,
6534 + "mls_affiliations": [
6535 + "OneKey"
6536 + ]
6537 + },
6538 + {
6539 + "name": "Douglas Elliman Long Island",
6540 + "website": "www.elliman.com/longisland",
6541 + "states": [
6542 + "NY"
6543 + ],
6544 + "cities": [
6545 + "Long Island"
6546 + ],
6547 + "estimated_agents": 1300,
6548 + "estimated_listings": 2600,
6549 + "mls_affiliations": [
6550 + "OneKey"
6551 + ]
6552 + },
6553 + {
6554 + "name": "Howard Hanna Rand Realty",
6555 + "website": "www.randrealty.com",
6556 + "states": [
6557 + "NY",
6558 + "NJ",
6559 + "CT"
6560 + ],
6561 + "cities": [
6562 + "White Plains",
6563 + "Nanuet"
6564 + ],
6565 + "estimated_agents": 1100,
6566 + "estimated_listings": 2300,
6567 + "mls_affiliations": [
6568 + "OneKey"
6569 + ]
6570 + },
6571 + {
6572 + "name": "Hunt Real Estate ERA",
6573 + "website": "www.huntrealestate.com",
6574 + "states": [
6575 + "NY",
6576 + "AZ"
6577 + ],
6578 + "cities": [
6579 + "Buffalo",
6580 + "Rochester",
6581 + "Syracuse"
6582 + ],
6583 + "estimated_agents": 1400,
6584 + "estimated_listings": 3000,
6585 + "mls_affiliations": [
6586 + "NYSAMLSs"
6587 + ]
6588 + },
6589 + {
6590 + "name": "Howard Hanna WNY",
6591 + "website": "www.howardhanna.com/ny",
6592 + "states": [
6593 + "NY"
6594 + ],
6595 + "cities": [
6596 + "Buffalo",
6597 + "Rochester"
6598 + ],
6599 + "estimated_agents": 900,
6600 + "estimated_listings": 2100,
6601 + "mls_affiliations": [
6602 + "NYSAMLSs"
6603 + ]
6604 + },
6605 + {
6606 + "name": "Keller Williams Realty Greater Rochester",
6607 + "website": "www.kwrochester.com",
6608 + "states": [
6609 + "NY"
6610 + ],
6611 + "cities": [
6612 + "Rochester"
6613 + ],
6614 + "estimated_agents": 300,
6615 + "estimated_listings": 650,
6616 + "mls_affiliations": [
6617 + "GENRIS"
6618 + ]
6619 + },
6620 + {
6621 + "name": "Berkshire Hathaway HomeServices CNY Realty",
6622 + "website": "www.bhhscnyrealty.com",
6623 + "states": [
6624 + "NY"
6625 + ],
6626 + "cities": [
6627 + "Syracuse"
6628 + ],
6629 + "estimated_agents": 130,
6630 + "estimated_listings": 320,
6631 + "mls_affiliations": [
6632 + "CNYIS"
6633 + ]
6634 + },
6635 + {
6636 + "name": "Select Sotheby's International Realty (ADK/Saratoga)",
6637 + "website": "www.selectsothebysrealty.com",
6638 + "states": [
6639 + "NY"
6640 + ],
6641 + "cities": [
6642 + "Saratoga Springs",
6643 + "Lake Placid"
6644 + ],
6645 + "estimated_agents": 90,
6646 + "estimated_listings": 260,
6647 + "mls_affiliations": [
6648 + "GlobalMLS"
6649 + ]
6650 + },
6651 + {
6652 + "name": "Miranda Real Estate Group",
6653 + "website": "www.mrgteam.com",
6654 + "states": [
6655 + "NY"
6656 + ],
6657 + "cities": [
6658 + "Albany",
6659 + "Clifton Park"
6660 + ],
6661 + "estimated_agents": 200,
6662 + "estimated_listings": 480,
6663 + "mls_affiliations": [
6664 + "GlobalMLS"
6665 + ]
6666 + },
6667 + {
6668 + "name": "Howard Hanna Capital Region",
6669 + "website": "www.howardhanna.com/albany",
6670 + "states": [
6671 + "NY"
6672 + ],
6673 + "cities": [
6674 + "Albany"
6675 + ],
6676 + "estimated_agents": 200,
6677 + "estimated_listings": 500,
6678 + "mls_affiliations": [
6679 + "GlobalMLS"
6680 + ]
6681 + },
6682 + {
6683 + "name": "Vylla Home",
6684 + "website": "www.vylla.com",
6685 + "states": [],
6686 + "cities": [],
6687 + "estimated_agents": 2000,
6688 + "estimated_listings": 3500,
6689 + "mls_affiliations": []
6690 + },
6691 + {
6692 + "name": "Radius Realty Group DMV",
6693 + "website": "www.radiusrealtygroup.com",
6694 + "states": [
6695 + "DC",
6696 + "MD",
6697 + "VA"
6698 + ],
6699 + "cities": [
6700 + "Washington"
6701 + ],
6702 + "estimated_agents": 60,
6703 + "estimated_listings": 160,
6704 + "mls_affiliations": [
6705 + "BRIGHT"
6706 + ]
6707 + },
6708 + {
6709 + "name": "Real Broker Northeast",
6710 + "website": "www.onereal.com/northeast",
6711 + "states": [
6712 + "NY",
6713 + "NJ",
6714 + "CT",
6715 + "MA"
6716 + ],
6717 + "cities": [],
6718 + "estimated_agents": 2000,
6719 + "estimated_listings": 3200,
6720 + "mls_affiliations": []
6721 + },
6722 + {
6723 + "name": "Realty of America",
6724 + "website": "www.realtyofamerica.com",
6725 + "states": [
6726 + "TX",
6727 + "FL",
6728 + "GA",
6729 + "NC",
6730 + "SC",
6731 + "TN",
6732 + "OH",
6733 + "AZ"
6734 + ],
6735 + "cities": [
6736 + "Houston"
6737 + ],
6738 + "estimated_agents": 2500,
6739 + "estimated_listings": 3800,
6740 + "mls_affiliations": [
6741 + "HAR"
6742 + ]
6743 + },
6744 + {
6745 + "name": "Epique Realty",
6746 + "website": "www.epiquerealty.com",
6747 + "states": [
6748 + "TX",
6749 + "FL",
6750 + "CA",
6751 + "GA",
6752 + "NC",
6753 + "CO",
6754 + "AZ",
6755 + "MI",
6756 + "OH",
6757 + "TN"
6758 + ],
6759 + "cities": [
6760 + "Houston"
6761 + ],
6762 + "estimated_agents": 4000,
6763 + "estimated_listings": 6000,
6764 + "mls_affiliations": []
6765 + },
6766 + {
6767 + "name": "Worth Clark Realty",
6768 + "website": "www.worthclark.com",
6769 + "states": [
6770 + "MO",
6771 + "IL",
6772 + "TX",
6773 + "CO",
6774 + "KS",
6775 + "IN",
6776 + "OH",
6777 + "VA",
6778 + "AZ",
6779 + "NM",
6780 + "FL"
6781 + ],
6782 + "cities": [
6783 + "Saint Louis"
6784 + ],
6785 + "estimated_agents": 900,
6786 + "estimated_listings": 1500,
6787 + "mls_affiliations": [
6788 + "MARIS"
6789 + ]
6790 + },
6791 + {
6792 + "name": "SimpleShowing",
6793 + "website": "www.simpleshowing.com",
6794 + "states": [
6795 + "FL",
6796 + "GA",
6797 + "TX"
6798 + ],
6799 + "cities": [
6800 + "Atlanta"
6801 + ],
6802 + "estimated_agents": 40,
6803 + "estimated_listings": 160,
6804 + "mls_affiliations": [
6805 + "FMLS"
6806 + ]
6807 + },
6808 + {
6809 + "name": "1 Percent Lists",
6810 + "website": "www.1percentlists.com",
6811 + "states": [
6812 + "LA",
6813 + "TX",
6814 + "FL",
6815 + "GA",
6816 + "AL",
6817 + "MS",
6818 + "TN",
6819 + "NC",
6820 + "SC",
6821 + "OH",
6822 + "KY",
6823 + "MO",
6824 + "CO"
6825 + ],
6826 + "cities": [
6827 + "Covington"
6828 + ],
6829 + "estimated_agents": 300,
6830 + "estimated_listings": 900,
6831 + "mls_affiliations": [
6832 + "GSREIN"
6833 + ]
6834 + },
6835 + {
6836 + "name": "Assist-2-Sell (network)",
6837 + "website": "www.assist2sell.com",
6838 + "states": [],
6839 + "cities": [
6840 + "Reno"
6841 + ],
6842 + "estimated_agents": 600,
6843 + "estimated_listings": 1500,
6844 + "mls_affiliations": []
6845 + },
6846 + {
6847 + "name": "Help-U-Sell Real Estate",
6848 + "website": "www.helpusell.com",
6849 + "states": [],
6850 + "cities": [],
6851 + "estimated_agents": 400,
6852 + "estimated_listings": 1000,
6853 + "mls_affiliations": []
6854 + },
6855 + {
6856 + "name": "ByOwner.com",
6857 + "website": "www.byowner.com",
6858 + "states": [],
6859 + "cities": [],
6860 + "estimated_agents": 20,
6861 + "estimated_listings": 1500,
6862 + "mls_affiliations": []
6863 + },
6864 + {
6865 + "name": "Unreal Estate",
6866 + "website": "www.unrealestate.com",
6867 + "states": [],
6868 + "cities": [],
6869 + "estimated_agents": 50,
6870 + "estimated_listings": 1200,
6871 + "mls_affiliations": []
6872 + },
6873 + {
6874 + "name": "Ideal Agent (referral+listings)",
6875 + "website": "www.idealagent.com",
6876 + "states": [],
6877 + "cities": [
6878 + "Tampa"
6879 + ],
6880 + "estimated_agents": 100,
6881 + "estimated_listings": 500,
6882 + "mls_affiliations": []
6883 + },
6884 + {
6885 + "name": "Coldwell Banker Kappel Gateway Realty",
6886 + "website": "www.kappelgateway.com",
6887 + "states": [
6888 + "CA"
6889 + ],
6890 + "cities": [
6891 + "Fairfield",
6892 + "Vacaville"
6893 + ],
6894 + "estimated_agents": 300,
6895 + "estimated_listings": 650,
6896 + "mls_affiliations": [
6897 + "BAREIS"
6898 + ]
6899 + },
6900 + {
6901 + "name": "RE/MAX Gold",
6902 + "website": "www.remaxgold.com",
6903 + "states": [
6904 + "CA"
6905 + ],
6906 + "cities": [
6907 + "Sacramento",
6908 + "Roseville"
6909 + ],
6910 + "estimated_agents": 1500,
6911 + "estimated_listings": 2800,
6912 + "mls_affiliations": [
6913 + "Metrolist"
6914 + ]
6915 + },
6916 + {
6917 + "name": "Century 21 Real Estate Alliance",
6918 + "website": "www.c21realestatealliance.com",
6919 + "states": [
6920 + "CA"
6921 + ],
6922 + "cities": [
6923 + "San Francisco",
6924 + "San Mateo"
6925 + ],
6926 + "estimated_agents": 800,
6927 + "estimated_listings": 1300,
6928 + "mls_affiliations": [
6929 + "SFAR"
6930 + ]
6931 + },
6932 + {
6933 + "name": "Redfin California",
6934 + "website": "www.redfin.com/state/california",
6935 + "states": [
6936 + "CA"
6937 + ],
6938 + "cities": [],
6939 + "estimated_agents": 500,
6940 + "estimated_listings": 2500,
6941 + "mls_affiliations": [
6942 + "CRMLS"
6943 + ]
6944 + },
6945 + {
6946 + "name": "Douglas Elliman California",
6947 + "website": "www.elliman.com/california",
6948 + "states": [
6949 + "CA"
6950 + ],
6951 + "cities": [
6952 + "Beverly Hills",
6953 + "Newport Beach"
6954 + ],
6955 + "estimated_agents": 500,
6956 + "estimated_listings": 1100,
6957 + "mls_affiliations": [
6958 + "CRMLS"
6959 + ]
6960 + },
6961 + {
6962 + "name": "Christie's International Real Estate Sereno Tahoe",
6963 + "website": "www.serenotahoe.com",
6964 + "states": [
6965 + "CA",
6966 + "NV"
6967 + ],
6968 + "cities": [
6969 + "Truckee"
6970 + ],
6971 + "estimated_agents": 100,
6972 + "estimated_listings": 280,
6973 + "mls_affiliations": [
6974 + "TahoeSierraMLS"
6975 + ]
6976 + },
6977 + {
6978 + "name": "Coldwell Banker Realty SoCal",
6979 + "website": "www.coldwellbankerhomes.com/ca",
6980 + "states": [
6981 + "CA"
6982 + ],
6983 + "cities": [
6984 + "Los Angeles",
6985 + "San Diego"
6986 + ],
6987 + "estimated_agents": 5000,
6988 + "estimated_listings": 9000,
6989 + "mls_affiliations": [
6990 + "CRMLS"
6991 + ]
6992 + },
6993 + {
6994 + "name": "Keller Williams Beverly Hills",
6995 + "website": "www.kwbh.com",
6996 + "states": [
6997 + "CA"
6998 + ],
6999 + "cities": [
7000 + "Beverly Hills"
7001 + ],
7002 + "estimated_agents": 700,
7003 + "estimated_listings": 1100,
7004 + "mls_affiliations": [
7005 + "CRMLS"
7006 + ]
7007 + },
7008 + {
7009 + "name": "Keller Williams Silicon Valley",
7010 + "website": "www.kwsvr.com",
7011 + "states": [
7012 + "CA"
7013 + ],
7014 + "cities": [
7015 + "San Jose",
7016 + "Cupertino"
7017 + ],
7018 + "estimated_agents": 600,
7019 + "estimated_listings": 950,
7020 + "mls_affiliations": [
7021 + "MLSListings"
7022 + ]
7023 + },
7024 + {
7025 + "name": "Redfin Corporation HQ brokerage",
7026 + "website": "www.redfin.com/about",
7027 + "states": [
7028 + "WA"
7029 + ],
7030 + "cities": [
7031 + "Seattle"
7032 + ],
7033 + "estimated_agents": 100,
7034 + "estimated_listings": 300,
7035 + "mls_affiliations": [
7036 + "NWMLS"
7037 + ]
7038 + },
7039 + {
7040 + "name": "Excellence Real Estate (SoCal)",
7041 + "website": "www.excellencere.com",
7042 + "states": [
7043 + "CA"
7044 + ],
7045 + "cities": [
7046 + "Los Angeles",
7047 + "Riverside"
7048 + ],
7049 + "estimated_agents": 900,
7050 + "estimated_listings": 1400,
7051 + "mls_affiliations": [
7052 + "CRMLS"
7053 + ]
7054 + },
7055 + {
7056 + "name": "Realty ONE Group West",
7057 + "website": "www.realtyonegroupwest.com",
7058 + "states": [
7059 + "CA"
7060 + ],
7061 + "cities": [
7062 + "Irvine"
7063 + ],
7064 + "estimated_agents": 1500,
7065 + "estimated_listings": 2300,
7066 + "mls_affiliations": [
7067 + "CRMLS"
7068 + ]
7069 + },
7070 + {
7071 + "name": "Pinnacle Estate Properties",
7072 + "website": "www.pinnacleestate.com",
7073 + "states": [
7074 + "CA"
7075 + ],
7076 + "cities": [
7077 + "Northridge",
7078 + "Ventura"
7079 + ],
7080 + "estimated_agents": 600,
7081 + "estimated_listings": 1100,
7082 + "mls_affiliations": [
7083 + "CRMLS"
7084 + ]
7085 + },
7086 + {
7087 + "name": "Realty Executives Santa Clarita",
7088 + "website": "www.realtyexecutivesscv.com",
7089 + "states": [
7090 + "CA"
7091 + ],
7092 + "cities": [
7093 + "Santa Clarita"
7094 + ],
7095 + "estimated_agents": 200,
7096 + "estimated_listings": 420,
7097 + "mls_affiliations": [
7098 + "CRMLS"
7099 + ]
7100 + },
7101 + {
7102 + "name": "Compass San Diego",
7103 + "website": "www.compass.com/sandiego",
7104 + "states": [
7105 + "CA"
7106 + ],
7107 + "cities": [
7108 + "San Diego"
7109 + ],
7110 + "estimated_agents": 900,
7111 + "estimated_listings": 1600,
7112 + "mls_affiliations": [
7113 + "SDMLS"
7114 + ]
7115 + },
7116 + {
7117 + "name": "Better Homes and Gardens RE HomeCity",
7118 + "website": "www.homecity.com",
7119 + "states": [
7120 + "TX"
7121 + ],
7122 + "cities": [
7123 + "Austin",
7124 + "San Antonio"
7125 + ],
7126 + "estimated_agents": 300,
7127 + "estimated_listings": 700,
7128 + "mls_affiliations": [
7129 + "ACTRIS"
7130 + ]
7131 + },
7132 + {
7133 + "name": "Coldwell Banker D'Ann Harper Realtors",
7134 + "website": "www.cbharper.com",
7135 + "states": [
7136 + "TX"
7137 + ],
7138 + "cities": [
7139 + "San Antonio"
7140 + ],
7141 + "estimated_agents": 400,
7142 + "estimated_listings": 900,
7143 + "mls_affiliations": [
7144 + "SABOR"
7145 + ]
7146 + },
7147 + {
7148 + "name": "RE/MAX North San Antonio",
7149 + "website": "www.remax-northsa.com",
7150 + "states": [
7151 + "TX"
7152 + ],
7153 + "cities": [
7154 + "San Antonio"
7155 + ],
7156 + "estimated_agents": 130,
7157 + "estimated_listings": 320,
7158 + "mls_affiliations": [
7159 + "SABOR"
7160 + ]
7161 + },
7162 + {
7163 + "name": "Redfin Dallas",
7164 + "website": "www.redfin.com/city/30794/tx/dallas",
7165 + "states": [
7166 + "TX"
7167 + ],
7168 + "cities": [
7169 + "Dallas"
7170 + ],
7171 + "estimated_agents": 60,
7172 + "estimated_listings": 350,
7173 + "mls_affiliations": [
7174 + "NTREIS"
7175 + ]
7176 + },
7177 + {
7178 + "name": "Keller Williams Dallas Preston Road",
7179 + "website": "www.kwdallas.com",
7180 + "states": [
7181 + "TX"
7182 + ],
7183 + "cities": [
7184 + "Dallas"
7185 + ],
7186 + "estimated_agents": 700,
7187 + "estimated_listings": 1100,
7188 + "mls_affiliations": [
7189 + "NTREIS"
7190 + ]
7191 + },
7192 + {
7193 + "name": "CENTURY 21 Judge Fite Company",
7194 + "website": "www.c21judgefite.com",
7195 + "states": [
7196 + "TX"
7197 + ],
7198 + "cities": [
7199 + "Dallas",
7200 + "Fort Worth"
7201 + ],
7202 + "estimated_agents": 900,
7203 + "estimated_listings": 1800,
7204 + "mls_affiliations": [
7205 + "NTREIS"
7206 + ]
7207 + },
7208 + {
7209 + "name": "RE/MAX Trinity Fort Worth",
7210 + "website": "www.remaxtrinitytx.com",
7211 + "states": [
7212 + "TX"
7213 + ],
7214 + "cities": [
7215 + "Fort Worth"
7216 + ],
7217 + "estimated_agents": 130,
7218 + "estimated_listings": 320,
7219 + "mls_affiliations": [
7220 + "NTREIS"
7221 + ]
7222 + },
7223 + {
7224 + "name": "Keller Williams Houston Memorial",
7225 + "website": "www.kwmemorial.com",
7226 + "states": [
7227 + "TX"
7228 + ],
7229 + "cities": [
7230 + "Houston"
7231 + ],
7232 + "estimated_agents": 800,
7233 + "estimated_listings": 1300,
7234 + "mls_affiliations": [
7235 + "HAR"
7236 + ]
7237 + },
7238 + {
7239 + "name": "RE/MAX The Woodlands & Spring",
7240 + "website": "www.thewoodlandstx.com",
7241 + "states": [
7242 + "TX"
7243 + ],
7244 + "cities": [
7245 + "The Woodlands"
7246 + ],
7247 + "estimated_agents": 200,
7248 + "estimated_listings": 480,
7249 + "mls_affiliations": [
7250 + "HAR"
7251 + ]
7252 + },
7253 + {
7254 + "name": "Perry Homes Realty",
7255 + "website": "www.perryhomes.com",
7256 + "states": [
7257 + "TX"
7258 + ],
7259 + "cities": [
7260 + "Houston",
7261 + "San Antonio"
7262 + ],
7263 + "estimated_agents": 100,
7264 + "estimated_listings": 900,
7265 + "mls_affiliations": [
7266 + "HAR"
7267 + ]
7268 + },
7269 + {
7270 + "name": "Coldwell Banker United Realtors Texas",
7271 + "website": "www.cbunited.com",
7272 + "states": [
7273 + "TX"
7274 + ],
7275 + "cities": [
7276 + "Houston"
7277 + ],
7278 + "estimated_agents": 1000,
7279 + "estimated_listings": 2000,
7280 + "mls_affiliations": [
7281 + "HAR"
7282 + ]
7283 + },
7284 + {
7285 + "name": "Side + Legacy: Nan and Company Properties",
7286 + "website": "www.nanproperties.com",
7287 + "states": [
7288 + "TX"
7289 + ],
7290 + "cities": [
7291 + "Houston"
7292 + ],
7293 + "estimated_agents": 200,
7294 + "estimated_listings": 500,
7295 + "mls_affiliations": [
7296 + "HAR"
7297 + ]
7298 + },
7299 + {
7300 + "name": "Magnolia Realty (Waco)",
7301 + "website": "www.magnoliarealty.com",
7302 + "states": [
7303 + "TX"
7304 + ],
7305 + "cities": [
7306 + "Waco",
7307 + "Dallas"
7308 + ],
7309 + "estimated_agents": 300,
7310 + "estimated_listings": 650,
7311 + "mls_affiliations": [
7312 + "WacoMLS"
7313 + ]
7314 + },
7315 + {
7316 + "name": "Keller Williams El Paso",
7317 + "website": "www.kwelpaso.com",
7318 + "states": [
7319 + "TX"
7320 + ],
7321 + "cities": [
7322 + "El Paso"
7323 + ],
7324 + "estimated_agents": 300,
7325 + "estimated_listings": 600,
7326 + "mls_affiliations": [
7327 + "EPMLS"
7328 + ]
7329 + },
7330 + {
7331 + "name": "ERA Sellers & Buyers Real Estate",
7332 + "website": "www.erasellersandbuyers.com",
7333 + "states": [
7334 + "TX",
7335 + "NM"
7336 + ],
7337 + "cities": [
7338 + "El Paso"
7339 + ],
7340 + "estimated_agents": 200,
7341 + "estimated_listings": 480,
7342 + "mls_affiliations": [
7343 + "EPMLS"
7344 + ]
7345 + },
7346 + {
7347 + "name": "Coldwell Banker Apex, Realtors RGV",
7348 + "website": "www.cbapex.com/rgv",
7349 + "states": [
7350 + "TX"
7351 + ],
7352 + "cities": [
7353 + "McAllen"
7354 + ],
7355 + "estimated_agents": 130,
7356 + "estimated_listings": 320,
7357 + "mls_affiliations": [
7358 + "GreaterMcAllenMLS"
7359 + ]
7360 + },
7361 + {
7362 + "name": "RE/MAX Realtec Group",
7363 + "website": "www.realtec.com",
7364 + "states": [
7365 + "FL"
7366 + ],
7367 + "cities": [
7368 + "Palm Harbor"
7369 + ],
7370 + "estimated_agents": 130,
7371 + "estimated_listings": 320,
7372 + "mls_affiliations": [
7373 + "Stellar"
7374 + ]
7375 + },
7376 + {
7377 + "name": "Keller Williams Tampa Properties",
7378 + "website": "www.kwtampa.com",
7379 + "states": [
7380 + "FL"
7381 + ],
7382 + "cities": [
7383 + "Tampa"
7384 + ],
7385 + "estimated_agents": 500,
7386 + "estimated_listings": 900,
7387 + "mls_affiliations": [
7388 + "Stellar"
7389 + ]
7390 + },
7391 + {
7392 + "name": "Compass Florida",
7393 + "website": "www.compass.com/fl",
7394 + "states": [
7395 + "FL"
7396 + ],
7397 + "cities": [
7398 + "Miami",
7399 + "Fort Lauderdale",
7400 + "Tampa"
7401 + ],
7402 + "estimated_agents": 2200,
7403 + "estimated_listings": 4200,
7404 + "mls_affiliations": [
7405 + "MiamiMLS",
7406 + "Stellar"
7407 + ]
7408 + },
7409 + {
7410 + "name": "Redfin Florida",
7411 + "website": "www.redfin.com/state/florida",
7412 + "states": [
7413 + "FL"
7414 + ],
7415 + "cities": [],
7416 + "estimated_agents": 200,
7417 + "estimated_listings": 1200,
7418 + "mls_affiliations": [
7419 + "Stellar",
7420 + "MiamiMLS"
7421 + ]
7422 + },
7423 + {
7424 + "name": "EXP Realty Florida",
7425 + "website": "www.exprealty.com/fl",
7426 + "states": [
7427 + "FL"
7428 + ],
7429 + "cities": [],
7430 + "estimated_agents": 8000,
7431 + "estimated_listings": 12000,
7432 + "mls_affiliations": [
7433 + "Stellar"
7434 + ]
7435 + },
7436 + {
7437 + "name": "RE/MAX Alliance Group",
7438 + "website": "www.remax-alliancegroup.com",
7439 + "states": [
7440 + "FL"
7441 + ],
7442 + "cities": [
7443 + "Sarasota",
7444 + "Bradenton"
7445 + ],
7446 + "estimated_agents": 350,
7447 + "estimated_listings": 800,
7448 + "mls_affiliations": [
7449 + "Stellar"
7450 + ]
7451 + },
7452 + {
7453 + "name": "RE/MAX Crown Realty",
7454 + "website": "www.remaxcrownrealty.com",
7455 + "states": [
7456 + "FL"
7457 + ],
7458 + "cities": [
7459 + "Fort Myers"
7460 + ],
7461 + "estimated_agents": 90,
7462 + "estimated_listings": 240,
7463 + "mls_affiliations": [
7464 + "NABOR"
7465 + ]
7466 + },
7467 + {
7468 + "name": "MVP Realty Associates",
7469 + "website": "www.mvprealty.com",
7470 + "states": [
7471 + "FL"
7472 + ],
7473 + "cities": [
7474 + "Naples",
7475 + "Fort Myers"
7476 + ],
7477 + "estimated_agents": 1800,
7478 + "estimated_listings": 2800,
7479 + "mls_affiliations": [
7480 + "NABOR"
7481 + ]
7482 + },
7483 + {
7484 + "name": "Downing-Frye Realty",
7485 + "website": "www.downingfrye.com",
7486 + "states": [
7487 + "FL"
7488 + ],
7489 + "cities": [
7490 + "Naples"
7491 + ],
7492 + "estimated_agents": 600,
7493 + "estimated_listings": 1300,
7494 + "mls_affiliations": [
7495 + "NABOR"
7496 + ]
7497 + },
7498 + {
7499 + "name": "Keller Williams Jacksonville Realty",
7500 + "website": "www.kwjacksonville.com",
7501 + "states": [
7502 + "FL"
7503 + ],
7504 + "cities": [
7505 + "Jacksonville"
7506 + ],
7507 + "estimated_agents": 400,
7508 + "estimated_listings": 800,
7509 + "mls_affiliations": [
7510 + "realMLS"
7511 + ]
7512 + },
7513 + {
7514 + "name": "Engel & Völkers First Coast",
7515 + "website": "www.firstcoast.evrealestate.com",
7516 + "states": [
7517 + "FL"
7518 + ],
7519 + "cities": [
7520 + "Jacksonville Beach"
7521 + ],
7522 + "estimated_agents": 90,
7523 + "estimated_listings": 240,
7524 + "mls_affiliations": [
7525 + "realMLS"
7526 + ]
7527 + },
7528 + {
7529 + "name": "Century 21 St Augustine Properties",
7530 + "website": "www.c21saprop.com",
7531 + "states": [
7532 + "FL"
7533 + ],
7534 + "cities": [
7535 + "Saint Augustine"
7536 + ],
7537 + "estimated_agents": 60,
7538 + "estimated_listings": 160,
7539 + "mls_affiliations": [
7540 + "SAMLS"
7541 + ]
7542 + },
7543 + {
7544 + "name": "ERA Grizzard Real Estate",
7545 + "website": "www.eragrizzard.com",
7546 + "states": [
7547 + "FL"
7548 + ],
7549 + "cities": [
7550 + "Leesburg",
7551 + "Orlando"
7552 + ],
7553 + "estimated_agents": 300,
7554 + "estimated_listings": 700,
7555 + "mls_affiliations": [
7556 + "Stellar"
7557 + ]
7558 + },
7559 + {
7560 + "name": "Coldwell Banker Paradise",
7561 + "website": "www.coldwellbankerparadise.com",
7562 + "states": [
7563 + "FL"
7564 + ],
7565 + "cities": [
7566 + "Vero Beach"
7567 + ],
7568 + "estimated_agents": 200,
7569 + "estimated_listings": 500,
7570 + "mls_affiliations": [
7571 + "IRMLS"
7572 + ]
7573 + },
7574 + {
7575 + "name": "The Corcoran Connection",
7576 + "website": "www.thecorcoranconnection.com",
7577 + "states": [
7578 + "FL"
7579 + ],
7580 + "cities": [
7581 + "Saint Cloud",
7582 + "Kissimmee"
7583 + ],
7584 + "estimated_agents": 90,
7585 + "estimated_listings": 240,
7586 + "mls_affiliations": [
7587 + "Stellar"
7588 + ]
7589 + },
7590 + {
7591 + "name": "Howard Hanna Michigan",
7592 + "website": "www.howardhanna.com/mi",
7593 + "states": [
7594 + "MI"
7595 + ],
7596 + "cities": [
7597 + "Detroit suburbs"
7598 + ],
7599 + "estimated_agents": 400,
7600 + "estimated_listings": 900,
7601 + "mls_affiliations": [
7602 + "Realcomp"
7603 + ]
7604 + },
7605 + {
7606 + "name": "KW Domain Birmingham MI",
7607 + "website": "www.kwdomain.com",
7608 + "states": [
7609 + "MI"
7610 + ],
7611 + "cities": [
7612 + "Birmingham"
7613 + ],
7614 + "estimated_agents": 200,
7615 + "estimated_listings": 420,
7616 + "mls_affiliations": [
7617 + "Realcomp"
7618 + ]
7619 + },
7620 + {
7621 + "name": "National Realty Centers",
7622 + "website": "www.nationalrealtycenters.com",
7623 + "states": [
7624 + "MI"
7625 + ],
7626 + "cities": [
7627 + "Northville"
7628 + ],
7629 + "estimated_agents": 700,
7630 + "estimated_listings": 1100,
7631 + "mls_affiliations": [
7632 + "Realcomp"
7633 + ]
7634 + },
7635 + {
7636 + "name": "Five Star Real Estate",
7637 + "website": "www.fivestarmichigan.com",
7638 + "states": [
7639 + "MI"
7640 + ],
7641 + "cities": [
7642 + "Grand Rapids"
7643 + ],
7644 + "estimated_agents": 900,
7645 + "estimated_listings": 1500,
7646 + "mls_affiliations": [
7647 + "MichRIC"
7648 + ]
7649 + },
7650 + {
7651 + "name": "Greenridge Realty",
7652 + "website": "www.greenridge.com",
7653 + "states": [
7654 + "MI"
7655 + ],
7656 + "cities": [
7657 + "Grand Rapids"
7658 + ],
7659 + "estimated_agents": 300,
7660 + "estimated_listings": 700,
7661 + "mls_affiliations": [
7662 + "MichRIC"
7663 + ]
7664 + },
7665 + {
7666 + "name": "CENTURY 21 Affiliated",
7667 + "website": "www.c21affiliated.com",
7668 + "states": [
7669 + "WI",
7670 + "IL",
7671 + "MI",
7672 + "IN",
7673 + "FL"
7674 + ],
7675 + "cities": [
7676 + "Madison",
7677 + "Chicago"
7678 + ],
7679 + "estimated_agents": 1400,
7680 + "estimated_listings": 2600,
7681 + "mls_affiliations": [
7682 + "SCWMLS",
7683 + "MRED"
7684 + ]
7685 + },
7686 + {
7687 + "name": "Coldwell Banker Real Estate Group",
7688 + "website": "www.coldwellhomes.com",
7689 + "states": [
7690 + "WI",
7691 + "IL",
7692 + "IN",
7693 + "MI",
7694 + "OH"
7695 + ],
7696 + "cities": [
7697 + "Green Bay",
7698 + "Fort Wayne"
7699 + ],
7700 + "estimated_agents": 900,
7701 + "estimated_listings": 1900,
7702 + "mls_affiliations": [
7703 + "RANW"
7704 + ]
7705 + },
7706 + {
7707 + "name": "RE/MAX 24/7 Real Estate",
7708 + "website": "www.densmorerealty.com",
7709 + "states": [
7710 + "WI"
7711 + ],
7712 + "cities": [
7713 + "Green Bay"
7714 + ],
7715 + "estimated_agents": 40,
7716 + "estimated_listings": 110,
7717 + "mls_affiliations": [
7718 + "RANW"
7719 + ]
7720 + },
7721 + {
7722 + "name": "Smythe Realtors (Cressy & Everett)",
7723 + "website": "www.cressyeverett.com",
7724 + "states": [
7725 + "IN",
7726 + "MI"
7727 + ],
7728 + "cities": [
7729 + "South Bend"
7730 + ],
7731 + "estimated_agents": 200,
7732 + "estimated_listings": 480,
7733 + "mls_affiliations": [
7734 + "IRMLS-IN"
7735 + ]
7736 + },
7737 + {
7738 + "name": "Berkshire Hathaway HomeServices Michigan Real Estate",
7739 + "website": "www.bhhsmi.com",
7740 + "states": [
7741 + "MI",
7742 + "IN"
7743 + ],
7744 + "cities": [
7745 + "Grand Rapids",
7746 + "Kalamazoo"
7747 + ],
7748 + "estimated_agents": 500,
7749 + "estimated_listings": 1100,
7750 + "mls_affiliations": [
7751 + "MichRIC"
7752 + ]
7753 + },
7754 + {
7755 + "name": "RE/MAX of Grand Rapids",
7756 + "website": "www.grar.com",
7757 + "states": [
7758 + "MI"
7759 + ],
7760 + "cities": [
7761 + "Grand Rapids"
7762 + ],
7763 + "estimated_agents": 200,
7764 + "estimated_listings": 480,
7765 + "mls_affiliations": [
7766 + "MichRIC"
7767 + ]
7768 + },
7769 + {
7770 + "name": "Key Realty Ohio",
7771 + "website": "www.keyrealtyohio.com",
7772 + "states": [
7773 + "OH",
7774 + "MI"
7775 + ],
7776 + "cities": [
7777 + "Toledo",
7778 + "Columbus"
7779 + ],
7780 + "estimated_agents": 1200,
7781 + "estimated_listings": 1900,
7782 + "mls_affiliations": [
7783 + "NORIS"
7784 + ]
7785 + },
7786 + {
7787 + "name": "The Danberry Company",
7788 + "website": "www.danberry.com",
7789 + "states": [
7790 + "OH",
7791 + "MI"
7792 + ],
7793 + "cities": [
7794 + "Toledo"
7795 + ],
7796 + "estimated_agents": 300,
7797 + "estimated_listings": 650,
7798 + "mls_affiliations": [
7799 + "NORIS"
7800 + ]
7801 + },
7802 + {
7803 + "name": "Howard Hanna Ohio",
7804 + "website": "www.howardhanna.com/oh",
7805 + "states": [
7806 + "OH"
7807 + ],
7808 + "cities": [
7809 + "Cleveland",
7810 + "Columbus"
7811 + ],
7812 + "estimated_agents": 2000,
7813 + "estimated_listings": 4200,
7814 + "mls_affiliations": [
7815 + "YesMLS"
7816 + ]
7817 + },
7818 + {
7819 + "name": "Keller Williams Capital Partners Columbus",
7820 + "website": "www.kwcolumbus.com",
7821 + "states": [
7822 + "OH"
7823 + ],
7824 + "cities": [
7825 + "Columbus"
7826 + ],
7827 + "estimated_agents": 500,
7828 + "estimated_listings": 950,
7829 + "mls_affiliations": [
7830 + "ColumbusMLS"
7831 + ]
7832 + },
7833 + {
7834 + "name": "RE/MAX Alliance Ohio",
7835 + "website": "www.remaxallianceohio.com",
7836 + "states": [
7837 + "OH"
7838 + ],
7839 + "cities": [
7840 + "Columbus"
7841 + ],
7842 + "estimated_agents": 130,
7843 + "estimated_listings": 320,
7844 + "mls_affiliations": [
7845 + "ColumbusMLS"
7846 + ]
7847 + },
7848 + {
7849 + "name": "Plum Tree Realty",
7850 + "website": "www.plumtreerealty.com",
7851 + "states": [
7852 + "OH",
7853 + "KY",
7854 + "IN"
7855 + ],
7856 + "cities": [
7857 + "Cincinnati"
7858 + ],
7859 + "estimated_agents": 700,
7860 + "estimated_listings": 1100,
7861 + "mls_affiliations": [
7862 + "CincyMLS"
7863 + ]
7864 + },
7865 + {
7866 + "name": "Star One Realtors",
7867 + "website": "www.starone.com",
7868 + "states": [
7869 + "OH"
7870 + ],
7871 + "cities": [
7872 + "Cincinnati",
7873 + "Dayton"
7874 + ],
7875 + "estimated_agents": 400,
7876 + "estimated_listings": 850,
7877 + "mls_affiliations": [
7878 + "CincyMLS",
7879 + "DaytonMLS"
7880 + ]
7881 + },
7882 + {
7883 + "name": "Irongate Realtors",
7884 + "website": "www.irongaterealtors.com",
7885 + "states": [
7886 + "OH"
7887 + ],
7888 + "cities": [
7889 + "Dayton"
7890 + ],
7891 + "estimated_agents": 300,
7892 + "estimated_listings": 650,
7893 + "mls_affiliations": [
7894 + "DaytonMLS"
7895 + ]
7896 + },
7897 + {
7898 + "name": "Berkshire Hathaway HomeServices Stouffer Realty",
7899 + "website": "www.stoufferrealty.com",
7900 + "states": [
7901 + "OH"
7902 + ],
7903 + "cities": [
7904 + "Akron"
7905 + ],
7906 + "estimated_agents": 300,
7907 + "estimated_listings": 650,
7908 + "mls_affiliations": [
7909 + "YesMLS"
7910 + ]
7911 + },
7912 + {
7913 + "name": "J Barrett & Company",
7914 + "website": "www.jbarrettrealty.com",
7915 + "states": [
7916 + "MA"
7917 + ],
7918 + "cities": [
7919 + "Beverly",
7920 + "Marblehead"
7921 + ],
7922 + "estimated_agents": 130,
7923 + "estimated_listings": 320,
7924 + "mls_affiliations": [
7925 + "MLSPIN"
7926 + ]
7927 + },
7928 + {
7929 + "name": "Sagan Harborside Sotheby's International Realty",
7930 + "website": "www.saganharborside.com",
7931 + "states": [
7932 + "MA"
7933 + ],
7934 + "cities": [
7935 + "Marblehead"
7936 + ],
7937 + "estimated_agents": 60,
7938 + "estimated_listings": 160,
7939 + "mls_affiliations": [
7940 + "MLSPIN"
7941 + ]
7942 + },
7943 + {
7944 + "name": "Kinlin Grover Compass",
7945 + "website": "www.kinlingrover.com",
7946 + "states": [
7947 + "MA"
7948 + ],
7949 + "cities": [
7950 + "Cape Cod",
7951 + "Osterville"
7952 + ],
7953 + "estimated_agents": 300,
7954 + "estimated_listings": 750,
7955 + "mls_affiliations": [
7956 + "CCIMLS"
7957 + ]
7958 + },
7959 + {
7960 + "name": "Robert Paul Properties (Compass)",
7961 + "website": "www.robertpaul.com",
7962 + "states": [
7963 + "MA"
7964 + ],
7965 + "cities": [
7966 + "Osterville",
7967 + "Boston"
7968 + ],
7969 + "estimated_agents": 90,
7970 + "estimated_listings": 240,
7971 + "mls_affiliations": [
7972 + "CCIMLS"
7973 + ]
7974 + },
7975 + {
7976 + "name": "LandVest",
7977 + "website": "www.landvest.com",
7978 + "states": [
7979 + "MA",
7980 + "ME",
7981 + "NH",
7982 + "VT",
7983 + "NY"
7984 + ],
7985 + "cities": [
7986 + "Boston"
7987 + ],
7988 + "estimated_agents": 60,
7989 + "estimated_listings": 200,
7990 + "mls_affiliations": [
7991 + "MLSPIN"
7992 + ]
7993 + },
7994 + {
7995 + "name": "The Stockman Associates (Berkshires)",
7996 + "website": "www.stonehousepropertiesma.com",
7997 + "states": [
7998 + "MA"
7999 + ],
8000 + "cities": [
8001 + "West Stockbridge"
8002 + ],
8003 + "estimated_agents": 20,
8004 + "estimated_listings": 70,
8005 + "mls_affiliations": [
8006 + "BerkshireMLS"
8007 + ]
8008 + },
8009 + {
8010 + "name": "William Pitt SIR Berkshires",
8011 + "website": "www.williampitt.com/berkshires",
8012 + "states": [
8013 + "MA"
8014 + ],
8015 + "cities": [
8016 + "Great Barrington"
8017 + ],
8018 + "estimated_agents": 40,
8019 + "estimated_listings": 130,
8020 + "mls_affiliations": [
8021 + "BerkshireMLS"
8022 + ]
8023 + },
8024 + {
8025 + "name": "Brown Harris Stevens Connecticut",
8026 + "website": "www.bhsusa.com/connecticut",
8027 + "states": [
8028 + "CT"
8029 + ],
8030 + "cities": [
8031 + "Greenwich"
8032 + ],
8033 + "estimated_agents": 130,
8034 + "estimated_listings": 320,
8035 + "mls_affiliations": [
8036 + "GreenwichMLS"
8037 + ]
8038 + },
8039 + {
8040 + "name": "Coldwell Banker Realty Greenwich",
8041 + "website": "www.coldwellbankerhomes.com/ct/greenwich",
8042 + "states": [
8043 + "CT"
8044 + ],
8045 + "cities": [
8046 + "Greenwich"
8047 + ],
8048 + "estimated_agents": 130,
8049 + "estimated_listings": 320,
8050 + "mls_affiliations": [
8051 + "GreenwichMLS"
8052 + ]
8053 + },
8054 + {
8055 + "name": "Carbutti & Co Realtors",
8056 + "website": "www.carbutti.com",
8057 + "states": [
8058 + "CT"
8059 + ],
8060 + "cities": [
8061 + "Wallingford"
8062 + ],
8063 + "estimated_agents": 40,
8064 + "estimated_listings": 110,
8065 + "mls_affiliations": [
8066 + "SmartMLS"
8067 + ]
8068 + },
8069 + {
8070 + "name": "Pearce Real Estate",
8071 + "website": "www.hpearce.com",
8072 + "states": [
8073 + "CT"
8074 + ],
8075 + "cities": [
8076 + "New Haven"
8077 + ],
8078 + "estimated_agents": 90,
8079 + "estimated_listings": 240,
8080 + "mls_affiliations": [
8081 + "SmartMLS"
8082 + ]
8083 + },
8084 + {
8085 + "name": "Seaport Real Estate Services",
8086 + "website": "www.seaportre.com",
8087 + "states": [
8088 + "CT"
8089 + ],
8090 + "cities": [
8091 + "Mystic"
8092 + ],
8093 + "estimated_agents": 30,
8094 + "estimated_listings": 90,
8095 + "mls_affiliations": [
8096 + "SmartMLS"
8097 + ]
8098 + },
8099 + {
8100 + "name": "Keller Williams NYC",
8101 + "website": "www.kwnyc.com",
8102 + "states": [
8103 + "NY"
8104 + ],
8105 + "cities": [
8106 + "New York"
8107 + ],
8108 + "estimated_agents": 900,
8109 + "estimated_listings": 1400,
8110 + "mls_affiliations": [
8111 + "REBNY"
8112 + ]
8113 + },
8114 + {
8115 + "name": "Elegran Real Estate",
8116 + "website": "www.elegran.com",
8117 + "states": [
8118 + "NY"
8119 + ],
8120 + "cities": [
8121 + "New York"
8122 + ],
8123 + "estimated_agents": 200,
8124 + "estimated_listings": 420,
8125 + "mls_affiliations": [
8126 + "REBNY"
8127 + ]
8128 + },
8129 + {
8130 + "name": "Casa Blanca Real Estate",
8131 + "website": "www.casa-blanca.com",
8132 + "states": [
8133 + "NY"
8134 + ],
8135 + "cities": [
8136 + "New York"
8137 + ],
8138 + "estimated_agents": 60,
8139 + "estimated_listings": 160,
8140 + "mls_affiliations": [
8141 + "REBNY"
8142 + ]
8143 + },
8144 + {
8145 + "name": "R New York",
8146 + "website": "www.rnewyork.com",
8147 + "states": [
8148 + "NY"
8149 + ],
8150 + "cities": [
8151 + "New York"
8152 + ],
8153 + "estimated_agents": 700,
8154 + "estimated_listings": 1100,
8155 + "mls_affiliations": [
8156 + "REBNY"
8157 + ]
8158 + },
8159 + {
8160 + "name": "Spire Group NY",
8161 + "website": "www.spiregroupny.com",
8162 + "states": [
8163 + "NY"
8164 + ],
8165 + "cities": [
8166 + "New York"
8167 + ],
8168 + "estimated_agents": 300,
8169 + "estimated_listings": 550,
8170 + "mls_affiliations": [
8171 + "REBNY"
8172 + ]
8173 + },
8174 + {
8175 + "name": "Exit Realty Premium",
8176 + "website": "www.exitrealtypremium.com",
8177 + "states": [
8178 + "NY"
8179 + ],
8180 + "cities": [
8181 + "Bronx",
8182 + "Yonkers"
8183 + ],
8184 + "estimated_agents": 130,
8185 + "estimated_listings": 300,
8186 + "mls_affiliations": [
8187 + "OneKey"
8188 + ]
8189 + },
8190 + {
8191 + "name": "Charles Rutenberg Realty NY",
8192 + "website": "www.crrny.com",
8193 + "states": [
8194 + "NY"
8195 + ],
8196 + "cities": [
8197 + "Plainview",
8198 + "Long Island"
8199 + ],
8200 + "estimated_agents": 1200,
8201 + "estimated_listings": 1900,
8202 + "mls_affiliations": [
8203 + "OneKey"
8204 + ]
8205 + },
8206 + {
8207 + "name": "Exit Family Realty Brooklyn",
8208 + "website": "www.exitfamilyrealty.com",
8209 + "states": [
8210 + "NY"
8211 + ],
8212 + "cities": [
8213 + "Brooklyn"
8214 + ],
8215 + "estimated_agents": 90,
8216 + "estimated_listings": 220,
8217 + "mls_affiliations": [
8218 + "OneKey"
8219 + ]
8220 + },
8221 + {
8222 + "name": "Berkshire Hathaway HomeServices PenFed Realty",
8223 + "website": "www.penfedrealty.com",
8224 + "states": [
8225 + "MD",
8226 + "VA",
8227 + "DC",
8228 + "PA",
8229 + "WV",
8230 + "KS",
8231 + "TX",
8232 + "FL"
8233 + ],
8234 + "cities": [
8235 + "Reston"
8236 + ],
8237 + "estimated_agents": 1500,
8238 + "estimated_listings": 2900,
8239 + "mls_affiliations": [
8240 + "BRIGHT"
8241 + ]
8242 + },
8243 + {
8244 + "name": "Compass DMV",
8245 + "website": "www.compass.com/dc",
8246 + "states": [
8247 + "DC",
8248 + "MD",
8249 + "VA"
8250 + ],
8251 + "cities": [
8252 + "Washington"
8253 + ],
8254 + "estimated_agents": 1400,
8255 + "estimated_listings": 2600,
8256 + "mls_affiliations": [
8257 + "BRIGHT"
8258 + ]
8259 + },
8260 + {
8261 + "name": "RLAH @properties (DMV)",
8262 + "website": "www.rlahre.com",
8263 + "states": [
8264 + "DC",
8265 + "MD",
8266 + "VA"
8267 + ],
8268 + "cities": [
8269 + "Washington"
8270 + ],
8271 + "estimated_agents": 700,
8272 + "estimated_listings": 1300,
8273 + "mls_affiliations": [
8274 + "BRIGHT"
8275 + ]
8276 + },
8277 + {
8278 + "name": "Keller Williams Capital Properties DC",
8279 + "website": "www.kwcapitalproperties.com",
8280 + "states": [
8281 + "DC",
8282 + "MD",
8283 + "VA"
8284 + ],
8285 + "cities": [
8286 + "Washington"
8287 + ],
8288 + "estimated_agents": 900,
8289 + "estimated_listings": 1500,
8290 + "mls_affiliations": [
8291 + "BRIGHT"
8292 + ]
8293 + },
8294 + {
8295 + "name": "Redfin Mid-Atlantic",
8296 + "website": "www.redfin.com/city/12839/dc/washington-dc",
8297 + "states": [
8298 + "DC",
8299 + "MD",
8300 + "VA"
8301 + ],
8302 + "cities": [
8303 + "Washington"
8304 + ],
8305 + "estimated_agents": 90,
8306 + "estimated_listings": 480,
8307 + "mls_affiliations": [
8308 + "BRIGHT"
8309 + ]
8310 + },
8311 + {
8312 + "name": "Berkshire Hathaway HomeServices Hodrick Realty",
8313 + "website": "www.hodrickrealty.com",
8314 + "states": [
8315 + "PA"
8316 + ],
8317 + "cities": [
8318 + "Williamsport"
8319 + ],
8320 + "estimated_agents": 90,
8321 + "estimated_listings": 240,
8322 + "mls_affiliations": [
8323 + "WestBranchMLS"
8324 + ]
8325 + },
8326 + {
8327 + "name": "Jack Gaughen Network Services (ERA)",
8328 + "website": "www.jgr.com",
8329 + "states": [
8330 + "PA"
8331 + ],
8332 + "cities": [
8333 + "Harrisburg"
8334 + ],
8335 + "estimated_agents": 300,
8336 + "estimated_listings": 650,
8337 + "mls_affiliations": [
8338 + "BRIGHT"
8339 + ]
8340 + },
8341 + {
8342 + "name": "RE/MAX Realty Associates Harrisburg",
8343 + "website": "www.forsalebyremax.com",
8344 + "states": [
8345 + "PA"
8346 + ],
8347 + "cities": [
8348 + "Harrisburg"
8349 + ],
8350 + "estimated_agents": 130,
8351 + "estimated_listings": 320,
8352 + "mls_affiliations": [
8353 + "BRIGHT"
8354 + ]
8355 + },
8356 + {
8357 + "name": "RSR Realtors",
8358 + "website": "www.rsrrealtors.com",
8359 + "states": [
8360 + "PA"
8361 + ],
8362 + "cities": [
8363 + "Lemoyne",
8364 + "Harrisburg"
8365 + ],
8366 + "estimated_agents": 90,
8367 + "estimated_listings": 240,
8368 + "mls_affiliations": [
8369 + "BRIGHT"
8370 + ]
8371 + },
8372 + {
8373 + "name": "Lusk & Associates Sotheby's International Realty",
8374 + "website": "www.luskandassociates.com",
8375 + "states": [
8376 + "PA"
8377 + ],
8378 + "cities": [
8379 + "Lancaster"
8380 + ],
8381 + "estimated_agents": 60,
8382 + "estimated_listings": 160,
8383 + "mls_affiliations": [
8384 + "BRIGHT"
8385 + ]
8386 + },
8387 + {
8388 + "name": "The Simpson Company (Gainesville GA)",
8389 + "website": "www.thesimpsoncompany.com",
8390 + "states": [
8391 + "GA"
8392 + ],
8393 + "cities": [
8394 + "Gainesville"
8395 + ],
8396 + "estimated_agents": 60,
8397 + "estimated_listings": 160,
8398 + "mls_affiliations": [
8399 + "GAMLS"
8400 + ]
8401 + },
8402 + {
8403 + "name": "Berkshire Hathaway HomeServices Georgia Properties",
8404 + "website": "www.bhhsgeorgia.com",
8405 + "states": [
8406 + "GA"
8407 + ],
8408 + "cities": [
8409 + "Atlanta",
8410 + "Roswell"
8411 + ],
8412 + "estimated_agents": 1400,
8413 + "estimated_listings": 2800,
8414 + "mls_affiliations": [
8415 + "FMLS",
8416 + "GAMLS"
8417 + ]
8418 + },
8419 + {
8420 + "name": "Keller Williams Realty Atlanta Partners",
8421 + "website": "www.kwatlantapartners.com",
8422 + "states": [
8423 + "GA"
8424 + ],
8425 + "cities": [
8426 + "Atlanta"
8427 + ],
8428 + "estimated_agents": 1200,
8429 + "estimated_listings": 2000,
8430 + "mls_affiliations": [
8431 + "FMLS"
8432 + ]
8433 + },
8434 + {
8435 + "name": "Coldwell Banker Realty Atlanta",
8436 + "website": "www.coldwellbankerhomes.com/ga",
8437 + "states": [
8438 + "GA"
8439 + ],
8440 + "cities": [
8441 + "Atlanta"
8442 + ],
8443 + "estimated_agents": 1300,
8444 + "estimated_listings": 2500,
8445 + "mls_affiliations": [
8446 + "FMLS"
8447 + ]
8448 + },
8449 + {
8450 + "name": "RE/MAX Around Atlanta",
8451 + "website": "www.remaxaroundatlanta.com",
8452 + "states": [
8453 + "GA"
8454 + ],
8455 + "cities": [
8456 + "Atlanta"
8457 + ],
8458 + "estimated_agents": 300,
8459 + "estimated_listings": 650,
8460 + "mls_affiliations": [
8461 + "FMLS"
8462 + ]
8463 + },
8464 + {
8465 + "name": "Century 21 Results",
8466 + "website": "www.c21results.com",
8467 + "states": [
8468 + "GA"
8469 + ],
8470 + "cities": [
8471 + "Roswell"
8472 + ],
8473 + "estimated_agents": 300,
8474 + "estimated_listings": 600,
8475 + "mls_affiliations": [
8476 + "FMLS"
8477 + ]
8478 + },
8479 + {
8480 + "name": "Cora Bett Thomas Realty",
8481 + "website": "www.corabettthomas.com",
8482 + "states": [
8483 + "GA",
8484 + "SC"
8485 + ],
8486 + "cities": [
8487 + "Savannah"
8488 + ],
8489 + "estimated_agents": 60,
8490 + "estimated_listings": 160,
8491 + "mls_affiliations": [
8492 + "SavannahMLS"
8493 + ]
8494 + },
8495 + {
8496 + "name": "ERA King Real Estate",
8497 + "website": "www.eraking.com",
8498 + "states": [
8499 + "AL",
8500 + "GA"
8501 + ],
8502 + "cities": [
8503 + "Anniston",
8504 + "Birmingham"
8505 + ],
8506 + "estimated_agents": 300,
8507 + "estimated_listings": 700,
8508 + "mls_affiliations": [
8509 + "GALMLS"
8510 + ]
8511 + },
8512 + {
8513 + "name": "Keller Williams Realty Augusta Partners",
8514 + "website": "www.kwaugusta.com",
8515 + "states": [
8516 + "GA",
8517 + "SC"
8518 + ],
8519 + "cities": [
8520 + "Augusta"
8521 + ],
8522 + "estimated_agents": 200,
8523 + "estimated_listings": 450,
8524 + "mls_affiliations": [
8525 + "AugustaMLS"
8526 + ]
8527 + },
8528 + {
8529 + "name": "Meybohm Real Estate",
8530 + "website": "www.meybohm.com",
8531 + "states": [
8532 + "GA",
8533 + "SC"
8534 + ],
8535 + "cities": [
8536 + "Augusta"
8537 + ],
8538 + "estimated_agents": 300,
8539 + "estimated_listings": 700,
8540 + "mls_affiliations": [
8541 + "AugustaMLS"
8542 + ]
8543 + },
8544 + {
8545 + "name": "Berkshire Hathaway HomeServices Beazley Realtors",
8546 + "website": "www.beazleyrealtors.com",
8547 + "states": [
8548 + "GA",
8549 + "SC"
8550 + ],
8551 + "cities": [
8552 + "Augusta"
8553 + ],
8554 + "estimated_agents": 200,
8555 + "estimated_listings": 480,
8556 + "mls_affiliations": [
8557 + "AugustaMLS"
8558 + ]
8559 + },
8560 + {
8561 + "name": "Weichert Realtors - The Griffin Company",
8562 + "website": "www.thegriffincompany.com",
8563 + "states": [
8564 + "AR"
8565 + ],
8566 + "cities": [
8567 + "Fayetteville",
8568 + "Fort Smith"
8569 + ],
8570 + "estimated_agents": 130,
8571 + "estimated_listings": 320,
8572 + "mls_affiliations": [
8573 + "NWAMLS"
8574 + ]
8575 + },
8576 + {
8577 + "name": "Chapman Hall Realtors",
8578 + "website": "www.chapmanhallrealtors.com",
8579 + "states": [
8580 + "GA"
8581 + ],
8582 + "cities": [
8583 + "Atlanta"
8584 + ],
8585 + "estimated_agents": 700,
8586 + "estimated_listings": 1100,
8587 + "mls_affiliations": [
8588 + "FMLS"
8589 + ]
8590 + },
8591 + {
8592 + "name": "Southern Classic Realtors",
8593 + "website": "www.southernclassicrealtors.com",
8594 + "states": [
8595 + "GA"
8596 + ],
8597 + "cities": [
8598 + "McDonough"
8599 + ],
8600 + "estimated_agents": 900,
8601 + "estimated_listings": 1400,
8602 + "mls_affiliations": [
8603 + "GAMLS"
8604 + ]
8605 + },
8606 + {
8607 + "name": "Duckworth Properties",
8608 + "website": "www.duckworthproperties.com",
8609 + "states": [
8610 + "GA"
8611 + ],
8612 + "cities": [
8613 + "Brunswick",
8614 + "Saint Simons"
8615 + ],
8616 + "estimated_agents": 40,
8617 + "estimated_listings": 120,
8618 + "mls_affiliations": [
8619 + "GoldenIslesMLS"
8620 + ]
8621 + },
8622 + {
8623 + "name": "DeLoach Sotheby's International Realty",
8624 + "website": "www.deloachsir.com",
8625 + "states": [
8626 + "GA"
8627 + ],
8628 + "cities": [
8629 + "Saint Simons Island"
8630 + ],
8631 + "estimated_agents": 40,
8632 + "estimated_listings": 130,
8633 + "mls_affiliations": [
8634 + "GoldenIslesMLS"
8635 + ]
8636 + },
8637 + {
8638 + "name": "Coldwell Banker Sea Coast Advantage",
8639 + "website": "www.seacoastrealty.com",
8640 + "states": [
8641 + "NC",
8642 + "SC"
8643 + ],
8644 + "cities": [
8645 + "Wilmington",
8646 + "Myrtle Beach"
8647 + ],
8648 + "estimated_agents": 900,
8649 + "estimated_listings": 2000,
8650 + "mls_affiliations": [
8651 + "CapeFearMLS",
8652 + "CCARMLS"
8653 + ]
8654 + },
8655 + {
8656 + "name": "Berkshire Hathaway HomeServices Carolinas Realty",
8657 + "website": "www.bhhscarolinas.com",
8658 + "states": [
8659 + "NC",
8660 + "SC"
8661 + ],
8662 + "cities": [
8663 + "Winston-Salem",
8664 + "Charlotte"
8665 + ],
8666 + "estimated_agents": 500,
8667 + "estimated_listings": 1100,
8668 + "mls_affiliations": [
8669 + "TriadMLS",
8670 + "Canopy"
8671 + ]
8672 + },
8673 + {
8674 + "name": "Leonard Ryden Burr Real Estate",
8675 + "website": "www.lrbrealestate.com",
8676 + "states": [
8677 + "NC"
8678 + ],
8679 + "cities": [
8680 + "Winston-Salem"
8681 + ],
8682 + "estimated_agents": 90,
8683 + "estimated_listings": 240,
8684 + "mls_affiliations": [
8685 + "TriadMLS"
8686 + ]
8687 + },
8688 + {
8689 + "name": "Berkshire Hathaway HomeServices Yost & Little Realty",
8690 + "website": "www.yostandlittle.com",
8691 + "states": [
8692 + "NC"
8693 + ],
8694 + "cities": [
8695 + "Greensboro"
8696 + ],
8697 + "estimated_agents": 130,
8698 + "estimated_listings": 320,
8699 + "mls_affiliations": [
8700 + "TriadMLS"
8701 + ]
8702 + },
8703 + {
8704 + "name": "Coldwell Banker Advantage NC",
8705 + "website": "www.cbadvantage.com",
8706 + "states": [
8707 + "NC"
8708 + ],
8709 + "cities": [
8710 + "Raleigh",
8711 + "Fayetteville"
8712 + ],
8713 + "estimated_agents": 700,
8714 + "estimated_listings": 1400,
8715 + "mls_affiliations": [
8716 + "TriangleMLS",
8717 + "LongleafPineMLS"
8718 + ]
8719 + },
8720 + {
8721 + "name": "Keller Williams Preferred Realty (Raleigh)",
8722 + "website": "www.kwpreferred.com",
8723 + "states": [
8724 + "NC"
8725 + ],
8726 + "cities": [
8727 + "Raleigh"
8728 + ],
8729 + "estimated_agents": 500,
8730 + "estimated_listings": 900,
8731 + "mls_affiliations": [
8732 + "TriangleMLS"
8733 + ]
8734 + },
8735 + {
8736 + "name": "Compass Charlotte",
8737 + "website": "www.compass.com/charlotte",
8738 + "states": [
8739 + "NC",
8740 + "SC"
8741 + ],
8742 + "cities": [
8743 + "Charlotte"
8744 + ],
8745 + "estimated_agents": 400,
8746 + "estimated_listings": 800,
8747 + "mls_affiliations": [
8748 + "Canopy"
8749 + ]
8750 + },
8751 + {
8752 + "name": "Ivester Jackson Christie's",
8753 + "website": "www.ivesterjackson.com",
8754 + "states": [
8755 + "NC"
8756 + ],
8757 + "cities": [
8758 + "Charlotte",
8759 + "Lake Norman"
8760 + ],
8761 + "estimated_agents": 130,
8762 + "estimated_listings": 350,
8763 + "mls_affiliations": [
8764 + "Canopy"
8765 + ]
8766 + },
8767 + {
8768 + "name": "Lake Norman Realty",
8769 + "website": "www.lakenormanrealty.com",
8770 + "states": [
8771 + "NC"
8772 + ],
8773 + "cities": [
8774 + "Mooresville"
8775 + ],
8776 + "estimated_agents": 90,
8777 + "estimated_listings": 260,
8778 + "mls_affiliations": [
8779 + "Canopy"
8780 + ]
8781 + },
8782 + {
8783 + "name": "The Litchfield Company",
8784 + "website": "www.thelitchfieldcompany.com",
8785 + "states": [
8786 + "SC"
8787 + ],
8788 + "cities": [
8789 + "Pawleys Island",
8790 + "Myrtle Beach"
8791 + ],
8792 + "estimated_agents": 200,
8793 + "estimated_listings": 550,
8794 + "mls_affiliations": [
8795 + "CCARMLS"
8796 + ]
8797 + },
8798 + {
8799 + "name": "Garden City Realty",
8800 + "website": "www.gardencityrealty.com",
8801 + "states": [
8802 + "SC"
8803 + ],
8804 + "cities": [
8805 + "Garden City Beach"
8806 + ],
8807 + "estimated_agents": 40,
8808 + "estimated_listings": 130,
8809 + "mls_affiliations": [
8810 + "CCARMLS"
8811 + ]
8812 + },
8813 + {
8814 + "name": "Charleston Trident: AgentOwned Realty",
8815 + "website": "www.agentownedrealty.com",
8816 + "states": [
8817 + "SC"
8818 + ],
8819 + "cities": [
8820 + "Charleston"
8821 + ],
8822 + "estimated_agents": 400,
8823 + "estimated_listings": 900,
8824 + "mls_affiliations": [
8825 + "CharlestonMLS"
8826 + ]
8827 + },
8828 + {
8829 + "name": "William Means Real Estate",
8830 + "website": "www.williammeans.com",
8831 + "states": [
8832 + "SC"
8833 + ],
8834 + "cities": [
8835 + "Charleston"
8836 + ],
8837 + "estimated_agents": 60,
8838 + "estimated_listings": 160,
8839 + "mls_affiliations": [
8840 + "CharlestonMLS"
8841 + ]
8842 + },
8843 + {
8844 + "name": "Long & Foster Virginia Beach",
8845 + "website": "www.longandfoster.com/va",
8846 + "states": [
8847 + "VA"
8848 + ],
8849 + "cities": [
8850 + "Virginia Beach"
8851 + ],
8852 + "estimated_agents": 300,
8853 + "estimated_listings": 700,
8854 + "mls_affiliations": [
8855 + "REIN"
8856 + ]
8857 + },
8858 + {
8859 + "name": "Berkshire Hathaway HomeServices Towne Realty Peninsula",
8860 + "website": "www.bhhstownerealty.com",
8861 + "states": [
8862 + "VA"
8863 + ],
8864 + "cities": [
8865 + "Newport News"
8866 + ],
8867 + "estimated_agents": 200,
8868 + "estimated_listings": 480,
8869 + "mls_affiliations": [
8870 + "REIN"
8871 + ]
8872 + },
8873 + {
8874 + "name": "Nest Realty",
8875 + "website": "www.nestrealty.com",
8876 + "states": [
8877 + "VA",
8878 + "NC",
8879 + "TN",
8880 + "SC",
8881 + "GA"
8882 + ],
8883 + "cities": [
8884 + "Charlottesville",
8885 + "Richmond"
8886 + ],
8887 + "estimated_agents": 400,
8888 + "estimated_listings": 900,
8889 + "mls_affiliations": [
8890 + "CAARMLS"
8891 + ]
8892 + },
8893 + {
8894 + "name": "Frank Hardy Sotheby's International Realty",
8895 + "website": "www.frankhardy.com",
8896 + "states": [
8897 + "VA"
8898 + ],
8899 + "cities": [
8900 + "Charlottesville"
8901 + ],
8902 + "estimated_agents": 60,
8903 + "estimated_listings": 170,
8904 + "mls_affiliations": [
8905 + "CAARMLS"
8906 + ]
8907 + },
8908 + {
8909 + "name": "Wiley Real Estate",
8910 + "website": "www.wileyproperty.com",
8911 + "states": [
8912 + "VA"
8913 + ],
8914 + "cities": [
8915 + "Orange",
8916 + "Charlottesville"
8917 + ],
8918 + "estimated_agents": 30,
8919 + "estimated_listings": 90,
8920 + "mls_affiliations": [
8921 + "CAARMLS"
8922 + ]
8923 + },
8924 + {
8925 + "name": "MKB Realtors",
8926 + "website": "www.mkbrealtors.com",
8927 + "states": [
8928 + "VA"
8929 + ],
8930 + "cities": [
8931 + "Roanoke"
8932 + ],
8933 + "estimated_agents": 130,
8934 + "estimated_listings": 320,
8935 + "mls_affiliations": [
8936 + "RVARMLS"
8937 + ]
8938 + },
8939 + {
8940 + "name": "Long & Foster Roanoke",
8941 + "website": "www.longandfoster.com/roanoke",
8942 + "states": [
8943 + "VA"
8944 + ],
8945 + "cities": [
8946 + "Roanoke"
8947 + ],
8948 + "estimated_agents": 90,
8949 + "estimated_listings": 240,
8950 + "mls_affiliations": [
8951 + "RVARMLS"
8952 + ]
8953 + },
8954 + {
8955 + "name": "8z Real Estate",
8956 + "website": "www.8z.com",
8957 + "states": [
8958 + "CO",
8959 + "CA"
8960 + ],
8961 + "cities": [
8962 + "Boulder",
8963 + "Denver"
8964 + ],
8965 + "estimated_agents": 200,
8966 + "estimated_listings": 500,
8967 + "mls_affiliations": [
8968 + "IRES",
8969 + "REcolorado"
8970 + ]
8971 + },
8972 + {
8973 + "name": "WK Real Estate",
8974 + "website": "www.wkre.com",
8975 + "states": [
8976 + "CO"
8977 + ],
8978 + "cities": [
8979 + "Boulder"
8980 + ],
8981 + "estimated_agents": 90,
8982 + "estimated_listings": 240,
8983 + "mls_affiliations": [
8984 + "IRES"
8985 + ]
8986 + },
8987 + {
8988 + "name": "Compass Colorado",
8989 + "website": "www.compass.com/co",
8990 + "states": [
8991 + "CO"
8992 + ],
8993 + "cities": [
8994 + "Denver",
8995 + "Boulder"
8996 + ],
8997 + "estimated_agents": 900,
8998 + "estimated_listings": 1700,
8999 + "mls_affiliations": [
9000 + "REcolorado"
9001 + ]
9002 + },
9003 + {
9004 + "name": "Coldwell Banker Mason Morse",
9005 + "website": "www.masonmorse.com",
9006 + "states": [
9007 + "CO"
9008 + ],
9009 + "cities": [
9010 + "Aspen",
9011 + "Carbondale"
9012 + ],
9013 + "estimated_agents": 90,
9014 + "estimated_listings": 240,
9015 + "mls_affiliations": [
9016 + "AspenGlenwoodMLS"
9017 + ]
9018 + },
9019 + {
9020 + "name": "Telluride Properties",
9021 + "website": "www.tellurideproperties.com",
9022 + "states": [
9023 + "CO"
9024 + ],
9025 + "cities": [
9026 + "Telluride"
9027 + ],
9028 + "estimated_agents": 40,
9029 + "estimated_listings": 140,
9030 + "mls_affiliations": [
9031 + "TellurideMLS"
9032 + ]
9033 + },
9034 + {
9035 + "name": "Steamboat Sotheby's International Realty",
9036 + "website": "www.steamboatsir.com",
9037 + "states": [
9038 + "CO"
9039 + ],
9040 + "cities": [
9041 + "Steamboat Springs"
9042 + ],
9043 + "estimated_agents": 90,
9044 + "estimated_listings": 260,
9045 + "mls_affiliations": [
9046 + "SteamboatMLS"
9047 + ]
9048 + },
9049 + {
9050 + "name": "Breckenridge Associates Real Estate",
9051 + "website": "www.breckenridgeassociates.com",
9052 + "states": [
9053 + "CO"
9054 + ],
9055 + "cities": [
9056 + "Breckenridge"
9057 + ],
9058 + "estimated_agents": 40,
9059 + "estimated_listings": 140,
9060 + "mls_affiliations": [
9061 + "SummitMLS"
9062 + ]
9063 + },
9064 + {
9065 + "name": "RE/MAX Properties of the Summit",
9066 + "website": "www.summitproperties.com",
9067 + "states": [
9068 + "CO"
9069 + ],
9070 + "cities": [
9071 + "Breckenridge"
9072 + ],
9073 + "estimated_agents": 40,
9074 + "estimated_listings": 130,
9075 + "mls_affiliations": [
9076 + "SummitMLS"
9077 + ]
9078 + },
9079 + {
9080 + "name": "NAV Real Estate (fka Keller Williams Park City)",
9081 + "website": "www.navrealestate.com",
9082 + "states": [
9083 + "UT"
9084 + ],
9085 + "cities": [
9086 + "Park City"
9087 + ],
9088 + "estimated_agents": 130,
9089 + "estimated_listings": 320,
9090 + "mls_affiliations": [
9091 + "ParkCityMLS"
9092 + ]
9093 + },
9094 + {
9095 + "name": "Berkshire Hathaway HomeServices Utah Properties",
9096 + "website": "www.bhhsutah.com",
9097 + "states": [
9098 + "UT"
9099 + ],
9100 + "cities": [
9101 + "Salt Lake City",
9102 + "Park City"
9103 + ],
9104 + "estimated_agents": 500,
9105 + "estimated_listings": 1100,
9106 + "mls_affiliations": [
9107 + "UtahRealEstate",
9108 + "ParkCityMLS"
9109 + ]
9110 + },
9111 + {
9112 + "name": "Century 21 Everest",
9113 + "website": "www.c21everest.com",
9114 + "states": [
9115 + "UT",
9116 + "CA",
9117 + "MO"
9118 + ],
9119 + "cities": [
9120 + "Salt Lake City"
9121 + ],
9122 + "estimated_agents": 700,
9123 + "estimated_listings": 1200,
9124 + "mls_affiliations": [
9125 + "UtahRealEstate"
9126 + ]
9127 + },
9128 + {
9129 + "name": "Realtypath",
9130 + "website": "www.realtypath.com",
9131 + "states": [
9132 + "UT"
9133 + ],
9134 + "cities": [
9135 + "Sandy"
9136 + ],
9137 + "estimated_agents": 1300,
9138 + "estimated_listings": 1900,
9139 + "mls_affiliations": [
9140 + "UtahRealEstate"
9141 + ]
9142 + },
9143 + {
9144 + "name": "KW South Valley Keller Williams",
9145 + "website": "www.kwsouthvalley.com",
9146 + "states": [
9147 + "UT"
9148 + ],
9149 + "cities": [
9150 + "South Jordan"
9151 + ],
9152 + "estimated_agents": 300,
9153 + "estimated_listings": 550,
9154 + "mls_affiliations": [
9155 + "UtahRealEstate"
9156 + ]
9157 + },
9158 + {
9159 + "name": "Red Rock Real Estate",
9160 + "website": "www.redrockutah.com",
9161 + "states": [
9162 + "UT"
9163 + ],
9164 + "cities": [
9165 + "Saint George"
9166 + ],
9167 + "estimated_agents": 300,
9168 + "estimated_listings": 600,
9169 + "mls_affiliations": [
9170 + "WCBRMLS"
9171 + ]
9172 + },
9173 + {
9174 + "name": "RE/MAX Associates Utah",
9175 + "website": "www.utahhomes.com",
9176 + "states": [
9177 + "UT"
9178 + ],
9179 + "cities": [
9180 + "Salt Lake City",
9181 + "Saint George"
9182 + ],
9183 + "estimated_agents": 400,
9184 + "estimated_listings": 850,
9185 + "mls_affiliations": [
9186 + "UtahRealEstate"
9187 + ]
9188 + },
9189 + {
9190 + "name": "HomeSmart Advantage Group Las Vegas",
9191 + "website": "www.homesmartlv.com",
9192 + "states": [
9193 + "NV"
9194 + ],
9195 + "cities": [
9196 + "Las Vegas"
9197 + ],
9198 + "estimated_agents": 1200,
9199 + "estimated_listings": 1800,
9200 + "mls_affiliations": [
9201 + "LVR"
9202 + ]
9203 + },
9204 + {
9205 + "name": "Coldwell Banker Premier Realty LV",
9206 + "website": "www.lvrealestate.com",
9207 + "states": [
9208 + "NV"
9209 + ],
9210 + "cities": [
9211 + "Las Vegas"
9212 + ],
9213 + "estimated_agents": 300,
9214 + "estimated_listings": 650,
9215 + "mls_affiliations": [
9216 + "LVR"
9217 + ]
9218 + },
9219 + {
9220 + "name": "Realty ONE Group Nevada",
9221 + "website": "www.realtyonegroup.com/nv",
9222 + "states": [
9223 + "NV"
9224 + ],
9225 + "cities": [
9226 + "Las Vegas"
9227 + ],
9228 + "estimated_agents": 2600,
9229 + "estimated_listings": 3800,
9230 + "mls_affiliations": [
9231 + "LVR"
9232 + ]
9233 + },
9234 + {
9235 + "name": "Sierra Sotheby's International Realty",
9236 + "website": "www.sierrasothebysrealty.com",
9237 + "states": [
9238 + "NV",
9239 + "CA"
9240 + ],
9241 + "cities": [
9242 + "Incline Village",
9243 + "Truckee"
9244 + ],
9245 + "estimated_agents": 200,
9246 + "estimated_listings": 500,
9247 + "mls_affiliations": [
9248 + "TahoeSierraMLS"
9249 + ]
9250 + },
9251 + {
9252 + "name": "Windermere Real Estate Spokane",
9253 + "website": "www.windermerespokane.com",
9254 + "states": [
9255 + "WA"
9256 + ],
9257 + "cities": [
9258 + "Spokane"
9259 + ],
9260 + "estimated_agents": 200,
9261 + "estimated_listings": 480,
9262 + "mls_affiliations": [
9263 + "SpokaneMLS"
9264 + ]
9265 + },
9266 + {
9267 + "name": "John L Scott Spokane",
9268 + "website": "www.johnlscottspokane.com",
9269 + "states": [
9270 + "WA"
9271 + ],
9272 + "cities": [
9273 + "Spokane"
9274 + ],
9275 + "estimated_agents": 130,
9276 + "estimated_listings": 320,
9277 + "mls_affiliations": [
9278 + "SpokaneMLS"
9279 + ]
9280 + },
9281 + {
9282 + "name": "Professional Realty Services International",
9283 + "website": "www.thepros.com",
9284 + "states": [
9285 + "WA",
9286 + "ID",
9287 + "OR",
9288 + "AZ",
9289 + "CA",
9290 + "NV"
9291 + ],
9292 + "cities": [
9293 + "Spokane"
9294 + ],
9295 + "estimated_agents": 900,
9296 + "estimated_listings": 1500,
9297 + "mls_affiliations": [
9298 + "SpokaneMLS"
9299 + ]
9300 + },
9301 + {
9302 + "name": "Windermere Real Estate Tacoma",
9303 + "website": "www.windermereprofessionalpartners.com",
9304 + "states": [
9305 + "WA"
9306 + ],
9307 + "cities": [
9308 + "Tacoma"
9309 + ],
9310 + "estimated_agents": 300,
9311 + "estimated_listings": 650,
9312 + "mls_affiliations": [
9313 + "NWMLS"
9314 + ]
9315 + },
9316 + {
9317 + "name": "Morrison House Sotheby's International Realty",
9318 + "website": "www.morrisonhousesir.com",
9319 + "states": [
9320 + "WA"
9321 + ],
9322 + "cities": [
9323 + "Kirkland"
9324 + ],
9325 + "estimated_agents": 60,
9326 + "estimated_listings": 160,
9327 + "mls_affiliations": [
9328 + "NWMLS"
9329 + ]
9330 + },
9331 + {
9332 + "name": "Redfin Oregon",
9333 + "website": "www.redfin.com/state/oregon",
9334 + "states": [
9335 + "OR"
9336 + ],
9337 + "cities": [
9338 + "Portland"
9339 + ],
9340 + "estimated_agents": 60,
9341 + "estimated_listings": 320,
9342 + "mls_affiliations": [
9343 + "RMLS"
9344 + ]
9345 + },
9346 + {
9347 + "name": "Soldera Properties",
9348 + "website": "www.soldera.com",
9349 + "states": [
9350 + "OR",
9351 + "WA"
9352 + ],
9353 + "cities": [
9354 + "Portland"
9355 + ],
9356 + "estimated_agents": 90,
9357 + "estimated_listings": 220,
9358 + "mls_affiliations": [
9359 + "RMLS"
9360 + ]
9361 + },
9362 + {
9363 + "name": "MORE Realty",
9364 + "website": "www.morerealty.com",
9365 + "states": [
9366 + "OR",
9367 + "WA"
9368 + ],
9369 + "cities": [
9370 + "Tigard",
9371 + "Portland"
9372 + ],
9373 + "estimated_agents": 1100,
9374 + "estimated_listings": 1700,
9375 + "mls_affiliations": [
9376 + "RMLS"
9377 + ]
9378 + },
9379 + {
9380 + "name": "eXp Realty Oregon",
9381 + "website": "www.exprealty.com/or",
9382 + "states": [
9383 + "OR"
9384 + ],
9385 + "cities": [],
9386 + "estimated_agents": 1300,
9387 + "estimated_listings": 1900,
9388 + "mls_affiliations": [
9389 + "RMLS"
9390 + ]
9391 + },
9392 + {
9393 + "name": "Berkshire Hathaway HomeServices Real Estate Professionals",
9394 + "website": "www.bhhsrep.com",
9395 + "states": [
9396 + "OR"
9397 + ],
9398 + "cities": [
9399 + "Salem"
9400 + ],
9401 + "estimated_agents": 200,
9402 + "estimated_listings": 480,
9403 + "mls_affiliations": [
9404 + "WVMLS"
9405 + ]
9406 + },
9407 + {
9408 + "name": "Windermere Real Estate Southern Oregon",
9409 + "website": "www.windermeresouthernoregon.com",
9410 + "states": [
9411 + "OR"
9412 + ],
9413 + "cities": [
9414 + "Medford",
9415 + "Ashland"
9416 + ],
9417 + "estimated_agents": 90,
9418 + "estimated_listings": 260,
9419 + "mls_affiliations": [
9420 + "SOMLS"
9421 + ]
9422 + },
9423 + {
9424 + "name": "Keller Williams Realty Diamond Partners",
9425 + "website": "www.kwdiamond.com",
9426 + "states": [
9427 + "KS"
9428 + ],
9429 + "cities": [
9430 + "Overland Park"
9431 + ],
9432 + "estimated_agents": 300,
9433 + "estimated_listings": 600,
9434 + "mls_affiliations": [
9435 + "Heartland"
9436 + ]
9437 + },
9438 + {
9439 + "name": "Weigand Realtors (J.P. Weigand & Sons)",
9440 + "website": "www.weigand.com",
9441 + "states": [
9442 + "KS"
9443 + ],
9444 + "cities": [
9445 + "Wichita"
9446 + ],
9447 + "estimated_agents": 300,
9448 + "estimated_listings": 700,
9449 + "mls_affiliations": [
9450 + "SCKMLS"
9451 + ]
9452 + },
9453 + {
9454 + "name": "Berkshire Hathaway HomeServices PenFed Realty Wichita",
9455 + "website": "www.penfedrealty.com/wichita",
9456 + "states": [
9457 + "KS"
9458 + ],
9459 + "cities": [
9460 + "Wichita"
9461 + ],
9462 + "estimated_agents": 130,
9463 + "estimated_listings": 320,
9464 + "mls_affiliations": [
9465 + "SCKMLS"
9466 + ]
9467 + },
9468 + {
9469 + "name": "McCurdy Real Estate & Auction",
9470 + "website": "www.mccurdy.com",
9471 + "states": [
9472 + "KS"
9473 + ],
9474 + "cities": [
9475 + "Wichita"
9476 + ],
9477 + "estimated_agents": 40,
9478 + "estimated_listings": 200,
9479 + "mls_affiliations": [
9480 + "SCKMLS"
9481 + ]
9482 + },
9483 + {
9484 + "name": "Coldwell Banker Plaza Real Estate",
9485 + "website": "www.cbplaza.com",
9486 + "states": [
9487 + "KS"
9488 + ],
9489 + "cities": [
9490 + "Wichita"
9491 + ],
9492 + "estimated_agents": 130,
9493 + "estimated_listings": 320,
9494 + "mls_affiliations": [
9495 + "SCKMLS"
9496 + ]
9497 + },
9498 + {
9499 + "name": "ERA Great American Realty",
9500 + "website": "www.eragreatamerican.com",
9501 + "states": [
9502 + "KS"
9503 + ],
9504 + "cities": [
9505 + "Wichita"
9506 + ],
9507 + "estimated_agents": 60,
9508 + "estimated_listings": 160,
9509 + "mls_affiliations": [
9510 + "SCKMLS"
9511 + ]
9512 + },
9513 + {
9514 + "name": "Gastineau Realty",
9515 + "website": "www.gastineaurealty.com",
9516 + "states": [
9517 + "MO"
9518 + ],
9519 + "cities": [
9520 + "Kansas City"
9521 + ],
9522 + "estimated_agents": 30,
9523 + "estimated_listings": 90,
9524 + "mls_affiliations": [
9525 + "Heartland"
9526 + ]
9527 + },
9528 + {
9529 + "name": "EXP Realty Missouri",
9530 + "website": "www.exprealty.com/mo",
9531 + "states": [
9532 + "MO"
9533 + ],
9534 + "cities": [],
9535 + "estimated_agents": 1300,
9536 + "estimated_listings": 2000,
9537 + "mls_affiliations": [
9538 + "MARIS",
9539 + "Heartland"
9540 + ]
9541 + },
9542 + {
9543 + "name": "Trelora Realty",
9544 + "website": "www.trelora.com",
9545 + "states": [
9546 + "CO",
9547 + "WA",
9548 + "AZ",
9549 + "MO",
9550 + "NC",
9551 + "SC",
9552 + "GA"
9553 + ],
9554 + "cities": [
9555 + "Denver"
9556 + ],
9557 + "estimated_agents": 100,
9558 + "estimated_listings": 350,
9559 + "mls_affiliations": [
9560 + "REcolorado"
9561 + ]
9562 + },
9563 + {
9564 + "name": "House to Home Realty (Springfield MO)",
9565 + "website": "www.housetohome.com",
9566 + "states": [
9567 + "MO"
9568 + ],
9569 + "cities": [
9570 + "Springfield"
9571 + ],
9572 + "estimated_agents": 40,
9573 + "estimated_listings": 110,
9574 + "mls_affiliations": [
9575 + "SOMOMLS"
9576 + ]
9577 + },
9578 + {
9579 + "name": "Keller Williams Greater Springfield",
9580 + "website": "www.kwspringfieldmo.com",
9581 + "states": [
9582 + "MO"
9583 + ],
9584 + "cities": [
9585 + "Springfield"
9586 + ],
9587 + "estimated_agents": 200,
9588 + "estimated_listings": 420,
9589 + "mls_affiliations": [
9590 + "SOMOMLS"
9591 + ]
9592 + },
9593 + {
9594 + "name": "ReeceNichols South Central Kansas",
9595 + "website": "www.reecenichols.com/wichita",
9596 + "states": [
9597 + "KS"
9598 + ],
9599 + "cities": [
9600 + "Wichita"
9601 + ],
9602 + "estimated_agents": 90,
9603 + "estimated_listings": 240,
9604 + "mls_affiliations": [
9605 + "SCKMLS"
9606 + ]
9607 + },
9608 + {
9609 + "name": "BHHS Stein & Summers",
9610 + "website": "www.steinsummers.com",
9611 + "states": [
9612 + "MO",
9613 + "KS"
9614 + ],
9615 + "cities": [
9616 + "Saint Joseph"
9617 + ],
9618 + "estimated_agents": 40,
9619 + "estimated_listings": 120,
9620 + "mls_affiliations": [
9621 + "Heartland"
9622 + ]
9623 + },
9624 + {
9625 + "name": "Coldwell Banker Select (Tulsa)",
9626 + "website": "www.cbtulsa.com",
9627 + "states": [
9628 + "OK"
9629 + ],
9630 + "cities": [
9631 + "Tulsa"
9632 + ],
9633 + "estimated_agents": 500,
9634 + "estimated_listings": 1100,
9635 + "mls_affiliations": [
9636 + "NORES"
9637 + ]
9638 + },
9639 + {
9640 + "name": "Walter & Associates Realtors",
9641 + "website": "www.walterandassociates.com",
9642 + "states": [
9643 + "OK"
9644 + ],
9645 + "cities": [
9646 + "Tulsa"
9647 + ],
9648 + "estimated_agents": 60,
9649 + "estimated_listings": 160,
9650 + "mls_affiliations": [
9651 + "NORES"
9652 + ]
9653 + },
9654 + {
9655 + "name": "Metro First Realty",
9656 + "website": "www.metrofirstrealty.com",
9657 + "states": [
9658 + "OK"
9659 + ],
9660 + "cities": [
9661 + "Oklahoma City"
9662 + ],
9663 + "estimated_agents": 500,
9664 + "estimated_listings": 850,
9665 + "mls_affiliations": [
9666 + "MLSOK"
9667 + ]
9668 + },
9669 + {
9670 + "name": "Whittington Realty",
9671 + "website": "www.whittingtonrealty.com",
9672 + "states": [
9673 + "OK"
9674 + ],
9675 + "cities": [
9676 + "Oklahoma City"
9677 + ],
9678 + "estimated_agents": 40,
9679 + "estimated_listings": 110,
9680 + "mls_affiliations": [
9681 + "MLSOK"
9682 + ]
9683 + },
9684 + {
9685 + "name": "Vermont Real Estate Company",
9686 + "website": "www.vermontrealestatecompany.com",
9687 + "states": [
9688 + "VT"
9689 + ],
9690 + "cities": [
9691 + "Burlington"
9692 + ],
9693 + "estimated_agents": 40,
9694 + "estimated_listings": 120,
9695 + "mls_affiliations": [
9696 + "NEREN"
9697 + ]
9698 + },
9699 + {
9700 + "name": "Coldwell Banker Classic Properties VT",
9701 + "website": "www.vermontproperty.net",
9702 + "states": [
9703 + "VT"
9704 + ],
9705 + "cities": [
9706 + "Ludlow"
9707 + ],
9708 + "estimated_agents": 20,
9709 + "estimated_listings": 70,
9710 + "mls_affiliations": [
9711 + "NEREN"
9712 + ]
9713 + },
9714 + {
9715 + "name": "Snyder Group Real Estate (KW Vermont)",
9716 + "website": "www.kwvermont.com",
9717 + "states": [
9718 + "VT"
9719 + ],
9720 + "cities": [
9721 + "South Burlington"
9722 + ],
9723 + "estimated_agents": 130,
9724 + "estimated_listings": 300,
9725 + "mls_affiliations": [
9726 + "NEREN"
9727 + ]
9728 + },
9729 + {
9730 + "name": "Maine Real Estate Network (BH&G The Masiello Group)",
9731 + "website": "www.masiello.com",
9732 + "states": [
9733 + "ME",
9734 + "NH",
9735 + "VT"
9736 + ],
9737 + "cities": [
9738 + "Portland",
9739 + "Keene"
9740 + ],
9741 + "estimated_agents": 500,
9742 + "estimated_listings": 1200,
9743 + "mls_affiliations": [
9744 + "MREIS",
9745 + "PrimeMLS"
9746 + ]
9747 + },
9748 + {
9749 + "name": "Duston Leddy Real Estate",
9750 + "website": "www.dustonleddy.com",
9751 + "states": [
9752 + "NH",
9753 + "ME"
9754 + ],
9755 + "cities": [
9756 + "Portsmouth"
9757 + ],
9758 + "estimated_agents": 90,
9759 + "estimated_listings": 240,
9760 + "mls_affiliations": [
9761 + "PrimeMLS"
9762 + ]
9763 + },
9764 + {
9765 + "name": "BHHS Verani Realty",
9766 + "website": "www.verani.com",
9767 + "states": [
9768 + "NH",
9769 + "MA",
9770 + "ME"
9771 + ],
9772 + "cities": [
9773 + "Londonderry"
9774 + ],
9775 + "estimated_agents": 500,
9776 + "estimated_listings": 1100,
9777 + "mls_affiliations": [
9778 + "PrimeMLS"
9779 + ]
9780 + },
9781 + {
9782 + "name": "Roche Realty Group",
9783 + "website": "www.rocherealty.com",
9784 + "states": [
9785 + "NH"
9786 + ],
9787 + "cities": [
9788 + "Meredith",
9789 + "Laconia"
9790 + ],
9791 + "estimated_agents": 40,
9792 + "estimated_listings": 130,
9793 + "mls_affiliations": [
9794 + "PrimeMLS"
9795 + ]
9796 + },
9797 + {
9798 + "name": "Berkshire Hathaway HomeServices Preferred Real Estate (Auburn)",
9799 + "website": "www.bhhspreferred.com",
9800 + "states": [
9801 + "AL"
9802 + ],
9803 + "cities": [
9804 + "Auburn",
9805 + "Opelika"
9806 + ],
9807 + "estimated_agents": 90,
9808 + "estimated_listings": 240,
9809 + "mls_affiliations": [
9810 + "LeeCountyMLS"
9811 + ]
9812 + },
9813 + {
9814 + "name": "Hamner Real Estate",
9815 + "website": "www.hamnerrealestate.com",
9816 + "states": [
9817 + "AL"
9818 + ],
9819 + "cities": [
9820 + "Tuscaloosa"
9821 + ],
9822 + "estimated_agents": 40,
9823 + "estimated_listings": 130,
9824 + "mls_affiliations": [
9825 + "TuscaloosaMLS"
9826 + ]
9827 + },
9828 + {
9829 + "name": "Keller Williams Realty Huntsville",
9830 + "website": "www.kwhuntsville.com",
9831 + "states": [
9832 + "AL"
9833 + ],
9834 + "cities": [
9835 + "Huntsville"
9836 + ],
9837 + "estimated_agents": 300,
9838 + "estimated_listings": 600,
9839 + "mls_affiliations": [
9840 + "ValleyMLS"
9841 + ]
9842 + },
9843 + {
9844 + "name": "Matt Curtis Real Estate",
9845 + "website": "www.mattcurtisrealestate.com",
9846 + "states": [
9847 + "AL",
9848 + "TN"
9849 + ],
9850 + "cities": [
9851 + "Madison",
9852 + "Huntsville"
9853 + ],
9854 + "estimated_agents": 130,
9855 + "estimated_listings": 350,
9856 + "mls_affiliations": [
9857 + "ValleyMLS"
9858 + ]
9859 + },
9860 + {
9861 + "name": "Legend Realty",
9862 + "website": "www.legendrealty.com",
9863 + "states": [
9864 + "AL"
9865 + ],
9866 + "cities": [
9867 + "Huntsville"
9868 + ],
9869 + "estimated_agents": 200,
9870 + "estimated_listings": 450,
9871 + "mls_affiliations": [
9872 + "ValleyMLS"
9873 + ]
9874 + },
9875 + {
9876 + "name": "RE/MAX by the Bay (AL Gulf Coast)",
9877 + "website": "www.remaxbythebayal.com",
9878 + "states": [
9879 + "AL"
9880 + ],
9881 + "cities": [
9882 + "Fairhope",
9883 + "Daphne"
9884 + ],
9885 + "estimated_agents": 60,
9886 + "estimated_listings": 170,
9887 + "mls_affiliations": [
9888 + "BaldwinMLS"
9889 + ]
9890 + },
9891 + {
9892 + "name": "Berkshire Hathaway HomeServices Cooper & Co Realtors",
9893 + "website": "www.bhhscooper.com",
9894 + "states": [
9895 + "AL"
9896 + ],
9897 + "cities": [
9898 + "Mobile"
9899 + ],
9900 + "estimated_agents": 130,
9901 + "estimated_listings": 320,
9902 + "mls_affiliations": [
9903 + "MobileMLS"
9904 + ]
9905 + },
9906 + {
9907 + "name": "Roberts Brothers Realtors",
9908 + "website": "www.robertsbrothers.com",
9909 + "states": [
9910 + "AL"
9911 + ],
9912 + "cities": [
9913 + "Mobile"
9914 + ],
9915 + "estimated_agents": 200,
9916 + "estimated_listings": 480,
9917 + "mls_affiliations": [
9918 + "MobileMLS"
9919 + ]
9920 + },
9921 + {
9922 + "name": "Keller Williams Realty Jackson MS",
9923 + "website": "www.kwjacksonms.com",
9924 + "states": [
9925 + "MS"
9926 + ],
9927 + "cities": [
9928 + "Ridgeland",
9929 + "Jackson"
9930 + ],
9931 + "estimated_agents": 200,
9932 + "estimated_listings": 400,
9933 + "mls_affiliations": [
9934 + "MGCMLS"
9935 + ]
9936 + },
9937 + {
9938 + "name": "Crye-Leike Oxford MS",
9939 + "website": "www.crye-leike.com/oxford",
9940 + "states": [
9941 + "MS"
9942 + ],
9943 + "cities": [
9944 + "Oxford"
9945 + ],
9946 + "estimated_agents": 40,
9947 + "estimated_listings": 120,
9948 + "mls_affiliations": [
9949 + "NEMSMLS"
9950 + ]
9951 + },
9952 + {
9953 + "name": "Tommy Morgan Realtors",
9954 + "website": "www.tmhomes.com",
9955 + "states": [
9956 + "MS"
9957 + ],
9958 + "cities": [
9959 + "Tupelo"
9960 + ],
9961 + "estimated_agents": 60,
9962 + "estimated_listings": 170,
9963 + "mls_affiliations": [
9964 + "NEMSMLS"
9965 + ]
9966 + },
9967 + {
9968 + "name": "Latter & Blum Mississippi Gulf Coast",
9969 + "website": "www.latter-blum.com/ms",
9970 + "states": [
9971 + "MS"
9972 + ],
9973 + "cities": [
9974 + "Gulfport"
9975 + ],
9976 + "estimated_agents": 90,
9977 + "estimated_listings": 240,
9978 + "mls_affiliations": [
9979 + "MGCMLS"
9980 + ]
9981 + },
9982 + {
9983 + "name": "Keller Williams Realty Services (Northshore LA)",
9984 + "website": "www.kwnorthshorela.com",
9985 + "states": [
9986 + "LA"
9987 + ],
9988 + "cities": [
9989 + "Mandeville",
9990 + "Covington"
9991 + ],
9992 + "estimated_agents": 300,
9993 + "estimated_listings": 600,
9994 + "mls_affiliations": [
9995 + "GSREIN"
9996 + ]
9997 + },
9998 + {
9999 + "name": "Berkshire Hathaway HomeServices United Properties (BR)",
10000 + "website": "www.bhhsunited.com",
10001 + "states": [
10002 + "LA"
10003 + ],
10004 + "cities": [
10005 + "Baton Rouge"
10006 + ],
10007 + "estimated_agents": 200,
10008 + "estimated_listings": 480,
10009 + "mls_affiliations": [
10010 + "GBRMLS"
10011 + ]
10012 + },
10013 + {
10014 + "name": "Burns & Co. Inc. Realtors (Baton Rouge)",
10015 + "website": "www.burnsandco-br.com",
10016 + "states": [
10017 + "LA"
10018 + ],
10019 + "cities": [
10020 + "Baton Rouge"
10021 + ],
10022 + "estimated_agents": 90,
10023 + "estimated_listings": 240,
10024 + "mls_affiliations": [
10025 + "GBRMLS"
10026 + ]
10027 + },
10028 + {
10029 + "name": "RE/MAX Professional (Lafayette LA)",
10030 + "website": "www.remaxacadiana.com",
10031 + "states": [
10032 + "LA"
10033 + ],
10034 + "cities": [
10035 + "Lafayette"
10036 + ],
10037 + "estimated_agents": 90,
10038 + "estimated_listings": 240,
10039 + "mls_affiliations": [
10040 + "RAAMLS"
10041 + ]
10042 + },
10043 + {
10044 + "name": "Keaty Real Estate",
10045 + "website": "www.keatyrealestate.com",
10046 + "states": [
10047 + "LA"
10048 + ],
10049 + "cities": [
10050 + "Lafayette"
10051 + ],
10052 + "estimated_agents": 130,
10053 + "estimated_listings": 320,
10054 + "mls_affiliations": [
10055 + "RAAMLS"
10056 + ]
10057 + }
10058 + ]
10059 +}
\ No newline at end of file
added frontend/index.html +33 −0
@@ -0,0 +1,33 @@
1 +<!doctype html>
2 +<!-- ---------------------------------------------------------------------------
3 + Home-Ka — US real-estate aggregator (Groupe KA)
4 + Author: Simon-Pierre Boucher — contact@spboucher.ai
5 +---------------------------------------------------------------------------- -->
6 +<html lang="en">
7 + <head>
8 + <meta charset="UTF-8" />
9 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10 + <title>Home-Ka — Every home for sale in America</title>
11 + <meta name="description" content="Home-Ka, a Groupe KA service, aggregates homes for sale across the United States from brokerages and listing platforms — every home for sale in America, in one place, always current." />
12 + <meta name="theme-color" content="#141814" />
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-title" content="Home-Ka" />
16 + <link rel="preconnect" href="https://fonts.googleapis.com" />
17 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
19 + <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
20 + <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
21 + <meta property="og:title" content="Home-Ka — Every home for sale in America" />
22 + <meta property="og:description" content="US real-estate aggregator — every home for sale in America, aggregated in one place, always current." />
23 + <meta property="og:image" content="https://www.home-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.home-ka.com/og.png" />
28 + </head>
29 + <body>
30 + <div id="root"></div>
31 + <script type="module" src="/src/main.tsx"></script>
32 + </body>
33 +</html>
added frontend/package-lock.json +1874 −0
@@ -0,0 +1,1874 @@
1 +{
2 + "name": "home-ka-frontend",
3 + "version": "1.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "home-ka-frontend",
9 + "version": "1.0.0",
10 + "dependencies": {
11 + "@groupe-ka/ka-maps": "file:../vendor/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 + "../vendor/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": "../vendor/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.6.0",
758 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
759 + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
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.4",
796 + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
797 + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
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.63.1",
812 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
813 + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
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.63.1",
826 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
827 + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
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.63.1",
840 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
841 + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
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.63.1",
854 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
855 + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
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.63.1",
868 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
869 + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
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.63.1",
882 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
883 + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
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.63.1",
896 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
897 + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
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.63.1",
913 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
914 + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
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.63.1",
930 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
931 + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
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.63.1",
947 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
948 + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
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.63.1",
964 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
965 + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
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.63.1",
981 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
982 + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
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.63.1",
998 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
999 + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
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.63.1",
1015 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
1016 + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
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.63.1",
1032 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
1033 + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
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.63.1",
1049 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
1050 + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
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.63.1",
1066 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
1067 + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
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.63.1",
1083 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
1084 + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
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.63.1",
1100 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
1101 + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
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.63.1",
1117 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
1118 + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
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.63.1",
1131 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
1132 + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
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.63.1",
1145 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
1146 + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
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.63.1",
1159 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
1160 + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
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.63.1",
1173 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
1174 + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
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.63.1",
1187 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
1188 + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
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.20",
1302 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
1303 + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
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.8",
1315 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
1316 + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
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.11.12",
1335 + "caniuse-lite": "^1.0.30001809",
1336 + "electron-to-chromium": "^1.5.402",
1337 + "node-releases": "^2.0.53",
1338 + "update-browserslist-db": "^1.3.0"
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.30001810",
1349 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
1350 + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
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.416",
1402 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz",
1403 + "integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==",
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.29.0",
1537 + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.29.0.tgz",
1538 + "integrity": "sha512-Fnh1WLsZMfihwRZY5scp456iQuZo9G97tTpb26bf/Ejsi/L7O+4dE9+I03VoOor2ul3DEOp6F2P3273omyVsNw==",
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.18",
1556 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1557 + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
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.54",
1575 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
1576 + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
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.6",
1656 + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
1657 + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
1658 + "license": "MIT",
1659 + "dependencies": {
1660 + "@remix-run/router": "1.23.4"
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.6",
1671 + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
1672 + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
1673 + "license": "MIT",
1674 + "dependencies": {
1675 + "@remix-run/router": "1.23.4",
1676 + "react-router": "6.30.6"
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.63.1",
1688 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
1689 + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
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.63.1",
1705 + "@rollup/rollup-android-arm64": "4.63.1",
1706 + "@rollup/rollup-darwin-arm64": "4.63.1",
1707 + "@rollup/rollup-darwin-x64": "4.63.1",
1708 + "@rollup/rollup-freebsd-arm64": "4.63.1",
1709 + "@rollup/rollup-freebsd-x64": "4.63.1",
1710 + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
1711 + "@rollup/rollup-linux-arm-musleabihf": "4.63.1",
1712 + "@rollup/rollup-linux-arm64-gnu": "4.63.1",
1713 + "@rollup/rollup-linux-arm64-musl": "4.63.1",
1714 + "@rollup/rollup-linux-loong64-gnu": "4.63.1",
1715 + "@rollup/rollup-linux-loong64-musl": "4.63.1",
1716 + "@rollup/rollup-linux-ppc64-gnu": "4.63.1",
1717 + "@rollup/rollup-linux-ppc64-musl": "4.63.1",
1718 + "@rollup/rollup-linux-riscv64-gnu": "4.63.1",
1719 + "@rollup/rollup-linux-riscv64-musl": "4.63.1",
1720 + "@rollup/rollup-linux-s390x-gnu": "4.63.1",
1721 + "@rollup/rollup-linux-x64-gnu": "4.63.1",
1722 + "@rollup/rollup-linux-x64-musl": "4.63.1",
1723 + "@rollup/rollup-openbsd-x64": "4.63.1",
1724 + "@rollup/rollup-openharmony-arm64": "4.63.1",
1725 + "@rollup/rollup-win32-arm64-msvc": "4.63.1",
1726 + "@rollup/rollup-win32-ia32-msvc": "4.63.1",
1727 + "@rollup/rollup-win32-x64-gnu": "4.63.1",
1728 + "@rollup/rollup-win32-x64-msvc": "4.63.1",
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.3.2",
1777 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
1778 + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
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": "home-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 + "@groupe-ka/ka-maps": "file:../vendor/ka-maps",
14 + "mapbox-gl": "^3.28.1",
15 + "react": "^18.3.1",
16 + "react-dom": "^18.3.1",
17 + "react-router-dom": "^6.26.0"
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/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="#e23744" stroke-opacity="0.35" stroke-width="2"/>
4 +<text x="32" y="43" text-anchor="middle" font-family="'Space Grotesk','Arial Black',sans-serif" font-size="30" font-weight="700" letter-spacing="-1" fill="#e23744" transform="rotate(-4 32 32)">Ka</text>
5 +</svg>
\ No newline at end of file
added frontend/public/og.png +0 −0

Binary file not shown.

added frontend/scripts/check-order.mjs +41 −0
@@ -0,0 +1,41 @@
1 +// Validation ordre des sections — fiche Immo-Ka (ordre DOM = ordre visuel)
2 +import { chromium, devices } from "playwright";
3 +
4 +const BASE = process.env.BASE || "http://localhost:18096";
5 +const UID = process.argv[2];
6 +const URL = `${BASE}/property/${encodeURIComponent(UID)}`;
7 +const SEL = [".f-galerie", ".f-hero", ".f-desc", "#caracteristiques", "#pieces", "#inclusions", "#carte", ".quartier"];
8 +
9 +async function check(name, ctxOpts) {
10 + const browser = await chromium.launch();
11 + const ctx = await browser.newContext(ctxOpts);
12 + const page = await ctx.newPage();
13 + await page.goto(URL, { waitUntil: "networkidle" });
14 + await page.waitForSelector(".f-galerie", { timeout: 15000 });
15 + await page.waitForTimeout(1200);
16 + const data = await page.evaluate((sel) => {
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 + out.push({ s, hidden: r.height === 0 && r.width === 0, top: Math.round(r.top + window.scrollY), left: Math.round(r.left), order: getComputedStyle(el).order });
23 + }
24 + return { out, scrollY: window.scrollY };
25 + }, SEL);
26 + console.log(`\n=== ${name} === scrollY: ${data.scrollY}`);
27 + for (const b of data.out)
28 + console.log(b.missing ? `${b.s.padEnd(18)} (non rendue)` : b.hidden ? `${b.s.padEnd(18)} (vide/masquée)` :
29 + `${b.s.padEnd(18)} top=${String(b.top).padStart(6)} left=${String(b.left).padStart(4)} order=${b.order}`);
30 + await browser.close();
31 + return data;
32 +}
33 +
34 +const mob = await check("iPhone 14 (mobile)", { ...devices["iPhone 14"] });
35 +await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } });
36 +const vis = mob.out.filter(b => !b.missing && !b.hidden);
37 +const sorted = vis.every((b, i) => i === 0 || b.top >= vis[i - 1].top);
38 +const ok = sorted && mob.scrollY === 0 && vis[0].s === ".f-galerie" && vis.every(b => b.order === "0");
39 +console.log(`\nMOBILE: ordre ${sorted ? "CROISSANT ✓" : "DÉSORDONNÉ ✗"} · scrollY=${mob.scrollY} · 1re=${vis[0].s} · sans order=${vis.every(b => b.order === "0")}`);
40 +console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC");
41 +process.exit(ok ? 0 : 1);
added frontend/src/App.tsx +199 −0
@@ -0,0 +1,199 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// App.tsx : global layout (header + live ticker + Groupe KA footer) + routing.
5 +// -----------------------------------------------------------------------------
6 +import { Ico } from "./components/Icons";
7 +import { useEffect, useState } from "react";
8 +import { NavLink, Route, Routes, useLocation } from "react-router-dom";
9 +import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";
10 +import GroupeKaBadge from "./ka/GroupeKaBadge";
11 +import KaFooter from "./ka/KaFooter";
12 +import ContactPage from "./pages/Contact";
13 +import Home from "./pages/Home";
14 +import { PrivacyPage, TermsPage } from "./pages/Legal";
15 +import ListingPage from "./pages/Listing";
16 +import SourcesPage from "./pages/Sources";
17 +import StatsPage from "./pages/Stats";
18 +import AdminPage from "./pages/Admin";
19 +
20 +function Ticker() {
21 + const [items, setItems] = useState<string[]>([]);
22 +
23 + useEffect(() => {
24 + Promise.all([fetchStats(), fetchFacets(), fetchSources()])
25 + .then(([stats, facets, src]) => {
26 + registerSourceNames(src.sources);
27 + const parts: string[] = [`${stats.total.toLocaleString("en-US")} homes for sale`];
28 + if (stats.states) parts.push(`${stats.states} states`);
29 + if (stats.cities) parts.push(`${stats.cities.toLocaleString("en-US")} cities`);
30 + if (stats.avg_price != null)
31 + parts.push(`Average price $${Math.round(stats.avg_price).toLocaleString("en-US")}`);
32 + for (const s of facets.sources.slice(0, 12))
33 + parts.push(`${sourceName(s.source)} · ${s.n}`);
34 + parts.push("Updated automatically");
35 + setItems(parts);
36 + })
37 + .catch(() => setItems(["Home-Ka — every home for sale in America"]));
38 + }, []);
39 +
40 + if (items.length === 0) return null;
41 + return (
42 + <div className="ticker" aria-hidden="true">
43 + <div className="ticker-track">
44 + {[...items, ...items].map((t, i) => (
45 + <span key={i}>{t}</span>
46 + ))}
47 + </div>
48 + </div>
49 + );
50 +}
51 +
52 +const NAV_LINKS = [
53 + { to: "/", label: "Homes", icon: "home", end: true },
54 + { to: "/?view=map", label: "Map", icon: "map", end: false, force: true },
55 + { to: "/stats", label: "Stats", icon: "chart", end: false },
56 + { to: "/sources", label: "Sources", icon: "building", end: false },
57 + { to: "/contact", label: "Contact", icon: "arrow", end: false },
58 +];
59 +
60 +function Header() {
61 + const [open, setOpen] = useState(false);
62 + const location = useLocation();
63 +
64 + useEffect(() => { setOpen(false); }, [location]);
65 + useEffect(() => {
66 + document.documentElement.classList.toggle("ka-scroll-lock", open);
67 + return () => { document.documentElement.classList.remove("ka-scroll-lock"); };
68 + }, [open]);
69 +
70 + return (
71 + <>
72 + <header className="header">
73 + <div className="container header-inner">
74 + <NavLink to="/" className="brand" aria-label="Home-Ka — home">
75 + Home<span className="ka">Ka</span>
76 + </NavLink>
77 + <GroupeKaBadge />
78 + <nav className="nav" aria-label="Main navigation">
79 + <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
80 + Homes
81 + </NavLink>
82 + <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
83 + Stats
84 + </NavLink>
85 + <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>
86 + Sources
87 + </NavLink>
88 + <NavLink to="/contact" className={({ isActive }) => (isActive ? "active" : "")}>
89 + Contact
90 + </NavLink>
91 + </nav>
92 + <button
93 + className={`menu-btn ${open ? "open" : ""}`}
94 + aria-expanded={open}
95 + aria-label={open ? "Close menu" : "Open menu"}
96 + onClick={() => setOpen(!open)}
97 + >
98 + <span /><span /><span />
99 + </button>
100 + </div>
101 +
102 + <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Mobile menu">
103 + <button type="button" className="mm-close" aria-label="Close menu"
104 + onClick={() => setOpen(false)}>✕</button>
105 + {NAV_LINKS.map((l, i) => (
106 + <NavLink
107 + key={l.to}
108 + to={l.to}
109 + end={l.end}
110 + style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}
111 + className={({ isActive }) => `mm-link ${isActive && !l.force ? "active" : ""}`}
112 + onClick={() => setOpen(false)}
113 + >
114 + <span className="mm-ico" aria-hidden="true"><Ico name={l.icon} size={19} /></span>
115 + {l.label}
116 + <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span>
117 + </NavLink>
118 + ))}
119 + <div className="mm-foot">
120 + <GroupeKaBadge />
121 + <p>
122 + Independent aggregator — updated automatically; every listing
123 + links back to the original announcement at the source.
124 + </p>
125 + </div>
126 + </div>
127 + </header>
128 + {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
129 + <Ticker />
130 + </>
131 + );
132 +}
133 +
134 +const LOCAL_LEGAL = [
135 + { label: "Terms of Use (Home-Ka)", href: "/terms" },
136 + { label: "Privacy Policy (Home-Ka)", href: "/privacy" },
137 + { label: "Contact", href: "/contact" },
138 +];
139 +
140 +/** Bottom navigation bar (mobile) — floating pill, same language as Lou-Ka v2. */
141 +function MobileTabBar() {
142 + const location = useLocation();
143 + const isMap = new URLSearchParams(location.search).get("view") === "map";
144 + const tabs = [
145 + { to: "/", label: "Discover", icon: <Ico name="search" size={20} stroke={1.6} />, on: location.pathname === "/" && !isMap },
146 + { to: "/?view=map", label: "Map", icon: <Ico name="map" size={20} stroke={1.6} />, on: location.pathname === "/" && isMap },
147 + { to: "/stats", label: "Stats", icon: <Ico name="chart" size={20} stroke={1.6} />, on: location.pathname.startsWith("/stats") },
148 + { to: "/sources", label: "Sources", icon: <Ico name="building" size={20} stroke={1.6} />, on: location.pathname.startsWith("/sources") },
149 + ];
150 + return (
151 + <nav className="tabbar" aria-label="Mobile navigation">
152 + {tabs.map((t) => (
153 + <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>
154 + {t.icon}
155 + {t.label}
156 + </NavLink>
157 + ))}
158 + </nav>
159 + );
160 +}
161 +
162 +export default function App() {
163 + return (
164 + <>
165 + <Header />
166 + <main>
167 + <Routes>
168 + <Route path="/" element={<Home />} />
169 + <Route path="/property/:uid" element={<ListingPage />} />
170 + {/* SEO canonical URL: /property/{uid}/{slug} — without this route any
171 + shared/direct link would land on the 404 page */}
172 + <Route path="/property/:uid/:slug" element={<ListingPage />} />
173 + <Route path="/stats" element={<StatsPage />} />
174 + <Route path="/sources" element={<SourcesPage />} />
175 + <Route path="/contact" element={<ContactPage />} />
176 + <Route path="/terms" element={<TermsPage />} />
177 + <Route path="/privacy" element={<PrivacyPage />} />
178 + {/* Admin — reachable by URL only, not linked in the nav */}
179 + <Route path="/admin" element={<AdminPage tab="sources" />} />
180 + <Route path="/admin/sources" element={<AdminPage tab="sources" />} />
181 + <Route path="/admin/connectors" element={<AdminPage tab="connectors" />} />
182 + <Route path="/admin/brokerages" element={<AdminPage tab="brokerages" />} />
183 + <Route
184 + path="*"
185 + element={
186 + <div className="notice container">
187 + <div className="big">🧭</div>
188 + <h2>Page not found</h2>
189 + <p>The requested link does not exist.</p>
190 + </div>
191 + }
192 + />
193 + </Routes>
194 + </main>
195 + <MobileTabBar />
196 + <KaFooter siteId="home-ka" localLegal={LOCAL_LEGAL} />
197 + </>
198 + );
199 +}
added frontend/src/api.ts +402 −0
@@ -0,0 +1,402 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// api.ts : types + robust API client (timeout, typed errors) for the Home-Ka
5 +// FastAPI backend (homeka/web.py) — public search/map/detail + admin API.
6 +// -----------------------------------------------------------------------------
7 +
8 +/** details: free-form dictionary of source characteristics (label → value). */
9 +export interface ListingDetails {
10 + price_from?: boolean;
11 + cover_thumb?: string;
12 + photo_captions?: string[];
13 + [key: string]: unknown;
14 +}
15 +
16 +/** A LISTING = one publication of a property by one source. */
17 +export interface Listing {
18 + uid: string;
19 + source: string;
20 + external_id: string;
21 + property_id?: number | null;
22 + url: string;
23 + title: string;
24 + street_address: string;
25 + unit?: string | null;
26 + city: string;
27 + state: string; // 2-letter USPS
28 + zip_code: string;
29 + county: string;
30 + property_type: string;
31 + property_subtype?: string | null;
32 + list_price: number | null;
33 + price_label: string;
34 + bedrooms: number | null;
35 + bathrooms: number | null; // can be 2.5
36 + bathrooms_full?: number | null;
37 + bathrooms_half?: number | null;
38 + living_area_sqft: number | null;
39 + lot_size_sqft: number | null;
40 + year_built: number | null;
41 + apn?: string | null;
42 + mls_id: string;
43 + mls_name?: string | null;
44 + status: string; // active | pending | sold | withdrawn | coming-soon
45 + listed_at?: string | null;
46 + days_on_market?: number | null;
47 + brokerage_name: string;
48 + office_name?: string | null;
49 + agent_name: string;
50 + agent_phone: string;
51 + agent_email?: string | null;
52 + description: string;
53 + features: string[];
54 + details: ListingDetails;
55 + images: string[];
56 + lat: number | null;
57 + lng: number | null;
58 + price_history?: { ts: number; price: number | null }[];
59 + duplicates?: DuplicateListing[]; // same property published elsewhere
60 + property?: Property | null; // the physical PROPERTY behind this listing
61 + first_seen?: number;
62 + last_seen?: number;
63 + updated_at?: number;
64 + active?: number;
65 + published?: number;
66 +}
67 +
68 +export interface DuplicateListing {
69 + uid: string;
70 + source: string;
71 + url: string;
72 + brokerage_name: string;
73 + agent_name: string;
74 + price_label: string;
75 +}
76 +
77 +/** One row of a property's listing history (properties → listings). */
78 +export interface PropertyListingRef {
79 + uid: string;
80 + source: string;
81 + status: string;
82 + list_price: number | null;
83 + first_seen: number | null;
84 + last_seen: number | null;
85 + active: number;
86 +}
87 +
88 +/** The physical PROPERTY — Home-Ka separates PROPERTY from LISTING. */
89 +export interface Property {
90 + id: number;
91 + apn?: string | null;
92 + street_address?: string | null;
93 + unit?: string | null;
94 + city?: string | null;
95 + state?: string | null;
96 + zip_code?: string | null;
97 + county?: string | null;
98 + property_type?: string | null;
99 + year_built?: number | null;
100 + living_area_sqft?: number | null;
101 + lot_size_sqft?: number | null;
102 + lat?: number | null;
103 + lng?: number | null;
104 + details?: Record<string, unknown>;
105 + first_seen?: number | null;
106 + last_seen?: number | null;
107 + listing_history?: PropertyListingRef[];
108 +}
109 +
110 +export interface Facets {
111 + states: { state: string; n: number }[];
112 + cities: { city: string; n: number }[];
113 + property_types: string[];
114 + sources: { source: string; n: number }[];
115 +}
116 +
117 +export interface Source {
118 + id: string;
119 + name: string;
120 + connector_type: string | null;
121 + states: string[];
122 + enabled: number;
123 + active_listings: number;
124 + last_sync: number | null;
125 +}
126 +
127 +export interface SyncLogRow {
128 + source: string;
129 + ts: number;
130 + found: number;
131 + added: number;
132 + updated: number;
133 + removed: number;
134 + ok: number;
135 + message: string;
136 +}
137 +
138 +export interface Stats {
139 + total: number;
140 + sources: number;
141 + cities: number;
142 + states: number;
143 + avg_price: number | null;
144 + min_price: number | null;
145 + max_price: number | null;
146 + properties: number;
147 + by_state: { state: string; n: number; avg_price: number | null }[];
148 + by_type: { property_type: string; n: number; avg_price: number | null }[];
149 + quality?: Record<string, unknown>;
150 + recent_syncs?: SyncLogRow[];
151 +}
152 +
153 +// --- Source display names -----------------------------------------------------
154 +const SOURCE_NAMES: Record<string, string> = {};
155 +export function registerSourceNames(sources: Source[]) {
156 + for (const s of sources) SOURCE_NAMES[s.id] = s.name;
157 +}
158 +export function sourceName(id: string): string {
159 + if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];
160 + return id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
161 +}
162 +
163 +async function get<T>(path: string, headers?: Record<string, string>): Promise<T> {
164 + const ctrl = new AbortController();
165 + const timer = setTimeout(() => ctrl.abort(), 25000);
166 + try {
167 + const res = await fetch(path, { signal: ctrl.signal, headers });
168 + if (!res.ok) throw new Error(`API ${res.status} — ${path}`);
169 + return (await res.json()) as T;
170 + } finally {
171 + clearTimeout(timer);
172 + }
173 +}
174 +
175 +async function post<T>(
176 + path: string, body?: unknown, headers?: Record<string, string>,
177 +): Promise<T> {
178 + const ctrl = new AbortController();
179 + const timer = setTimeout(() => ctrl.abort(), 60000);
180 + try {
181 + const res = await fetch(path, {
182 + method: "POST",
183 + headers: { "Content-Type": "application/json", ...(headers ?? {}) },
184 + body: body === undefined ? undefined : JSON.stringify(body),
185 + signal: ctrl.signal,
186 + });
187 + if (!res.ok) throw new Error(`API ${res.status} — ${path}`);
188 + return (await res.json()) as T;
189 + } finally {
190 + clearTimeout(timer);
191 + }
192 +}
193 +
194 +export interface ListingFilters {
195 + q?: string;
196 + city?: string;
197 + state?: string;
198 + zip_code?: string;
199 + county?: string;
200 + property_type?: string;
201 + status?: string;
202 + source?: string;
203 + price_min?: string;
204 + price_max?: string;
205 + beds_min?: string;
206 + baths_min?: string;
207 + sqft_min?: string;
208 + sort?: string; // recent | price_asc | price_desc
209 +}
210 +
211 +export function listingParams(f: ListingFilters): URLSearchParams {
212 + const params = new URLSearchParams();
213 + for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
214 + return params;
215 +}
216 +
217 +export function fetchListings(f: ListingFilters, limit = 60, offset = 0) {
218 + const params = listingParams(f);
219 + params.set("limit", String(limit));
220 + params.set("offset", String(offset));
221 + return get<{ total: number; count: number; listings: Listing[] }>(
222 + `/api/listings?${params}`);
223 +}
224 +
225 +export const fetchListing = (uid: string) =>
226 + get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);
227 +export const fetchFacets = (state?: string) =>
228 + get<Facets>(`/api/facets${state ? `?state=${encodeURIComponent(state)}` : ""}`);
229 +export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
230 +export const fetchStats = () => get<Stats>("/api/stats");
231 +
232 +// --- Formatting (US) ----------------------------------------------------------
233 +export const fmtPrice = (p: number | null | undefined, label?: string) =>
234 + p != null
235 + ? "$" + Math.round(p).toLocaleString("en-US")
236 + : label || "Price on request";
237 +
238 +export const fmtArea = (a: number | null | undefined): string | null =>
239 + a != null ? `${Math.round(a).toLocaleString("en-US")} sq ft` : null;
240 +
241 +export const fmtDate = (ts: number): string =>
242 + new Date(ts * 1000).toLocaleDateString("en-US", {
243 + month: "long", day: "numeric", year: "numeric",
244 + });
245 +
246 +/** 2.5 → "2.5", 2 → "2" — bathrooms can be half-counts. */
247 +export const fmtBaths = (b: number | null | undefined): string =>
248 + b == null ? "—" : (Number.isInteger(b) ? String(b) : b.toFixed(1));
249 +
250 +/** "Austin, TX 78704" from the parts that exist. */
251 +export const fmtCityLine = (
252 + l: { city?: string | null; state?: string | null; zip_code?: string | null },
253 +): string =>
254 + [
255 + [l.city, l.state].filter(Boolean).join(", "),
256 + l.zip_code ?? "",
257 + ].filter(Boolean).join(" ");
258 +
259 +export const STATUS_LABELS: Record<string, string> = {
260 + "active": "For sale",
261 + "pending": "Pending",
262 + "sold": "Sold",
263 + "withdrawn": "Withdrawn",
264 + "coming-soon": "Coming soon",
265 +};
266 +
267 +// =============================================================================
268 +// Admin API — /api/admin/* (optional X-Admin-Token header, stored locally)
269 +// =============================================================================
270 +
271 +export const ADMIN_TOKEN_KEY = "homeka_admin_token";
272 +
273 +export function getAdminToken(): string {
274 + try { return localStorage.getItem(ADMIN_TOKEN_KEY) ?? ""; } catch { return ""; }
275 +}
276 +export function setAdminToken(token: string) {
277 + try {
278 + if (token) localStorage.setItem(ADMIN_TOKEN_KEY, token);
279 + else localStorage.removeItem(ADMIN_TOKEN_KEY);
280 + } catch { /* storage unavailable */ }
281 +}
282 +function adminHeaders(): Record<string, string> {
283 + const t = getAdminToken();
284 + return t ? { "X-Admin-Token": t } : {};
285 +}
286 +
287 +export interface AdminOverview {
288 + active_listings: number;
289 + properties: number;
290 + enabled_sources: number;
291 + errors_24h: number;
292 + brokerages_by_status: { partnership_status: string; n: number }[];
293 + last_successful_sync: number | null;
294 +}
295 +
296 +export interface AdminSource {
297 + id: string;
298 + name: string;
299 + connector_type: string | null;
300 + enabled: number;
301 + authority: number | null;
302 + states: string[];
303 + notes: string;
304 + brokerage_id: number | null;
305 + config: Record<string, unknown>;
306 + active_listings: number;
307 + published: number;
308 + no_geo: number;
309 + last_sync: number | null;
310 + last_ok: number | null;
311 + last_message: string | null;
312 + last_found: number | null;
313 + last_added: number | null;
314 + last_removed: number | null;
315 + errors_7d: number;
316 + freshness_hours: number | null;
317 +}
318 +
319 +export interface ConnectorFamily {
320 + family: string;
321 + class: string;
322 + module: string;
323 + doc: string;
324 + instances: number;
325 +}
326 +export interface CustomConnector {
327 + source_id: string;
328 + class: string;
329 + module: string;
330 +}
331 +
332 +export interface Brokerage {
333 + id: number;
334 + name: string;
335 + website: string | null;
336 + states: string[];
337 + cities: string[];
338 + estimated_agents: number | null;
339 + estimated_listings: number | null;
340 + mls_affiliations: string[];
341 + idx_present: number | null;
342 + idx_provider: string | null;
343 + reso_detected: number | null;
344 + possible_feed_type: string | null;
345 + contact_page: string | null;
346 + partnership_contact: string | null;
347 + technical_contact: string | null;
348 + feed_probability_score: number | null;
349 + priority_score: number | null;
350 + partnership_status: string;
351 + source_id: string | null;
352 + last_inspected: number | null;
353 + inspect_error: string | null;
354 + evidence: Record<string, unknown>;
355 +}
356 +
357 +export const PARTNERSHIP_STATUSES = [
358 + "prospect", "to-contact", "contacted", "in-discussion",
359 + "feed-received", "live", "declined",
360 +] as const;
361 +
362 +export const fetchAdminOverview = () =>
363 + get<AdminOverview>("/api/admin/overview", adminHeaders());
364 +
365 +export const fetchAdminSources = () =>
366 + get<{ sources: AdminSource[] }>("/api/admin/sources", adminHeaders());
367 +
368 +export const fetchAdminConnectors = () =>
369 + get<{ families: ConnectorFamily[]; custom: CustomConnector[] }>(
370 + "/api/admin/connectors", adminHeaders());
371 +
372 +export interface BrokerageFilters {
373 + state?: string;
374 + status?: string;
375 + feed_type?: string;
376 + idx_provider?: string;
377 + q?: string;
378 + min_priority?: string;
379 + inspected?: string; // "1" | "0" | ""
380 + sort?: string; // priority | feed | name | inspected
381 +}
382 +
383 +export function fetchAdminBrokerages(f: BrokerageFilters, limit = 50, offset = 0) {
384 + const params = new URLSearchParams();
385 + for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
386 + params.set("limit", String(limit));
387 + params.set("offset", String(offset));
388 + return get<{ total: number; count: number; brokerages: Brokerage[] }>(
389 + `/api/admin/brokerages?${params}`, adminHeaders());
390 +}
391 +
392 +export const inspectBrokerage = (id: number) =>
393 + post<Record<string, unknown>>(
394 + `/api/admin/brokerages/${id}/inspect`, undefined, adminHeaders());
395 +
396 +export const setBrokerageStatus = (id: number, status: string) =>
397 + post<{ id: number; status: string }>(
398 + `/api/admin/brokerages/${id}/status`, { status }, adminHeaders());
399 +
400 +export const runDiscovery = (limit = 25) =>
401 + post<{ status: string; limit: number }>(
402 + `/api/admin/discover?limit=${limit}`, undefined, adminHeaders());
added frontend/src/components/AmenityIco.tsx +111 −0
@@ -0,0 +1,111 @@
1 +// -----------------------------------------------------------------------------
2 +// Groupe KA — shared component (Home-Ka edition)
3 +// components/AmenityIco.tsx : contextual icon for a feature/amenity label.
4 +// Each label is matched by keyword (accents neutralized) to one of the ~30
5 +// house pictograms — 1.8 stroke, 24×24, currentColor. Fallback: ✓.
6 +// -----------------------------------------------------------------------------
7 +import { ReactNode } from "react";
8 +
9 +const ICONS: Record<string, ReactNode> = {
10 + 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" /></>),
11 + bolt: <path d="M13 2.5 4.5 13.5H11l-1.5 8 8.5-11H11.5l1.5-8z" />,
12 + 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" /></>),
13 + 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" />,
14 + 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" /></>),
15 + 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" /></>),
16 + 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" /></>),
17 + snow: <path d="M12 2.5v19M3.8 7.25l16.4 9.5M20.2 7.25 3.8 16.75" />,
18 + 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" /></>),
19 + 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" />,
20 + 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" />,
21 + gym: <path d="M6.7 6.7v10.6M17.3 6.7v10.6M3.5 9.2v5.6M20.5 9.2v5.6M6.7 12h10.6" />,
22 + 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" />,
23 + 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" />,
24 + 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" /></>),
25 + garage: <path d="M3.5 20V9.5L12 4l8.5 5.5V20M7 20v-6.5h10V20M7 16.8h10" />,
26 + 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" />,
27 + nosmoke: <path d="M4 4l16 16M14.5 13H4v3h6.5M17.5 13H20v3h-1" />,
28 + 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" /></>),
29 + 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" /></>),
30 + tree: <path d="M12 3 7.2 9.5h2.4L5.5 15.5h13L14.4 9.5h2.4zM12 15.5V21" />,
31 + 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" />,
32 + 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" /></>),
33 + 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" /></>),
34 + 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" />,
35 + 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" /></>),
36 + 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" />,
37 + bed: <path d="M3.5 18V6.5M3.5 13.5h17V18M3.5 10h6.5v3.5M20.5 13.5v-1a3 3 0 0 0-3-3H10" />,
38 + 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" />,
39 + 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" /></>),
40 + stairs: <path d="M3.5 20.5h4.2v-4.2h4.2v-4.2h4.2V7.9h4.4" />,
41 + door: (<><rect x="6" y="3" width="12" height="18" rx="1.5" /><path d="M14.8 12h.01M4 21h16" /></>),
42 + home: <path d="m3.5 11 8.5-7 8.5 7M6 9.5V20h12V9.5" />,
43 + 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" /></>),
44 + check: <path d="m4.5 12.5 5.3 5.3L19.5 6.5" />,
45 + 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" />,
46 +};
47 +
48 +/** [pattern (on lowercase accent-free label), icon key] — order = priority */
49 +const RULES: [RegExp, string][] = [
50 + [/dish ?washer/, "dishwasher"],
51 + [/washer|dryer|laundry hookup/, "washer"],
52 + [/laundry/, "laundry"],
53 + [/appliance|fridge|refrigerator|range\b|oven|stove|microwave/, "fridge"],
54 + [/air condition|central air|cooling|heat pump|\ba\/?c\b|hvac/, "snow"],
55 + [/heat|furnace|radiant|baseboard/, "heat"],
56 + [/water heater|hot water/, "droplet"],
57 + [/electric|solar|generator/, "bolt"],
58 + [/internet|wi-?fi|\bcable\b|fiber/, "wifi"],
59 + [/elevator/, "elevator"],
60 + [/balcon|terrace|patio|porch|deck|veranda/, "balcony"],
61 + [/pool/, "pool"],
62 + [/gym|fitness|exercise/, "gym"],
63 + [/storage|closet|walk-?in|pantry|shed/, "box"],
64 + [/garage/, "garage"],
65 + [/parking|carport|driveway/, "parking"],
66 + [/furnish/, "sofa"],
67 + [/non-?smoking|smoke-?free/, "nosmoke"],
68 + [/\bpet\b|\bdog\b|\bcat\b/, "paw"],
69 + [/fireplace|wood stove|chimney/, "fire"],
70 + [/spa\b|jacuzzi|sauna|hot tub|whirlpool/, "spa"],
71 + [/security|surveillance|camera|alarm|intercom|gated|doorman/, "shield"],
72 + [/\bview\b|panoram/, "eye"],
73 + [/waterfront|lake|river|beach|ocean|dock|boat/, "waves"],
74 + [/yard|garden|tree|wooded|lawn|landscap|fence/, "tree"],
75 + [/sunn?y|natural light|bright|sunroom|skylight/, "sun"],
76 + [/available|move-?in/, "calendar"],
77 + [/sq ?ft|square (feet|foot)|acre/, "ruler"],
78 + [/bed ?room|\bbed\b/, "bed"],
79 + [/bath ?room|\bbath\b|shower/, "bath"],
80 + [/occupant|tenant|resident/, "people"],
81 + [/stor(y|ies)|floor|stair|level|basement|attic|loft/, "stairs"],
82 + [/studio|\bunit\b/, "door"],
83 + [/single ?family|ranch|bungalow|house/, "home"],
84 + [/sewer|septic/, "waves"],
85 + [/water supply|well water|city water/, "droplet"],
86 + [/construction|new build|foundation|roof|siding|remodel|renovat|updated/, "home"],
87 + [/commercial|zoning|use\b/, "box"],
88 +];
89 +
90 +/** lowercase + accents neutralized, for robust matching */
91 +const norm = (s: string) =>
92 + s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
93 +
94 +export function amenityKey(label: string, fallback = "check"): string {
95 + const n = norm(label);
96 + for (const [re, key] of RULES) if (re.test(n)) return key;
97 + return fallback;
98 +}
99 +
100 +export default function AmenityIco({ label, size = 16, fallback = "check" }:
101 + { label: string; size?: number; fallback?: string }) {
102 + return (
103 + <svg
104 + width={size} height={size} viewBox="0 0 24 24" aria-hidden="true"
105 + fill="none" stroke="currentColor" strokeWidth={1.8}
106 + strokeLinecap="round" strokeLinejoin="round"
107 + >
108 + {ICONS[amenityKey(label, fallback)] ?? ICONS.check}
109 + </svg>
110 + );
111 +}
added frontend/src/components/Icons.tsx +188 −0
@@ -0,0 +1,188 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/Icons.tsx : HOUSE iconography — 1.7 px strokes on a 24 grid
5 +// (zero emoji, zero generic packs). Each icon is a clean stroke path that
6 +// inherits the text color (currentColor).
7 +// -----------------------------------------------------------------------------
8 +import { ReactNode } from "react";
9 +
10 +const P: Record<string, ReactNode> = {
11 + // --- navigation -----------------------------------------------------------
12 + home: (<>
13 + <path d="M3.5 10.6 12 3.4l8.5 7.2" />
14 + <path d="M5.6 9.4V20h12.8V9.4" />
15 + <path d="M10 20v-5.6h4V20" />
16 + </>),
17 + map: (<>
18 + <path d="M3.2 6.4 9 4.2l6 2.2 5.8-2.2v13.4L15 19.8l-6-2.2-5.8 2.2z" />
19 + <path d="M9 4.2v13.4M15 6.4v13.4" />
20 + </>),
21 + chart: (<>
22 + <path d="M4 20h16" />
23 + <path d="M7.2 20v-5.6M12 20V8.8M16.8 20V12" />
24 + </>),
25 + building: (<>
26 + <path d="M5 20V5.6A1.6 1.6 0 0 1 6.6 4h6.2a1.6 1.6 0 0 1 1.6 1.6V20" />
27 + <path d="M14.4 9.4h3.4A1.6 1.6 0 0 1 19.4 11v9" />
28 + <path d="M3.2 20h17.6" />
29 + <path d="M8 8h2.4M8 11.6h2.4M8 15.2h2.4M16.4 13h.9M16.4 16.2h.9" />
30 + </>),
31 +
32 + // --- specs propriété --------------------------------------------------------
33 + bed: (<>
34 + <path d="M3.4 6.8V18.6" />
35 + <path d="M3.4 15.4h17.2v3.2" />
36 + <path d="M3.4 12.2h6.2v3.2" />
37 + <circle cx="6.5" cy="9.9" r="1.35" />
38 + <path d="M11.4 12.2h5.8a3.4 3.4 0 0 1 3.4 3.2" />
39 + </>),
40 + bath: (<>
41 + <path d="M3.6 12.4h16.8v1.4a5.2 5.2 0 0 1-5.2 5.2H8.8a5.2 5.2 0 0 1-5.2-5.2z" />
42 + <path d="M5.8 12.4V5.9a2.1 2.1 0 0 1 4-1" />
43 + <path d="m7 19.4-1 2.1M17 19.4l1 2.1" />
44 + </>),
45 + drop: (<>
46 + <path d="M12 3.6s6 6.4 6 10.6a6 6 0 1 1-12 0C6 10 12 3.6 12 3.6z" />
47 + <path d="M9.4 14.2a2.7 2.7 0 0 0 2 2.6" />
48 + </>),
49 + area: (<>
50 + <path d="M4.4 19.6 19.6 4.4" />
51 + <path d="M4.4 14v5.6H10" />
52 + <path d="M19.6 10V4.4H14" />
53 + </>),
54 + land: (<>
55 + <path d="M4.4 9.6v9.8M9.5 9.6v9.8M14.5 9.6v9.8M19.6 9.6v9.8" />
56 + <path d="M3 12.6h18M3 16.4h18" />
57 + <path d="M4.4 9.6 12 5.2l7.6 4.4" />
58 + </>),
59 + calendar: (<>
60 + <rect x="4" y="5.6" width="16" height="14.8" rx="2" />
61 + <path d="M4 10.2h16M8.2 3.4v4M15.8 3.4v4" />
62 + </>),
63 + tag: (<>
64 + <path d="m12.9 3.6 7.5 7.5a1.8 1.8 0 0 1 0 2.5l-6.8 6.8a1.8 1.8 0 0 1-2.5 0L3.6 12.9V3.6z" />
65 + <circle cx="8" cy="8" r="1.5" />
66 + </>),
67 + camera: (<>
68 + <rect x="3.4" y="7" width="17.2" height="13" rx="2" />
69 + <path d="M8.6 7 10 4.4h4L15.4 7" />
70 + <circle cx="12" cy="13.2" r="3.4" />
71 + </>),
72 +
73 + // --- actions / états ----------------------------------------------------------
74 + search: (<>
75 + <circle cx="10.6" cy="10.6" r="6.2" />
76 + <path d="m15.3 15.3 5.3 5.3" />
77 + </>),
78 + arrow: (<>
79 + <path d="M4 12h15.2" />
80 + <path d="m13.8 6.4 5.4 5.6-5.4 5.6" />
81 + </>),
82 + check: (<path d="m5 12.8 4.3 4.4L19 7.4" />),
83 + alert: (<>
84 + <path d="M12 4.2 2.9 19.4h18.2z" />
85 + <path d="M12 10.2v4.4" />
86 + <path d="M12 17.4v.05" />
87 + </>),
88 + phone: (<path d="M5.2 4h3.6L10.4 8.4 8.3 10.1a12.5 12.5 0 0 0 5.6 5.6l1.7-2.1 4.4 1.6v3.6a1.8 1.8 0 0 1-1.9 1.8A16.4 16.4 0 0 1 3.4 5.9 1.8 1.8 0 0 1 5.2 4z" />),
89 + trendup: (<>
90 + <path d="m3.6 17.4 6-6.4 4 3.6 6.8-7.8" />
91 + <path d="M15.6 6.8h4.8v4.8" />
92 + </>),
93 + trenddown: (<>
94 + <path d="m3.6 6.8 6 6.4 4-3.6 6.8 7.8" />
95 + <path d="M15.6 17.4h4.8v-4.8" />
96 + </>),
97 + external: (<>
98 + <path d="M14.4 4h5.6v5.6" />
99 + <path d="M20 4 11.4 12.6" />
100 + <path d="M18.6 13.6V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7.4a2 2 0 0 1 2-2h4.4" />
101 + </>),
102 + pin: (<>
103 + <path d="M12 21.2S5.4 15.7 5.4 10.8a6.6 6.6 0 0 1 13.2 0c0 4.9-6.6 10.4-6.6 10.4z" />
104 + <circle cx="12" cy="10.6" r="2.3" />
105 + </>),
106 + download: (<>
107 + <path d="M12 3.6v11.2" />
108 + <path d="m7.2 10.4 4.8 4.8 4.8-4.8" />
109 + <path d="M4.4 19.2h15.2" />
110 + </>),
111 +
112 + // --- quartier ---------------------------------------------------------------------
113 + leaf: (<>
114 + <path d="M5 19.2C5 9.6 12 5 20 4.2c0 9-4.2 15-13.2 15z" />
115 + <path d="M5 19.2C7 14 11 10 15 8" />
116 + </>),
117 + thermo: (<>
118 + <path d="M10.4 4.2a1.9 1.9 0 0 1 3.8 0v8.8a4.2 4.2 0 1 1-3.8 0z" />
119 + <path d="M12.3 8.4v7" />
120 + </>),
121 + sun: (<>
122 + <circle cx="12" cy="12" r="4.2" />
123 + <path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.8 5.8l1.4 1.4M16.8 16.8l1.4 1.4M18.2 5.8l-1.4 1.4M7.2 16.8l-1.4 1.4" />
124 + </>),
125 + shield: (<>
126 + <path d="M12 3.4 5.2 5.9v5.9c0 4.6 3 7.6 6.8 8.8 3.8-1.2 6.8-4.2 6.8-8.8V5.9z" />
127 + <path d="m9.2 12 2 2 3.8-4" />
128 + </>),
129 + cart: (<>
130 + <circle cx="9.6" cy="19.6" r="1.35" />
131 + <circle cx="17" cy="19.6" r="1.35" />
132 + <path d="M3.4 4.4h2.2l2.5 10.8h9.6l2.7-7.8H7" />
133 + </>),
134 + bus: (<>
135 + <rect x="4.6" y="3.8" width="14.8" height="13.4" rx="2.4" />
136 + <path d="M4.6 10.4h14.8" />
137 + <path d="M7.6 20.2v-3M16.4 20.2v-3" />
138 + <path d="M8.3 14h.05M15.7 14h.05" />
139 + </>),
140 + tree: (<>
141 + <path d="M12 3.4 6.8 11.4h2.8L5.4 17.8h13.2l-4.2-6.4h2.8z" />
142 + <path d="M12 17.8v3.4" />
143 + </>),
144 + school: (<>
145 + <path d="m12 4.2 9.8 4.4L12 13 2.2 8.6z" />
146 + <path d="M6.6 10.8v5c0 1.6 2.4 3 5.4 3s5.4-1.4 5.4-3v-5" />
147 + <path d="M21.8 8.6v5.4" />
148 + </>),
149 + health: (<>
150 + <circle cx="12" cy="12" r="8.4" />
151 + <path d="M12 8.4v7.2M8.4 12h7.2" />
152 + </>),
153 + pill: (<>
154 + <rect x="3.2" y="8.6" width="17.6" height="6.8" rx="3.4" transform="rotate(-33 12 12)" />
155 + <path d="M12 8.6v6.8" transform="rotate(-33 12 12)" />
156 + </>),
157 + people: (<>
158 + <circle cx="9" cy="8" r="3.2" />
159 + <path d="M3.6 20a5.4 5.4 0 0 1 10.8 0" />
160 + <path d="M15.4 5.4a3.2 3.2 0 0 1 0 5.9M17 14.8a5.4 5.4 0 0 1 3.4 5.2" />
161 + </>),
162 +};
163 +
164 +export type IconName = keyof typeof P;
165 +
166 +export function Ico({ name, size = 18, className = "", stroke = 1.7 }:
167 + { name: IconName | string; size?: number; className?: string; stroke?: number }) {
168 + const paths = P[name as IconName];
169 + if (!paths) return null;
170 + return (
171 + <svg
172 + className={`ico ${className}`}
173 + width={size} height={size} viewBox="0 0 24 24"
174 + fill="none" stroke="currentColor" strokeWidth={stroke}
175 + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
176 + >
177 + {paths}
178 + </svg>
179 + );
180 +}
181 +
182 +/** HTML-string version for map popups (outside React). */
183 +export function icoHTML(name: IconName, size = 30): string {
184 + const d: Record<string, string> = {
185 + home: '<path d="M3.5 10.6 12 3.4l8.5 7.2"/><path d="M5.6 9.4V20h12.8V9.4"/><path d="M10 20v-5.6h4V20"/>',
186 + };
187 + return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${d[name] ?? d.home}</svg>`;
188 +}
added frontend/src/components/ListingCard.tsx +56 −0
@@ -0,0 +1,56 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/ListingCard.tsx : home card (results grid)
5 +// -----------------------------------------------------------------------------
6 +import { Link } from "react-router-dom";
7 +import { Listing, STATUS_LABELS, fmtArea, fmtBaths, fmtCityLine, fmtPrice, sourceName } from "../api";
8 +import { Ico } from "./Icons";
9 +import PropertyImg from "./PropertyImg";
10 +
11 +export default function ListingCard({ l }: { l: Listing }) {
12 + // lightweight thumbnail if the connector provides one (mobile/cellular),
13 + // otherwise the full-size cover photo
14 + const img = (typeof l.details?.cover_thumb === "string" && l.details.cover_thumb)
15 + || (l.images && l.images.length > 0 ? l.images[0] : null);
16 + const meta: { ico: string; txt: string }[] = [];
17 + if (l.bedrooms != null) meta.push({ ico: "bed", txt: `${l.bedrooms} bd` });
18 + if (l.bathrooms != null) meta.push({ ico: "bath", txt: `${fmtBaths(l.bathrooms)} ba` });
19 + const area = fmtArea(l.living_area_sqft);
20 + if (area) meta.push({ ico: "area", txt: area });
21 + const statusBadge = l.status && l.status !== "active"
22 + ? STATUS_LABELS[l.status] ?? l.status : null;
23 +
24 + return (
25 + <Link to={`/property/${encodeURIComponent(l.uid)}`} className="card">
26 + <div className="card-img">
27 + <PropertyImg src={img} alt={l.street_address || l.title} type={l.property_type} />
28 + {l.property_type && <span className="badge type">{l.property_type}</span>}
29 + {statusBadge && <span className="badge status">{statusBadge}</span>}
30 + {l.images.length > 1 && (
31 + <span className="badge right"><Ico name="camera" size={12} /> {l.images.length}</span>
32 + )}
33 + </div>
34 + <div className="card-body">
35 + <div className="card-price">
36 + {fmtPrice(l.list_price, l.price_label)}
37 + </div>
38 + <div className="card-title">{l.street_address || l.title}</div>
39 + <div className="card-meta">
40 + <span>{fmtCityLine(l) || "—"}</span>
41 + </div>
42 + {meta.length > 0 && (
43 + <div className="card-specs">
44 + {meta.map((m) => (
45 + <span key={m.ico}><Ico name={m.ico} size={13} /> {m.txt}</span>
46 + ))}
47 + </div>
48 + )}
49 + <div className="card-foot">
50 + <span className="source-tag">{sourceName(l.source)}</span>
51 + {l.mls_id && <span className="avail">MLS {l.mls_id}</span>}
52 + </div>
53 + </div>
54 + </Link>
55 + );
56 +}
added frontend/src/components/MapView.tsx +323 −0
@@ -0,0 +1,323 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Groupe Ka / Ka Maps (Home-Ka integration)
5 +// components/MapView.tsx : the Home-Ka MAP MODE (Ka Map System v2) — same
6 +// architecture as Lou-Ka Maps, themed green/off-white/ink.
7 +// · viewport takeover (KaMapShell): mobile edge-to-edge + 3-notch results
8 +// bottom sheet, desktop resizable list|map split;
9 +// · viewport-driven data: /api/listings.geojson?bbox=… (Ka Maps adapter —
10 +// cancellable requests, never a storm);
11 +// · unified toolbar (zoom, 3D, draw, locate), contextual "Search this
12 +// area", drawn zone clipped CLIENT-SIDE (setClipPolygon) with a
13 +// "N homes in this area" CTA;
14 +// · contextual preview card v2: photo, price, swipe/chevrons between
15 +// neighboring homes.
16 +// -----------------------------------------------------------------------------
17 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18 +import { useNavigate } from "react-router-dom";
19 +import "mapbox-gl/dist/mapbox-gl.css";
20 +import "@groupe-ka/ka-maps/styles.css";
21 +import type { KaMap, MapProperty } from "@groupe-ka/ka-maps";
22 +import { cameraFromParams, cameraToParams } from "@groupe-ka/ka-maps";
23 +import {
24 + KaBrandBadge,
25 + KaDrawAreaMode,
26 + KaMapShell,
27 + KaMapToolbar,
28 + KaMapView,
29 + KaPropertyPreview,
30 + KaToolbar3D,
31 + KaToolbarDraw,
32 + KaToolbarGroup,
33 + KaToolbarLocate,
34 + KaToolbarZoom,
35 + LoadingIndicator,
36 + SearchAreaControl,
37 + useKaMap,
38 + useKaShell,
39 +} from "@groupe-ka/ka-maps/react";
40 +import { Listing, ListingFilters, sourceName } from "../api";
41 +import ListingCard from "./ListingCard";
42 +import { homeKaMapTheme } from "../kamaps/theme";
43 +import { homeKaMapAdapter } from "../kamaps/adapter";
44 +import { MAPBOX_TOKEN } from "../kamaps/config";
45 +
46 +// Continental US — initial camera
47 +const US_CENTER = { lat: 39.81, lng: -98.55 };
48 +const US_ZOOM = 4;
49 +
50 +/** Preview card — Ka Map System ka-prevcard structure, Home-Ka content
51 + * (the adapter already carries photo/price/traits: no extra fetch). */
52 +function PreviewCard({ p }: { p: MapProperty }) {
53 + const navigate = useNavigate();
54 + const extra = (p.extra ?? {}) as {
55 + title?: string | null; priceLabel?: string | null;
56 + source?: string; state?: string | null;
57 + };
58 + const price = p.price != null
59 + ? `$${p.price.toLocaleString("en-US", { maximumFractionDigits: 0 })}`
60 + : extra.priceLabel || "Price on request";
61 + const meta = [
62 + p.propertyType,
63 + p.bedrooms != null ? `${p.bedrooms} bd` : "",
64 + p.bathrooms != null ? `${p.bathrooms} ba` : "",
65 + [p.city, extra.state].filter(Boolean).join(", "),
66 + extra.source ? sourceName(extra.source) : "",
67 + ].filter(Boolean).join(" · ");
68 + const detail = p.originalUrl ?? "/";
69 + return (
70 + <>
71 + <div className="ka-prevcard-media">
72 + {p.thumbnailUrl ? (
73 + <img
74 + src={p.thumbnailUrl} alt="" loading="lazy"
75 + onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
76 + />
77 + ) : (
78 + <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div>
79 + )}
80 + </div>
81 + <div className="ka-prevcard-body">
82 + <div className="ka-prevcard-price">{price}</div>
83 + <div className="ka-prevcard-addr">{p.address ?? extra.title ?? ""}</div>
84 + <div className="ka-prevcard-meta">{meta}</div>
85 + <div className="ka-prevcard-actions">
86 + <a
87 + className="ka-prevcard-cta"
88 + href={detail}
89 + onClick={(e) => { e.preventDefault(); navigate(detail); }}
90 + >
91 + View details →
92 + </a>
93 + </div>
94 + </div>
95 + </>
96 + );
97 +}
98 +
99 +/** Bridge: exposes the KaMap engine to the parent component (off-canvas). */
100 +function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) {
101 + const map = useKaMap();
102 + useEffect(() => {
103 + onEngine(map);
104 + return () => onEngine(null);
105 + }, [map, onEngine]);
106 + return null;
107 +}
108 +
109 +/** Mobile: selecting a marker collapses the sheet to mini. */
110 +function SheetAutoCollapse({ selectedUid }: { selectedUid: string | null }) {
111 + const shell = useKaShell();
112 + const shellRef = useRef(shell);
113 + shellRef.current = shell;
114 + useEffect(() => {
115 + const s = shellRef.current;
116 + if (selectedUid && s?.isMobile) s.setSheet("mini");
117 + }, [selectedUid]);
118 + return null;
119 +}
120 +
121 +export interface MapViewProps {
122 + filters: ListingFilters;
123 + /** Current list page (12 listings) — results panel of the shell. */
124 + listings: Listing[] | null;
125 + total: number;
126 + page: number;
127 + totalPages: number;
128 + onPage: (p: number) => void;
129 + sort: string;
130 + onSort: (s: string) => void;
131 + onExit?: () => void;
132 + onOpenFilters?: () => void;
133 + filtersCount?: number;
134 +}
135 +
136 +export default function MapView({
137 + filters, listings, total, page, totalPages, onPage, sort, onSort,
138 + onExit, onOpenFilters, filtersCount = 0,
139 +}: MapViewProps) {
140 + // initial camera: shared URL (?lat&lng&zoom), otherwise continental US
141 + const initialCamera = useMemo(() => {
142 + const cam = cameraFromParams(new URLSearchParams(window.location.search));
143 + return cam ?? { ...US_CENTER, zoom: US_ZOOM };
144 + }, []);
145 +
146 + const [selectedUid, setSelectedUid] = useState<string | null>(null);
147 + const [mapCount, setMapCount] = useState<number | null>(null);
148 + const [hasZone, setHasZone] = useState(false);
149 + const [isMobile, setIsMobile] = useState(
150 + () => window.matchMedia("(max-width: 780px)").matches);
151 + const engineRef = useRef<KaMap | null>(null);
152 + const listRef = useRef<HTMLDivElement | null>(null);
153 +
154 + useEffect(() => {
155 + const mq = window.matchMedia("(max-width: 780px)");
156 + const update = () => setIsMobile(mq.matches);
157 + mq.addEventListener("change", update);
158 + return () => mq.removeEventListener("change", update);
159 + }, []);
160 +
161 + // camera → URL (replaceState: no router re-render)
162 + const onMoveEnd = useCallback((center: { lat: number; lng: number }, zoom: number) => {
163 + const url = new URL(window.location.href);
164 + url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();
165 + window.history.replaceState(null, "", url);
166 + }, []);
167 +
168 + const mapFilters = useMemo(
169 + () => Object.fromEntries(Object.entries(filters).filter(([, v]) => v)),
170 + [filters],
171 + );
172 +
173 + const handleEngine = useCallback((m: KaMap | null) => {
174 + engineRef.current = m;
175 + }, []);
176 +
177 + // map selection: preview card + list card scrolled into view
178 + const onSelect = useCallback((p: MapProperty | null) => {
179 + const uid = p?.id ?? null;
180 + setSelectedUid(uid);
181 + if (!uid) return;
182 + const card = listRef.current?.querySelector<HTMLElement>(
183 + `[data-uid="${CSS.escape(uid)}"]`);
184 + card?.scrollIntoView({ behavior: "smooth", block: "nearest" });
185 + }, []);
186 +
187 + // drawn zone: CLIENT-SIDE clip of the displayed set (the Home-Ka API does
188 + // not filter by polygon) — the CTA count comes from the data event.
189 + const onDraw = useCallback((polygon: [number, number][] | null, drawing: boolean) => {
190 + if (drawing) return;
191 + setHasZone(polygon !== null);
192 + engineRef.current?.setClipPolygon(polygon);
193 + }, []);
194 +
195 + const listHeader = (
196 + <div className="ms2-head">
197 + <div className="ms2-count" role="status" aria-live="polite">
198 + <b>{total.toLocaleString("en-US")}</b>
199 + {" "}home{total !== 1 ? "s" : ""}
200 + {mapCount != null && hasZone && (
201 + <span className="ms2-zone">{mapCount.toLocaleString("en-US")} in this area</span>
202 + )}
203 + </div>
204 + <label className="ms2-sort">
205 + <select
206 + value={sort}
207 + onChange={(e) => onSort(e.target.value)}
208 + aria-label="Sort results"
209 + >
210 + <option value="recent">Newest</option>
211 + <option value="price_asc">Price: low to high</option>
212 + <option value="price_desc">Price: high to low</option>
213 + </select>
214 + </label>
215 + </div>
216 + );
217 +
218 + const listPane = (
219 + <div className="ms2-list" aria-label="List results" ref={listRef}>
220 + {(listings ?? []).map((l) => (
221 + <div
222 + key={l.uid}
223 + data-uid={l.uid}
224 + className={`map-card${selectedUid === l.uid ? " map-card-sel" : ""}`}
225 + onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")}
226 + onMouseLeave={() => engineRef.current?.setHovered(null, "app")}
227 + >
228 + <ListingCard l={l} />
229 + </div>
230 + ))}
231 + {listings !== null && listings.length === 0 && (
232 + <div className="ms-empty" role="status">
233 + <h3>No homes match your criteria</h3>
234 + <p>Try widening your search.</p>
235 + </div>
236 + )}
237 + {totalPages > 1 && (
238 + <nav className="pager ms2-pager" aria-label="Pagination">
239 + <button className="pager-btn" onClick={() => {
240 + onPage(page - 1);
241 + listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });
242 + }} disabled={page <= 1}>‹ Prev</button>
243 + <span className="pager-info">Page {page} / {totalPages}</span>
244 + <button className="pager-btn" onClick={() => {
245 + onPage(page + 1);
246 + listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });
247 + }} disabled={page >= totalPages}>Next ›</button>
248 + </nav>
249 + )}
250 + </div>
251 + );
252 +
253 + return (
254 + <KaMapShell
255 + className="ms2"
256 + brand={<span className="ms2-brand"><b>Home·Ka</b><span>Map</span></span>}
257 + onExit={onExit}
258 + exitLabel="List"
259 + storageKey="homeka-map-split"
260 + listHeader={listHeader}
261 + list={listPane}
262 + topExtras={
263 + onOpenFilters ? (
264 + <button className="ka-top-btn" onClick={onOpenFilters}>
265 + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
266 + <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />
267 + </svg>
268 + Filters
269 + {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>}
270 + </button>
271 + ) : null
272 + }
273 + >
274 + <KaMapView
275 + theme={homeKaMapTheme}
276 + adapter={homeKaMapAdapter}
277 + mapboxToken={MAPBOX_TOKEN}
278 + filters={mapFilters}
279 + center={initialCamera}
280 + zoom={initialCamera.zoom}
281 + pitch={50}
282 + // Realistic rendering: full-color Standard + 3D landmarks — same
283 + // settings as Lou-Ka Maps.
284 + basemap={{ theme: "default", showLandmarks: true }}
285 + // valueClamp: bounds each LIST PRICE's contribution to the cluster
286 + // bubble (a $20M mansion does not skew the average)
287 + cluster={{ maxZoom: 15, valueClamp: [100_000, 3_000_000], valueMinCount: 10 }}
288 + searchMode="manual"
289 + navControl={false}
290 + onMoveEnd={onMoveEnd}
291 + onSelect={onSelect}
292 + onData={(count) => setMapCount(count)}
293 + onDraw={onDraw}
294 + >
295 + <EngineBridge onEngine={handleEngine} />
296 + <SheetAutoCollapse selectedUid={selectedUid} />
297 + <KaBrandBadge />
298 +
299 + {/* THE control cluster — zoom (desktop), 3D, draw, locate */}
300 + <KaMapToolbar>
301 + {!isMobile && (
302 + <KaToolbarGroup><KaToolbarZoom /></KaToolbarGroup>
303 + )}
304 + <KaToolbarGroup>
305 + <KaToolbar3D />
306 + <KaToolbarDraw />
307 + <KaToolbarLocate />
308 + </KaToolbarGroup>
309 + </KaMapToolbar>
310 +
311 + {/* draw mode: temporary banner + "N homes" CTA */}
312 + <KaDrawAreaMode
313 + formatCount={(n) => `${n.toLocaleString("en-US")} home${n !== 1 ? "s" : ""}`}
314 + onClear={() => engineRef.current?.setClipPolygon(null)}
315 + />
316 +
317 + <SearchAreaControl />
318 + <LoadingIndicator label="Updating homes…" />
319 + <KaPropertyPreview render={(p) => <PreviewCard p={p} />} />
320 + </KaMapView>
321 + </KaMapShell>
322 + );
323 +}
added frontend/src/components/PropertyImg.tsx +47 −0
@@ -0,0 +1,47 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/PropertyImg.tsx : robust property image
5 +// · <TypeFallback/> : elegant PER-PROPERTY-TYPE fallback in Home-Ka colors —
6 +// never a broken-image icon or a gray void.
7 +// · <PropertyImg/> : <img> with automatic fallback if the URL fails to load.
8 +// -----------------------------------------------------------------------------
9 +import { useState } from "react";
10 +import { Ico } from "./Icons";
11 +
12 +const TYPE_ICONS: Record<string, string> = {
13 + "Single Family": "home", "Single-Family": "home", "House": "home",
14 + "Townhouse": "home", "Mobile Home": "home", "Manufactured": "home",
15 + "Cabin": "tree", "Condo": "building", "Condominium": "building",
16 + "Apartment": "building", "Multi-Family": "building", "Duplex": "building",
17 + "Triplex": "building", "Land": "land", "Lot": "land", "Farm": "leaf",
18 + "Ranch": "leaf", "Commercial": "cart",
19 +};
20 +
21 +/** Fallback image per property type (green gradient + icon + label). */
22 +export function TypeFallback({ type, label = true }: { type?: string; label?: boolean }) {
23 + const ico = TYPE_ICONS[type ?? ""] ?? "home";
24 + return (
25 + <div className="type-fallback" aria-label={type || "Property"}>
26 + <Ico name={ico} size={40} />
27 + {label && <span>{type || "Photos coming soon"}</span>}
28 + </div>
29 + );
30 +}
31 +
32 +/** <img> that switches to the type fallback if loading fails. */
33 +export default function PropertyImg({
34 + src, alt, type, eager = false,
35 +}: { src?: string | null; alt: string; type?: string; eager?: boolean }) {
36 + const [broken, setBroken] = useState(false);
37 + if (!src || broken) return <TypeFallback type={type} />;
38 + return (
39 + <img
40 + src={src}
41 + alt={alt}
42 + loading={eager ? "eager" : "lazy"}
43 + decoding="async"
44 + onError={() => setBroken(true)}
45 + />
46 + );
47 +}
added frontend/src/components/PropertyMap.tsx +70 −0
@@ -0,0 +1,70 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Groupe Ka / Ka Maps (Home-Ka integration)
5 +// components/PropertyMap.tsx : 3D mini-map of the detail page — same component
6 +// as Lou-Ka (KaSpotlightMap): tight camera on the address, realistic Mapbox
7 +// Standard basemap, the listing's building highlighted in Home-Ka GREEN.
8 +// 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 { homeKaMapTheme } from "../kamaps/theme";
16 +import { HOMEKA_APP_SOURCE } from "../kamaps/adapter";
17 +import { MAPBOX_TOKEN } from "../kamaps/config";
18 +
19 +/** Home-Ka signal green — same language as the brand accent. */
20 +export const BUILDING_GREEN = "#0f7b4a";
21 +
22 +export interface PropertyMapProps {
23 + uid: string;
24 + lat: number;
25 + lng: number;
26 + price?: number | null;
27 + propertyType?: string;
28 + address?: string;
29 + city?: string;
30 + image?: string | null;
31 + deal?: boolean;
32 +}
33 +
34 +export default function PropertyMap({
35 + uid, lat, lng, price, propertyType, address, city, image, deal,
36 +}: PropertyMapProps) {
37 + const property = useMemo<MapProperty>(() => ({
38 + id: uid,
39 + appSource: HOMEKA_APP_SOURCE,
40 + latitude: lat,
41 + longitude: lng,
42 + kind: "listing",
43 + listingType: "sale",
44 + price: price ?? undefined,
45 + propertyType: propertyType || undefined,
46 + address: address || undefined,
47 + city: city || undefined,
48 + thumbnailUrl: image ?? undefined,
49 + highlight: deal ?? false,
50 + }), [uid, lat, lng, price, propertyType, address, city, image, deal]);
51 +
52 + return (
53 + <div
54 + className="lmap3d" role="img"
55 + aria-label={`3D map — ${address || "property"}, the listed building in green`}
56 + >
57 + <KaSpotlightMap
58 + theme={homeKaMapTheme}
59 + mapboxToken={MAPBOX_TOKEN}
60 + property={property}
61 + buildingColor={BUILDING_GREEN}
62 + >
63 + <KaBrandBadge />
64 + </KaSpotlightMap>
65 + <span className="lmap3d-legende" aria-hidden="true">
66 + <i /> Listed building
67 + </span>
68 + </div>
69 + );
70 +}
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 +102 −0
@@ -0,0 +1,102 @@
1 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +// ka-ui/react/KaFooter.tsx — shared Groupe KA footer (ink background),
3 +// vendored in each Vite/React app under frontend/src/ka/. Consumes
4 +// ecosystem.json for the site list; copy localized in US English for Home-Ka.
5 +import eco from "./ecosystem.json";
6 +
7 +type LocalLink = { label: string; href: string };
8 +
9 +const TAGLINE_EN =
10 + "A holding of fully automated product and service aggregators.";
11 +const DISCLAIMER_EN =
12 + "Groupe KA is a content aggregator: we sell nothing, rent nothing and are party to no transaction.";
13 +
14 +// English labels for the hub legal pages (hrefs unchanged)
15 +const LEGAL_LABELS_EN: Record<string, string> = {
16 + "https://www.groupe-ka.com/conditions": "Terms of Use (Groupe KA)",
17 + "https://www.groupe-ka.com/confidentialite": "Privacy Policy (Groupe KA)",
18 + "https://www.groupe-ka.com/loi-25": "Personal information protection",
19 + "https://www.groupe-ka.com/bots": "Crawler transparency",
20 +};
21 +
22 +export default function KaFooter({
23 + siteId,
24 + localLegal = [],
25 +}: {
26 + /** id of the current site in ecosystem.json (e.g. "home-ka") */
27 + siteId: string;
28 + /** legal pages OWNED by the site (e.g. /terms), shown before the hub's */
29 + localLegal?: LocalLink[];
30 +}) {
31 + const year = new Date().getFullYear();
32 + // graceful fallback if the siteId is not (yet) in ecosystem.json
33 + const site = eco.sites.find((s) => s.id === siteId) ?? null;
34 + const others = eco.sites.filter((s) => s.id !== siteId);
35 + return (
36 + <footer className="ka-footer" id="contact">
37 + <div className="container">
38 + <a
39 + className="wordmark"
40 + href={eco.hub.url}
41 + target={siteId === "groupe-ka" ? undefined : "_blank"}
42 + rel="noopener noreferrer"
43 + aria-label="Groupe KA — the ecosystem portal"
44 + >
45 + Groupe <span className="ka">KA</span>
46 + </a>
47 + <p className="desc">
48 + {TAGLINE_EN} Hundreds of connectors read the sites at the source,
49 + normalize the data and resynchronize on their own.{" "}
50 + {site ? (
51 + <>
52 + <b style={{ color: "var(--paper)" }}>{site.wordmark}</b> —{" "}
53 + {site.tagline} — is a Groupe KA service.
54 + </>
55 + ) : null}
56 + </p>
57 + <p className="notice">
58 + <b>{DISCLAIMER_EN}</b>
59 + </p>
60 + <ul className="sites">
61 + {others.map((s) => (
62 + <li key={s.id}>
63 + <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer">
64 + {s.wordmark}
65 + </a>
66 + </li>
67 + ))}
68 + {eco.extraFooterLinks.map((l) => (
69 + <li key={l.href}>
70 + <a href={l.href} target="_blank" rel="noopener noreferrer">
71 + {l.label}
72 + </a>
73 + </li>
74 + ))}
75 + </ul>
76 + <div className="contacts">
77 + <p style={{ margin: 0 }}>
78 + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>
79 + <span>All Home-Ka inquiries — data, partnerships, legal & press</span>
80 + </p>
81 + </div>
82 + <p className="legal">
83 + © {year} {eco.org.copyrightHolder}
84 + {localLegal.map((l) => (
85 + <span key={l.href}>
86 + {" · "}
87 + <a href={l.href}>{l.label}</a>
88 + </span>
89 + ))}
90 + {eco.legal.map((l) => (
91 + <span key={l.href}>
92 + {" · "}
93 + <a href={l.href} target="_blank" rel="noopener noreferrer">
94 + {LEGAL_LABELS_EN[l.href] ?? l.label}
95 + </a>
96 + </span>
97 + ))}
98 + </p>
99 + </div>
100 + </footer>
101 + );
102 +}
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": "immo-ka",
79 + "wordmark": "Immo·Ka",
80 + "domain": "www.immo-ka.com",
81 + "accent": "#e23744",
82 + "accentSoft": "#fbe0e2",
83 + "accentDeep": "#a8232e",
84 + "onAccent": "#ffffff",
85 + "tagline": "Toutes les propriétés à vendre"
86 + },
87 + {
88 + "id": "home-ka",
89 + "wordmark": "Home·Ka",
90 + "domain": "www.home-ka.com",
91 + "accent": "#0f7b4a",
92 + "accentSoft": "#dcefe5",
93 + "accentDeep": "#0b5e38",
94 + "onAccent": "#ffffff",
95 + "tagline": "Every home for sale in America"
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/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 +101 −0
@@ -0,0 +1,101 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Groupe Ka / Ka Maps (Home-Ka integration)
5 +// kamaps/adapter.ts : Home-Ka → MapProperty adapter. Viewport-driven data via
6 +// /api/listings.geojson?bbox=… (cancellable requests, never a storm).
7 +// -----------------------------------------------------------------------------
8 +import type {
9 + BoundsQuery,
10 + BoundsQueryResult,
11 + KaAppSource,
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 +/** ka-maps ships a closed KaAppSource union (pre-Home-Ka) — cast our id. */
19 +export const HOMEKA_APP_SOURCE = "home-ka" as KaAppSource;
20 +
21 +interface HomeKaFeatureProps {
22 + uid: string;
23 + title: string | null;
24 + address: string | null;
25 + price: number | null;
26 + price_label: string | null;
27 + property_type: string | null;
28 + bedrooms: number | null;
29 + bathrooms: number | null;
30 + source: string;
31 + city: string | null;
32 + state: string | null;
33 + image: string | null;
34 +}
35 +
36 +interface HomeKaFC {
37 + type: "FeatureCollection";
38 + features: {
39 + geometry: { coordinates: [number, number] };
40 + properties: HomeKaFeatureProps;
41 + }[];
42 + totalGeocoded?: number;
43 + totalMatching?: number;
44 +}
45 +
46 +function toMapProperty(
47 + coords: [number, number],
48 + p: HomeKaFeatureProps,
49 +): MapProperty {
50 + return {
51 + id: p.uid,
52 + appSource: HOMEKA_APP_SOURCE,
53 + longitude: coords[0],
54 + latitude: coords[1],
55 + kind: "listing",
56 + listingType: "sale",
57 + price: p.price ?? undefined,
58 + propertyType: p.property_type ?? undefined,
59 + bedrooms: p.bedrooms ?? undefined,
60 + bathrooms: p.bathrooms ?? undefined,
61 + address: p.address ?? p.title ?? undefined,
62 + city: p.city ?? undefined,
63 + region: p.state ?? undefined,
64 + thumbnailUrl: p.image ?? undefined,
65 + originalUrl: `/property/${encodeURIComponent(p.uid)}`,
66 + extra: {
67 + title: p.title,
68 + priceLabel: p.price_label,
69 + source: p.source,
70 + state: p.state,
71 + },
72 + };
73 +}
74 +
75 +/** Viewport→data adapter for the Home-Ka map. */
76 +export const homeKaMapAdapter: KaDataAdapter = {
77 + id: "home-ka-listings",
78 + appSource: HOMEKA_APP_SOURCE,
79 + async fetchInBounds(query: BoundsQuery): Promise<BoundsQueryResult> {
80 + const params = new URLSearchParams();
81 + const filters = (query.filters ?? {}) as Partial<ListingFilters>;
82 + for (const [k, v] of Object.entries(filters)) {
83 + if (v && k !== "sort") params.set(k, String(v));
84 + }
85 + params.set("bbox", bboxToString(query.bbox));
86 + params.set("limit", "3000");
87 +
88 + const res = await fetch(`/api/listings.geojson?${params}`, {
89 + signal: query.signal,
90 + });
91 + if (!res.ok) throw new Error(`Map: API ${res.status}`);
92 + const data = (await res.json()) as HomeKaFC;
93 +
94 + return {
95 + properties: data.features.map((f) =>
96 + toMapProperty(f.geometry.coordinates, f.properties),
97 + ),
98 + totalCount: data.totalMatching,
99 + };
100 + },
101 +};
added frontend/src/kamaps/config.ts +11 −0
@@ -0,0 +1,11 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Groupe Ka / Ka Maps (Home-Ka integration)
5 +// kamaps/config.ts : PUBLIC Mapbox token (pk.…), overridable at build time via
6 +// VITE_MAPBOX_TOKEN; URL restrictions managed in the Mapbox dashboard.
7 +// -----------------------------------------------------------------------------
8 +
9 +export const MAPBOX_TOKEN: string =
10 + (import.meta.env.VITE_MAPBOX_TOKEN as string | undefined) ??
11 + "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q";
added frontend/src/kamaps/theme.ts +48 −0
@@ -0,0 +1,48 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Groupe Ka / Ka Maps (Home-Ka integration)
5 +// kamaps/theme.ts : Home-Ka Maps theme — forest-green / off-white / ink.
6 +// Premium price pins: off-white with ink text; the GREEN #0f7b4a is reserved
7 +// for brand moments (selection, controls, highlights).
8 +// -----------------------------------------------------------------------------
9 +import type { KaMapTheme } from "@groupe-ka/ka-maps";
10 +
11 +const INK = "#12181a";
12 +const GREEN = "#0f7b4a";
13 +const OFFWHITE = "#fffdf8";
14 +const WHITE = "#ffffff";
15 +
16 +export const homeKaMapTheme: KaMapTheme = {
17 + id: "home-ka",
18 + productName: "Home-Ka Maps",
19 + accent: GREEN,
20 + onAccent: WHITE,
21 + fontFamily: '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif',
22 + markers: {
23 + // List price: off-white pin, ink text — readable without crushing the
24 + // basemap. Selection/hover: solid ink, the absolute anchor.
25 + sale: {
26 + background: OFFWHITE,
27 + text: INK,
28 + halo: "rgba(18, 24, 26, 0.30)",
29 + selectedBackground: INK,
30 + selectedText: WHITE,
31 + },
32 + highlight: {
33 + background: GREEN,
34 + text: WHITE,
35 + halo: "rgba(255, 255, 255, 0.55)",
36 + selectedBackground: INK,
37 + selectedText: WHITE,
38 + },
39 + },
40 + cluster: {
41 + background: OFFWHITE,
42 + text: INK,
43 + border: GREEN,
44 + valueText: "#0b5e38",
45 + valueHalo: "rgba(255, 253, 248, 0.92)",
46 + },
47 + supportsDark: false,
48 +};
added frontend/src/main.tsx +20 −0
@@ -0,0 +1,20 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// main.tsx : React entry point — Groupe KA tokens (ka/tokens.css) BEFORE the
5 +// local stylesheet.
6 +// -----------------------------------------------------------------------------
7 +import React from "react";
8 +import ReactDOM from "react-dom/client";
9 +import { BrowserRouter } from "react-router-dom";
10 +import App from "./App";
11 +import "./ka/tokens.css";
12 +import "./styles.css";
13 +
14 +ReactDOM.createRoot(document.getElementById("root")!).render(
15 + <React.StrictMode>
16 + <BrowserRouter>
17 + <App />
18 + </BrowserRouter>
19 + </React.StrictMode>
20 +);
added frontend/src/pages/Admin.tsx +478 −0
@@ -0,0 +1,478 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Admin.tsx : back-office (/admin/sources, /admin/connectors,
5 +// /admin/brokerages) — reachable by URL only, not linked in the nav.
6 +// Optional token (X-Admin-Token) stored in localStorage (homeka_admin_token).
7 +// -----------------------------------------------------------------------------
8 +import { useCallback, useEffect, useState } from "react";
9 +import { Link } from "react-router-dom";
10 +import {
11 + AdminOverview, AdminSource, Brokerage, BrokerageFilters, ConnectorFamily,
12 + CustomConnector, PARTNERSHIP_STATUSES,
13 + fetchAdminBrokerages, fetchAdminConnectors, fetchAdminOverview,
14 + fetchAdminSources, getAdminToken, inspectBrokerage, runDiscovery,
15 + setAdminToken, setBrokerageStatus, sourceName,
16 +} from "../api";
17 +import { Ico } from "../components/Icons";
18 +
19 +const BROK_PAGE = 50;
20 +
21 +const n = (x: number | null | undefined) =>
22 + x == null ? "—" : Math.round(x).toLocaleString("en-US");
23 +
24 +function ago(ts: number | null | undefined): string {
25 + if (ts == null) return "never";
26 + const s = Math.max(0, Math.floor(Date.now() / 1000 - (ts > 1e12 ? ts / 1000 : ts)));
27 + if (s < 3600) return `${Math.round(s / 60)} min ago`;
28 + if (s < 172800) return `${Math.round(s / 3600)} h ago`;
29 + return `${Math.round(s / 86400)} d ago`;
30 +}
31 +
32 +// --- Token bar -----------------------------------------------------------------
33 +function TokenBar({ onChange }: { onChange: () => void }) {
34 + const [token, setToken] = useState(getAdminToken());
35 + const [saved, setSaved] = useState(false);
36 + return (
37 + <div className="adm-token">
38 + <label htmlFor="adm-token-input">Admin token</label>
39 + <input
40 + id="adm-token-input"
41 + type="password"
42 + placeholder="X-Admin-Token (leave empty if the API is open)"
43 + value={token}
44 + onChange={(e) => { setToken(e.target.value); setSaved(false); }}
45 + autoComplete="off"
46 + />
47 + <button
48 + className="btn btn-ghost"
49 + onClick={() => { setAdminToken(token.trim()); setSaved(true); onChange(); }}
50 + >
51 + {saved ? "Saved ✓" : "Save"}
52 + </button>
53 + </div>
54 + );
55 +}
56 +
57 +// --- Overview cards --------------------------------------------------------------
58 +function Overview({ ov, error }: { ov: AdminOverview | null; error: string | null }) {
59 + if (error) return <div className="notice">Overview unavailable — {error}</div>;
60 + if (!ov) return <div className="notice">Loading overview…</div>;
61 + return (
62 + <section className="tiles adm-tiles" aria-label="Overview">
63 + <div className="tile">
64 + <div className="tile-v">{n(ov.active_listings)}</div>
65 + <div className="tile-k">Active listings</div>
66 + </div>
67 + <div className="tile">
68 + <div className="tile-v">{n(ov.properties)}</div>
69 + <div className="tile-k">Properties</div>
70 + </div>
71 + <div className="tile">
72 + <div className="tile-v">{n(ov.enabled_sources)}</div>
73 + <div className="tile-k">Enabled sources</div>
74 + </div>
75 + <div className="tile">
76 + <div className="tile-v">{n(ov.errors_24h)}</div>
77 + <div className="tile-k">Errors (24 h)</div>
78 + </div>
79 + <div className="tile">
80 + <div className="tile-v">{ago(ov.last_successful_sync)}</div>
81 + <div className="tile-k">Last successful sync</div>
82 + </div>
83 + <div className="tile adm-tile-wide">
84 + <div className="tile-k" style={{ marginBottom: 6 }}>Brokerages by partnership status</div>
85 + <div className="adm-status-row">
86 + {ov.brokerages_by_status.length === 0 && <span>—</span>}
87 + {ov.brokerages_by_status.map((b) => (
88 + <span key={b.partnership_status} className="adm-badge">
89 + {b.partnership_status || "unknown"} <b>{n(b.n)}</b>
90 + </span>
91 + ))}
92 + </div>
93 + </div>
94 + </section>
95 + );
96 +}
97 +
98 +// --- Sources tab -------------------------------------------------------------------
99 +function SourcesTab({ reloadKey }: { reloadKey: number }) {
100 + const [rows, setRows] = useState<AdminSource[] | null>(null);
101 + const [error, setError] = useState<string | null>(null);
102 +
103 + useEffect(() => {
104 + setRows(null); setError(null);
105 + fetchAdminSources().then((r) => setRows(r.sources)).catch((e) => setError(String(e)));
106 + }, [reloadKey]);
107 +
108 + if (error) return <div className="notice">Could not load sources — {error}</div>;
109 + if (!rows) return <div className="notice">Loading sources…</div>;
110 + return (
111 + <div className="q-table-wrap">
112 + <table className="q-table adm-table">
113 + <thead>
114 + <tr>
115 + <th>Source</th><th>Type</th><th>States</th>
116 + <th>Active</th><th>Published</th><th>Last sync</th>
117 + <th>Freshness</th><th>Errors 7d</th><th>Last message</th>
118 + </tr>
119 + </thead>
120 + <tbody>
121 + {rows.map((s) => (
122 + <tr key={s.id}>
123 + <td>
124 + <b>{s.name || s.id}</b>
125 + {!s.enabled && <span className="adm-badge adm-off"> disabled</span>}
126 + <div className="adm-sub">{s.id}</div>
127 + </td>
128 + <td>{s.connector_type ?? "—"}</td>
129 + <td>{s.states?.length ? s.states.join(", ") : "—"}</td>
130 + <td className="num">{n(s.active_listings)}</td>
131 + <td className="num">{n(s.published)}</td>
132 + <td>
133 + {s.last_sync != null ? ago(s.last_sync) : "never"}{" "}
134 + {s.last_sync != null && (
135 + s.last_ok
136 + ? <span className="adm-badge adm-ok">OK</span>
137 + : <span className="adm-badge adm-err">Error</span>
138 + )}
139 + </td>
140 + <td className="num">{s.freshness_hours != null ? `${s.freshness_hours} h` : "—"}</td>
141 + <td className="num">{n(s.errors_7d)}</td>
142 + <td className="adm-msg" title={s.last_message ?? ""}>{s.last_message ?? "—"}</td>
143 + </tr>
144 + ))}
145 + </tbody>
146 + </table>
147 + </div>
148 + );
149 +}
150 +
151 +// --- Connectors tab ------------------------------------------------------------------
152 +function ConnectorsTab({ reloadKey }: { reloadKey: number }) {
153 + const [families, setFamilies] = useState<ConnectorFamily[] | null>(null);
154 + const [custom, setCustom] = useState<CustomConnector[]>([]);
155 + const [error, setError] = useState<string | null>(null);
156 +
157 + useEffect(() => {
158 + setFamilies(null); setError(null);
159 + fetchAdminConnectors()
160 + .then((r) => { setFamilies(r.families); setCustom(r.custom); })
161 + .catch((e) => setError(String(e)));
162 + }, [reloadKey]);
163 +
164 + if (error) return <div className="notice">Could not load connectors — {error}</div>;
165 + if (!families) return <div className="notice">Loading connectors…</div>;
166 + return (
167 + <>
168 + <div className="adm-cards">
169 + {families.map((f) => (
170 + <div className="adm-card" key={f.family}>
171 + <div className="adm-card-head">
172 + <b>{f.family}</b>
173 + <span className="adm-badge">{n(f.instances)} instance{f.instances !== 1 ? "s" : ""}</span>
174 + </div>
175 + <div className="adm-sub">{f.class} · {f.module}</div>
176 + <p>{f.doc || "—"}</p>
177 + </div>
178 + ))}
179 + </div>
180 + {custom.length > 0 && (
181 + <>
182 + <h2 style={{ marginTop: 22 }}>Custom connectors</h2>
183 + <div className="q-table-wrap">
184 + <table className="q-table">
185 + <thead><tr><th>Source</th><th>Class</th><th>Module</th></tr></thead>
186 + <tbody>
187 + {custom.map((c) => (
188 + <tr key={c.source_id}>
189 + <td>{sourceName(c.source_id)} <span className="adm-sub">{c.source_id}</span></td>
190 + <td>{c.class}</td>
191 + <td>{c.module}</td>
192 + </tr>
193 + ))}
194 + </tbody>
195 + </table>
196 + </div>
197 + </>
198 + )}
199 + </>
200 + );
201 +}
202 +
203 +// --- Brokerages tab ---------------------------------------------------------------------
204 +function BrokeragesTab({ reloadKey }: { reloadKey: number }) {
205 + const [rows, setRows] = useState<Brokerage[] | null>(null);
206 + const [total, setTotal] = useState(0);
207 + const [page, setPage] = useState(1);
208 + const [error, setError] = useState<string | null>(null);
209 + const [busy, setBusy] = useState<Record<number, boolean>>({});
210 + const [discovering, setDiscovering] = useState(false);
211 +
212 + // filters
213 + const [q, setQ] = useState("");
214 + const [state, setState] = useState("");
215 + const [status, setStatus] = useState("");
216 + const [feedType, setFeedType] = useState("");
217 + const [minPriority, setMinPriority] = useState("");
218 + const [inspected, setInspected] = useState("");
219 + const [sort, setSort] = useState("priority");
220 +
221 + const filters: BrokerageFilters = {
222 + q, state, status, feed_type: feedType,
223 + min_priority: minPriority, inspected, sort,
224 + };
225 + const filterKey = JSON.stringify(filters);
226 +
227 + const load = useCallback((p: number) => {
228 + setRows(null); setError(null);
229 + fetchAdminBrokerages(JSON.parse(filterKey) as BrokerageFilters, BROK_PAGE, (p - 1) * BROK_PAGE)
230 + .then((r) => { setRows(r.brokerages); setTotal(r.total); })
231 + .catch((e) => setError(String(e)));
232 + }, [filterKey]);
233 +
234 + useEffect(() => { setPage(1); }, [filterKey]);
235 + useEffect(() => { load(page); }, [load, page, reloadKey]);
236 +
237 + const totalPages = Math.max(1, Math.ceil(total / BROK_PAGE));
238 +
239 + const onStatus = async (b: Brokerage, next: string) => {
240 + setBusy((x) => ({ ...x, [b.id]: true }));
241 + try {
242 + await setBrokerageStatus(b.id, next);
243 + setRows((rs) => rs?.map((r) => r.id === b.id ? { ...r, partnership_status: next } : r) ?? null);
244 + } catch (e) {
245 + alert(`Status update failed: ${e}`);
246 + } finally {
247 + setBusy((x) => ({ ...x, [b.id]: false }));
248 + }
249 + };
250 +
251 + const onInspect = async (b: Brokerage) => {
252 + setBusy((x) => ({ ...x, [b.id]: true }));
253 + try {
254 + await inspectBrokerage(b.id);
255 + // refresh the page to pick up the newly inspected row
256 + load(page);
257 + } catch (e) {
258 + alert(`Inspection failed: ${e}`);
259 + setBusy((x) => ({ ...x, [b.id]: false }));
260 + }
261 + };
262 +
263 + const onDiscover = async () => {
264 + setDiscovering(true);
265 + try {
266 + await runDiscovery();
267 + alert("Discovery batch started in the background.");
268 + } catch (e) {
269 + alert(`Discovery failed to start: ${e}`);
270 + } finally {
271 + setDiscovering(false);
272 + }
273 + };
274 +
275 + const contacts = (b: Brokerage): { label: string; email: string }[] => {
276 + const out: { label: string; email: string }[] = [];
277 + if (b.partnership_contact && b.partnership_contact.includes("@"))
278 + out.push({ label: "partnership", email: b.partnership_contact });
279 + if (b.technical_contact && b.technical_contact.includes("@"))
280 + out.push({ label: "technical", email: b.technical_contact });
281 + return out;
282 + };
283 +
284 + return (
285 + <>
286 + <div className="adm-controls">
287 + <input
288 + placeholder="Search name or website…"
289 + value={q}
290 + onChange={(e) => setQ(e.target.value)}
291 + aria-label="Search brokerages"
292 + />
293 + <input
294 + placeholder="State (e.g. TX)"
295 + value={state}
296 + maxLength={2}
297 + style={{ width: 90 }}
298 + onChange={(e) => setState(e.target.value.toUpperCase())}
299 + aria-label="State"
300 + />
301 + <select value={status} onChange={(e) => setStatus(e.target.value)} aria-label="Partnership status">
302 + <option value="">Any status</option>
303 + {PARTNERSHIP_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
304 + </select>
305 + <select value={feedType} onChange={(e) => setFeedType(e.target.value)} aria-label="Feed type">
306 + <option value="">Any feed</option>
307 + {["reso", "rets", "json-api", "xml", "csv", "idx-partner", "crawl"].map((f) => (
308 + <option key={f} value={f}>{f}</option>
309 + ))}
310 + </select>
311 + <input
312 + type="number"
313 + placeholder="Min priority"
314 + value={minPriority}
315 + style={{ width: 110 }}
316 + onChange={(e) => setMinPriority(e.target.value)}
317 + aria-label="Minimum priority score"
318 + />
319 + <select value={inspected} onChange={(e) => setInspected(e.target.value)} aria-label="Inspected">
320 + <option value="">Inspected or not</option>
321 + <option value="1">Inspected</option>
322 + <option value="0">Not inspected</option>
323 + </select>
324 + <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">
325 + <option value="priority">Sort: priority</option>
326 + <option value="feed">Sort: feed probability</option>
327 + <option value="name">Sort: name</option>
328 + <option value="inspected">Sort: last inspected</option>
329 + </select>
330 + <button className="btn btn-primary" onClick={onDiscover} disabled={discovering}>
331 + {discovering ? "Starting…" : "Run discovery batch"}
332 + </button>
333 + </div>
334 +
335 + {error && <div className="notice">Could not load brokerages — {error}</div>}
336 + {!rows && !error && <div className="notice">Loading brokerages…</div>}
337 +
338 + {rows && (
339 + <>
340 + <p className="stats-foot" style={{ marginTop: 0 }}>
341 + <b>{n(total)}</b> brokerage{total !== 1 ? "s" : ""} — page {page} / {totalPages}
342 + </p>
343 + <div className="q-table-wrap">
344 + <table className="q-table adm-table">
345 + <thead>
346 + <tr>
347 + <th>Brokerage</th><th>States</th><th>Agents</th><th>Listings</th>
348 + <th>IDX provider</th><th>Feed</th><th>Feed %</th><th>Priority</th>
349 + <th>Status</th><th>Contacts</th><th>Inspected</th><th></th>
350 + </tr>
351 + </thead>
352 + <tbody>
353 + {rows.map((b) => (
354 + <tr key={b.id}>
355 + <td>
356 + {b.website
357 + ? <a href={b.website} target="_blank" rel="noopener noreferrer"><b>{b.name}</b> <Ico name="external" size={11} /></a>
358 + : <b>{b.name}</b>}
359 + {b.mls_affiliations?.length > 0 && (
360 + <div className="adm-sub">{b.mls_affiliations.join(", ")}</div>
361 + )}
362 + </td>
363 + <td>{b.states?.length ? b.states.join(", ") : "—"}</td>
364 + <td className="num">{n(b.estimated_agents)}</td>
365 + <td className="num">{n(b.estimated_listings)}</td>
366 + <td>
367 + {b.idx_provider ?? "—"}
368 + {b.reso_detected ? <span className="adm-badge adm-ok"> RESO</span> : null}
369 + </td>
370 + <td>{b.possible_feed_type ?? "—"}</td>
371 + <td className="num">{b.feed_probability_score != null ? Math.round(b.feed_probability_score) : "—"}</td>
372 + <td className="num">{b.priority_score != null ? Math.round(b.priority_score) : "—"}</td>
373 + <td>
374 + <select
375 + className="adm-status-select"
376 + value={b.partnership_status || "prospect"}
377 + disabled={!!busy[b.id]}
378 + onChange={(e) => onStatus(b, e.target.value)}
379 + aria-label={`Partnership status — ${b.name}`}
380 + >
381 + {PARTNERSHIP_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
382 + </select>
383 + </td>
384 + <td>
385 + {contacts(b).length === 0 && "—"}
386 + {contacts(b).map((c) => (
387 + <div key={c.email}>
388 + <a href={`mailto:${c.email}`} title={c.label}>{c.email}</a>
389 + </div>
390 + ))}
391 + </td>
392 + <td>
393 + {b.last_inspected != null ? ago(b.last_inspected) : "never"}
394 + {b.inspect_error && (
395 + <span className="adm-badge adm-err" title={b.inspect_error}> err</span>
396 + )}
397 + </td>
398 + <td>
399 + <button
400 + className="btn btn-ghost adm-inspect"
401 + disabled={!!busy[b.id]}
402 + onClick={() => onInspect(b)}
403 + >
404 + {busy[b.id] ? "…" : "Inspect"}
405 + </button>
406 + </td>
407 + </tr>
408 + ))}
409 + {rows.length === 0 && (
410 + <tr><td colSpan={12}>No brokerage matches these filters.</td></tr>
411 + )}
412 + </tbody>
413 + </table>
414 + </div>
415 + {totalPages > 1 && (
416 + <nav className="pager" aria-label="Brokerage pagination">
417 + <button className="pager-btn" onClick={() => setPage(page - 1)} disabled={page <= 1}>‹ Prev</button>
418 + <span className="pager-info">Page {page} / {totalPages}</span>
419 + <button className="pager-btn" onClick={() => setPage(page + 1)} disabled={page >= totalPages}>Next ›</button>
420 + </nav>
421 + )}
422 + </>
423 + )}
424 + </>
425 + );
426 +}
427 +
428 +// --- Page ------------------------------------------------------------------------
429 +export type AdminTab = "sources" | "connectors" | "brokerages";
430 +
431 +export default function AdminPage({ tab }: { tab: AdminTab }) {
432 + const [ov, setOv] = useState<AdminOverview | null>(null);
433 + const [ovError, setOvError] = useState<string | null>(null);
434 + const [reloadKey, setReloadKey] = useState(0);
435 +
436 + const loadOverview = useCallback(() => {
437 + setOv(null); setOvError(null);
438 + fetchAdminOverview().then(setOv).catch((e) => setOvError(String(e)));
439 + }, []);
440 + useEffect(() => { loadOverview(); }, [loadOverview, reloadKey]);
441 +
442 + const tabs: { id: AdminTab; label: string }[] = [
443 + { id: "sources", label: "Sources" },
444 + { id: "connectors", label: "Connectors" },
445 + { id: "brokerages", label: "Brokerages" },
446 + ];
447 +
448 + return (
449 + <div className="container admin">
450 + <span className="kicker">Back office</span>
451 + <h1>Home-Ka admin</h1>
452 + <p className="sub">
453 + Operational view of the aggregation pipeline — sources, connector
454 + families and the brokerage partnership funnel.
455 + </p>
456 +
457 + <TokenBar onChange={() => setReloadKey((k) => k + 1)} />
458 +
459 + <Overview ov={ov} error={ovError} />
460 +
461 + <nav className="adm-tabs" aria-label="Admin sections">
462 + {tabs.map((t) => (
463 + <Link
464 + key={t.id}
465 + to={`/admin/${t.id}`}
466 + className={tab === t.id ? "on" : ""}
467 + >
468 + {t.label}
469 + </Link>
470 + ))}
471 + </nav>
472 +
473 + {tab === "sources" && <SourcesTab reloadKey={reloadKey} />}
474 + {tab === "connectors" && <ConnectorsTab reloadKey={reloadKey} />}
475 + {tab === "brokerages" && <BrokeragesTab reloadKey={reloadKey} />}
476 + </div>
477 + );
478 +}
added frontend/src/pages/Contact.tsx +65 −0
@@ -0,0 +1,65 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Contact.tsx : contact page — operator Groupe KA, contact@spboucher.ai.
5 +// -----------------------------------------------------------------------------
6 +import eco from "../ka/ecosystem.json";
7 +
8 +export default function ContactPage() {
9 + const sites = eco.sites.filter((s) => s.id !== "home-ka");
10 + return (
11 + <div className="container contact">
12 + <span className="kicker">Contact</span>
13 + <h1>
14 + Get in touch with <span className="hl">Groupe KA</span>
15 + </h1>
16 + <p className="lede">
17 + Home-Ka is a <b>Groupe KA</b> service — an independent, fully automated
18 + US real-estate aggregator. For anything related to Home-Ka (data,
19 + partnerships, brokerage feeds, listing removal, press), write to us.
20 + </p>
21 +
22 + <div className="contact-cards">
23 + <a className="contact-card" href="mailto:contact@spboucher.ai">
24 + <span className="contact-role">All inquiries — data, partnerships, legal & press</span>
25 + <span className="contact-mail">contact@spboucher.ai</span>
26 + </a>
27 + </div>
28 +
29 + <p className="contact-disclaimer">
30 + <b>
31 + Groupe KA is a content aggregator: we sell nothing, rent nothing and
32 + are party to no transaction.
33 + </b>{" "}
34 + Every listing links back to the original announcement at the source:
35 + for a specific home, contact the listing agent or brokerage shown on
36 + the page directly.
37 + </p>
38 +
39 + <div className="contact-hub">
40 + <a
41 + className="btn btn-primary"
42 + href={eco.hub.url}
43 + target="_blank"
44 + rel="noopener noreferrer"
45 + >
46 + Visit the groupe-ka.com portal ↗
47 + </a>
48 + </div>
49 +
50 + <section className="contact-sites">
51 + <h2>The ·Ka ecosystem</h2>
52 + <ul>
53 + {sites.map((s) => (
54 + <li key={s.id}>
55 + <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer">
56 + <b>{s.wordmark}</b>
57 + <span>{s.tagline}</span>
58 + </a>
59 + </li>
60 + ))}
61 + </ul>
62 + </section>
63 + </div>
64 + );
65 +}
added frontend/src/pages/Home.tsx +519 −0
@@ -0,0 +1,519 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Home.tsx : home — hero, live stats, advanced filters, grid + map view
5 +// -----------------------------------------------------------------------------
6 +import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";
7 +import { useSearchParams } from "react-router-dom";
8 +import {
9 + Facets, Listing, ListingFilters, Stats,
10 + fetchFacets, fetchListings, fetchSources, fetchStats,
11 + registerSourceNames, sourceName,
12 +} from "../api";
13 +import ListingCard from "../components/ListingCard";
14 +import { Ico } from "../components/Icons";
15 +
16 +const MapView = lazy(() => import("../components/MapView"));
17 +
18 +const PRICE_STEPS = [100000, 200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000, 3000000];
19 +const SQFT_STEPS = [800, 1000, 1500, 2000, 3000];
20 +const PAGE = 12;
21 +
22 +const fmtK = (n: number) =>
23 + n >= 1_000_000 ? `$${n / 1_000_000}M` : `$${Math.round(n / 1000)}K`;
24 +
25 +/** Animated hero counter (~0.9 s, cubic easing). Respects reduced-motion. */
26 +function useCountUp(target: number | null | undefined, ms = 900): string | null {
27 + const [v, setV] = useState<number | null>(null);
28 + useEffect(() => {
29 + if (target == null) return;
30 + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setV(target); return; }
31 + let raf = 0;
32 + const t0 = performance.now();
33 + const step = (t: number) => {
34 + const p = Math.min(1, (t - t0) / ms);
35 + setV(Math.round(target * (1 - Math.pow(1 - p, 3))));
36 + if (p < 1) raf = requestAnimationFrame(step);
37 + };
38 + raf = requestAnimationFrame(step);
39 + return () => cancelAnimationFrame(raf);
40 + }, [target, ms]);
41 + return v == null ? null : v.toLocaleString("en-US");
42 +}
43 +
44 +/** Live "X ago" — re-rendered every second while the ts exists. */
45 +function useAgo(ts: number | null): string | null {
46 + const [, tick] = useState(0);
47 + useEffect(() => {
48 + if (ts == null) return;
49 + const id = setInterval(() => tick((x) => x + 1), 1000);
50 + return () => clearInterval(id);
51 + }, [ts]);
52 + if (ts == null) return null;
53 + const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
54 + if (s < 90) return `${s} s ago`;
55 + if (s < 5400) return `${Math.round(s / 60)} min ago`;
56 + return `${Math.round(s / 3600)} h ago`;
57 +}
58 +
59 +// pagination window: 1 … (p-1) p (p+1) … N
60 +function pageNumbers(p: number, n: number): (number | "…")[] {
61 + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1);
62 + const out: (number | "…")[] = [1];
63 + const lo = Math.max(2, p - 1), hi = Math.min(n - 1, p + 1);
64 + if (lo > 2) out.push("…");
65 + for (let i = lo; i <= hi; i++) out.push(i);
66 + if (hi < n - 1) out.push("…");
67 + out.push(n);
68 + return out;
69 +}
70 +
71 +export default function Home() {
72 + const [listings, setListings] = useState<Listing[] | null>(null);
73 + const [total, setTotal] = useState(0);
74 + const [params, setParams] = useSearchParams();
75 + const [page, setPage] = useState(Number(params.get("page")) || 1); // 12/page, in the URL
76 + const [facets, setFacets] = useState<Facets | null>(null);
77 + const [cities, setCities] = useState<{ city: string; n: number }[]>([]);
78 + const [stats, setStats] = useState<Stats | null>(null);
79 + const [error, setError] = useState<string | null>(null);
80 + const [q, setQ] = useState(params.get("q") ?? "");
81 + const [usState, setUsState] = useState(params.get("state") ?? "");
82 + const [city, setCity] = useState(params.get("city") ?? "");
83 + const [ptype, setPtype] = useState(params.get("property_type") ?? "");
84 + const [source, setSource] = useState(params.get("source") ?? "");
85 + const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");
86 + const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
87 + const [bedsMin, setBedsMin] = useState(params.get("beds_min") ?? "");
88 + const [bathsMin, setBathsMin] = useState(params.get("baths_min") ?? "");
89 + const [sqftMin, setSqftMin] = useState(params.get("sqft_min") ?? "");
90 + const [sort, setSort] = useState(params.get("sort") ?? "recent");
91 +
92 + const [sheetOpen, setSheetOpen] = useState(false);
93 + const [advOpen, setAdvOpen] = useState(false);
94 +
95 + // Filters bottom-sheet: background scroll lock + Escape to close
96 + useEffect(() => {
97 + if (!sheetOpen) return;
98 + document.body.style.overflow = "hidden";
99 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };
100 + window.addEventListener("keydown", onKey);
101 + return () => {
102 + document.body.style.overflow = "";
103 + window.removeEventListener("keydown", onKey);
104 + };
105 + }, [sheetOpen]);
106 + const activeFilters = [q, usState, city, ptype, source, priceMin, priceMax, bedsMin, bathsMin, sqftMin].filter(Boolean).length;
107 + const advCount = [bedsMin, bathsMin, sqftMin, source].filter(Boolean).length;
108 +
109 + const [view, setView] = useState<"list" | "map">(params.get("view") === "map" ? "map" : "list");
110 + useEffect(() => { setView(params.get("view") === "map" ? "map" : "list"); }, [params]);
111 +
112 + // map mode (Ka Map System v2): body class — the page's filter sheet becomes
113 + // a modal ABOVE the full-viewport shell.
114 + useEffect(() => {
115 + document.body.classList.toggle("ka-map-mode", view === "map");
116 + return () => document.body.classList.remove("ka-map-mode");
117 + }, [view]);
118 +
119 + const filters: ListingFilters = useMemo(() => ({
120 + q, city, state: usState, property_type: ptype, source,
121 + price_min: priceMin, price_max: priceMax,
122 + beds_min: bedsMin, baths_min: bathsMin, sqft_min: sqftMin, sort,
123 + }), [q, city, usState, ptype, source, priceMin, priceMax, bedsMin, bathsMin, sqftMin, sort]);
124 +
125 + useEffect(() => {
126 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
127 + fetchFacets().then(setFacets).catch(() => {});
128 + fetchStats().then(setStats).catch(() => {});
129 + }, []);
130 +
131 + // cities narrowed by the selected state
132 + useEffect(() => {
133 + fetchFacets(usState || undefined).then((f) => setCities(f.cities)).catch(() => setCities([]));
134 + }, [usState]);
135 +
136 + // back to page 1 when filters change
137 + const firstRender = useRef(true);
138 + useEffect(() => {
139 + if (firstRender.current) { firstRender.current = false; return; }
140 + setPage(1);
141 + }, [filters]);
142 +
143 + // sync filters + page + sort + view into the URL → going back from a
144 + // detail page returns to the SAME page/filters.
145 + useEffect(() => {
146 + const p = new URLSearchParams();
147 + const set = (k: string, v: string) => { if (v) p.set(k, v); };
148 + set("q", q); set("state", usState); set("city", city);
149 + set("property_type", ptype); set("source", source);
150 + set("price_min", priceMin); set("price_max", priceMax);
151 + set("beds_min", bedsMin); set("baths_min", bathsMin);
152 + set("sqft_min", sqftMin);
153 + if (sort && sort !== "recent") p.set("sort", sort);
154 + if (view === "map") p.set("view", "map");
155 + if (page > 1) p.set("page", String(page));
156 + setParams(p, { replace: true });
157 + }, [filters, page, view, q, usState, city, ptype, source, priceMin, priceMax,
158 + bedsMin, bathsMin, sqftMin, sort, setParams]);
159 +
160 + // load the current page (12 listings) — replaces the grid
161 + useEffect(() => {
162 + let cancelled = false;
163 + setListings(null); setError(null);
164 + fetchListings(filters, PAGE, (page - 1) * PAGE)
165 + .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } })
166 + .catch((e) => !cancelled && setError(String(e)));
167 + return () => { cancelled = true; };
168 + }, [filters, page]);
169 +
170 + const totalPages = Math.max(1, Math.ceil(total / PAGE));
171 + const gotoPage = (p: number) => {
172 + setPage(Math.min(Math.max(1, p), totalPages));
173 + if (view === "map") return; // the map-mode panel manages its own scroll
174 + document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });
175 + };
176 +
177 + const resetAll = () => {
178 + setQ(""); setUsState(""); setCity(""); setPtype(""); setSource("");
179 + setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin(""); setSqftMin("");
180 + };
181 +
182 + // live hero data — real connector syncs (recent_syncs)
183 + const totalLive = useCountUp(stats?.total);
184 + const lastSync = useMemo(() => {
185 + const ss = stats?.recent_syncs ?? [];
186 + if (!ss.length) return null;
187 + const ts = Math.max(...ss.map((s) => s.ts));
188 + return ts > 1e12 ? Math.round(ts / 1000) : ts;
189 + }, [stats]);
190 + const syncAgo = useAgo(lastSync);
191 + const newToday = useMemo(() => {
192 + const ss = stats?.recent_syncs ?? [];
193 + const now = Date.now() / 1000;
194 + return ss
195 + .filter((s) => (s.ts > 1e12 ? s.ts / 1000 : s.ts) > now - 86400)
196 + .reduce((n, s) => n + (s.added || 0), 0);
197 + }, [stats]);
198 +
199 + const pills: { label: string; clear: () => void }[] = [];
200 + if (q) pills.push({ label: `“${q}”`, clear: () => setQ("") });
201 + if (usState) pills.push({ label: usState, clear: () => { setUsState(""); setCity(""); } });
202 + if (city) pills.push({ label: city, clear: () => setCity("") });
203 + if (ptype) pills.push({ label: ptype, clear: () => setPtype("") });
204 + if (priceMin) pills.push({ label: `≥ ${fmtK(Number(priceMin))}`, clear: () => setPriceMin("") });
205 + if (priceMax) pills.push({ label: `≤ ${fmtK(Number(priceMax))}`, clear: () => setPriceMax("") });
206 + if (bedsMin) pills.push({ label: `${bedsMin}+ bd`, clear: () => setBedsMin("") });
207 + if (bathsMin) pills.push({ label: `${bathsMin}+ ba`, clear: () => setBathsMin("") });
208 + if (sqftMin) pills.push({ label: `≥ ${sqftMin} sq ft`, clear: () => setSqftMin("") });
209 + if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });
210 +
211 + const topTypes = (facets?.property_types ?? []).slice(0, 7);
212 +
213 + return (
214 + <div className="container">
215 + <section className="hero">
216 + <div className="hero-wrap">
217 + <div className="hero-main">
218 + <span className="kicker">Aggregator — every listing source in the U.S.</span>
219 + <h1 className="hero-display" aria-label="Every home for sale in America. One place.">
220 + <span className="hd-l1" aria-hidden="true">Every home</span>
221 + <span className="hd-l2" aria-hidden="true">for sale</span>
222 + <span className="hd-l3" aria-hidden="true">in America.</span>
223 + <span className="hd-l4" aria-hidden="true">One <em className="signal">place</em>.</span>
224 + </h1>
225 + <p className="lede">
226 + Brokerages, MLS feeds and listing platforms across the United States —
227 + aggregated in one place, always current, with full photos and details
228 + and a direct link to the original listing.
229 + </p>
230 + <div className="live-line" aria-label="Live data">
231 + <span className="live-flag"><span className="live-dot" /> live</span>
232 + {syncAgo && <span className="live-item">synced {syncAgo}</span>}
233 + {newToday > 0 && (
234 + <span className="live-item"><b>+{newToday.toLocaleString("en-US")}</b> today</span>
235 + )}
236 + {stats && stats.sources > 0 && (
237 + <span className="live-item"><b>{stats.sources}</b> sources</span>
238 + )}
239 + </div>
240 + </div>
241 + <aside className="hero-data" aria-label="The market in numbers">
242 + <div className="hd-row">
243 + <b>{totalLive ?? "—"}</b><span>homes indexed</span>
244 + </div>
245 + {stats && stats.states != null && stats.states > 0 && (
246 + <div className="hd-row">
247 + <b>{stats.states.toLocaleString("en-US")}</b><span>states</span>
248 + </div>
249 + )}
250 + {stats?.avg_price != null && (
251 + <div className="hd-row">
252 + <b>${Math.round(stats.avg_price).toLocaleString("en-US")}</b><span>average price</span>
253 + </div>
254 + )}
255 + {stats?.max_price != null && (
256 + <div className="hd-row">
257 + <b>{fmtK(stats.max_price)}</b><span>highest price</span>
258 + </div>
259 + )}
260 + </aside>
261 + </div>
262 + </section>
263 +
264 + {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />}
265 + <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Search and filters">
266 + <div className="sheet-handle" aria-hidden="true" />
267 + <div className="sheet-head">
268 + <span>Refine your search</span>
269 + <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Close filters">✕</button>
270 + </div>
271 +
272 + {/* — search, the heart of the product: one large underlined field — */}
273 + <div className="q-big">
274 + <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
275 + <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
276 + </svg>
277 + <input id="f-q" placeholder="Where do you want to live?" value={q}
278 + onChange={(e) => setQ(e.target.value)}
279 + aria-label="Search — address, city, ZIP or MLS number" />
280 + {q && <button className="f-clear" onClick={() => setQ("")} aria-label="Clear search">✕</button>}
281 + </div>
282 +
283 + {/* — inline criteria, separated by rules (no boxes) — */}
284 + <div className="crit-line">
285 + <label className="crit">
286 + <span>State</span>
287 + <select value={usState} onChange={(e) => { setUsState(e.target.value); setCity(""); }}>
288 + <option value="">All</option>
289 + {(facets?.states ?? []).map((s) => (
290 + <option key={s.state} value={s.state}>{s.state} ({s.n.toLocaleString("en-US")})</option>
291 + ))}
292 + </select>
293 + </label>
294 + <label className="crit">
295 + <span>City</span>
296 + <select value={city} onChange={(e) => setCity(e.target.value)}>
297 + <option value="">All</option>
298 + {cities.map((c) => <option key={c.city} value={c.city}>{c.city}</option>)}
299 + </select>
300 + </label>
301 + <label className="crit">
302 + <span>Type</span>
303 + <select value={ptype} onChange={(e) => setPtype(e.target.value)}>
304 + <option value="">All</option>
305 + {(facets?.property_types ?? []).map((t) => <option key={t} value={t}>{t}</option>)}
306 + </select>
307 + </label>
308 + <div className="crit">
309 + <span>Price</span>
310 + <div className="range-pair">
311 + <select aria-label="Minimum price" value={priceMin} onChange={(e) => setPriceMin(e.target.value)}>
312 + <option value="">Min</option>
313 + {PRICE_STEPS.map((p) => (
314 + <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>{fmtK(p)}</option>
315 + ))}
316 + </select>
317 + <span className="range-sep">—</span>
318 + <select aria-label="Maximum price" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}>
319 + <option value="">Max</option>
320 + {PRICE_STEPS.map((p) => (
321 + <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>{fmtK(p)}</option>
322 + ))}
323 + </select>
324 + </div>
325 + </div>
326 + <button className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>
327 + All criteria
328 + {advCount > 0 && <span className="crit-badge">{advCount}</span>}
329 + <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" />
330 + </button>
331 + </div>
332 +
333 + {(advOpen || sheetOpen) && (
334 + <div className="f-adv">
335 + <div className="f-group">
336 + <label>Bedrooms (min.)</label>
337 + <div className="seg" role="group">
338 + <button className={bedsMin === "" ? "on" : ""} onClick={() => setBedsMin("")}>Any</button>
339 + {["1", "2", "3", "4", "5"].map((n) => (
340 + <button key={n} className={bedsMin === n ? "on" : ""} onClick={() => setBedsMin(n)}>{n}+</button>
341 + ))}
342 + </div>
343 + </div>
344 + <div className="f-group">
345 + <label>Bathrooms (min.)</label>
346 + <div className="seg" role="group">
347 + <button className={bathsMin === "" ? "on" : ""} onClick={() => setBathsMin("")}>Any</button>
348 + {["1", "2", "3"].map((n) => (
349 + <button key={n} className={bathsMin === n ? "on" : ""} onClick={() => setBathsMin(n)}>{n}+</button>
350 + ))}
351 + </div>
352 + </div>
353 + <div className="f-group">
354 + <label>Minimum living area</label>
355 + <div className="seg" role="group">
356 + <button className={sqftMin === "" ? "on" : ""} onClick={() => setSqftMin("")}>Any</button>
357 + {SQFT_STEPS.map((a) => (
358 + <button key={a} className={sqftMin === String(a) ? "on" : ""} onClick={() => setSqftMin(String(a))}>{a}+</button>
359 + ))}
360 + </div>
361 + </div>
362 + <div className="f-group">
363 + <label>Source</label>
364 + <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>
365 + <option value="">All</option>
366 + {(facets?.sources ?? []).map((s) => (
367 + <option key={s.source} value={s.source}>{sourceName(s.source)} ({s.n})</option>
368 + ))}
369 + </select>
370 + </div>
371 + <div className="f-group f-group-end">
372 + <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>
373 + Reset all{activeFilters > 0 ? ` (${activeFilters})` : ""}
374 + </button>
375 + </div>
376 + </div>
377 + )}
378 +
379 + <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
380 + Show {listings ? `${total.toLocaleString("en-US")} homes` : "results"}
381 + </button>
382 + </section>
383 +
384 + {/* mobile: criteria summary — opens the sheet (search included, no FAB) */}
385 + <button className="crit-summary" onClick={() => setSheetOpen(true)}>
386 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
387 + <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />
388 + </svg>
389 + <span className="cs-txt">
390 + {pills.length > 0
391 + ? pills.slice(0, 3).map((p) => p.label).join(" · ") + (pills.length > 3 ? ` · +${pills.length - 3}` : "")
392 + : "State, city, type, price, beds…"}
393 + </span>
394 + <span className="f-chev" aria-hidden="true" />
395 + </button>
396 +
397 + <div className="chips" role="group" aria-label="Quick filters">
398 + {topTypes.map((t) => (
399 + <button key={t} className={`chip ${ptype === t ? "on" : ""}`} onClick={() => setPtype(ptype === t ? "" : t)}>
400 + {t}
401 + </button>
402 + ))}
403 + </div>
404 +
405 + {pills.length > 0 && (
406 + <div className="pills" aria-label="Active filters">
407 + {pills.map((p) => (
408 + <button key={p.label} className="pill" onClick={p.clear} aria-label={`Remove filter ${p.label}`}>
409 + {p.label} <span className="pill-x">✕</span>
410 + </button>
411 + ))}
412 + <button className="pill pill-clear" onClick={resetAll}>Clear all</button>
413 + </div>
414 + )}
415 +
416 + <div className="results-bar" id="results-top">
417 + <h2 className="rb-count">
418 + {listings
419 + ? <><b>{total.toLocaleString("en-US")}</b> home{total !== 1 ? "s" : ""}</>
420 + : "Homes"}
421 + </h2>
422 + <div className="rb-tools">
423 + <label className="rb-sort">
424 + <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">
425 + <option value="recent">Newest</option>
426 + <option value="price_asc">Price: low to high</option>
427 + <option value="price_desc">Price: high to low</option>
428 + </select>
429 + </label>
430 + <div className="rb-tabs" role="tablist" aria-label="View mode">
431 + <button role="tab" aria-selected={view === "list"} className={`rb-tab ${view === "list" ? "on" : ""}`} onClick={() => setView("list")}>
432 + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
433 + <path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01" />
434 + </svg>
435 + List
436 + </button>
437 + <button role="tab" aria-selected={view === "map"} className={`rb-tab ${view === "map" ? "on" : ""}`} onClick={() => setView("map")}>
438 + <Ico name="map" size={13} /> Map
439 + </button>
440 + </div>
441 + <button className="rb-filters" onClick={() => setSheetOpen(true)}>
442 + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
443 + <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />
444 + </svg>
445 + Filters
446 + {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>}
447 + </button>
448 + </div>
449 + </div>
450 +
451 + {error && (
452 + <div className="notice">
453 + <div className="big"><Ico name="alert" size={40} /></div>
454 + <h2>Could not load listings</h2>
455 + <p>{error}</p>
456 + <button className="btn btn-primary" onClick={() => window.location.reload()}>Retry</button>
457 + </div>
458 + )}
459 +
460 + {!error && view === "list" && listings === null && (
461 + <div className="grid grid-edito" aria-busy="true">
462 + {Array.from({ length: 8 }).map((_, i) => (
463 + <div className="skel" key={i}><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div>
464 + ))}
465 + </div>
466 + )}
467 +
468 + {!error && view === "list" && listings !== null && listings.length === 0 && (
469 + <div className="notice">
470 + <div className="big"><Ico name="search" size={40} /></div>
471 + <h2>No homes match your criteria</h2>
472 + <p>Try widening your search
473 + {activeFilters > 0 && <> — or <button className="link-btn" onClick={resetAll}>remove the {activeFilters} active filters</button></>}.</p>
474 + </div>
475 + )}
476 +
477 + {!error && view === "map" && (
478 + <div className="view-pane" key="map">
479 + <Suspense fallback={<div className="ka-shell-fallback">Loading the map…</div>}>
480 + <MapView
481 + filters={filters}
482 + listings={listings}
483 + total={total}
484 + page={page}
485 + totalPages={totalPages}
486 + onPage={gotoPage}
487 + sort={sort}
488 + onSort={setSort}
489 + onExit={() => setView("list")}
490 + onOpenFilters={() => setSheetOpen(true)}
491 + filtersCount={activeFilters}
492 + />
493 + </Suspense>
494 + </div>
495 + )}
496 +
497 + {!error && view === "list" && listings !== null && listings.length > 0 && (
498 + <div className="view-pane">
499 + <div className="grid grid-edito">
500 + {listings.map((l) => <ListingCard key={l.uid} l={l} />)}
501 + </div>
502 + {totalPages > 1 && (
503 + <nav className="pager" aria-label="Pagination">
504 + <button className="pager-btn" onClick={() => gotoPage(page - 1)} disabled={page <= 1}>‹ Prev</button>
505 + {pageNumbers(page, totalPages).map((p, i) =>
506 + p === "…"
507 + ? <span key={`e${i}`} className="pager-gap">…</span>
508 + : <button key={p} className={`pager-btn ${p === page ? "on" : ""}`}
509 + onClick={() => gotoPage(p as number)}>{p}</button>
510 + )}
511 + <button className="pager-btn" onClick={() => gotoPage(page + 1)} disabled={page >= totalPages}>Next ›</button>
512 + <span className="pager-info">Page {page} / {totalPages}</span>
513 + </nav>
514 + )}
515 + </div>
516 + )}
517 + </div>
518 + );
519 +}
added frontend/src/pages/Legal.tsx +129 −0
@@ -0,0 +1,129 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// Legal.tsx : Terms of Use & Privacy Policy
5 +// -----------------------------------------------------------------------------
6 +const UPDATED = "August 28, 2026";
7 +
8 +function LegalShell({ title, children }: { title: string; children: React.ReactNode }) {
9 + return (
10 + <div className="container legal">
11 + <h1>{title}</h1>
12 + <p className="legal-meta">
13 + Home-Ka is a <b>Groupe KA</b> application. Last updated: {UPDATED}.
14 + </p>
15 + {children}
16 + </div>
17 + );
18 +}
19 +
20 +export function TermsPage() {
21 + return (
22 + <LegalShell title="Terms of Use">
23 + <h2>1. The application</h2>
24 + <p>
25 + Home-Ka (<a href="https://www.home-ka.com">www.home-ka.com</a>) is an
26 + independent aggregator of homes for sale in the United States, operated
27 + by <b>Groupe KA</b>. Listings come from public brokerage websites,
28 + MLS/RESO feeds and listing platforms; every listing links back to the
29 + original announcement at the source.
30 + </p>
31 +
32 + <h2>2. No brokerage role</h2>
33 + <p>
34 + Groupe KA is not a real-estate brokerage and does not act as a broker,
35 + agent or intermediary under any US state real-estate license law.
36 + Home-Ka takes part in no transaction: any purchase inquiry must be made
37 + directly with the brokerage, agent or seller identified on the listing.
38 + </p>
39 +
40 + <h2>3. Accuracy of information</h2>
41 + <p>
42 + Data (prices, characteristics, photos, statuses) is provided for
43 + informational purposes only, with no warranty of accuracy, completeness
44 + or timeliness. The official record remains the original listing at the
45 + source. Nothing on Home-Ka constitutes a professional appraisal or
46 + financial advice.
47 + </p>
48 +
49 + <h2>4. Intellectual property</h2>
50 + <p>
51 + Photos and descriptions belong to their respective brokerages, agents
52 + or sellers. Any rights holder may request removal of content by writing
53 + to <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a> —
54 + requests are handled promptly.
55 + </p>
56 +
57 + <h2>5. Permitted use</h2>
58 + <p>
59 + The application is offered for personal, non-commercial use. Bulk data
60 + extraction, resale of the data or interference with the service are
61 + prohibited.
62 + </p>
63 +
64 + <h2>6. Limitation of liability</h2>
65 + <p>
66 + Groupe KA cannot be held liable for decisions made on the basis of the
67 + information displayed, nor for errors originating from aggregated
68 + sources. The application is provided "as is", with no availability
69 + warranty.
70 + </p>
71 +
72 + <h2>7. Contact</h2>
73 + <p>
74 + Questions about these terms:{" "}
75 + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>.
76 + </p>
77 + </LegalShell>
78 + );
79 +}
80 +
81 +export function PrivacyPage() {
82 + return (
83 + <LegalShell title="Privacy Policy">
84 + <h2>1. Information collected</h2>
85 + <p>
86 + Home-Ka works <b>without user accounts</b> and collects no personally
87 + identifiable information while you browse. Technical server logs (IP
88 + address, pages viewed, browser) are used solely to operate and secure
89 + the service, and are purged periodically.
90 + </p>
91 +
92 + <h2>2. Cookies</h2>
93 + <p>
94 + The application uses no advertising cookies and no third-party
95 + trackers. Only local settings (display preferences, the optional admin
96 + token) may be stored in your browser.
97 + </p>
98 +
99 + <h2>3. Displayed data</h2>
100 + <p>
101 + Property pages come from listings already public on the source
102 + brokerage websites and platforms. Agent names and contact details shown
103 + are those the source itself publishes for professional solicitation.
104 + </p>
105 +
106 + <h2>4. Sharing</h2>
107 + <p>
108 + Groupe KA does not sell, rent or share any visit data with third
109 + parties.
110 + </p>
111 +
112 + <h2>5. Your rights</h2>
113 + <p>
114 + You may request access to, correction of, or removal of personal
115 + information concerning you — for instance if your property or your
116 + contact details appear on a page and you want them removed. Applicable
117 + state privacy laws (such as the California Consumer Privacy Act) are
118 + honored.
119 + </p>
120 +
121 + <h2>6. Contact</h2>
122 + <p>
123 + Privacy requests and general questions:{" "}
124 + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>. Every
125 + request receives a reply within 30 days.
126 + </p>
127 + </LegalShell>
128 + );
129 +}
added frontend/src/pages/Listing.tsx +501 −0
@@ -0,0 +1,501 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Listing.tsx : full property detail page
5 +// Groupe KA standard section order (DOM order = visual order, mobile AND
6 +// desktop — no CSS `order:` / `column-reverse` reordering):
7 +// 1. photo gallery (hero) → 2. price + status badge + address + key facts →
8 +// 3. description → 4. features → 5. practical details → 6. price history →
9 +// 7. map → 8. "Also listed on" + PROPERTY history → footer.
10 +// -----------------------------------------------------------------------------
11 +import { Suspense, lazy, useEffect, useRef, useState } from "react";
12 +import { Link, useParams } from "react-router-dom";
13 +import {
14 + Listing, PropertyListingRef, STATUS_LABELS, fetchListing, fetchSources,
15 + fmtArea, fmtBaths, fmtCityLine, fmtDate, fmtPrice,
16 + registerSourceNames, sourceName,
17 +} from "../api";
18 +
19 +const PropertyMap = lazy(() => import("../components/PropertyMap"));
20 +import { Ico } from "../components/Icons";
21 +import AmenityIco from "../components/AmenityIco";
22 +import { TypeFallback } from "../components/PropertyImg";
23 +
24 +// --- Lightbox: pinch to zoom + pan + swipe between photos ---------------------
25 +function ZoomImg({ src, onSwipe }: { src: string; onSwipe: (dir: 1 | -1) => void }) {
26 + const [t, setT] = useState({ scale: 1, x: 0, y: 0 });
27 + const pointers = useRef(new Map<number, { x: number; y: number }>());
28 + const start = useRef({ scale: 1, x: 0, y: 0, dist: 0, cx: 0, cy: 0, t: 0 });
29 + const lastTap = useRef(0);
30 +
31 + // reset when switching photos
32 + useEffect(() => { setT({ scale: 1, x: 0, y: 0 }); }, [src]);
33 +
34 + const dist = () => {
35 + const p = [...pointers.current.values()];
36 + return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
37 + };
38 + const center = () => {
39 + const p = [...pointers.current.values()];
40 + return p.length < 2
41 + ? p[0] ?? { x: 0, y: 0 }
42 + : { x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 };
43 + };
44 +
45 + const onDown = (e: React.PointerEvent) => {
46 + (e.target as HTMLElement).setPointerCapture(e.pointerId);
47 + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
48 + const c = center();
49 + start.current = { scale: t.scale, x: t.x, y: t.y, dist: dist(), cx: c.x, cy: c.y, t: Date.now() };
50 + };
51 + const onMove = (e: React.PointerEvent) => {
52 + if (!pointers.current.has(e.pointerId)) return;
53 + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
54 + const s = start.current;
55 + if (pointers.current.size >= 2 && s.dist > 0) {
56 + // pinch: zoom around the two-finger midpoint
57 + const scale = Math.min(4, Math.max(1, (dist() / s.dist) * s.scale));
58 + const c = center();
59 + setT({ scale, x: s.x + (c.x - s.cx), y: s.y + (c.y - s.cy) });
60 + } else if (pointers.current.size === 1 && t.scale > 1) {
61 + // pan once zoomed
62 + const p = pointers.current.get(e.pointerId)!;
63 + setT({ scale: t.scale, x: s.x + (p.x - s.cx), y: s.y + (p.y - s.cy) });
64 + }
65 + };
66 + const onUp = (e: React.PointerEvent) => {
67 + const p = pointers.current.get(e.pointerId);
68 + pointers.current.delete(e.pointerId);
69 + const s = start.current;
70 + if (pointers.current.size === 0 && p) {
71 + const dx = p.x - s.cx, dy = p.y - s.cy, dt = Date.now() - s.t;
72 + if (t.scale <= 1.05 && Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) {
73 + onSwipe(dx < 0 ? 1 : -1); // swipe → next photo
74 + } else if (dt < 260 && Math.abs(dx) < 8 && Math.abs(dy) < 8) {
75 + const now = Date.now();
76 + if (now - lastTap.current < 320) // double-tap: ×2.4 zoom
77 + setT(t.scale > 1 ? { scale: 1, x: 0, y: 0 } : { scale: 2.4, x: 0, y: 0 });
78 + lastTap.current = now;
79 + }
80 + if (t.scale <= 1.02) setT({ scale: 1, x: 0, y: 0 });
81 + }
82 + };
83 +
84 + return (
85 + <img
86 + src={src} alt="" draggable={false}
87 + style={{
88 + transform: `translate(${t.x}px, ${t.y}px) scale(${t.scale})`,
89 + transition: pointers.current.size ? "none" : "transform 0.15s ease",
90 + touchAction: "none", cursor: t.scale > 1 ? "grab" : "zoom-out",
91 + }}
92 + onClick={(e) => e.stopPropagation()}
93 + onPointerDown={onDown} onPointerMove={onMove}
94 + onPointerUp={onUp} onPointerCancel={onUp}
95 + />
96 + );
97 +}
98 +
99 +// --- Gallery: native swipe (scroll-snap) + thumbnails + fullscreen -------------
100 +function Gallery({ images, captions, title, type }:
101 + { images: string[]; captions?: string[]; title: string; type?: string }) {
102 + const [idx, setIdx] = useState(0);
103 + const [zoom, setZoom] = useState(false);
104 + const [dead, setDead] = useState<Set<string>>(new Set());
105 + const track = useRef<HTMLDivElement>(null);
106 +
107 + // images that fail to load are removed on the fly (never a broken-image
108 + // icon); captions kept aligned
109 + const alive = images
110 + .map((u, i) => ({ u, cap: captions && captions.length === images.length ? captions[i] : "" }))
111 + .filter(({ u }) => !dead.has(u));
112 + const markDead = (u: string) => setDead((d) => new Set(d).add(u));
113 +
114 + const onScroll = () => {
115 + const el = track.current;
116 + if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
117 + };
118 + const goto = (i: number) =>
119 + track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
120 +
121 + useEffect(() => {
122 + if (!zoom) return;
123 + const onKey = (e: KeyboardEvent) => {
124 + if (e.key === "Escape") setZoom(false);
125 + if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1));
126 + if (e.key === "ArrowRight") setIdx((i) => Math.min(alive.length - 1, i + 1));
127 + };
128 + window.addEventListener("keydown", onKey);
129 + // freeze the background during fullscreen (mobile)
130 + document.body.style.overflow = "hidden";
131 + return () => {
132 + window.removeEventListener("keydown", onKey);
133 + document.body.style.overflow = "";
134 + };
135 + }, [zoom, alive.length]);
136 +
137 + if (alive.length === 0)
138 + return <div className="carousel"><div className="carousel-empty"><TypeFallback type={type} /></div></div>;
139 +
140 + const cur = Math.min(idx, alive.length - 1);
141 + const swipe = (dir: 1 | -1) =>
142 + setIdx((i) => Math.min(alive.length - 1, Math.max(0, i + dir)));
143 +
144 + return (
145 + <>
146 + <div className="carousel">
147 + <div className="carousel-track" ref={track} onScroll={onScroll}>
148 + {alive.map(({ u }, i) => (
149 + <img key={u} src={u} loading={i <= 1 ? "eager" : "lazy"} decoding="async"
150 + alt={`${title} — photo ${i + 1} of ${alive.length}`}
151 + onError={() => markDead(u)} onClick={() => setZoom(true)} />
152 + ))}
153 + </div>
154 + {alive[cur]?.cap && <span className="carousel-caption">{alive[cur].cap}</span>}
155 + <span className="carousel-count" aria-live="polite">{cur + 1}/{alive.length}</span>
156 + {cur > 0 && <button className="carousel-nav prev" aria-label="Previous photo" onClick={() => goto(cur - 1)}>‹</button>}
157 + {cur < alive.length - 1 && <button className="carousel-nav next" aria-label="Next photo" onClick={() => goto(cur + 1)}>›</button>}
158 + </div>
159 + {alive.length > 1 && (
160 + <div className="thumbs">
161 + {alive.map(({ u }, i) => (
162 + <button key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>
163 + <img src={u} alt="" loading="lazy" decoding="async" onError={() => markDead(u)} />
164 + </button>
165 + ))}
166 + </div>
167 + )}
168 + {zoom && (
169 + <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Enlarged photo">
170 + <button className="lb-close" aria-label="Close" onClick={() => setZoom(false)}>✕</button>
171 + {cur > 0 && <button className="lb-nav prev" aria-label="Previous" onClick={(e) => { e.stopPropagation(); setIdx(cur - 1); }}>‹</button>}
172 + <ZoomImg src={alive[cur].u} onSwipe={swipe} />
173 + {cur < alive.length - 1 && <button className="lb-nav next" aria-label="Next" onClick={(e) => { e.stopPropagation(); setIdx(cur + 1); }}>›</button>}
174 + <span className="lb-count">
175 + {alive[cur]?.cap ? `${alive[cur].cap} · ` : ""}{cur + 1} / {alive.length}
176 + </span>
177 + </div>
178 + )}
179 + </>
180 + );
181 +}
182 +
183 +// technical `details` keys never shown in "Practical details"
184 +const DETAIL_HIDDEN = new Set([
185 + "price_from", "cover_thumb", "photo_captions", "img_audited",
186 + "needs_image_review", "listing_origin_url",
187 +]);
188 +
189 +// icon for each key-fact row (Icons.tsx)
190 +const SPEC_ICONS: Record<string, string> = {
191 + "Type": "home", "Bedrooms": "bed", "Bathrooms": "bath",
192 + "Living area": "area", "Lot size": "land",
193 + "Year built": "calendar", "MLS #": "tag",
194 +};
195 +
196 +const STATUS_CLASS: Record<string, string> = {
197 + "active": "st-active", "pending": "st-pending", "sold": "st-sold",
198 + "withdrawn": "st-withdrawn", "coming-soon": "st-coming",
199 +};
200 +
201 +function historyDate(ts: number | null): string {
202 + if (ts == null) return "—";
203 + return fmtDate(ts > 1e12 ? ts / 1000 : ts);
204 +}
205 +
206 +export default function ListingPage() {
207 + const { uid } = useParams<{ uid: string }>();
208 + const [l, setL] = useState<Listing | null>(null);
209 + const [error, setError] = useState<string | null>(null);
210 + // re-render when source names arrive (otherwise Title Case fallback)
211 + const [, setSrcTick] = useState(0);
212 +
213 + useEffect(() => {
214 + fetchSources().then((r) => { registerSourceNames(r.sources); setSrcTick(1); }).catch(() => {});
215 + if (!uid) return;
216 + setL(null); setError(null);
217 + fetchListing(uid).then(setL).catch((e) => setError(String(e)));
218 + window.scrollTo(0, 0);
219 + }, [uid]);
220 +
221 + if (error)
222 + return (
223 + <div className="notice container">
224 + <div className="big"><Ico name="alert" size={44} /></div>
225 + <h2>Property not found</h2>
226 + <p>{error}</p>
227 + <Link className="btn btn-primary" to="/">Back to homes</Link>
228 + </div>
229 + );
230 +
231 + if (!l)
232 + return (
233 + <div className="container detail">
234 + <div className="fiche" aria-busy="true">
235 + <div className="skel"><div className="sk-img" /></div>
236 + <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
237 + </div>
238 + </div>
239 + );
240 +
241 + // 2. key facts
242 + const specs: { k: string; v: string }[] = [];
243 + if (l.property_type) specs.push({ k: "Type", v: l.property_type });
244 + if (l.bedrooms != null) specs.push({ k: "Bedrooms", v: String(l.bedrooms) });
245 + if (l.bathrooms != null) specs.push({ k: "Bathrooms", v: fmtBaths(l.bathrooms) });
246 + if (l.living_area_sqft != null) specs.push({ k: "Living area", v: fmtArea(l.living_area_sqft)! });
247 + if (l.lot_size_sqft != null) specs.push({ k: "Lot size", v: fmtArea(l.lot_size_sqft)! });
248 + if (l.year_built != null) specs.push({ k: "Year built", v: String(l.year_built) });
249 + if (l.mls_id) specs.push({ k: "MLS #", v: l.mls_id });
250 +
251 + // 5. practical details: structured fields first, then source `details`
252 + const practical: { k: string; v: string }[] = [];
253 + if (l.property_subtype) practical.push({ k: "Subtype", v: l.property_subtype });
254 + if (l.status) practical.push({ k: "Status", v: STATUS_LABELS[l.status] ?? l.status });
255 + if (l.county) practical.push({ k: "County", v: l.county });
256 + if (l.zip_code) practical.push({ k: "ZIP code", v: l.zip_code });
257 + if (l.apn) practical.push({ k: "APN (parcel #)", v: l.apn });
258 + if (l.mls_name) practical.push({ k: "MLS", v: l.mls_name });
259 + if (l.listed_at) practical.push({ k: "Listed on", v: l.listed_at });
260 + if (l.days_on_market != null) practical.push({ k: "Days on market", v: String(l.days_on_market) });
261 + if (l.brokerage_name) practical.push({ k: "Brokerage", v: l.brokerage_name });
262 + if (l.office_name) practical.push({ k: "Office", v: l.office_name });
263 + if (l.agent_name) practical.push({ k: "Listing agent", v: l.agent_name });
264 + const detEntries: [string, string][] = Object.entries(l.details ?? {})
265 + .filter(([k, v]) => !DETAIL_HIDDEN.has(k)
266 + && (typeof v === "string" || typeof v === "number") && String(v).trim())
267 + .map(([k, v]) => [k, String(v)]);
268 +
269 + // 6. price history
270 + const hist = (l.price_history ?? []).filter((h) => h.price != null);
271 + const drop = hist.length >= 2 && hist[0].price !== hist[1].price
272 + ? { from: hist[1].price!, to: hist[0].price! } : null;
273 + const updated = l.updated_at ? fmtDate(l.updated_at) : null;
274 +
275 + const propHistory: PropertyListingRef[] = l.property?.listing_history ?? [];
276 + const statusLabel = STATUS_LABELS[l.status] ?? l.status;
277 + const addr = l.street_address || l.title;
278 + const cityLine = fmtCityLine(l);
279 +
280 + return (
281 + <div className="container detail">
282 + <nav className="crumbs" aria-label="Breadcrumb">
283 + <Link to="/">Homes</Link> ›
284 + {l.state && <Link to={`/?state=${encodeURIComponent(l.state)}`}>{l.state}</Link>} ›
285 + {l.city && <span>{l.city}</span>} ›
286 + <span>{addr}</span>
287 + </nav>
288 +
289 + <div className="fiche">
290 + {/* -------- main column: gallery, price/summary, description, ----------
291 + -------- features, details, price history — DOM order = visual ---- */}
292 + <div className="f-col">
293 + {/* 1. photo gallery (hero) */}
294 + <section className="f-bloc f-galerie" aria-label="Photos">
295 + <Gallery
296 + images={l.images ?? []}
297 + captions={Array.isArray(l.details?.photo_captions)
298 + ? (l.details!.photo_captions as string[]) : undefined}
299 + title={addr}
300 + type={l.property_type}
301 + />
302 + </section>
303 +
304 + {/* 2. price + status badge + address + key facts */}
305 + <section className="f-bloc f-hero">
306 + <div className="price-kicker">List price</div>
307 + <div className="price-row">
308 + <div className="price">{fmtPrice(l.list_price, l.price_label)}</div>
309 + {l.status && (
310 + <span className={`status-chip ${STATUS_CLASS[l.status] ?? ""}`}>{statusLabel}</span>
311 + )}
312 + {l.property_type && (
313 + <span className="type-chip">
314 + <Ico name={SPEC_ICONS["Type"]} size={13} /> {l.property_type}
315 + {l.details?.price_from ? " · from" : ""}
316 + </span>
317 + )}
318 + </div>
319 + {l.list_price != null && l.living_area_sqft != null && l.living_area_sqft > 200 && (
320 + <div className="price-sub">${Math.round(l.list_price / l.living_area_sqft).toLocaleString("en-US")} / sq ft</div>
321 + )}
322 + <h1>{addr}</h1>
323 + <div className="loc"><Ico name="pin" size={13} /> {[cityLine, l.county ? `${l.county} County` : ""].filter(Boolean).join(" · ")}</div>
324 +
325 + <div className="spec-list">
326 + {specs.map((s) => (
327 + <div className="spec-row" key={s.k}>
328 + <span className="spec-badge"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={15} /></span>
329 + <span className="spec-k">{s.k}</span>
330 + <b className="spec-v">{s.v}</b>
331 + </div>
332 + ))}
333 + </div>
334 +
335 + {drop && (
336 + <div className={`prix-histo ${drop.to < drop.from ? "down" : ""}`}>
337 + <Ico name={drop.to < drop.from ? "trenddown" : "trendup"} size={16} /> Price changed from {fmtPrice(drop.from)} to <b>{fmtPrice(drop.to)}</b>
338 + </div>
339 + )}
340 +
341 + {(l.agent_name || l.agent_phone || l.brokerage_name) && (
342 + <div className="broker">
343 + <div className="broker-k">Listed by</div>
344 + {l.agent_name && <div className="broker-name">{l.agent_name}</div>}
345 + {l.brokerage_name && <div className="broker-agency">{l.brokerage_name}</div>}
346 + {l.agent_phone && <a className="broker-tel" href={`tel:${l.agent_phone.replace(/\s/g, "")}`}><Ico name="phone" size={14} /> {l.agent_phone}</a>}
347 + </div>
348 + )}
349 +
350 + <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
351 + View the original listing at {sourceName(l.source)} <Ico name="external" size={15} />
352 + </a>
353 + <div className="fine">
354 + Aggregated by Home-Ka — {sourceName(l.source)}{updated ? ` · synced ${updated}` : ""}.
355 + </div>
356 + </section>
357 +
358 + {/* 3. description */}
359 + {l.description && (
360 + <section className="f-bloc f-desc" id="description">
361 + <h2>Description</h2>
362 + <p className="desc-text">{l.description}</p>
363 + </section>
364 + )}
365 +
366 + {/* 4. features */}
367 + {l.features && l.features.length > 0 && (
368 + <section className="f-bloc" id="features">
369 + <h2>Features</h2>
370 + <div className="amenity-grid">
371 + {l.features.map((f, i) => (
372 + <span className="amenity-it" key={i}>
373 + <span className="am-ico"><AmenityIco label={f} /></span>
374 + <span className="am-txt">{f}</span>
375 + </span>
376 + ))}
377 + </div>
378 + </section>
379 + )}
380 +
381 + {/* 5. practical details */}
382 + {(practical.length > 0 || detEntries.length > 0) && (
383 + <section className="f-bloc" id="details">
384 + <h2>Practical details</h2>
385 + <div className="dtable">
386 + {practical.map(({ k, v }) => (
387 + <div className="drow" key={`p-${k}`}>
388 + <span>{k}</span><b>{v}</b>
389 + </div>
390 + ))}
391 + {detEntries.map(([k, v]) => (
392 + <div className="drow" key={k}>
393 + <span>{k}</span>
394 + {/^https?:\/\//.test(v)
395 + ? <b><a href={v} target="_blank" rel="noopener noreferrer">Open ↗</a></b>
396 + : <b>{v}</b>}
397 + </div>
398 + ))}
399 + </div>
400 + </section>
401 + )}
402 +
403 + {/* 6. price history */}
404 + {hist.length > 0 && (
405 + <section className="f-bloc" id="price-history">
406 + <h2>Price history</h2>
407 + <div className="dtable">
408 + {hist.map((h, i) => (
409 + <div className="drow" key={`${h.ts}-${i}`}>
410 + <span>{historyDate(h.ts)}</span>
411 + <b>{fmtPrice(h.price)}</b>
412 + </div>
413 + ))}
414 + </div>
415 + </section>
416 + )}
417 + </div>
418 +
419 + {/* -------- second column (desktop): map, other publications ---------- */}
420 + <div className="f-col">
421 + {/* 7. map */}
422 + {l.lat != null && l.lng != null && (
423 + <section className="f-bloc" id="map">
424 + <h2>Location</h2>
425 + <Suspense fallback={<div className="lmap3d lmap3d-skel map-loading">Loading the map…</div>}>
426 + <PropertyMap
427 + uid={l.uid} lat={l.lat} lng={l.lng} price={l.list_price}
428 + propertyType={l.property_type} address={addr}
429 + city={l.city} image={l.images?.[0]}
430 + />
431 + </Suspense>
432 + </section>
433 + )}
434 +
435 + {/* 8a. also listed on (duplicates) */}
436 + {l.duplicates && l.duplicates.length > 0 && (
437 + <section className="f-bloc" id="also-listed">
438 + <h2>Also listed on</h2>
439 + <p className="dups-note">
440 + This property was found on {l.duplicates.length}{" "}
441 + other platform{l.duplicates.length > 1 ? "s" : ""} —
442 + Home-Ka shows the most complete version.
443 + </p>
444 + <div className="dups-list">
445 + {l.duplicates.map((d) => (
446 + <a key={d.uid} className="dup-item" href={d.url} target="_blank" rel="noopener noreferrer">
447 + <span className="dup-src">{sourceName(d.source)}</span>
448 + {(d.agent_name || d.brokerage_name) && (
449 + <span className="dup-broker">{d.agent_name || d.brokerage_name}</span>
450 + )}
451 + <span className="dup-go">View listing <Ico name="external" size={13} /></span>
452 + </a>
453 + ))}
454 + </div>
455 + </section>
456 + )}
457 +
458 + {/* 8b. PROPERTY history — every listing ever seen for this home */}
459 + {propHistory.length > 0 && (
460 + <section className="f-bloc" id="property-history">
461 + <h2>Property history</h2>
462 + <p className="dups-note">
463 + Home-Ka tracks the physical property behind each listing — every
464 + publication of this home across sources, past and present.
465 + </p>
466 + <div className="dtable">
467 + {propHistory.map((h) => (
468 + <div className="drow" key={h.uid}>
469 + <span>
470 + {h.uid === l.uid
471 + ? sourceName(h.source)
472 + : <Link to={`/property/${encodeURIComponent(h.uid)}`}>{sourceName(h.source)}</Link>}
473 + {" · "}{historyDate(h.first_seen)}
474 + {h.active ? "" : " (inactive)"}
475 + </span>
476 + <b>
477 + {fmtPrice(h.list_price, "—")}
478 + {h.status ? ` · ${STATUS_LABELS[h.status] ?? h.status}` : ""}
479 + </b>
480 + </div>
481 + ))}
482 + </div>
483 + </section>
484 + )}
485 + </div>
486 + </div>
487 +
488 + <div className="fine f-foot">
489 + Prices and availability are those displayed by the source — every listing
490 + links back to the original announcement.
491 + </div>
492 +
493 + <div className="cta-sticky">
494 + <span className="cta-sticky-prix">{fmtPrice(l.list_price, l.price_label)}</span>
495 + <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
496 + View at {sourceName(l.source)} ↗
497 + </a>
498 + </div>
499 + </div>
500 + );
501 +}
added frontend/src/pages/Sources.tsx +92 −0
@@ -0,0 +1,92 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Sources.tsx : public registry of data sources (/api/sources)
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { Link } from "react-router-dom";
8 +import { Source, fetchSources, registerSourceNames } from "../api";
9 +
10 +function ago(ts: number | null): string {
11 + if (ts == null) return "never";
12 + const s = Math.max(0, Math.floor(Date.now() / 1000 - (ts > 1e12 ? ts / 1000 : ts)));
13 + if (s < 3600) return `${Math.round(s / 60)} min ago`;
14 + if (s < 172800) return `${Math.round(s / 3600)} h ago`;
15 + return `${Math.round(s / 86400)} d ago`;
16 +}
17 +
18 +export default function SourcesPage() {
19 + const [sources, setSources] = useState<Source[] | null>(null);
20 + const [error, setError] = useState<string | null>(null);
21 +
22 + useEffect(() => {
23 + fetchSources()
24 + .then((r) => { registerSourceNames(r.sources); setSources(r.sources); })
25 + .catch((e) => setError(String(e)));
26 + }, []);
27 +
28 + const totalListings = (sources ?? []).reduce((s, x) => s + x.active_listings, 0);
29 + const enabled = (sources ?? []).filter((s) => s.enabled).length;
30 +
31 + return (
32 + <div className="container sources">
33 + <span className="kicker">Registry — data sources</span>
34 + <h1>Data sources</h1>
35 + <p className="sub">
36 + Every listing on Home-Ka comes from a registered source — brokerage
37 + feeds, MLS/RESO APIs and listing platforms. Each source is synced by a
38 + dedicated connector and de-duplicated by MLS number and property match.
39 + Click a count to browse its homes.
40 + </p>
41 +
42 + {error && <div className="notice">{error}</div>}
43 + {!sources && !error && <div className="notice">Loading…</div>}
44 +
45 + {sources && (
46 + <>
47 + <p className="stats-foot" style={{ marginTop: 0 }}>
48 + <b>{totalListings.toLocaleString("en-US")}</b> active listings ·{" "}
49 + <b>{sources.length}</b> sources ·{" "}
50 + <b>{enabled}</b> enabled
51 + </p>
52 +
53 + <div className="q-table-wrap">
54 + <table className="q-table">
55 + <thead>
56 + <tr>
57 + <th>Source</th><th>Connector</th><th>States</th>
58 + <th>Active listings</th><th>Last sync</th>
59 + </tr>
60 + </thead>
61 + <tbody>
62 + {sources.map((s) => (
63 + <tr key={s.id}>
64 + <td>
65 + {s.name || s.id}
66 + {!s.enabled && <span className="adm-badge adm-off"> disabled</span>}
67 + </td>
68 + <td>{s.connector_type ?? "—"}</td>
69 + <td>{s.states?.length ? s.states.join(", ") : "—"}</td>
70 + <td className="num">
71 + {s.active_listings > 0
72 + ? <Link className="count-pill" to={`/?source=${encodeURIComponent(s.id)}`}>
73 + {s.active_listings.toLocaleString("en-US")}
74 + </Link>
75 + : "0"}
76 + </td>
77 + <td>{ago(s.last_sync)}</td>
78 + </tr>
79 + ))}
80 + </tbody>
81 + </table>
82 + </div>
83 +
84 + <p className="stats-foot">
85 + Listings published by more than one source are de-duplicated by MLS
86 + number and by matching the underlying property — no double counting.
87 + </p>
88 + </>
89 + )}
90 + </div>
91 + );
92 +}
added frontend/src/pages/Stats.tsx +176 −0
@@ -0,0 +1,176 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Stats.tsx : simple live dashboard — totals, by state, by type,
5 +// recent syncs (/api/stats).
6 +// -----------------------------------------------------------------------------
7 +import { useEffect, useState } from "react";
8 +import { Stats, fetchStats, sourceName } from "../api";
9 +
10 +const n = (x: number | null | undefined) =>
11 + x == null ? "—" : Math.round(x).toLocaleString("en-US");
12 +const usd = (x: number | null | undefined) =>
13 + x == null ? "—" : "$" + Math.round(x).toLocaleString("en-US");
14 +
15 +function ago(ts: number): string {
16 + const s = Math.max(0, Math.floor(Date.now() / 1000 - (ts > 1e12 ? ts / 1000 : ts)));
17 + if (s < 3600) return `${Math.round(s / 60)} min ago`;
18 + if (s < 172800) return `${Math.round(s / 3600)} h ago`;
19 + return `${Math.round(s / 86400)} d ago`;
20 +}
21 +
22 +export default function StatsPage() {
23 + const [stats, setStats] = useState<Stats | null>(null);
24 + const [error, setError] = useState<string | null>(null);
25 +
26 + useEffect(() => {
27 + fetchStats().then(setStats).catch((e) => setError(String(e)));
28 + }, []);
29 +
30 + return (
31 + <div className="container stats-page" style={{ display: "grid", gap: 22, minWidth: 0 }}>
32 + <header style={{ minWidth: 0 }}>
33 + <span className="kicker">The aggregated market</span>
34 + <h1 className="stats-title">Statistics</h1>
35 + <p className="sub" style={{ marginBottom: 14 }}>
36 + Live dashboard of the homes for sale aggregated by Home-Ka — volumes
37 + and prices computed on the platform's real data (source duplicates
38 + excluded, "price on request" excluded).
39 + </p>
40 + </header>
41 +
42 + {error && (
43 + <div className="notice">
44 + <h2>Statistics unavailable</h2>
45 + <p>{error}</p>
46 + </div>
47 + )}
48 + {!stats && !error && <div className="notice">Loading…</div>}
49 +
50 + {stats && (
51 + <>
52 + {/* --- totals row -------------------------------------------------- */}
53 + <section aria-label="Key totals" className="tiles">
54 + <div className="tile hero-tile">
55 + <div className="tile-v">{n(stats.total)}</div>
56 + <div className="tile-k">Homes for sale</div>
57 + </div>
58 + <div className="tile">
59 + <div className="tile-v">{n(stats.properties)}</div>
60 + <div className="tile-k">Physical properties tracked</div>
61 + </div>
62 + <div className="tile">
63 + <div className="tile-v">{n(stats.states)}</div>
64 + <div className="tile-k">States</div>
65 + </div>
66 + <div className="tile">
67 + <div className="tile-v">{n(stats.cities)}</div>
68 + <div className="tile-k">Cities</div>
69 + </div>
70 + <div className="tile">
71 + <div className="tile-v">{n(stats.sources)}</div>
72 + <div className="tile-k">Sources</div>
73 + </div>
74 + <div className="tile">
75 + <div className="tile-v">{usd(stats.avg_price)}</div>
76 + <div className="tile-k">Average price</div>
77 + </div>
78 + <div className="tile">
79 + <div className="tile-v">{usd(stats.min_price)}</div>
80 + <div className="tile-k">Lowest price</div>
81 + </div>
82 + <div className="tile">
83 + <div className="tile-v">{usd(stats.max_price)}</div>
84 + <div className="tile-k">Highest price</div>
85 + </div>
86 + </section>
87 +
88 + {/* --- by state ----------------------------------------------------- */}
89 + {stats.by_state.length > 0 && (
90 + <section aria-label="By state">
91 + <h2>By state</h2>
92 + <div className="q-table-wrap">
93 + <table className="q-table">
94 + <thead>
95 + <tr><th>State</th><th>Homes</th><th>Average price</th></tr>
96 + </thead>
97 + <tbody>
98 + {stats.by_state.map((r) => (
99 + <tr key={r.state}>
100 + <td>{r.state}</td>
101 + <td className="num">{n(r.n)}</td>
102 + <td className="num">{usd(r.avg_price)}</td>
103 + </tr>
104 + ))}
105 + </tbody>
106 + </table>
107 + </div>
108 + </section>
109 + )}
110 +
111 + {/* --- by property type ---------------------------------------------- */}
112 + {stats.by_type.length > 0 && (
113 + <section aria-label="By property type">
114 + <h2>By property type</h2>
115 + <div className="q-table-wrap">
116 + <table className="q-table">
117 + <thead>
118 + <tr><th>Type</th><th>Homes</th><th>Average price</th></tr>
119 + </thead>
120 + <tbody>
121 + {stats.by_type.map((r) => (
122 + <tr key={r.property_type}>
123 + <td>{r.property_type}</td>
124 + <td className="num">{n(r.n)}</td>
125 + <td className="num">{usd(r.avg_price)}</td>
126 + </tr>
127 + ))}
128 + </tbody>
129 + </table>
130 + </div>
131 + </section>
132 + )}
133 +
134 + {/* --- recent syncs --------------------------------------------------- */}
135 + {(stats.recent_syncs?.length ?? 0) > 0 && (
136 + <section aria-label="Recent syncs">
137 + <h2>Recent syncs</h2>
138 + <div className="q-table-wrap">
139 + <table className="q-table">
140 + <thead>
141 + <tr>
142 + <th>Source</th><th>When</th><th>Found</th>
143 + <th>Added</th><th>Removed</th><th>Status</th>
144 + </tr>
145 + </thead>
146 + <tbody>
147 + {stats.recent_syncs!.map((s, i) => (
148 + <tr key={`${s.source}-${s.ts}-${i}`}>
149 + <td>{sourceName(s.source)}</td>
150 + <td>{ago(s.ts)}</td>
151 + <td className="num">{n(s.found)}</td>
152 + <td className="num">{n(s.added)}</td>
153 + <td className="num">{n(s.removed)}</td>
154 + <td>
155 + {s.ok
156 + ? <span className="adm-badge adm-ok">OK</span>
157 + : <span className="adm-badge adm-err" title={s.message}>Error</span>}
158 + </td>
159 + </tr>
160 + ))}
161 + </tbody>
162 + </table>
163 + </div>
164 + </section>
165 + )}
166 + </>
167 + )}
168 +
169 + <p className="stats-foot">
170 + Updated automatically — statistics are computed on the aggregated
171 + listings. Every listing links back to the original announcement at
172 + the source.
173 + </p>
174 + </div>
175 + );
176 +}
added frontend/src/styles.css +1874 −0
@@ -0,0 +1,1874 @@
1 +/* -----------------------------------------------------------------------------
2 + Home-Ka — US real-estate aggregator (Groupe KA)
3 + Author: Simon-Pierre Boucher — contact@spboucher.ai
4 + styles.css : Home-Ka classes on top of the shared Groupe KA design system
5 + (ka/tokens.css, imported BEFORE this file in main.tsx).
6 + · The base (paper/ink palette, type, grain, selection, focus, component
7 + base) comes from ka/tokens.css — do NOT duplicate it here.
8 + · ONLY the brand accent is overridden: Home-Ka FOREST GREEN.
9 +----------------------------------------------------------------------------- */
10 +:root {
11 + /* ---- Home-Ka FOREST GREEN accent (only override of ka/tokens.css) ---- */
12 + --accent: #0f7b4a;
13 + --accent-soft: #dcefe5;
14 + --accent-deep: #0b5e38;
15 + --on-accent: #ffffff;
16 + /* legacy aliases — the CSS below uses --lime */
17 + --lime: var(--accent);
18 + --lime-soft: var(--accent-soft);
19 +}
20 +
21 +/* Ancre sous le header collant (spécifique Immo-Ka) */
22 +html { scroll-padding-top: 76px; }
23 +h1, h2, h3, h4 { margin: 0; }
24 +a { text-decoration: none; }
25 +button { font-family: inherit; }
26 +
27 +.mono { font-family: var(--font-mono); }
28 +.kicker {
29 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
30 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--green);
31 + display: inline-flex; align-items: center; gap: 8px;
32 +}
33 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
34 +
35 +.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; }
36 +@media (max-width: 640px) { .container { padding: 0 16px; } }
37 +
38 +/* ================= Header ================= */
39 +.header {
40 + position: sticky; top: 0; z-index: var(--z-header, 500);
41 + background: var(--paper);
42 + border-bottom: 2px solid var(--ink);
43 +}
44 +/* Menu mobile ouvert : le panneau vit DANS le header — on monte tout le header
45 + au niveau modale pour qu il passe au-dessus du voile (--z-overlay). */
46 +.header:has(.mobile-menu.open) { z-index: var(--z-modal, 900); }
47 +.header-inner { display: flex; align-items: center; gap: 20px; height: 64px; }
48 +.brand { font-family: var(--font-display); font-weight: 700; font-size: 26px; letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1; }
49 +.brand .ka { background: var(--ink); color: var(--lime); padding: 2px 7px 4px; border-radius: 6px; margin-left: 3px; transform: rotate(-2deg); transition: transform 0.2s ease; }
50 +.brand:hover .ka { transform: rotate(0deg); }
51 +.brand-tag { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.08em; text-transform: uppercase; margin-left: 12px; }
52 +@media (max-width: 860px) { .brand-tag { display: none; } }
53 +.nav { margin-left: auto; display: flex; gap: 4px; }
54 +.nav a { padding: 9px 16px; border-radius: 999px; font-weight: 600; font-size: 14px; color: var(--ink-2); border: 1.5px solid transparent; transition: all 0.15s ease; min-height: 40px; display: inline-flex; align-items: center; }
55 +.nav a:hover { border-color: var(--ink); color: var(--ink); }
56 +.nav a.active { background: var(--ink); color: var(--lime); }
57 +
58 +/* ================= Ticker ================= */
59 +.ticker { background: var(--ink); color: var(--lime); overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; padding: 7px 0; white-space: nowrap; border-bottom: 1px solid rgba(15, 123, 74, 0.25); }
60 +.ticker-track { display: inline-flex; gap: 0; animation: ticker 48s linear infinite; will-change: transform; }
61 +.ticker span { padding: 0 26px; position: relative; }
62 +.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; }
63 +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
64 +@media (prefers-reduced-motion: reduce) { .ticker-track { animation: none; } * { transition-duration: 0.01ms !important; } }
65 +
66 +/* ================= Hero ================= */
67 +.hero { position: relative; padding: 64px 0 26px; }
68 +@media (max-width: 640px) { .hero { padding: 36px 0 14px; } }
69 +/* halo cerise + trame de points en fond — décoratif, sous le texte */
70 +.hero::before {
71 + content: ""; position: absolute; z-index: -1; top: -60px; right: -120px;
72 + width: 580px; height: 460px; pointer-events: none;
73 + background: radial-gradient(closest-side, rgba(15, 123, 74, 0.12), transparent 72%);
74 +}
75 +.hero::after {
76 + content: ""; position: absolute; z-index: -1; inset: -20px -40px 0 0; pointer-events: none;
77 + background-image: radial-gradient(rgba(20, 24, 20, 0.18) 1.2px, transparent 1.7px);
78 + background-size: 22px 22px;
79 + -webkit-mask-image: linear-gradient(112deg, transparent 58%, rgba(0, 0, 0, 0.9));
80 + mask-image: linear-gradient(112deg, transparent 58%, rgba(0, 0, 0, 0.9));
81 +}
82 +.hero h1 { font-size: clamp(38px, 6.4vw, 78px); font-weight: 700; line-height: 0.98; text-transform: uppercase; letter-spacing: -0.035em; max-width: 940px; margin-top: 14px; }
83 +.hero h1 .outline { color: transparent; -webkit-text-stroke: 2px var(--ink); }
84 +.hero h1 .hl { background: var(--lime); color: #fff; padding: 0 10px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); }
85 +.hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 680px; margin: 20px 0 0; }
86 +
87 +@media (max-width: 640px) {
88 + .hero::before { width: 320px; height: 260px; top: -30px; right: -90px; }
89 +}
90 +
91 +.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; }
92 +.stat-chip { background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px; padding: 8px 16px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); display: flex; gap: 8px; align-items: center; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.12); transition: transform 0.13s ease, box-shadow 0.13s ease; }
93 +@media (hover: hover) { .stat-chip:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(26, 18, 20, 0.2); } }
94 +.stat-chip b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }
95 +.stat-chip .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite; }
96 +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(15, 123, 74, 0.4); } }
97 +
98 +/* ================= Filter bar ================= */
99 +.filterbar { background: var(--surface); border: 2px solid var(--ink); border-radius: 14px; box-shadow: var(--shadow-off-soft); padding: 14px; margin: 34px 0 6px; display: flex; flex-direction: column; gap: 0; min-width: 0; }
100 +.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; }
101 +.f-search { flex: 1 1 240px; min-width: 0; display: flex; align-items: center; gap: 9px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface-2); padding: 0 14px; min-height: 52px; color: var(--ink-2); transition: box-shadow 0.15s ease, background 0.15s ease, color 0.15s ease; }
102 +.f-search:focus-within { box-shadow: 3px 3px 0 var(--lime); background: var(--surface); color: var(--accent-deep); }
103 +.f-search input { border: none; background: none; outline: none; flex: 1; min-width: 0; font-size: 15px; color: var(--ink); font-family: inherit; }
104 +.f-search input::placeholder { color: var(--ink-3); }
105 +.f-clear { border: none; background: var(--line); color: var(--ink-2); border-radius: 50%; width: 22px; height: 22px; font-size: 10px; cursor: pointer; flex: none; display: grid; place-items: center; transition: background 0.12s ease, color 0.12s ease; }
106 +.f-clear:hover { background: var(--ink); color: #fff; }
107 +.f-ctl { flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 2px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); padding: 7px 12px 6px; min-height: 52px; cursor: pointer; transition: box-shadow 0.15s ease, background 0.15s ease; }
108 +@media (hover: hover) { .f-ctl:hover { background: var(--surface-2); } }
109 +.f-ctl:focus-within { box-shadow: 3px 3px 0 var(--lime); background: var(--surface); }
110 +.f-ctl > span { font-family: var(--font-mono); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
111 +.f-ctl select { border: none; background: transparent; outline: none; font-family: var(--font-display); font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer; appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 180px; text-overflow: ellipsis; 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"); background-repeat: no-repeat; background-position: right center; }
112 +.range-pair { display: flex; align-items: center; gap: 4px; }
113 +.range-pair select { max-width: 92px; }
114 +.range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; }
115 +.f-more { flex: none; align-self: stretch; display: inline-flex; align-items: center; gap: 8px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); color: var(--ink); padding: 0 18px; cursor: pointer; font-family: var(--font-display); font-weight: 700; font-size: 14px; min-height: 52px; transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.13s ease, color 0.13s ease; }
116 +.f-more:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); background: var(--surface); }
117 +.f-more:active { transform: none; box-shadow: none; }
118 +.f-more.on { background: var(--ink); border-color: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 var(--lime); }
119 +.f-more.on:hover { transform: none; }
120 +.f-more-badge { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--lime); color: var(--on-accent); font-size: 11.5px; font-weight: 700; font-variant-numeric: tabular-nums; }
121 +.f-chev { width: 8px; height: 8px; flex: none; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); margin-top: -4px; transition: transform 0.18s ease, margin 0.18s ease; }
122 +.f-chev.up { transform: rotate(-135deg); margin-top: 4px; }
123 +.f-adv { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 18px 26px; border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 16px; min-width: 0; animation: adv-in 0.22s cubic-bezier(0.2, 0.9, 0.3, 1); }
124 +@keyframes adv-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
125 +.f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
126 +.f-group > label { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
127 +.f-group-end { justify-content: flex-end; }
128 +.f-group .btn:disabled { opacity: 0.4; cursor: default; }
129 +.f-native { border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl); padding: 10px 30px 10px 12px; font-size: 14px; color: var(--ink); outline: none; font-family: inherit; min-height: 42px; width: 100%; min-width: 0; appearance: none; -webkit-appearance: none; 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"); background-repeat: no-repeat; background-position: right 12px center; }
130 +.f-native:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
131 +.seg { display: inline-flex; flex-wrap: wrap; row-gap: 6px; }
132 +.seg button { border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink-2); padding: 8px 13px; font-family: var(--font-display); font-weight: 600; font-size: 13px; cursor: pointer; margin-left: -1.5px; white-space: nowrap; min-height: 40px; transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; }
133 +.seg button:first-child { border-radius: 8px 0 0 8px; margin-left: 0; }
134 +.seg button:last-child { border-radius: 0 8px 8px 0; }
135 +.seg button:hover { background: var(--lime-soft); color: var(--ink); }
136 +.seg button.on { background: var(--ink); color: var(--lime); position: relative; z-index: 1; box-shadow: inset 0 -2.5px 0 var(--lime); }
137 +
138 +/* pastilles de filtres actifs — survol = intention de retrait (danger) */
139 +.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 2px; }
140 +.pills .pill { display: inline-flex; align-items: center; gap: 7px; border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; font-family: var(--font-mono); text-transform: none; letter-spacing: normal; cursor: pointer; transition: all 0.13s ease; box-shadow: 2px 2px 0 var(--lime-soft); animation: pill-in 0.18s ease; }
141 +@keyframes pill-in { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: none; } }
142 +.pills .pill:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); box-shadow: none; }
143 +.pills .pill:hover .pill-x { opacity: 1; }
144 +.pill-x { font-size: 10px; opacity: 0.6; }
145 +.pills .pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); box-shadow: none; }
146 +.pills .pill-clear:hover { background: var(--danger); color: #fff; }
147 +.link-btn { background: none; border: none; padding: 0; color: var(--green); font: inherit; text-decoration: underline; cursor: pointer; }
148 +
149 +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px; font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px; background: var(--surface); color: var(--ink); transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease, color 0.15s ease; font-family: var(--font-display); letter-spacing: 0.01em; text-decoration: none; }
150 +.btn:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); }
151 +.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }
152 +.btn:disabled { opacity: 0.45; pointer-events: none; }
153 +.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); }
154 +.btn-primary:hover { background: var(--green-deep); box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); }
155 +.btn-ghost { background: transparent; color: var(--ink); }
156 +.btn-ghost:hover { background: var(--lime); color: #fff; box-shadow: 4px 4px 0 rgba(26, 18, 20,0.2); }
157 +
158 +/* ================= Chips ================= */
159 +.chips { display: flex; gap: 8px; margin: 16px 0 4px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: none; }
160 +.chips::-webkit-scrollbar { display: none; }
161 +.chip { display: inline-flex; align-items: center; gap: 7px; border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); border-radius: 999px; padding: 9px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer; font-family: var(--font-display); white-space: nowrap; min-height: 42px; transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.13s ease, color 0.13s ease; }
162 +.chip:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); }
163 +.chip:active { transform: none; box-shadow: none; }
164 +.chip.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 var(--lime); }
165 +
166 +/* ================= Results ================= */
167 +.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; flex-wrap: wrap; }
168 +.results-head h2 { font-size: 22px; text-transform: uppercase; letter-spacing: -0.02em; }
169 +.results-head > span { font-family: var(--font-mono); color: var(--ink-3); font-size: 12px; letter-spacing: 0.06em; }
170 +.results-tools { display: flex; align-items: center; gap: 12px; margin-left: auto; }
171 +.sort-ctl select { border: 1.5px solid var(--ink); background: var(--surface); border-radius: var(--r-ctl); padding: 8px 28px 8px 12px; min-height: 36px; font-family: var(--font-mono); font-size: 12px; color: var(--ink); cursor: pointer; appearance: none; -webkit-appearance: none; 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"); background-repeat: no-repeat; background-position: right 10px center; transition: box-shadow 0.13s ease; }
172 +.sort-ctl select:hover { box-shadow: 2px 2px 0 rgba(26, 18, 20, 0.18); }
173 +.sort-ctl select:focus { outline: none; box-shadow: 3px 3px 0 var(--lime); }
174 +.view-toggle { display: inline-flex; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); overflow: hidden; background: var(--surface); box-shadow: var(--shadow-flat); }
175 +.view-toggle button { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; padding: 8px 14px; min-height: 36px; cursor: pointer; font-family: var(--font-mono); font-size: 12px; font-weight: 700; color: var(--ink-2); transition: background 0.12s ease, color 0.12s ease; }
176 +.view-toggle button:hover { background: var(--surface-2); color: var(--ink); }
177 +.view-toggle button + button { border-left: 1.5px solid var(--ink); }
178 +.view-toggle button.on { background: var(--ink); color: var(--lime); box-shadow: inset 0 -2.5px 0 var(--lime); }
179 +
180 +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 22px; padding-bottom: 20px; }
181 +@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; } }
182 +.more-wrap { display: flex; justify-content: center; padding: 8px 0 70px; }
183 +
184 +.card { background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-flat); transition: transform 0.16s ease, box-shadow 0.16s ease; }
185 +.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }
186 +.card:focus-visible { outline: 3px solid var(--lime); outline-offset: 2px; }
187 +.card-img { position: relative; aspect-ratio: 16/10.5; background: repeating-linear-gradient(45deg, #eceae3 0 12px, #f3f1ea 12px 24px); overflow: hidden; }
188 +.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
189 +.card:hover .card-img img { transform: scale(1.05); }
190 +.card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; }
191 +.badge { position: absolute; top: 12px; left: 12px; border-radius: 6px; padding: 4px 10px; font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; background: rgba(255, 255, 255, 0.95); color: var(--ink); border: 1px solid var(--ink); }
192 +.badge.type { background: var(--ink); color: var(--lime); border-color: var(--ink); }
193 +.badge.right { left: auto; right: 12px; background: rgba(255,255,255,0.92); border-color: transparent; }
194 +.card-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
195 +.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.02em; }
196 +.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
197 +.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; font-family: var(--font-mono); }
198 +.card-meta .sep { width: 4px; height: 4px; background: var(--lime); border: 1px solid var(--ink); border-radius: 1px; transform: rotate(45deg); }
199 +.card-specs { display: flex; gap: 12px; font-size: 12.5px; color: var(--ink-2); font-family: var(--font-mono); }
200 +.card-foot { margin-top: auto; padding-top: 11px; border-top: 1.5px dashed var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
201 +.source-tag { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--green-deep); background: var(--lime-soft); border: 1px solid var(--green); border-radius: 4px; padding: 3px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 62%; }
202 +.avail { font-size: 11px; color: var(--ink-3); font-weight: 500; text-align: right; font-family: var(--font-mono); }
203 +
204 +/* ================= Skeletons ================= */
205 +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
206 +.skel { border-radius: var(--r-card); border: 1.5px solid var(--line); overflow: hidden; background: var(--surface); }
207 +.skel .sk-img, .skel .sk-line { background: linear-gradient(90deg, #eeece5 25%, #f7f5ef 50%, #eeece5 75%); background-size: 800px 100%; animation: shimmer 1.4s infinite linear; }
208 +.skel .sk-img { aspect-ratio: 16/10.5; }
209 +.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; }
210 +.skel .sk-line.short { width: 45%; }
211 +
212 +/* ================= Empty / error ================= */
213 +.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); }
214 +.notice .big { font-size: 44px; margin-bottom: 10px; }
215 +.notice h2 { text-transform: uppercase; }
216 +
217 +/* ================= Detail page ================= */
218 +.detail { padding: 30px 0 90px; }
219 +.crumbs { font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
220 +.crumbs a { border-bottom: 1.5px solid transparent; }
221 +.crumbs a:hover { color: var(--green); border-color: var(--green); }
222 +
223 +/* Ordre DOM = ordre visuel sur TOUS les breakpoints (standard Groupe Ka
224 + « Ordre des sections — pages détail »). Interdit : `order` / `column-reverse`
225 + / `display:contents` pour réordonner — un bloc sans `order` retombe à
226 + order:0 et passe devant la galerie sur mobile (bug corrigé sur Lou-Ka).
227 + Mobile : colonnes empilées ; desktop : grid 2 colonnes (fiche | annexes). */
228 +.fiche { display: flex; flex-direction: column; gap: 22px; }
229 +.f-col { display: flex; flex-direction: column; gap: 22px; min-width: 0; }
230 +@media (min-width: 900px) {
231 + .fiche { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
232 + .f-col { gap: 26px; }
233 + .f-col:last-child { position: sticky; top: 88px; }
234 +}
235 +.f-bloc { min-width: 0; }
236 +.f-bloc h2 { font-size: 21px; letter-spacing: -0.02em; margin-bottom: 12px; border-left: 4px solid var(--lime); padding-left: 10px; }
237 +.f-bloc:empty { display: none; }
238 +
239 +/* galerie */
240 +.carousel { position: relative; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); background: var(--surface-2); box-shadow: var(--shadow-off-soft); }
241 +.carousel-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scrollbar-width: none; aspect-ratio: 16/11; }
242 +.carousel-track::-webkit-scrollbar { display: none; }
243 +.carousel-track img { flex: 0 0 100%; width: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; }
244 +.carousel-empty { display: flex; align-items: center; justify-content: center; aspect-ratio: 16/11; font-size: 48px; }
245 +.carousel-count { position: absolute; right: 12px; bottom: 12px; z-index: 2; background: rgba(26, 18, 20, 0.82); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; }
246 +.carousel-nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid var(--ink); background: rgba(255, 255, 255, 0.92); font-size: 22px; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; }
247 +.carousel-nav.prev { left: 10px; } .carousel-nav.next { right: 10px; }
248 +@media (max-width: 640px) { .carousel-nav { display: none; } }
249 +.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; margin-top: 10px; }
250 +.thumbs button { border: 2px solid var(--line); border-radius: 8px; overflow: hidden; padding: 0; cursor: pointer; aspect-ratio: 4/3; background: #eceae3; transition: border-color 0.12s ease, transform 0.12s ease; }
251 +.thumbs button:hover { transform: translateY(-2px); }
252 +.thumbs button.on { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
253 +.thumbs img { width: 100%; height: 100%; object-fit: cover; }
254 +@media (max-width: 640px) { .thumbs { display: flex; overflow-x: auto; scrollbar-width: none; } .thumbs::-webkit-scrollbar { display: none; } .thumbs button { flex: 0 0 84px; } }
255 +
256 +/* lightbox */
257 +.lightbox { position: fixed; inset: 0; background: rgba(16, 18, 16, 0.94); z-index: 100; display: flex; align-items: center; justify-content: center; cursor: zoom-out; padding: max(16px, env(safe-area-inset-top)) 16px; }
258 +.lightbox img { max-width: 94vw; max-height: 86vh; border-radius: 6px; border: 2px solid var(--lime); object-fit: contain; }
259 +.lb-close { position: absolute; top: 18px; right: 22px; background: none; border: none; color: #fff; font-size: 30px; cursor: pointer; }
260 +.lb-nav { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--lime); font-size: 52px; cursor: pointer; padding: 0 22px; user-select: none; }
261 +.lb-nav.prev { left: 6px; } .lb-nav.next { right: 6px; }
262 +.lb-count { position: absolute; bottom: 24px; color: #c9ccc2; font-family: var(--font-mono); font-size: 13px; }
263 +
264 +/* hero panneau (colonne droite) */
265 +.f-hero { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px; }
266 +.price-kicker { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--green); }
267 +.price-kicker::before { content: ""; display: inline-block; width: 16px; height: 2px; background: var(--lime); vertical-align: 3px; margin-right: 7px; }
268 +.price-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
269 +.f-hero .price { font-family: var(--font-display); font-size: clamp(30px, 6vw, 40px); font-weight: 700; letter-spacing: -0.03em; }
270 +.type-chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 11px; background: var(--lime-soft); color: var(--green-deep); border: 1px solid rgba(179, 32, 43, 0.25); border-radius: 999px; font-size: 12px; font-weight: 600; }
271 +.price-sub { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-3); margin-top: 2px; }
272 +.f-hero h1 { font-size: clamp(20px, 4.5vw, 24px); margin-top: 10px; }
273 +.f-hero .loc { color: var(--ink-2); font-size: 13.5px; margin-top: 3px; display: flex; align-items: center; gap: 5px; }
274 +.f-hero .loc .ico { color: var(--lime); }
275 +
276 +/* liste de specs — fiche technique premium */
277 +.spec-list { margin: 18px 0 4px; border-top: 1.5px solid var(--line-strong); }
278 +.spec-row { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 11px; padding: 8.5px 0; border-bottom: 1px dashed var(--line); }
279 +.spec-row:last-child { border-bottom: 1.5px solid var(--line-strong); }
280 +.spec-badge { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 9px; background: var(--lime-soft); color: var(--green-deep); }
281 +.spec-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-2); }
282 +.spec-v { font-family: var(--font-display); font-size: 15.5px; font-weight: 700; text-align: right; }
283 +.deal-badge { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 4px; border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; border: 1.5px solid var(--line-strong); }
284 +.deal-ok { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
285 +
286 +.specs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; margin: 18px 0; }
287 +.spec { border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); padding: 9px 11px; background: var(--surface-2); }
288 +.spec b { display: block; font-family: var(--font-display); font-size: 16px; }
289 +.spec span { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }
290 +
291 +.broker { margin: 16px 0; padding: 14px; border: 1.5px dashed var(--line-strong); border-radius: var(--r-ctl); background: var(--surface-2); }
292 +.broker-k { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); }
293 +.broker-name { font-family: var(--font-display); font-weight: 700; font-size: 15px; margin-top: 3px; }
294 +.broker-tel { display: inline-block; margin-top: 6px; color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
295 +
296 +.cta { display: block; text-align: center; background: var(--ink); color: var(--lime); font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl); padding: 15px; border: 1.5px solid var(--ink); min-height: 48px; box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); transition: all 0.14s ease; margin-top: 6px; }
297 +.cta:hover { background: var(--lime); color: #fff; }
298 +.cta:active { transform: translate(2px, 2px); box-shadow: none; }
299 +.f-hero .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; font-family: var(--font-mono); letter-spacing: 0.02em; }
300 +
301 +.desc-text { color: var(--ink-2); font-size: 14.5px; white-space: pre-line; line-height: 1.6; margin: 0; }
302 +
303 +/* tableau caractéristiques */
304 +.dtable { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0 22px; }
305 +.drow { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; }
306 +.drow span { color: var(--ink-3); } .drow b { text-align: right; font-family: var(--font-display); }
307 +
308 +/* tableau pièces */
309 +.rooms-wrap { overflow-x: auto; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); }
310 +table.rooms { width: 100%; border-collapse: collapse; font-size: 13.5px; min-width: 460px; }
311 +table.rooms th { text-align: left; background: var(--ink); color: var(--paper); padding: 9px 12px; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
312 +table.rooms td { padding: 8px 12px; border-bottom: 1px solid var(--line); }
313 +table.rooms tr:last-child td { border-bottom: none; }
314 +table.rooms tr:nth-child(even) td { background: var(--surface-2); }
315 +
316 +.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; }
317 +.amenity { background: var(--lime-soft); color: var(--green-deep); font-size: 12px; font-weight: 600; border: 1px solid var(--green); border-radius: 999px; padding: 5px 12px; }
318 +
319 +.prix-histo { margin-top: 14px; padding: 10px 14px; border-radius: var(--r-card); border: 1.5px solid var(--line-strong); font-size: 13.5px; background: var(--surface); }
320 +.prix-histo.down { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
321 +
322 +.mini-map { height: 320px; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; }
323 +.mini-map .mapview { border: none; border-radius: 0; box-shadow: none; height: 100%; }
324 +
325 +.cta-sticky { position: fixed; left: 0; right: 0; bottom: 0; z-index: 45; display: flex; align-items: center; gap: 12px; background: rgba(247, 241, 239, 0.94); backdrop-filter: blur(12px); border-top: 2px solid var(--ink); padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); }
326 +.cta-sticky .cta { flex: 1; margin: 0; }
327 +.cta-sticky-prix { font-family: var(--font-display); font-weight: 700; font-size: 19px; white-space: nowrap; }
328 +@media (min-width: 900px) { .cta-sticky { display: none; } }
329 +@media (max-width: 899px) { .detail { padding-bottom: 84px; } }
330 +.f-foot { margin-top: 26px; }
331 +.fine { font-size: 12px; color: var(--ink-3); }
332 +
333 +/* Box « Aussi publiée sur… » : autres publications de la même propriété */
334 +.dups-note { font-size: 13px; color: var(--ink-3); margin: 4px 0 10px; }
335 +.dups-list { display: flex; flex-direction: column; gap: 8px; }
336 +.dup-item { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 11px 13px; border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); background: var(--surface-2); transition: all 0.14s ease; min-height: 44px; }
337 +.dup-item:hover { border-color: var(--ink); box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.18); }
338 +.dup-src { font-family: var(--font-display); font-weight: 700; font-size: 14px; }
339 +.dup-broker { font-size: 12.5px; color: var(--ink-3); }
340 +.dup-go { margin-left: auto; font-size: 12.5px; font-weight: 600; color: var(--green-deep); border-bottom: 1.5px solid var(--lime); white-space: nowrap; }
341 +.dup-go .ico { vertical-align: -2px; }
342 +
343 +/* ================= Sources / Agences ================= */
344 +.sources { padding: 44px 0 90px; }
345 +.sources h1 { font-size: clamp(28px, 4vw, 40px); text-transform: uppercase; margin: 10px 0 6px; }
346 +.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 720px; }
347 +.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); }
348 +.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 760px; }
349 +.src-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); padding: 14px 18px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2); }
350 +.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; }
351 +.src-table tr:last-child td { border-bottom: none; }
352 +.src-table tr:hover td { background: var(--surface-2); }
353 +.src-table a { color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
354 +.pill.ok { background: var(--lime); color: #fff; border: 1px solid var(--ink); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; }
355 +.pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; }
356 +.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; border-bottom: 1.5px solid var(--lime); }
357 +
358 +/* ================= Stats ================= */
359 +.stats-page { padding: 44px 0 90px; }
360 +.stats-title { font-size: clamp(30px, 4.6vw, 46px); text-transform: uppercase; margin: 10px 0 6px; }
361 +.stats-page .sub { color: var(--ink-2); max-width: 660px; margin-bottom: 30px; }
362 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }
363 +.tile { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 18px 20px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.1); }
364 +.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(22px, 3vw, 32px); letter-spacing: -0.03em; line-height: 1.05; }
365 +.tile-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; }
366 +.hero-tile { background: var(--ink); color: var(--lime); border-color: var(--ink); }
367 +.hero-tile .tile-k { color: rgba(15, 123, 74, 0.7); }
368 +.viz-card { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px; min-width: 0; max-width: 100%; overflow-x: auto; }
369 +/* mobile : une table large ne doit JAMAIS déborder la page (grid blowout) —
370 + chaque bloc de la page Stats défile horizontalement en interne au besoin */
371 +.stats-page section, .stats-page > * { min-width: 0; max-width: 100%; }
372 +@media (max-width: 640px) { .stats-page .viz-card { padding: 18px 14px 14px; } }
373 +.viz-card h2 { font-size: 19px; text-transform: uppercase; letter-spacing: -0.01em; }
374 +.viz-sub { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; }
375 +.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
376 +@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } }
377 +.hbars { display: flex; flex-direction: column; gap: 7px; }
378 +.hbar-row { display: grid; grid-template-columns: minmax(96px, 180px) 1fr auto; gap: 12px; align-items: center; min-height: 26px; }
379 +.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
380 +.hbar-label a { border-bottom: 1.5px solid var(--lime); }
381 +.hbar-label a:hover { color: var(--green-deep); }
382 +.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; }
383 +.hbar-fill { display: block; height: 100%; background: var(--green); border-radius: 0 4px 4px 0; min-width: 2px; transition: background 0.12s ease; }
384 +.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
385 +.hbar-value { font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; white-space: nowrap; }
386 +.hbar-value em { font-style: normal; font-weight: 500; color: var(--ink-3); }
387 +.viz-table { margin-top: 4px; }
388 +.viz-table table { width: 100%; border-collapse: collapse; font-size: 13px; }
389 +.viz-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--ink); }
390 +.viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
391 +.stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; }
392 +
393 +/* ---- Iconographie maison (Icons.tsx) ---- */
394 +.ico { vertical-align: -3px; flex-shrink: 0; }
395 +.card-specs span { display: inline-flex; align-items: center; gap: 5px; }
396 +.card-specs .ico { color: var(--green); }
397 +.badge.right .ico { vertical-align: -2px; }
398 +.noimg .ico, .carousel-empty .ico { color: var(--ink-3); }
399 +.notice .big .ico { color: var(--lime); }
400 +.amenity .ico { color: var(--green); margin-right: 2px; }
401 +.q-badge .ico { vertical-align: -3px; margin-right: 2px; }
402 +.q-bar-label { display: inline-flex; align-items: center; gap: 7px; }
403 +.q-bar-label .ico { color: var(--green); }
404 +.mm-ico .ico { vertical-align: -4px; }
405 +.mv-noimg svg { color: var(--ink-3); }
406 +.broker-tel .ico { vertical-align: -2.5px; }
407 +.cta .ico { vertical-align: -2.5px; margin-left: 3px; }
408 +.prix-histo .ico { vertical-align: -3.5px; }
409 +.spec { position: relative; }
410 +.spec-ico { position: absolute; top: 10px; right: 10px; color: var(--lime); opacity: 0.85; }
411 +
412 +/* ---- Survalorisation vs Vrai-Prix (jauges divergentes) ---- */
413 +.vpg-axis { display: grid; grid-template-columns: 220px 1fr 74px 90px; gap: 10px; margin-bottom: 2px; }
414 +.vpg-axis > div { grid-column: 2; display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); }
415 +.vpg-row { display: grid; grid-template-columns: 220px 1fr 74px 90px; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px dashed var(--line); }
416 +.vpg-row:last-of-type { border-bottom: none; }
417 +.vpg-name { font-size: 13.5px; font-weight: 600; }
418 +.vpg-name em { display: block; font-style: normal; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); }
419 +.vpg-track { position: relative; height: 18px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
420 +.vpg-zero { position: absolute; left: 50%; top: 0; bottom: 0; width: 1.5px; background: var(--line-strong); opacity: 0.5; }
421 +.vpg-band { position: absolute; top: 3px; bottom: 3px; background: rgba(26, 18, 20, 0.14); border-radius: 999px; }
422 +.vpg-median { position: absolute; top: 1px; bottom: 1px; width: 4px; margin-left: -2px; border-radius: 2px; }
423 +.vpg-median.vp-sur { background: var(--lime); }
424 +.vpg-median.vp-juste { background: #d9a942; }
425 +.vpg-median.vp-sous { background: #4c8b4f; }
426 +.vpg-value { font-family: var(--font-display); font-weight: 700; font-size: 15px; text-align: right; }
427 +.vpg-value.vp-sur { color: var(--green); }
428 +.vpg-value.vp-juste { color: #a97b1e; }
429 +.vpg-value.vp-sous { color: #3c7440; }
430 +.vpg-split { display: flex; height: 10px; border-radius: 999px; overflow: hidden; border: 1px solid var(--line); }
431 +.vps-sous { background: #7fb283; }
432 +.vps-juste { background: #d9cfc7; }
433 +.vps-sur { background: var(--lime); }
434 +.vpg-sep { border-top: 1.5px solid var(--line-strong); margin: 4px 0; }
435 +.vpg-legend { display: flex; gap: 16px; margin-top: 10px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); flex-wrap: wrap; }
436 +.vpg-legend i { display: inline-block; width: 14px; height: 9px; border-radius: 999px; margin-right: 5px; vertical-align: -1px; }
437 +@media (max-width: 780px) { .vpg-row { grid-template-columns: 1fr 64px; } .vpg-track { grid-column: 1 / -1; } .vpg-split { grid-column: 1 / -1; } .vpg-axis { display: none; } }
438 +
439 +/* ================= Carte (Ka Maps — framework partagé Groupe Ka) =========
440 + Jetons du design system appliqués au chrome de la carte, mêmes réglages
441 + que Lou-Ka Maps. */
442 +.mapview .ka-map, .lmap3d .ka-map {
443 + --ka-accent: var(--accent);
444 + --ka-on-accent: var(--on-accent);
445 + --ka-surface: var(--surface);
446 + --ka-ink: var(--ink);
447 + --ka-line: var(--line-strong);
448 + --ka-radius: var(--r-ctl);
449 + --ka-shadow: 0 8px 24px rgba(26, 18, 20, 0.14);
450 + --ka-font: var(--font-body);
451 +}
452 +.mapview { position: relative; overflow: hidden; }
453 +.mapview .ka-map { position: absolute; inset: 0; }
454 +
455 +/* mini-carte 3D de la fiche (Emplacement) — bâtiment de l'annonce en cerise */
456 +.lmap3d {
457 + position: relative; height: 340px; border: 1.5px solid var(--ink);
458 + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
459 + overflow: hidden; background: var(--surface-2);
460 +}
461 +.lmap3d-skel {
462 + background: linear-gradient(90deg, #eeece5 25%, #f7f5ef 50%, #eeece5 75%);
463 + background-size: 800px 100%; animation: shimmer 1.4s infinite linear;
464 +}
465 +.lmap3d-legende {
466 + position: absolute; left: 10px; bottom: 10px; z-index: 5;
467 + display: inline-flex; align-items: center; gap: 6px;
468 + padding: 4px 10px; border: 1.5px solid var(--ink);
469 + border-radius: 999px; background: var(--surface);
470 + font-size: 10.5px; font-weight: 500; color: var(--ink-2);
471 + pointer-events: none;
472 +}
473 +.lmap3d-legende i {
474 + width: 10px; height: 10px; border-radius: 3px;
475 + background: var(--accent); border: 1px solid rgba(26, 18, 20, 0.25);
476 +}
477 +@media (max-width: 780px) { .lmap3d { height: 280px; } }
478 +
479 +/* ================= Carte ================= */
480 +.map-split { display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 18px; height: calc(100dvh - 200px); min-height: 420px; }
481 +.map-list { overflow-y: auto; display: flex; flex-direction: column; gap: 14px; padding-right: 4px; scrollbar-width: thin; }
482 +.map-list .card { margin: 0; flex: 0 0 auto; }
483 +/* sync liste ↔ carte : fiche sélectionnée depuis une pastille de la carte */
484 +.map-card { border-radius: var(--r-card); }
485 +.map-card-sel .card { border-color: var(--accent); box-shadow: 4px 4px 0 var(--accent-soft), 0 0 0 2px var(--accent); }
486 +.mapwrap { position: relative; width: 100%; height: 100%; }
487 +.mapview { width: 100%; height: 100%; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); overflow: hidden; background: var(--surface-2); }
488 +.mv-brand { position: absolute; top: 10px; left: 10px; z-index: 3; display: flex; align-items: center; gap: 6px; padding: 5px 11px; background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: 999px; box-shadow: var(--shadow-off-soft); font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.04em; color: var(--ink-2); pointer-events: none; }
489 +.mv-brand b { color: var(--ink); }
490 +.mv-brand-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 0 3px var(--lime-soft); }
491 +.mv-legend { position: absolute; bottom: 26px; left: 10px; z-index: 3; display: flex; flex-direction: column; gap: 4px; padding: 8px 11px; background: rgba(255, 255, 255, 0.92); border: 1px solid var(--line); border-radius: var(--r-ctl); font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); pointer-events: none; }
492 +.mv-legend span { display: flex; align-items: center; gap: 7px; }
493 +.mv-lg-pill { display: inline-block; width: 22px; height: 12px; border-radius: 999px; background: #fff; border: 1.5px solid rgba(26, 18, 20, 0.35); }
494 +.mv-lg-deal { background: var(--lime); border-color: var(--green); }
495 +.mv-credit { position: absolute; bottom: 8px; right: 8px; z-index: 3; padding: 4px 10px; background: rgba(255, 255, 255, 0.9); border-radius: 999px; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.04em; color: var(--ink-2); pointer-events: none; }
496 +.mv-credit b { color: var(--ink); }
497 +.mini-map .mv-brand, .mini-map .mv-legend { display: none; }
498 +.mini-map .mapwrap { height: 100%; }
499 +
500 +/* ---- carte propriété (fiche) : marqueur pulsant + anneaux piéton ---- */
501 +.pm-wrap { position: relative; width: 100%; height: 100%; }
502 +.pm-marker { display: flex; flex-direction: column; align-items: center; }
503 +.pm-pill { padding: 5px 13px; background: #fff; border: 1.5px solid var(--line-strong); border-radius: 999px; font-family: var(--font-display); font-weight: 700; font-size: 14px; color: var(--ink); box-shadow: var(--shadow-off-soft); white-space: nowrap; margin-bottom: 4px; }
504 +.pm-pill-deal { background: var(--lime); border-color: var(--green); color: #fff; }
505 +.pm-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--lime); border: 2.5px solid #fff; box-shadow: 0 1px 4px rgba(26, 18, 20, 0.4); }
506 +.pm-pulse { position: absolute; bottom: -8px; width: 30px; height: 30px; border-radius: 50%; background: var(--lime); opacity: 0.35; animation: pm-pulse 2s ease-out infinite; }
507 +@keyframes pm-pulse { 0% { transform: scale(0.5); opacity: 0.45; } 80% { transform: scale(1.9); opacity: 0; } 100% { opacity: 0; } }
508 +.pm-rings-legend { position: absolute; bottom: 8px; left: 8px; z-index: 3; display: flex; gap: 12px; padding: 4px 10px; background: rgba(255, 255, 255, 0.9); border-radius: 999px; font-family: var(--font-mono); font-size: 10px; color: var(--green); pointer-events: none; }
509 +.mini-map .pm-wrap .mv-credit { display: block; }
510 +.map-loading { display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-family: var(--font-mono); font-size: 13px; }
511 +@media (max-width: 780px) { .map-split { grid-template-columns: 1fr; height: calc(100dvh - 230px); } .map-list { display: none; } .results-tools { width: 100%; justify-content: space-between; } }
512 +.maplibregl-popup-content { padding: 0; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); box-shadow: var(--shadow-off); font-family: var(--font-body); }
513 +.maplibregl-popup-close-button { font-size: 18px; padding: 2px 8px; color: var(--paper); z-index: 2; text-shadow: 0 0 4px rgba(26, 18, 20, 0.8); }
514 +.mv-pop img, .mv-noimg { width: 100%; height: 130px; object-fit: cover; }
515 +.mv-noimg { display: flex; align-items: center; justify-content: center; font-size: 34px; background: var(--surface-2); }
516 +.mv-pop-body { padding: 10px 12px 12px; }
517 +.mv-pop-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; }
518 +.mv-pop-title { font-size: 13px; color: var(--ink-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
519 +.mv-pop-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); margin-top: 6px; }
520 +.mv-pop-src { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
521 +.mv-pop-cta { display: block; margin-top: 10px; padding: 8px 10px; text-align: center; background: var(--ink); color: #fff; border-radius: var(--r-ctl); font-weight: 600; font-size: 13px; }
522 +.mv-pop-cta:hover { background: var(--green-deep); }
523 +.mv-pop-deal { display: inline-block; margin-left: 6px; padding: 2px 8px; vertical-align: 3px; background: var(--lime); color: #fff; border-radius: 999px; font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.05em; }
524 +.maplibregl-ctrl-attrib { font-size: 10px; }
525 +
526 +/* ================= Menu mobile ================= */
527 +.menu-btn { display: none; position: relative; z-index: 60; width: 44px; height: 44px; margin-left: auto; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface); cursor: pointer; padding: 0; flex-direction: column; align-items: center; justify-content: center; gap: 5px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.15); transition: box-shadow 0.15s ease, transform 0.15s ease; }
528 +.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(26, 18, 20,0.15); }
529 +.menu-btn span { display: block; width: 18px; height: 2px; background: var(--ink); border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease; }
530 +.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
531 +.menu-btn.open span:nth-child(2) { opacity: 0; }
532 +.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
533 +/* KA Nav v2 (2026-08-25) : panneau PLEIN ECRAN fixed inset:0 - visible peu
534 + importe le scroll (l'ancien dropdown absolute 480px clippait/se coupait). */
535 +.mobile-menu {
536 + display: none; position: fixed; inset: 0;
537 + background: var(--paper);
538 + padding: 76px 16px calc(24px + env(safe-area-inset-bottom));
539 + overflow-y: auto; overscroll-behavior: contain;
540 +}
541 +.mobile-menu.open { display: block; animation: mm-in 0.16s ease; }
542 +@keyframes mm-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
543 +/* burger masque a l'ouverture : la fermeture passe par le X fixe du panneau */
544 +.header:has(.mobile-menu.open) .menu-btn { visibility: hidden; }
545 +/* bouton de fermeture du panneau : fixe en haut a droite du viewport */
546 +.mm-close {
547 + position: fixed; top: 10px; right: 14px; z-index: 2;
548 + width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;
549 + border: 1.5px solid var(--ink); border-radius: 999px;
550 + background: var(--ink); color: var(--paper);
551 + font-size: 17px; font-weight: 700; line-height: 1; cursor: pointer; padding: 0;
552 +}
553 +/* verrou du scroll d'arriere-plan quand le menu est ouvert (CSS pur) */
554 +html:has(.mobile-menu.open) { overflow: hidden; }
555 +.mm-link { display: flex; align-items: center; gap: 12px; padding: 15px 10px; min-height: 52px; font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; border-bottom: 1.5px dashed var(--line); opacity: 0; transform: translateY(-8px); transition: opacity 0.25s ease, transform 0.25s ease; }
556 +.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); }
557 +.mm-link.active { color: var(--green); }
558 +.mm-ico { width: 26px; text-align: center; font-size: 17px; }
559 +.mm-arrow { margin-left: auto; opacity: 0.25; }
560 +.mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); }
561 +.mm-foot { padding: 12px 10px 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em; }
562 +.mm-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay, 800); background: rgba(26, 18, 20, 0.35); backdrop-filter: blur(2px); }
563 +@media (max-width: 760px) { .menu-btn { display: flex; } .nav { display: none; } }
564 +
565 +/* ================= Footer =================
566 + Remplacé par le footer commun Groupe KA (ka/KaFooter.tsx — styles .ka-footer
567 + dans ka/tokens.css). */
568 +
569 +/* ================= Mobile : feuille de filtres + FAB ================= */
570 +.sheet-head { display: none; }
571 +.sheet-handle { display: none; }
572 +.sheet-apply { display: none; }
573 +.sheet-backdrop { display: none; }
574 +.fab { display: none; }
575 +.fab-badge { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--lime); color: #fff; font-size: 11.5px; font-weight: 700; font-variant-numeric: tabular-nums; margin-left: 2px; }
576 +@media (max-width: 640px) {
577 + .header-inner { height: 56px; }
578 + .brand { font-size: 21px; }
579 + .hero h1 { font-size: clamp(30px, 9.4vw, 44px); }
580 + .hero p.lede { font-size: 15px; }
581 + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }
582 + .stat-row::-webkit-scrollbar { display: none; }
583 + .stat-chip { flex: 0 0 auto; white-space: nowrap; }
584 + .filterbar { display: none; }
585 + .filterbar.open .f-primary { flex-direction: column; }
586 + .filterbar.open .f-ctl select { max-width: none; width: 100%; }
587 + .filterbar.open .f-more { display: none; }
588 + .filterbar.open { display: flex; flex-direction: column; gap: 12px; position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900); margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0; max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 10px 18px calc(18px + env(safe-area-inset-bottom)); box-shadow: 0 -16px 48px rgba(16, 18, 16, 0.35); animation: sheet-up 0.24s cubic-bezier(0.2, 0.9, 0.3, 1); }
589 + @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }
590 + .filterbar.open .sheet-handle { display: block; flex: none; width: 44px; height: 5px; border-radius: 999px; background: var(--line); margin: 0 auto 2px; }
591 + .filterbar.open .sheet-head { display: flex; justify-content: space-between; align-items: center; font-family: var(--font-display); font-weight: 700; font-size: 17px; text-transform: uppercase; position: sticky; top: -10px; background: var(--surface); padding: 6px 0 8px; border-bottom: 1.5px solid var(--line); margin-bottom: 2px; z-index: 1; }
592 + .sheet-close { border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%; width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; }
593 + .filterbar.open .sheet-apply { display: flex; width: 100%; min-height: 50px; position: sticky; bottom: 0; z-index: 1; box-shadow: 0 -14px 18px -12px rgba(16, 18, 16, 0.4), 5px 5px 0 var(--lime); }
594 + .sheet-backdrop { display: block; position: fixed; inset: 0; z-index: 90; background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px); }
595 + .fab { display: flex; align-items: center; gap: 8px; position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80; background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink); border-radius: 999px; padding: 13px 24px; font-family: var(--font-display); font-weight: 700; font-size: 15px; cursor: pointer; box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(15, 123, 74, 0.55); }
596 + .fab:active { transform: translateX(-50%) scale(0.97); }
597 + .results-head { margin-top: 20px; }
598 + .chips { margin-right: -16px; padding-right: 16px; }
599 + .notice { padding: 48px 16px; }
600 +}
601 +@media (max-width: 900px) { .f-search input, .f-native, .f-ctl select { font-size: 16px; } }
602 +
603 +/* --- Sources : bannières → sous-agences -------------------------------- */
604 +.franchise-list { display: flex; flex-direction: column; gap: 10px; margin: 18px 0; }
605 +.franchise { border: 2px solid var(--ink); background: var(--surface); box-shadow: var(--shadow-off-soft); }
606 +.franchise-head {
607 + width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px;
608 + background: none; border: 0; cursor: pointer; font-family: var(--font-display);
609 + font-size: 16px; font-weight: 600; color: var(--ink); text-align: left;
610 +}
611 +.franchise-head:hover { background: var(--surface-2); }
612 +.fh-caret { color: var(--green); width: 14px; }
613 +.fh-name { flex: 1; }
614 +.fh-sub { font-size: 12px; font-weight: 500; color: var(--ink-3); font-family: var(--font-mono); }
615 +.fh-count {
616 + font-family: var(--font-mono); font-weight: 700; background: var(--ink);
617 + color: var(--lime); padding: 3px 10px; min-width: 64px; text-align: right;
618 +}
619 +.subagency-list { list-style: none; margin: 0; padding: 0 16px 10px 40px; border-top: 1px solid var(--line); }
620 +.subagency-list li {
621 + display: flex; align-items: center; justify-content: space-between; gap: 12px;
622 + padding: 8px 0; border-bottom: 1px dotted var(--line); font-size: 14px;
623 +}
624 +.subagency-list li:last-child { border-bottom: 0; }
625 +.sa-name { color: var(--ink-2); }
626 +
627 +/* --- Pagination (accueil) ---------------------------------------------- */
628 +.pager { display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
629 + gap: 6px; margin: 32px 0 8px; }
630 +.pager-btn {
631 + font-family: var(--font-mono); font-size: 14px; min-width: 42px; min-height: 42px; padding: 8px 12px;
632 + border: 2px solid var(--ink); border-radius: 8px; background: var(--surface); color: var(--ink);
633 + cursor: pointer; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.14);
634 + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
635 +}
636 +.pager-btn:hover:not(:disabled):not(.on) { background: var(--lime-soft); transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(26, 18, 20, 0.2); }
637 +.pager-btn:active:not(:disabled) { transform: none; box-shadow: none; }
638 +.pager-btn.on { background: var(--ink); color: var(--lime); font-weight: 700; box-shadow: 3px 3px 0 var(--lime); }
639 +.pager-btn:disabled { opacity: .4; cursor: not-allowed; box-shadow: none; }
640 +.pager-gap { padding: 0 4px; color: var(--ink-3); }
641 +.pager-info { width: 100%; text-align: center; margin-top: 8px; font-size: 12px;
642 + color: var(--ink-3); font-family: var(--font-mono); }
643 +
644 +
645 +
646 +/* --- Le quartier (census / proximité / chaleur / crime) — porté de Lou-Ka --- */
647 +/* --- Section « Le quartier » (fiche) --------------------------------------- */
648 +.quartier { margin-top: 34px; }
649 +.quartier h2 { font-size: 24px; letter-spacing: -0.02em; }
650 +.q-sub { color: var(--ink-3); font-size: 13px; margin: 4px 0 16px; }
651 +.q-grid {
652 + display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
653 +}
654 +.q-grid { grid-template-columns: repeat(2, 1fr); }
655 +.q-cell {
656 + background: var(--surface); border: 1.5px solid var(--line-strong);
657 + border-radius: var(--r-card); padding: 12px 14px; box-shadow: var(--shadow-flat);
658 +}
659 +.q-val { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; }
660 +.q-label { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3);
661 + text-transform: uppercase; letter-spacing: 0.06em; margin-top: 3px; }
662 +.q-prox { margin-top: 18px; display: flex; flex-direction: column; gap: 8px; }
663 +.q-bar { display: flex; align-items: center; gap: 10px; font-size: 13px; }
664 +.q-bar-label { flex: 0 0 150px; color: var(--ink-2); }
665 +.q-bar-label { flex-basis: 120px; font-size: 12px; }
666 +.q-bar-track {
667 + flex: 1; height: 10px; background: var(--surface-2);
668 + border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden;
669 +}
670 +.q-bar-fill { display: block; height: 100%; background: var(--green); border-radius: 999px; }
671 +.q-bar-num { flex: 0 0 30px; text-align: right; font-family: var(--font-mono);
672 + font-size: 11.5px; font-weight: 700; }
673 +.q-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
674 +.q-badge {
675 + display: inline-flex; align-items: center; gap: 6px;
676 + border: 1.5px solid var(--line-strong); border-radius: 999px;
677 + padding: 7px 13px; font-size: 12.5px; background: var(--surface);
678 +}
679 +.q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
680 +.q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
681 +.quartier .fine { margin-top: 10px; }
682 +.q-cell { padding: 9px 11px; }
683 +.q-val { font-size: 16px; }
684 +.q-bar { gap: 8px; font-size: 12px; }
685 +.q-bar-track { height: 8px; }
686 +.q-badge-sub { display: block; font-size: 10.5px; color: var(--ink-3); font-weight: 400; margin-left: 4px; }
687 +
688 +/* --- Vrai-Prix : estimation de valeur ---------------------------------- */
689 +.vraiprix { border: 2px solid var(--ink); background: var(--surface-2);
690 + box-shadow: var(--shadow-off-soft); padding: 14px; margin: 14px 0; }
691 +.vp-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
692 +.vp-logo { font-family: var(--font-display); font-weight: 700; color: var(--green-deep);
693 + letter-spacing: -.3px; }
694 +.vp-conf { font-family: var(--font-mono); font-size: 11px; padding: 2px 7px; border: 1px solid var(--ink); }
695 +.vp-conf-A { background: var(--lime); color: #fff; } .vp-conf-B { background: var(--lime-soft); }
696 +.vp-conf-C { background: var(--amber-soft); } .vp-conf-D { background: #f0d9d9; }
697 +.vp-k { font-size: 11px; color: var(--ink-3); text-transform: uppercase;
698 + letter-spacing: .5px; margin-top: 8px; }
699 +.vp-value { font-family: var(--font-display); font-size: 26px; font-weight: 700;
700 + letter-spacing: -1px; color: var(--ink); }
701 +.vp-range { font-size: 12px; color: var(--ink-2); font-family: var(--font-mono); }
702 +/* jauge Vrai-Prix : fourchette P10–P90 + marqueurs estimation / prix demandé */
703 +.vp-gauge { margin-top: 10px; }
704 +.vp-gauge-track { position: relative; height: 14px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line-strong); overflow: hidden; }
705 +.vp-gauge-band { position: absolute; inset: 3px 6%; background: linear-gradient(90deg, var(--lime-soft), rgba(15, 123, 74, 0.35), var(--lime-soft)); border-radius: 999px; }
706 +.vp-gauge-est { position: absolute; top: 0; bottom: 0; width: 4px; margin-left: -2px; background: var(--lime); border-radius: 2px; }
707 +.vp-gauge-ask { position: absolute; top: 2px; bottom: 2px; width: 4px; margin-left: -2px; background: var(--ink); border-radius: 2px; }
708 +.vp-gauge-ends { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); margin-top: 4px; }
709 +.vp-gauge-legend { display: flex; gap: 14px; font-family: var(--font-mono); font-size: 10px; color: var(--ink-2); margin-top: 5px; }
710 +.vp-dot-est, .vp-dot-ask { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 4px; vertical-align: -1px; }
711 +.vp-dot-est { background: var(--lime); }
712 +.vp-dot-ask { background: var(--ink); }
713 +.vp-delta { font-size: 13px; margin-top: 8px; padding: 6px 8px; border-radius: var(--r-ctl); }
714 +.vp-over { background: #f6e2e2; color: #8a2b2b; }
715 +.vp-under { background: #e2f0e6; color: #1c5c41; }
716 +.vp-fair { background: var(--surface); color: var(--ink-2); }
717 +.vp-link { display: block; text-align: center; margin-top: 10px; font-weight: 700;
718 + font-size: 13px; padding: 8px; background: var(--green-deep); color: #fff;
719 + text-decoration: none; }
720 +.vp-link:hover { background: var(--green); }
721 +
722 +/* =============================================================================
723 + Harmonisation Groupe KA — header (badge + compte KA ID), pages Profil,
724 + Contact et légales. Le socle vient de ka/tokens.css.
725 +============================================================================= */
726 +
727 +/* ---- Header : badge « Un service Groupe KA » + zone compte ---- */
728 +.header-inner .gk-badge { flex: none; }
729 +@media (max-width: 900px) { .header-inner .gk-badge { display: none; } }
730 +.header-acct { display: flex; align-items: center; gap: 10px; flex: none; }
731 +.ka-auth { display: flex; align-items: center; gap: 10px; }
732 +.ka-login {
733 + display: inline-flex; align-items: center; gap: 6px; min-height: 40px;
734 + padding: 8px 15px; border: 1.5px solid var(--ink); border-radius: 999px;
735 + background: var(--surface); color: var(--ink); cursor: pointer;
736 + font-family: var(--font-display); font-weight: 600; font-size: 13.5px;
737 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15);
738 + transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.15s ease;
739 +}
740 +.ka-login b { background: var(--ink); color: var(--accent); border-radius: 5px; padding: 0 6px 1px; transform: rotate(-2deg); transition: transform 0.15s ease; }
741 +.ka-login:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.2); }
742 +.ka-login:hover b { transform: rotate(0); }
743 +.ka-login:active { transform: translate(2px, 2px); box-shadow: none; }
744 +.ka-signup { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); text-decoration: underline; text-underline-offset: 3px; white-space: nowrap; }
745 +.ka-signup:hover { color: var(--accent-deep); }
746 +@media (max-width: 1100px) { .header-acct .ka-signup { display: none; } }
747 +.ka-acct {
748 + display: inline-flex; align-items: center; gap: 8px; min-height: 40px;
749 + padding: 6px 14px; border: 1.5px solid var(--ink); border-radius: 999px;
750 + background: var(--surface); font-family: var(--font-display);
751 + font-weight: 600; font-size: 13.5px; color: var(--ink);
752 + max-width: 160px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
753 +}
754 +.ka-acct:hover, .ka-acct.active { background: var(--ink); color: var(--accent); }
755 +.ka-acct-pic { width: 24px; height: 24px; border-radius: 50%; flex: none; border: 1px solid var(--line); }
756 +@media (max-width: 760px) {
757 + .header-acct { margin-left: auto; }
758 + .menu-btn { margin-left: 0; }
759 + .ka-login { min-height: 44px; }
760 +}
761 +@media (max-width: 380px) { .header-acct { display: none; } } /* → menu mobile */
762 +
763 +/* ---- Menu mobile : compte + badge groupe ---- */
764 +.mm-auth { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 15px 10px; border-bottom: 1.5px dashed var(--line); }
765 +.mm-auth .ka-login { min-height: 44px; }
766 +.mm-foot p { margin: 10px 0 0; }
767 +
768 +/* ---- Générique : lede (Profil / Contact) ---- */
769 +.lede { color: var(--ink-2); font-size: 15.5px; max-width: 640px; }
770 +
771 +/* ================= Page Profil (KA ID) ================= */
772 +.profil { padding: 44px 0 90px; }
773 +.profil h1 { font-size: clamp(28px, 4.6vw, 44px); margin: 10px 0 24px; letter-spacing: -0.03em; }
774 +.profil .lede { margin-bottom: 22px; }
775 +.profil .notice { padding: 60px 0; }
776 +
777 +/* Carte de membre — encre + cerise */
778 +.pc {
779 + position: relative; overflow: hidden; max-width: 460px;
780 + background: var(--ink); color: var(--paper);
781 + border: 1.5px solid var(--ink); border-radius: var(--r-card);
782 + box-shadow: var(--shadow-off-soft); padding: 22px 26px 0;
783 +}
784 +.pc-watermark {
785 + position: absolute; right: -18px; bottom: 14px; font-family: var(--font-display);
786 + font-weight: 700; font-size: 150px; line-height: 1; letter-spacing: -0.06em;
787 + color: rgba(245, 243, 238, 0.05); pointer-events: none; user-select: none;
788 +}
789 +.pc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
790 +.pc-brand { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.03em; }
791 +.pc-ka { display: inline-block; background: var(--accent); color: var(--on-accent); border-radius: 6px; padding: 0 7px 2px; margin-left: 3px; transform: rotate(-2deg); }
792 +.pc-label { font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.16em; color: rgba(245, 243, 238, 0.5); }
793 +.pc-id-block { margin: 26px 0 22px; display: flex; flex-direction: column; gap: 4px; }
794 +.pc-id-label { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.2em; color: var(--accent); }
795 +.pc-id { font-family: var(--font-mono); font-weight: 700; font-size: clamp(17px, 4.4vw, 22px); letter-spacing: 0.04em; word-break: break-all; }
796 +.pc-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; padding-bottom: 18px; }
797 +.pc-holder { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
798 +.pc-holder-name { font-family: var(--font-display); font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
799 +.pc-role-badge { align-self: flex-start; font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; background: var(--accent); color: var(--on-accent); border-radius: 999px; padding: 2px 9px; }
800 +.pc-holder-since { font-family: var(--font-mono); font-size: 10.5px; color: rgba(245, 243, 238, 0.5); letter-spacing: 0.06em; }
801 +.pc-avatar { width: 52px; height: 52px; border-radius: 50%; border: 2px solid var(--accent); flex: none; object-fit: cover; }
802 +.pc-strip { display: flex; align-items: flex-end; gap: 5px; margin: 0 -26px; padding: 9px 18px; background: rgba(15, 123, 74, 0.14); border-top: 1px solid rgba(15, 123, 74, 0.4); overflow: hidden; }
803 +.pc-strip i { display: block; width: 3px; border-radius: 1px; background: var(--accent); opacity: 0.75; }
804 +.pc-strip i:nth-child(3n) { height: 14px; opacity: 0.4; }
805 +.pc-strip i:nth-child(3n+1) { height: 9px; }
806 +.pc-strip i:nth-child(3n+2) { height: 17px; opacity: 0.95; }
807 +.pc-copy { margin-top: 16px; }
808 +.pc-copy.ok { background: var(--ink); color: var(--accent); }
809 +
810 +.profil-grid {
811 + display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
812 + gap: 12px; margin-top: 28px; max-width: 720px;
813 +}
814 +.pg-item { background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
815 +.pg-label { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
816 +.pg-value { font-weight: 600; font-size: 14.5px; word-break: break-word; }
817 +.pg-value.mono { font-family: var(--font-mono); color: var(--accent-deep); }
818 +.profil-note { max-width: 640px; color: var(--ink-2); font-size: 13.5px; margin-top: 22px; }
819 +.profil-note a { text-decoration: underline; text-underline-offset: 3px; }
820 +.profil-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 26px; }
821 +
822 +/* Profil hub Groupe KA (lecture seule) */
823 +.hub-profile { margin-top: 34px; padding-top: 24px; border-top: 1.5px solid var(--line-strong); max-width: 720px; display: flex; flex-direction: column; gap: 14px; }
824 +.hub-profile h3 { font-size: 18px; }
825 +.hub-profile .hub-bio { color: var(--ink-2); font-size: 14px; margin: 0; max-width: 560px; }
826 +.hub-profile .pub-meta { display: flex; gap: 8px; flex-wrap: wrap; }
827 +.hub-profile .stat-chip { display: inline-flex; align-items: center; gap: 6px; }
828 +.hub-profile a.stat-chip:hover { background: var(--accent-soft); }
829 +.hub-hint { color: var(--ink-3); font-size: 12.5px; margin: 0; }
830 +
831 +/* ================= Pages légales ================= */
832 +.legal { padding: 48px 0 80px; max-width: 780px; }
833 +.legal h1 { font-size: clamp(28px, 4.6vw, 44px); margin: 8px 0 6px; }
834 +.legal .legal-meta { color: var(--ink-3); font-family: var(--font-mono); font-size: 12px; margin: 0 0 26px; }
835 +.legal h2 { font-size: 20px; margin: 30px 0 8px; border-left: 4px solid var(--accent); padding-left: 10px; }
836 +.legal p, .legal li { color: var(--ink-2); font-size: 14.5px; }
837 +.legal ul { padding-left: 20px; display: flex; flex-direction: column; gap: 6px; }
838 +.legal a { text-decoration: underline; text-underline-offset: 3px; }
839 +.legal b { color: var(--ink); }
840 +
841 +/* ================= Page Contact (écosystème Groupe KA) ================= */
842 +.contact { padding: 48px 0 90px; }
843 +.contact h1 { font-size: clamp(30px, 5vw, 52px); margin: 12px 0 14px; text-transform: uppercase; letter-spacing: -0.03em; }
844 +.contact-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; margin: 28px 0 6px; max-width: 900px; }
845 +.contact-card {
846 + display: flex; flex-direction: column; gap: 8px; min-height: var(--touch, 44px);
847 + background: var(--surface); border: 1.5px solid var(--ink);
848 + border-radius: var(--r-card); padding: 18px 20px;
849 + box-shadow: var(--shadow-off-soft);
850 + transition: transform 0.15s ease, box-shadow 0.15s ease;
851 +}
852 +.contact-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }
853 +.contact-role { font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--green); }
854 +.contact-mail { font-family: var(--font-display); font-weight: 700; font-size: clamp(14px, 2vw, 17px); word-break: break-all; border-bottom: 2px solid var(--accent); align-self: flex-start; }
855 +.contact-disclaimer { max-width: 720px; margin: 24px 0 0; padding: 14px 16px; border-left: 3px solid var(--accent); background: var(--accent-soft); color: var(--ink-2); font-size: 13.5px; }
856 +.contact-disclaimer b { color: var(--ink); }
857 +.contact-hub { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 26px; }
858 +.contact-sites { margin-top: 46px; }
859 +.contact-sites h2 { font-size: 21px; text-transform: uppercase; margin-bottom: 14px; }
860 +.contact-sites ul { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; }
861 +.contact-sites a { display: flex; flex-direction: column; gap: 3px; min-height: var(--touch, 44px); background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); padding: 12px 14px; transition: transform 0.14s ease, box-shadow 0.14s ease; }
862 +.contact-sites a:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--ink); }
863 +.contact-sites b { font-family: var(--font-display); font-size: 15px; }
864 +.contact-sites span { font-size: 12px; color: var(--ink-3); }
865 +@media (max-width: 480px) {
866 + .header-inner { gap: 10px; }
867 + .ka-login { font-size: 12px; padding: 6px 10px; gap: 4px; min-height: 44px; }
868 +}
869 +
870 +/* Bulle KA Agent : sur mobile, remontée au-dessus de la barre CTA collante des
871 + fiches (sinon elle chevauche le bouton « Voir chez … »). */
872 +@media (max-width: 899px) {
873 + .kaa-btn {
874 + bottom: calc(88px + env(safe-area-inset-bottom, 0px)) !important;
875 + }
876 +}
877 +
878 +/* Footer commun KA : zones tactiles ≥ 44 px pour les petits liens (Ka2, Ka4…)
879 + — padding compensé par une marge négative : aucun changement visuel. */
880 +.ka-footer .sites a, .ka-footer .legal a, .ka-footer .contacts a {
881 + display: inline-block; padding: 12px 8px; margin: -12px -8px;
882 +}
883 +
884 +/* ================= Qualité des annonces / images ================= */
885 +/* Image de secours par TYPE DE BIEN (jamais d'icône d'image cassée) */
886 +.type-fallback {
887 + display: flex; flex-direction: column; align-items: center; justify-content: center;
888 + gap: 9px; width: 100%; height: 100%; min-height: 120px;
889 + background:
890 + radial-gradient(circle at 78% 18%, rgba(15, 123, 74, 0.14), transparent 52%),
891 + repeating-linear-gradient(45deg, #f3ece9 0 14px, #f7f1ef 14px 28px);
892 + color: var(--accent-deep);
893 +}
894 +.type-fallback .ico { opacity: 0.75; }
895 +.type-fallback span {
896 + font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;
897 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
898 +}
899 +.carousel-empty .type-fallback { aspect-ratio: 16/11; }
900 +
901 +/* légende de photo (étiquette de pièce de la source) */
902 +.carousel-caption {
903 + position: absolute; left: 12px; bottom: 12px; z-index: 2; max-width: 60%;
904 + background: rgba(26, 18, 20, 0.82); color: #f5f3ee;
905 + font-family: var(--font-mono); font-size: 11px; font-weight: 600;
906 + padding: 4px 10px; border-radius: 999px;
907 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
908 +}
909 +
910 +/* mention loyer mensuel (transaction = location) */
911 +.per-month { font-size: 0.55em; font-weight: 600; color: var(--ink-3); letter-spacing: 0; }
912 +
913 +/* lightbox : l'image zoomable ne doit pas déclencher le scroll de la page */
914 +.lightbox img { user-select: none; -webkit-user-drag: none; }
915 +
916 +/* ---- Stats : qualité des données ---- */
917 +.q-table-wrap { overflow-x: auto; }
918 +.q-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 520px; }
919 +.q-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--ink); }
920 +.q-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
921 +.q-table .num { text-align: right; font-family: var(--font-mono); }
922 +.q-meter { display: inline-block; width: 74px; height: 9px; background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden; vertical-align: -1px; margin-right: 7px; }
923 +.q-meter i { display: block; height: 100%; background: var(--green); }
924 +
925 +
926 +/* ---- Criminalité détaillée SPVM (bloc quartier) ---- */
927 +.q-crime { width: 100%; }
928 +.q-crime-cats { list-style: none; margin: 8px 0 0; padding: 0; }
929 +.q-crime-cats li { display: flex; align-items: center; gap: 8px;
930 + padding: 3px 0; font-size: 12.5px; }
931 +.q-crime-nom { flex: 0 0 46%; color: var(--ink-2, #555); overflow: hidden;
932 + text-overflow: ellipsis; white-space: nowrap; }
933 +.q-crime-barre { flex: 1 1 auto; height: 10px; background: var(--surface-2, #f0ede8);
934 + border-radius: 5px; overflow: hidden; }
935 +.q-crime-barre i { display: block; height: 100%; border-radius: 5px;
936 + background: var(--accent, #0f7b4a); opacity: 0.55; }
937 +.q-crime-n { flex: 0 0 52px; text-align: right; font-weight: 600;
938 + color: var(--ink, #222); white-space: nowrap; }
939 +.q-crime-n small { font-weight: 400; color: var(--ink-3, #888); }
940 +.q-crime-src { margin-top: 8px; }
941 +
942 +
943 +/* ---- Risque d'inondation (fiche) — BDZI gouv. du Québec ---- */
944 +.f-zi .zi-head { display: flex; align-items: center; gap: 10px; }
945 +.zi-badge { display: inline-block; padding: 3px 10px; border-radius: 999px;
946 + font-size: 12.5px; font-weight: 700; }
947 +.zi-eleve { background: #fde8e8; color: #a12622; }
948 +.zi-modere { background: #fdf3e0; color: #8a5a00; }
949 +.zi-present { background: #fdf3e0; color: #8a5a00; }
950 +.zi-ok { background: #e7f4ea; color: #1e6b34; }
951 +.zi-nc { background: var(--surface-2, #f0ede8); color: var(--ink-3, #888); }
952 +.zi-liste { margin: 8px 0 0; padding: 0; list-style: none; font-size: 13px; }
953 +.zi-liste li { padding: 4px 0; border-top: 1px solid var(--line, #e6e4df); }
954 +
955 +
956 +/* ---- tuiles KPI + tableau compacts (partagés air/essence) ---- */
957 +.rdl-kpis { display: flex; flex-wrap: wrap; gap: 10px; margin: 2px 0 12px; }
958 +.rdl-kpi { flex: 1 1 90px; min-width: 90px; background: var(--surface-2, #f7f5f1);
959 + border-radius: 10px; padding: 10px 12px; }
960 +.rdl-kpi-v { display: block; font-size: 20px; font-weight: 700;
961 + letter-spacing: -0.02em; color: var(--ink, #222); }
962 +.rdl-kpi-l { display: block; font-size: 11px; line-height: 1.35;
963 + color: var(--ink-3, #888); margin-top: 2px; }
964 +.rdl-table { width: 100%; border-collapse: collapse; font-size: 13px; }
965 +.rdl-table td { padding: 5px 8px 5px 0; border-top: 1px solid var(--line, #e6e4df);
966 + vertical-align: top; }
967 +.rdl-table .rdl-addr { max-width: 46%; }
968 +.rdl-table .rdl-prix { font-weight: 600; white-space: nowrap; }
969 +.rdl-table .rdl-date { white-space: nowrap; color: var(--ink-2, #555); }
970 +.rdl-table .rdl-dist { color: var(--ink-3, #888); white-space: nowrap; text-align: right; }
971 +.rdl-cap { caption-side: top; text-align: left; font-size: 13px; font-weight: 700;
972 + color: var(--ink, #222); padding: 6px 0; }
973 +
974 +/* ---- Qualité de l'air (RSQAQ) + Essence à proximité ---- */
975 +.air-liste { list-style: none; margin: 10px 0 0; padding: 0; }
976 +.air-liste li { display: flex; align-items: center; gap: 8px;
977 + padding: 4px 0; font-size: 12.5px; }
978 +.air-nom { flex: 0 0 44%; color: var(--ink-2); overflow: hidden;
979 + text-overflow: ellipsis; white-space: nowrap; }
980 +.air-barre { flex: 1 1 auto; height: 10px; background: var(--surface-2, #f0ede8);
981 + border-radius: 5px; overflow: hidden; }
982 +.air-barre i { display: block; height: 100%; border-radius: 5px;
983 + background: #4d9e64; }
984 +.air-barre i.air-sur { background: #c96a1f; }
985 +.air-val { flex: 0 0 34%; text-align: right; font-weight: 600;
986 + color: var(--ink); white-space: nowrap; overflow: hidden;
987 + text-overflow: ellipsis; }
988 +.air-val small { font-weight: 400; color: var(--ink-3); }
989 +.air-val .air-ref { display: block; font-size: 10px; }
990 +.gaz-head th { text-align: left; font-size: 11px; color: var(--ink-3);
991 + font-weight: 600; padding: 2px 8px 2px 0; }
992 +.gaz-best { display: inline-block; margin-left: 6px; padding: 1px 7px;
993 + border-radius: 999px; background: #e7f4ea; color: #1e6b34;
994 + font-size: 10.5px; font-weight: 700; }
995 +.gaz-adr { display: block; font-size: 11px; color: var(--ink-3); }
996 +
997 +
998 +/* ---- Commerces et transport (fiche) ---- */
999 +.cm-grille { list-style: none; margin: 6px 0 0; padding: 0;
1000 + display: grid; grid-template-columns: 1fr 1fr; gap: 4px 18px; }
1001 +@media (max-width: 560px) { .cm-grille { grid-template-columns: 1fr; } }
1002 +.cm-item { display: flex; align-items: center; gap: 9px; padding: 5px 0;
1003 + border-bottom: 1px solid var(--line, #eeece7); min-width: 0; }
1004 +.cm-ico { flex: 0 0 28px; }
1005 +.cm-txt { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
1006 +.cm-nom { font-size: 13px; font-weight: 600; color: var(--ink, #222);
1007 + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1008 +.cm-poi { font-size: 11px; color: var(--ink-3, #8a877f);
1009 + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1010 +.cm-dist { flex: 0 0 auto; font-size: 13px; font-weight: 700;
1011 + color: var(--ink-2, #4c4a45); white-space: nowrap; }
1012 +
1013 +
1014 +/* ---- Coût d'électricité (Hydro-Québec) ---- */
1015 +.hydro-intro { margin: 2px 0 12px; color: var(--ink-2, #4c4a45); }
1016 +.hydro-btn { appearance: none; border: 0; border-radius: 10px; cursor: pointer;
1017 + background: var(--accent, #2b7a3b); color: #fff; font-weight: 700;
1018 + font-size: 14px; padding: 11px 18px; }
1019 +.hydro-btn:disabled { opacity: 0.6; cursor: progress; }
1020 +.hydro-montant { font-size: 26px; font-weight: 800; letter-spacing: -0.02em;
1021 + color: var(--navy, #1c1b18); display: flex; align-items: baseline;
1022 + gap: 10px; flex-wrap: wrap; }
1023 +.hydro-montant small { font-size: 14px; font-weight: 500; color: var(--ink-3); }
1024 +.hydro-an { font-size: 14px; font-weight: 600; color: var(--ink-2, #4c4a45); }
1025 +.hydro-vide { color: var(--ink-3, #8a877f); }
1026 +
1027 +
1028 +/* ============================================================================
1029 + PASSE DE DESIGN — « éditorial sharp » appliqué à toute la fiche
1030 + Blocs = cartes encre (bordure 1,5 px + ombre décalée douce), titres à
1031 + marqueur accent + filet, tuiles KPI blanches à ombre dure, tableaux zébrés,
1032 + barres en dégradé, pastilles unifiées (mono, point d'état, liseré).
1033 + Aucune propriété `order` (règle Groupe Ka : ordre DOM = ordre visuel).
1034 + ========================================================================== */
1035 +
1036 +/* --- Blocs de la fiche : cartes posées sur le papier --- */
1037 +.f-bloc:not(.f-galerie):not(.f-hero),
1038 +.quartier {
1039 + background: var(--surface);
1040 + border: 1.5px solid var(--ink);
1041 + border-radius: var(--r-card);
1042 + padding: 18px 20px 16px;
1043 + box-shadow: var(--shadow-off-soft);
1044 +}
1045 +@media (max-width: 640px) {
1046 + .f-bloc:not(.f-galerie):not(.f-hero),
1047 + .quartier { padding: 15px 15px 13px; }
1048 +}
1049 +/* blocs pleine largeur sous la grille : respiration entre les cartes */
1050 +.detail > .f-bloc { margin-top: 22px; }
1051 +
1052 +/* --- Titres de section : carré accent + filet éditorial --- */
1053 +.f-bloc h2, .quartier h2 {
1054 + display: flex; align-items: center; gap: 10px;
1055 + font-size: 19px; letter-spacing: -0.015em; margin-bottom: 14px;
1056 + border-left: 0; padding-left: 0;
1057 +}
1058 +.f-bloc h2::before, .quartier h2::before {
1059 + content: ""; flex: 0 0 9px; width: 9px; height: 9px;
1060 + background: var(--accent); border: 1.5px solid var(--ink);
1061 + box-shadow: 2px 2px 0 var(--ink);
1062 +}
1063 +.f-bloc h2::after, .quartier h2::after {
1064 + content: ""; flex: 1 1 auto; height: 1.5px; min-width: 20px;
1065 + background: var(--line);
1066 +}
1067 +
1068 +/* --- Tuiles KPI : blanches, bord encre, ombre dure accent --- */
1069 +.rdl-kpi {
1070 + background: var(--surface);
1071 + border: 1.5px solid var(--ink);
1072 + border-radius: var(--r-ctl);
1073 + box-shadow: 3px 3px 0 var(--accent-soft);
1074 +}
1075 +.rdl-kpi-v { font-family: var(--font-display); }
1076 +.q-cell { box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1); border-radius: var(--r-ctl); }
1077 +.q-val { font-size: 20px; }
1078 +
1079 +/* --- Tableaux : en-têtes mono, zébrure, survol accent --- */
1080 +.rdl-table td { padding: 7px 8px 7px 0; }
1081 +.rdl-table tbody tr:nth-child(even) td,
1082 +.rdl-table > tr:nth-child(even) td { background: var(--surface-2); }
1083 +.rdl-table tr:hover td { background: var(--accent-soft); }
1084 +.gaz-head th {
1085 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
1086 + text-transform: uppercase; letter-spacing: 0.08em;
1087 + border-bottom: 1.5px solid var(--ink);
1088 +}
1089 +.rdl-cap { font-family: var(--font-display); }
1090 +table.rooms tbody tr:hover td { background: var(--accent-soft); }
1091 +
1092 +/* --- Barres : pistes bordées + remplissage en dégradé --- */
1093 +.q-bar-track, .q-crime-barre, .air-barre {
1094 + background: var(--surface-2);
1095 + border: 1px solid var(--line);
1096 + height: 12px;
1097 +}
1098 +.q-bar-fill {
1099 + background: linear-gradient(90deg, var(--accent), var(--accent-deep));
1100 +}
1101 +.q-crime-barre i {
1102 + background: linear-gradient(90deg, #f0808a, var(--accent));
1103 + opacity: 0.9;
1104 +}
1105 +.air-barre i { background: linear-gradient(90deg, #6fbc85, #2e7d4a); }
1106 +.air-barre i.air-sur { background: linear-gradient(90deg, #e08a48, #c96a1f); }
1107 +
1108 +/* --- Pastilles d'état unifiées (zi-badge & cie) : mono + point d'état --- */
1109 +.zi-badge {
1110 + display: inline-flex; align-items: center; gap: 6px;
1111 + font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;
1112 + text-transform: uppercase; letter-spacing: 0.06em;
1113 + padding: 4px 10px; border: 1.5px solid currentColor;
1114 +}
1115 +.zi-badge::before {
1116 + content: ""; width: 6px; height: 6px; border-radius: 50%;
1117 + background: currentColor;
1118 +}
1119 +.gaz-best { border: 1px solid currentColor; }
1120 +
1121 +/* --- Listes riches (zones d'inondation) : mini-cartes à liseré accent --- */
1122 +.zi-liste { display: flex; flex-direction: column; gap: 8px; }
1123 +.zi-liste li {
1124 + border: 1px solid var(--line); border-left: 3px solid var(--accent);
1125 + border-top: 1px solid var(--line);
1126 + border-radius: var(--r-ctl); padding: 9px 12px;
1127 + background: var(--surface-2);
1128 +}
1129 +
1130 +/* --- Inclusions : icône et libellé alignés sur une seule ligne --- */
1131 +.amenity { display: inline-flex; align-items: center; gap: 6px; }
1132 +
1133 +/* --- Notes de bas de bloc : filet pointillé --- */
1134 +.f-bloc .fine, .quartier .fine {
1135 + margin-top: 12px; padding-top: 9px;
1136 + border-top: 1px dashed var(--line);
1137 +}
1138 +
1139 +/* --- CTA secondaire : téléchargement de la fiche PDF --- */
1140 +.cta-pdf {
1141 + background: var(--surface); color: var(--ink);
1142 + box-shadow: 4px 4px 0 rgba(26, 18, 20, 0.12);
1143 + margin-top: 10px;
1144 +}
1145 +.cta-pdf:hover { background: var(--ink); color: var(--paper); }
1146 +
1147 +/* ================= Financement (fiche) + page Taux hypothécaires ========= */
1148 +.f-mtg .mtg-intro { color: var(--ink-2); font-size: 14px; margin: 4px 0 14px; }
1149 +.f-mtg .mtg-intro a { color: var(--lime); font-weight: 600; }
1150 +
1151 +.mtg-form {
1152 + display: grid; gap: 10px;
1153 + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
1154 +}
1155 +.mtg-form label { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
1156 +.mtg-form label > span {
1157 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1158 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
1159 +}
1160 +.mtg-form input, .mtg-form select {
1161 + border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface);
1162 + padding: 9px 10px; font-size: 14.5px; font-family: inherit; color: var(--ink);
1163 + min-height: 42px; min-width: 0;
1164 +}
1165 +.mtg-form input:focus, .mtg-form select:focus {
1166 + outline: none; box-shadow: 3px 3px 0 var(--lime);
1167 +}
1168 +
1169 +.mtg-err { color: var(--accent-deep); font-size: 13.5px; margin: 12px 0 0; }
1170 +
1171 +.mtg-resultat {
1172 + display: grid; gap: 10px; margin-top: 16px;
1173 + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1174 +}
1175 +.mtg-kpi {
1176 + border: 1.5px solid var(--line-strong); border-radius: var(--r-card);
1177 + background: var(--surface-2); padding: 12px 14px;
1178 + display: flex; flex-direction: column; gap: 2px; min-width: 0;
1179 +}
1180 +.mtg-kpi-k {
1181 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1182 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
1183 +}
1184 +.mtg-kpi-v {
1185 + font-family: var(--font-display); font-weight: 700; font-size: 22px;
1186 + letter-spacing: -0.02em;
1187 +}
1188 +.mtg-kpi-sub { font-size: 12px; color: var(--ink-3); }
1189 +.mtg-kpi:first-child { background: var(--ink); border-color: var(--ink); }
1190 +.mtg-kpi:first-child .mtg-kpi-v { color: var(--lime); }
1191 +.mtg-kpi:first-child .mtg-kpi-k, .mtg-kpi:first-child .mtg-kpi-sub { color: rgba(255,255,255,0.75); }
1192 +
1193 +.mtg-source { margin-top: 10px; }
1194 +
1195 +.mtg-schl {
1196 + margin-top: 14px; border: 1.5px dashed var(--line-strong);
1197 + border-radius: var(--r-card); padding: 12px 14px; font-size: 13.5px;
1198 + background: var(--surface);
1199 +}
1200 +.mtg-schl ul { margin: 8px 0 0; padding-left: 18px; display: grid; gap: 3px; }
1201 +.mtg-schl-no { border-color: var(--accent-deep); }
1202 +.mtg-issue { margin: 8px 0 0; color: var(--accent-deep); font-size: 13px; }
1203 +
1204 +.mtg-detail {
1205 + margin-top: 10px; border: 1.5px solid var(--line-strong);
1206 + border-radius: var(--r-card); background: var(--surface);
1207 +}
1208 +.mtg-detail > summary {
1209 + cursor: pointer; padding: 11px 14px; font-weight: 700; font-size: 14px;
1210 + list-style: none; display: flex; align-items: center; gap: 8px;
1211 +}
1212 +.mtg-detail > summary::before { content: "+"; font-family: var(--font-mono); color: var(--lime); font-weight: 700; }
1213 +.mtg-detail[open] > summary::before { content: "−"; }
1214 +.mtg-detail > summary::-webkit-details-marker { display: none; }
1215 +.mtg-detail > *:not(summary) { margin: 0 14px 12px; }
1216 +.mtg-detail .fine { border-top: none; padding-top: 0; }
1217 +.mtg-total { border-top: 2px solid var(--ink); }
1218 +.mtg-comp td, .mtg-comp th { white-space: nowrap; }
1219 +.mtg-stale { color: var(--accent-deep); }
1220 +
1221 +.mtg-chart svg { width: 100%; height: auto; display: block; }
1222 +.mtg-grid { stroke: var(--line); stroke-width: 1; }
1223 +.mtg-tick { font-family: var(--font-mono); font-size: 10px; fill: var(--ink-3); }
1224 +.mtg-line { fill: none; stroke: var(--lime); stroke-width: 2.5; stroke-linejoin: round; }
1225 +.mtg-dot { fill: var(--ink); stroke: var(--lime); stroke-width: 2.5; }
1226 +
1227 +/* ---- page /taux-hypothecaires ---- */
1228 +.taux-page { padding-bottom: 40px; }
1229 +.taux-head { margin: 28px 0 6px; max-width: 760px; }
1230 +.taux-head h1 { font-family: var(--font-display); font-size: clamp(28px, 5vw, 40px); letter-spacing: -0.03em; }
1231 +.taux-head p { color: var(--ink-2); font-size: 15px; margin: 12px 0 0; }
1232 +.taux-page > .f-bloc { margin-top: 22px; }
1233 +
1234 +.taux-grid {
1235 + display: grid; gap: 10px;
1236 + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
1237 +}
1238 +.taux-card {
1239 + border: 1.5px solid var(--ink); border-radius: var(--r-card);
1240 + background: var(--surface); padding: 12px 14px; text-align: left;
1241 + cursor: pointer; display: flex; flex-direction: column; gap: 2px;
1242 + transition: box-shadow 0.15s ease; font-family: inherit; min-width: 0;
1243 +}
1244 +.taux-card:hover { box-shadow: 3px 3px 0 var(--lime); }
1245 +.taux-card.on { background: var(--ink); color: var(--paper); }
1246 +.taux-card.on .taux-card-v { color: var(--lime); }
1247 +.taux-card-k {
1248 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1249 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
1250 +}
1251 +.taux-card.on .taux-card-k, .taux-card.on .taux-card-sub { color: rgba(255,255,255,0.7); }
1252 +.taux-card-v { font-family: var(--font-display); font-weight: 700; font-size: 24px; }
1253 +.taux-card-sub { font-size: 11.5px; color: var(--ink-3); }
1254 +
1255 +.taux-filtres { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
1256 +.taux-filtres label { display: flex; flex-direction: column; gap: 3px; }
1257 +.taux-filtres label > span {
1258 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1259 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
1260 +}
1261 +.taux-filtres select {
1262 + border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface);
1263 + padding: 9px 12px; font-size: 14.5px; font-family: inherit; min-height: 42px;
1264 +}
1265 +.taux-best td { background: var(--lime-soft); }
1266 +.taux-badge {
1267 + font-family: var(--font-mono); font-size: 9px; font-weight: 700;
1268 + text-transform: uppercase; letter-spacing: 0.1em; background: var(--ink);
1269 + color: var(--lime); border-radius: 999px; padding: 2px 8px; margin-left: 8px;
1270 +}
1271 +.taux-sante { display: flex; flex-wrap: wrap; gap: 8px; }
1272 +.taux-src {
1273 + border: 1.5px solid var(--line-strong); border-radius: 999px;
1274 + padding: 6px 12px; font-size: 12.5px; font-weight: 600;
1275 + display: inline-flex; align-items: center; gap: 7px;
1276 +}
1277 +.taux-src::before { content: ""; width: 8px; height: 8px; border-radius: 50%; }
1278 +.taux-src-ok::before { background: var(--green, #2f9e44); }
1279 +.taux-src-warning::before { background: #e8a000; }
1280 +.taux-src-error::before { background: var(--accent-deep); }
1281 +
1282 +/* =============================================================================
1283 + REFONTE ÉDITORIALE v2 (2026-08-25) — accueil « produit premium »
1284 + Personnalité Immo-Ka : architecturale et financière — composition
1285 + typographique majuscule plein/outline décalée, colonne de données mono
1286 + façon terminal, cerise en couleur de SIGNAL (jamais en aplat décoratif).
1287 + Primitives locales : rayons 0/6/10/18, durées 150/240/350 ms.
1288 + Couche posée en FIN de fichier : elle a le dernier mot sur la cascade.
1289 +============================================================================= */
1290 +:root {
1291 + --r-0: 0px; --r-1: 6px; --r-2: 10px; --r-3: 18px;
1292 + --dur-1: 150ms; --dur-2: 240ms; --dur-3: 350ms;
1293 + --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
1294 + --hairline: rgba(20, 24, 20, 0.14);
1295 + --hairline-strong: rgba(20, 24, 20, 0.6);
1296 +}
1297 +
1298 +/* ---- Héro : composition typographique + colonne de données ---- */
1299 +.hero-wrap { display: grid; grid-template-columns: 1fr auto; gap: 24px 56px; align-items: end; }
1300 +.hero h1.hero-display {
1301 + display: flex; flex-direction: column;
1302 + font-size: clamp(40px, 6.8vw, 92px); line-height: 0.96;
1303 + text-transform: uppercase; letter-spacing: -0.04em;
1304 + max-width: none; margin-top: 16px;
1305 +}
1306 +.hero-display .hd-l1, .hero-display .hd-l2,
1307 +.hero-display .hd-l3, .hero-display .hd-l4 { display: block; }
1308 +.hero-display .hd-l2 { color: transparent; -webkit-text-stroke: 2.5px var(--ink); }
1309 +.hero-display .hd-l3 { margin-left: clamp(24px, 6vw, 110px); }
1310 +.hero-display .hd-l4 {
1311 + font-size: 0.5em; letter-spacing: -0.02em; margin-top: 0.35em;
1312 + margin-left: clamp(2px, 1vw, 8px);
1313 +}
1314 +.hero-display .signal {
1315 + font-style: normal; color: var(--lime); position: relative;
1316 + border-bottom: 4px solid var(--lime); padding-bottom: 1px;
1317 +}
1318 +.hero p.lede { font-size: 16px; max-width: 560px; }
1319 +.hero::before { opacity: 0.7; }
1320 +.hero::after { opacity: 0.5; }
1321 +
1322 +/* colonne « terminal » : les chiffres du marché comme élément graphique */
1323 +.hero-data {
1324 + display: flex; flex-direction: column; gap: 18px;
1325 + border-left: 1px solid var(--hairline-strong); padding-left: 28px;
1326 + min-width: 190px;
1327 +}
1328 +.hd-row b {
1329 + display: block; font-family: var(--font-display); font-weight: 700;
1330 + font-size: clamp(20px, 2vw, 27px); letter-spacing: -0.03em; color: var(--ink);
1331 + font-variant-numeric: tabular-nums; line-height: 1.1;
1332 +}
1333 +.hd-row span {
1334 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1335 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3);
1336 +}
1337 +@media (max-width: 900px) {
1338 + .hero-wrap { grid-template-columns: 1fr; align-items: start; }
1339 + .hero-data {
1340 + flex-direction: row; flex-wrap: wrap; gap: 16px 28px;
1341 + border-left: 0; border-top: 1px solid var(--hairline);
1342 + padding: 16px 0 0; min-width: 0; margin-top: 6px;
1343 + }
1344 +}
1345 +
1346 +/* ---- Ligne de données vivantes (mono + filets) ---- */
1347 +.live-line {
1348 + display: flex; flex-wrap: wrap; align-items: baseline;
1349 + margin-top: 30px; font-family: var(--font-mono); font-size: 12px;
1350 + color: var(--ink-2); letter-spacing: 0.02em; row-gap: 10px;
1351 +}
1352 +.live-flag {
1353 + display: inline-flex; align-items: center; gap: 7px;
1354 + font-weight: 700; font-size: 10.5px; letter-spacing: 0.18em;
1355 + text-transform: uppercase; color: var(--accent-deep);
1356 + padding-right: 14px; margin-right: 14px; border-right: 1px solid var(--hairline-strong);
1357 +}
1358 +.live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--lime); animation: pulse 2.4s ease infinite; }
1359 +.live-item { padding-right: 14px; margin-right: 14px; border-right: 1px solid var(--hairline); }
1360 +.live-item:last-child { border-right: 0; padding-right: 0; margin-right: 0; }
1361 +.live-item b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }
1362 +@media (max-width: 640px) {
1363 + .live-line { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
1364 + white-space: nowrap; margin-right: -16px; padding-right: 16px; }
1365 + .live-line::-webkit-scrollbar { display: none; }
1366 +}
1367 +
1368 +/* ---- Recherche au cœur : grand champ souligné + critères en filets ---- */
1369 +.search-zone { margin: 42px 0 0; }
1370 +.q-big {
1371 + display: flex; align-items: center; gap: 14px;
1372 + border: 0; border-bottom: 2px solid var(--ink); border-radius: 0;
1373 + background: transparent; padding: 4px 2px 14px; color: var(--ink-3);
1374 + transition: border-color var(--dur-2) var(--ease-out), color var(--dur-2) ease;
1375 +}
1376 +.q-big:focus-within { border-color: var(--lime); color: var(--accent-deep); }
1377 +.q-big svg { flex: none; }
1378 +.q-big input {
1379 + flex: 1; min-width: 0; border: 0; background: none; outline: none; padding: 0;
1380 + font-family: var(--font-display); font-weight: 600;
1381 + font-size: clamp(19px, 2.8vw, 28px); letter-spacing: -0.02em; color: var(--ink);
1382 +}
1383 +.q-big input::placeholder { color: var(--ink-3); font-weight: 500; }
1384 +.crit-line { display: flex; align-items: stretch; flex-wrap: wrap; }
1385 +.crit {
1386 + display: flex; flex-direction: column; justify-content: center; gap: 1px;
1387 + padding: 12px 26px 12px 0; margin-right: 26px;
1388 + border-right: 1px solid var(--hairline); min-width: 0; cursor: pointer;
1389 +}
1390 +.crit > span {
1391 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1392 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3);
1393 +}
1394 +.crit select {
1395 + border: 0; background: transparent; outline: none; cursor: pointer;
1396 + font-family: var(--font-display); font-weight: 700; font-size: 15.5px;
1397 + color: var(--ink); appearance: none; -webkit-appearance: none;
1398 + padding: 2px 18px 2px 0; min-width: 0; max-width: 190px; text-overflow: ellipsis;
1399 + 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='%238b928c'/%3E%3C/svg%3E");
1400 + background-repeat: no-repeat; background-position: right center;
1401 +}
1402 +.crit:hover select { color: var(--accent-deep); }
1403 +.crit .range-pair select { max-width: 92px; }
1404 +.crit-more {
1405 + display: inline-flex; align-items: center; gap: 9px; align-self: center;
1406 + border: 0; background: none; padding: 12px 0; cursor: pointer;
1407 + font-family: var(--font-display); font-weight: 700; font-size: 14.5px; color: var(--ink);
1408 + transition: color var(--dur-1) ease;
1409 +}
1410 +.crit-more:hover, .crit-more.on { color: var(--accent-deep); }
1411 +.crit-badge, .rb-badge {
1412 + display: inline-grid; place-items: center; min-width: 19px; height: 19px; padding: 0 5px;
1413 + border-radius: 999px; background: var(--lime); color: #fff;
1414 + font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums;
1415 +}
1416 +.search-zone .f-adv { border-top: 1px solid var(--hairline); margin-top: 2px; padding-top: 18px; }
1417 +.search-zone .seg button {
1418 + border: 1px solid var(--hairline-strong); background: transparent; color: var(--ink-2);
1419 + min-height: 38px; font-weight: 600; margin-left: -1px;
1420 +}
1421 +.search-zone .seg button:first-child { border-radius: var(--r-1) 0 0 var(--r-1); }
1422 +.search-zone .seg button:last-child { border-radius: 0 var(--r-1) var(--r-1) 0; }
1423 +.search-zone .seg button:hover { background: var(--lime-soft); color: var(--accent-deep); }
1424 +.search-zone .seg button.on { background: var(--ink); border-color: var(--ink); color: var(--paper); box-shadow: none; }
1425 +.search-zone .f-native { border: 1px solid var(--hairline-strong); background: transparent; }
1426 +
1427 +/* résumé mobile des critères — remplace le FAB (voir media 640 plus bas) */
1428 +.crit-summary { display: none; }
1429 +
1430 +/* ---- Barre de résultats sticky : N propriétés · tri · Liste|Carte ---- */
1431 +.results-bar {
1432 + position: sticky; top: 64px; z-index: var(--z-sticky, 300);
1433 + display: flex; align-items: center; gap: 16px;
1434 + margin: 30px 0 22px; padding: 12px 0 11px;
1435 + border-bottom: 1px solid var(--hairline-strong);
1436 + background: color-mix(in srgb, var(--paper) 86%, transparent);
1437 + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
1438 +}
1439 +@supports not (background: color-mix(in srgb, red 50%, blue)) {
1440 + .results-bar { background: rgba(245, 243, 238, 0.92); }
1441 +}
1442 +.rb-count { margin: 0; font-size: clamp(16px, 2vw, 20px); letter-spacing: -0.02em; color: var(--ink); text-transform: none; }
1443 +.rb-count b { font-variant-numeric: tabular-nums; }
1444 +.rb-tools { margin-left: auto; display: flex; align-items: center; gap: 20px; }
1445 +.rb-sort select {
1446 + border: 0; background: transparent; outline: none; cursor: pointer;
1447 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 700;
1448 + text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-2);
1449 + appearance: none; -webkit-appearance: none; padding: 8px 16px 8px 0;
1450 + 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='%238b928c'/%3E%3C/svg%3E");
1451 + background-repeat: no-repeat; background-position: right center;
1452 +}
1453 +.rb-sort select:hover { color: var(--ink); }
1454 +.rb-tabs { display: flex; gap: 18px; }
1455 +.rb-tab {
1456 + position: relative; display: inline-flex; align-items: center; gap: 6px;
1457 + border: 0; background: none; padding: 8px 0; cursor: pointer;
1458 + font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink-3);
1459 + transition: color var(--dur-1) ease;
1460 +}
1461 +.rb-tab:hover, .rb-tab.on { color: var(--ink); }
1462 +.rb-tab::after {
1463 + content: ""; position: absolute; left: 0; right: 0; bottom: -12px; height: 2.5px;
1464 + background: var(--lime); transform: scaleX(0); transform-origin: left;
1465 + transition: transform var(--dur-2) var(--ease-out);
1466 +}
1467 +.rb-tab.on::after { transform: scaleX(1); }
1468 +.rb-filters {
1469 + display: inline-flex; align-items: center; gap: 7px;
1470 + border: 0; background: none; padding: 8px 0; cursor: pointer;
1471 + font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink);
1472 +}
1473 +.rb-filters:hover { color: var(--accent-deep); }
1474 +@media (min-width: 641px) { .rb-filters { display: none; } }
1475 +
1476 +/* ---- Transition douce liste ↔ carte ---- */
1477 +.view-pane { animation: pane-in var(--dur-2) var(--ease-out); }
1478 +@keyframes pane-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
1479 +
1480 +/* ---- Chips rapides : plus légères ---- */
1481 +.chip {
1482 + border: 1px solid var(--hairline-strong); background: transparent;
1483 + min-height: 38px; padding: 7px 15px; font-size: 12.5px; box-shadow: none;
1484 +}
1485 +.chip:hover { transform: none; box-shadow: none; background: var(--lime-soft); color: var(--accent-deep); }
1486 +.chip.on { background: var(--ink); border-color: var(--ink); color: var(--paper); box-shadow: none; }
1487 +.chips { margin: 20px 0 0; }
1488 +
1489 +/* ---- Pastilles actives : discrètes, survol = retrait ---- */
1490 +.pills { margin: 14px 0 0; }
1491 +.pills .pill { border: 1px solid var(--hairline-strong); background: transparent; box-shadow: none; padding: 5px 12px; font-size: 12px; }
1492 +.pills .pill:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
1493 +
1494 +/* ---- Fiches éditoriales : l'image domine, plus de boîte ---- */
1495 +.card, .card:hover { background: transparent; border: 0; border-radius: 0; box-shadow: none; transform: none; }
1496 +.card-img { border-radius: var(--r-2); aspect-ratio: 4/3; }
1497 +.card-img img { transition: transform var(--dur-3) var(--ease-out); }
1498 +@media (hover: hover) {
1499 + .card:hover .card-img img { transform: scale(1.025); }
1500 + .card:hover .card-price { color: var(--accent-deep); }
1501 +}
1502 +.card:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; border-radius: var(--r-2); }
1503 +.card-body { padding: 13px 2px 0; gap: 3px; }
1504 +.card-price { font-size: 22px; letter-spacing: -0.03em; color: var(--ink); transition: color var(--dur-1) ease; }
1505 +.card-title { font-size: 14px; font-weight: 500; color: var(--ink-2); }
1506 +.card-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); }
1507 +.card-meta .sep { width: 3px; height: 3px; border: 0; border-radius: 50%; background: var(--lime); transform: none; }
1508 +.card-specs { font-size: 11.5px; color: var(--ink-2); }
1509 +.card-foot { border-top: 0; padding-top: 8px; }
1510 +.source-tag { background: none; border: 0; border-radius: 0; padding: 0; color: var(--ink-3); font-size: 10px; letter-spacing: 0.08em; max-width: 62%; }
1511 +.avail { font-size: 11px; color: var(--ink-3); }
1512 +.badge { border: 0; border-radius: 4px; background: rgba(20, 24, 20, 0.78); color: var(--paper);
1513 + backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
1514 + font-size: 9.5px; padding: 4px 9px; letter-spacing: 0.08em; }
1515 +.badge.type { background: var(--lime); color: #fff; }
1516 +.badge.right { background: rgba(20, 24, 20, 0.55); }
1517 +/* la liste latérale de la vue carte garde ses fiches synchronisées lisibles */
1518 +.map-card { border-radius: var(--r-2); }
1519 +.map-card-sel { outline: 2px solid var(--lime); outline-offset: 3px; border-radius: var(--r-2); }
1520 +
1521 +/* ---- Grille à rythme éditorial (accueil) ---- */
1522 +.grid.grid-edito { grid-template-columns: repeat(3, 1fr); gap: 36px 26px; align-items: start; }
1523 +@media (max-width: 980px) {
1524 + .grid.grid-edito { grid-template-columns: 1fr 1fr; gap: 30px 20px; }
1525 +}
1526 +@media (max-width: 640px) {
1527 + .grid.grid-edito { grid-template-columns: 1fr; gap: 32px; }
1528 +}
1529 +@media (prefers-reduced-motion: no-preference) {
1530 + .grid-edito > * { animation: rise 0.5s var(--ease-out) backwards; }
1531 + .grid-edito > *:nth-child(3n+2) { animation-delay: 60ms; }
1532 + .grid-edito > *:nth-child(3n) { animation-delay: 120ms; }
1533 +}
1534 +@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
1535 +
1536 +/* ---- Squelettes sans boîte ---- */
1537 +.skel { border: 0; border-radius: 0; background: transparent; }
1538 +.skel .sk-img { aspect-ratio: 4/3; border-radius: var(--r-2); }
1539 +.skel .sk-line { margin: 12px 2px; }
1540 +
1541 +/* ---- Pagination éditoriale : chiffres nus, page courante soulignée ---- */
1542 +.pager { border-top: 1px solid var(--hairline); padding-top: 16px; margin-top: 26px; }
1543 +.pager-info { font-family: var(--font-mono); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); }
1544 +.pager-btn {
1545 + min-width: 34px; min-height: 38px; height: auto; padding: 0 10px; border: 0; border-radius: var(--r-1);
1546 + background: none; color: var(--ink-2); box-shadow: none;
1547 + font-family: var(--font-display); font-size: 13.5px; font-weight: 600; position: relative;
1548 +}
1549 +.pager-btn:hover:not(:disabled):not(.on) { background: var(--lime-soft); color: var(--accent-deep); transform: none; box-shadow: none; }
1550 +.pager-btn.on { background: none; border: 0; color: var(--ink); font-weight: 700; box-shadow: none; }
1551 +.pager-btn.on::after { content: ""; position: absolute; left: 9px; right: 9px; bottom: 3px; height: 2.5px; background: var(--lime); }
1552 +.pager-btn:disabled { box-shadow: none; }
1553 +
1554 +/* ---- Mobile : recherche condensée + feuille de critères premium ---- */
1555 +@media (max-width: 640px) {
1556 + .hero h1.hero-display { font-size: clamp(38px, 11vw, 52px); }
1557 + .results-bar { top: 64px; gap: 10px; flex-wrap: wrap; }
1558 + .rb-tools { gap: 14px; }
1559 + .search-zone { margin: 30px 0 0; }
1560 + .crit-line, .search-zone .f-adv, .search-zone .sheet-apply { display: none; }
1561 + .crit-summary {
1562 + display: flex; align-items: center; gap: 10px; width: 100%;
1563 + border: 0; border-bottom: 1px solid var(--hairline); background: none;
1564 + padding: 14px 2px; margin: 0; cursor: pointer; text-align: left;
1565 + font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
1566 + }
1567 + .crit-summary svg { flex: none; color: var(--ink); }
1568 + .crit-summary .cs-txt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1569 + .crit-summary .f-chev { margin: 0 2px 0 0; }
1570 +
1571 + .search-zone.open {
1572 + display: flex; flex-direction: column; gap: 12px;
1573 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
1574 + margin: 0; border-radius: var(--r-3) var(--r-3) 0 0; background: var(--surface);
1575 + border: 0;
1576 + max-height: 86dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
1577 + padding: 10px 18px calc(16px + env(safe-area-inset-bottom));
1578 + box-shadow: 0 -18px 50px rgba(20, 24, 20, 0.3);
1579 + animation: sheet-up 0.26s var(--ease-out);
1580 + }
1581 + .search-zone.open .sheet-handle {
1582 + display: block; flex: none; width: 40px; height: 4.5px;
1583 + border-radius: 999px; background: var(--line); margin: 0 auto;
1584 + }
1585 + .search-zone.open .sheet-head {
1586 + display: flex; justify-content: space-between; align-items: center;
1587 + font-family: var(--font-display); font-weight: 700; font-size: 17px;
1588 + letter-spacing: -0.01em; color: var(--ink); text-transform: none;
1589 + position: sticky; top: -10px; background: var(--surface); padding: 6px 0 8px;
1590 + border-bottom: 1px solid var(--hairline); z-index: 1;
1591 + }
1592 + .search-zone.open .crit-summary { display: none; }
1593 + .search-zone.open .q-big { padding: 2px 0 10px; }
1594 + .search-zone.open .q-big input { font-size: 17px; }
1595 + .search-zone.open .crit-line { display: flex; flex-direction: column; }
1596 + .search-zone.open .crit { border-right: 0; border-bottom: 1px solid var(--hairline); margin: 0; padding: 10px 0; }
1597 + .search-zone.open .crit select { max-width: none; width: 100%; font-size: 16px; }
1598 + .search-zone.open .crit .range-pair select { width: auto; flex: 1; }
1599 + .search-zone.open .crit-more { display: none; }
1600 + .search-zone.open .f-adv { display: grid; border-top: 0; padding-top: 4px; margin-top: 0; }
1601 + .search-zone.open .sheet-apply {
1602 + display: flex; position: sticky; bottom: 0; z-index: 1; width: 100%; min-height: 50px;
1603 + box-shadow: 0 -14px 18px -12px rgba(20, 24, 20, 0.35);
1604 + }
1605 +}
1606 +
1607 +/* ---- Navigation mobile flottante (pilule détachée du bord) ---- */
1608 +.tabbar { display: none; }
1609 +@media (max-width: 760px) {
1610 + .tabbar {
1611 + display: grid; grid-template-columns: repeat(4, 1fr);
1612 + position: fixed; left: 12px; right: 12px;
1613 + bottom: calc(10px + env(safe-area-inset-bottom));
1614 + z-index: var(--z-bottombar, 600);
1615 + border: 1px solid var(--hairline-strong); border-radius: var(--r-3);
1616 + background: rgba(250, 249, 245, 0.88);
1617 + backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
1618 + box-shadow: 0 14px 34px rgba(20, 24, 20, 0.16);
1619 + padding: 6px;
1620 + }
1621 + .tabbar a {
1622 + display: flex; flex-direction: column; align-items: center; justify-content: center;
1623 + gap: 2px; padding: 5px 2px; min-height: 48px; border-radius: 12px;
1624 + font-family: var(--font-display); font-size: 10px; font-weight: 600;
1625 + color: var(--ink-3); text-decoration: none; transition: color var(--dur-1) ease;
1626 + }
1627 + .tabbar a svg { display: block; }
1628 + .tabbar a.active { color: var(--ink); }
1629 + .tabbar a.active::after { content: ""; width: 16px; height: 2.5px; border-radius: 2px; background: var(--lime); }
1630 + body { padding-bottom: calc(80px + env(safe-area-inset-bottom)); }
1631 + .ka-footer { padding-bottom: calc(56px + env(safe-area-inset-bottom)); }
1632 +
1633 + /* widget KA Agent : dégagé de la tabbar (style injecté après le bundle →
1634 + spécificité doublée pour gagner) */
1635 + .kaa-btn.kaa-btn { bottom: calc(86px + env(safe-area-inset-bottom, 0px)); right: 14px; }
1636 + .kaa-panel.kaa-panel:not(.kaa-full) { bottom: calc(152px + env(safe-area-inset-bottom, 0px)); }
1637 +}
1638 +
1639 +/* ---- Header : jamais de débordement à droite ----
1640 + La nav (8 entrées) + connexion dépassaient le conteneur même à 1440px
1641 + (masqué par overflow-x: clip). Resserrage, badge Groupe KA masqué quand
1642 + l'espace manque, burger sous 1200px. ---- */
1643 +.nav a { white-space: nowrap; padding: 8px 11px; font-size: 13px; }
1644 +@media (max-width: 1400px) { .header .gk-badge { display: none; } }
1645 +@media (max-width: 1200px) {
1646 + .nav { display: none; }
1647 + .menu-btn { display: flex; }
1648 +}
1649 +
1650 +/* header pleine largeur : la nav dense s'aligne aux bords de l'écran au lieu
1651 + de déborder du conteneur 1240px sur les grands écrans */
1652 +.header .header-inner { max-width: none; }
1653 +
1654 +/* ---- Fiche propriété : DA v2 — sections en filets, icônes contextuelles ---- */
1655 +.f-bloc h2 { border-left: 0; padding-left: 0; display: flex; align-items: center; gap: 10px; }
1656 +.f-bloc h2::before { content: ""; width: 20px; height: 3px; border-radius: 2px; background: var(--lime); flex: none; }
1657 +/* le panneau héros perd sa boîte : la typographie structure */
1658 +.f-hero { background: transparent; border: 0; box-shadow: none; padding: 0; }
1659 +.f-hero .price { font-size: clamp(36px, 7vw, 54px); letter-spacing: -0.04em; }
1660 +/* inclusions : grille d'items en filets — chaque inclusion a SON icône */
1661 +.amenity-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 0 28px; margin-bottom: 8px; }
1662 +.amenity-it {
1663 + display: flex; align-items: center; gap: 11px; padding: 7px 0;
1664 + border-bottom: 1px solid var(--hairline); font-size: 13.5px; color: var(--ink); min-height: 46px;
1665 +}
1666 +.am-ico {
1667 + display: inline-grid; place-items: center; width: 32px; height: 32px;
1668 + border-radius: 10px; background: var(--lime-soft); color: var(--green-deep); flex: none;
1669 +}
1670 +.am-txt { min-width: 0; }
1671 +/* caractéristiques : clés en micro-mono */
1672 +.dtable { gap: 0 28px; }
1673 +.drow { padding: 9px 0; }
1674 +.drow span { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); align-self: center; }
1675 +/* galerie : cadre allégé */
1676 +.carousel { border: 1px solid var(--hairline-strong); box-shadow: none; border-radius: var(--r-2); }
1677 +.thumbs button { border-radius: 8px; }
1678 +/* CTA sticky : sur la fiche, il prend le bas d'écran — la tabbar s'efface */
1679 +body:has(.cta-sticky) .tabbar { display: none; }
1680 +.cta-sticky { z-index: var(--z-dropdown, 700); border-top: 1px solid var(--hairline-strong); background: rgba(250, 249, 245, 0.92); }
1681 +@media (max-width: 760px) {
1682 + body:has(.cta-sticky) { padding-bottom: 0; }
1683 +}
1684 +
1685 +/* colonnes de la fiche : les sections respirent sans boîte — les modules
1686 + pleine largeur (.detail > .f-bloc : financement, quartier…) restent en cartes */
1687 +.fiche > .f-col > .f-bloc:not(.f-galerie) {
1688 + background: transparent; border: 0; border-radius: 0; padding: 0; box-shadow: none;
1689 +}
1690 +@media (max-width: 640px) {
1691 + .fiche > .f-col > .f-bloc:not(.f-galerie) { padding: 0; }
1692 +}
1693 +/* tiret accent des titres : sans bordure ni ombre héritées de la couche fiche */
1694 +.f-bloc h2::before, .quartier h2::before { border: 0; box-shadow: none; }
1695 +
1696 +/* =============================================================================
1697 + KA MAP SYSTEM v2 — MODE CARTE IMMO-KA (shell plein viewport)
1698 + Jetons du design system appliqués au shell ; le sélecteur .ka-map du
1699 + framework pose ses défauts directement sur le canevas — re-déclarer les
1700 + jetons dessus, sinon l'accent retombe au noir générique dans la carte.
1701 +============================================================================= */
1702 +.ka-shell,
1703 +.ka-shell .ka-map {
1704 + --ka-accent: var(--accent);
1705 + --ka-on-accent: var(--on-accent, #fff);
1706 + --ka-surface: var(--surface);
1707 + --ka-ink: var(--ink);
1708 + --ka-line: var(--line-strong);
1709 + --ka-radius: var(--r-ctl);
1710 + --ka-shadow: 0 8px 24px rgba(26, 18, 20, 0.14);
1711 + --ka-font: var(--font-body);
1712 +}
1713 +.ka-shell-fallback {
1714 + position: fixed; inset: 0; z-index: 640;
1715 + display: grid; place-items: center;
1716 + background: var(--surface); color: var(--ink-3);
1717 + font-family: var(--font-mono, inherit); font-size: 13px;
1718 +}
1719 +.ms2-brand { display: inline-flex; align-items: baseline; gap: 7px; font-family: var(--font-display); min-width: 0; }
1720 +.ms2-brand b { font-size: 15px; letter-spacing: -0.01em; }
1721 +.ms2-brand span {
1722 + font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.14em;
1723 + color: var(--ink-3); font-weight: 600;
1724 +}
1725 +.ka-top-btn {
1726 + display: inline-flex; align-items: center; gap: 6px;
1727 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
1728 + background: var(--surface); color: var(--ink);
1729 + font-family: var(--font-display); font-weight: 600; font-size: 12.5px;
1730 + padding: 8px 13px; cursor: pointer; min-height: 38px; white-space: nowrap;
1731 +}
1732 +.ka-top-btn:hover { background: var(--ink); color: #fff; }
1733 +.ka-top-badge {
1734 + min-width: 17px; height: 17px; border-radius: 999px;
1735 + background: var(--accent); color: var(--on-accent, #fff);
1736 + font-size: 10.5px; font-weight: 700; line-height: 17px;
1737 + text-align: center; padding: 0 4px;
1738 +}
1739 +.ms2-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-width: 0; }
1740 +.ms2-count { font-size: 13px; color: var(--ink-2); min-width: 0; }
1741 +.ms2-count b { color: var(--ink); font-size: 15px; }
1742 +.ms2-zone {
1743 + margin-left: 8px; font-size: 11px; color: var(--accent-deep, var(--accent));
1744 + border: 1px dashed var(--accent); border-radius: 999px; padding: 2px 8px;
1745 +}
1746 +.ms2-sort select {
1747 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
1748 + background: var(--surface); padding: 6px 8px; font-size: 12.5px;
1749 + font-weight: 600; color: var(--ink); max-width: 168px;
1750 +}
1751 +.ms2-list { display: flex; flex-direction: column; gap: 14px; position: relative; }
1752 +.ms2-list .map-card { flex: 0 0 auto; }
1753 +.ka-sheet .ms2-list { gap: 12px; padding-bottom: 10px; }
1754 +.ms2-pager { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 6px 0 4px; }
1755 +
1756 +/* mode carte : le widget Ka Agent s'efface (le sheet occupe son coin) */
1757 +body.ka-map-mode .kaa-btn, body.ka-map-mode .kaa-hello { display: none !important; }
1758 +
1759 +/* filtres de la page AU-DESSUS du mode carte — desktop : modale centrée */
1760 +@media (min-width: 781px) {
1761 + body.ka-map-mode .sheet-backdrop {
1762 + display: block; position: fixed; inset: 0;
1763 + z-index: var(--z-overlay, 800); background: rgba(26, 18, 20, 0.45);
1764 + }
1765 + body.ka-map-mode .search-zone { display: none; }
1766 + body.ka-map-mode .search-zone.open {
1767 + display: block; position: fixed; top: 7dvh; left: 50%;
1768 + transform: translateX(-50%); width: min(760px, 94vw);
1769 + max-height: 84dvh; overflow-y: auto; z-index: var(--z-modal, 900);
1770 + background: var(--surface); border: 1.5px solid var(--ink);
1771 + border-radius: var(--r-card); box-shadow: var(--shadow-off);
1772 + padding: 16px 22px 20px; margin: 0;
1773 + }
1774 + body.ka-map-mode .search-zone.open .sheet-head {
1775 + display: flex; align-items: center; justify-content: space-between;
1776 + font-weight: 700; font-size: 14px; margin-bottom: 10px;
1777 + }
1778 + body.ka-map-mode .search-zone.open .sheet-close {
1779 + display: grid; place-items: center; width: 34px; height: 34px;
1780 + border: 1.5px solid var(--ink); border-radius: 50%;
1781 + background: var(--surface); cursor: pointer; font-size: 14px;
1782 + }
1783 +}
1784 +
1785 +/* --- KA ID v2 : badge « Recommandé pour vous » (parcimonieux, serveur) --- */
1786 +.badge.ka-reco { top: auto; bottom: 12px; background: var(--ink); color: var(--accent); }
1787 +
1788 +/* ================= Home-Ka specifics ================= */
1789 +/* status chip on the detail page (2. price + status badge) */
1790 +.status-chip {
1791 + display: inline-flex; align-items: center; gap: 6px;
1792 + font-family: var(--font-mono); font-size: 11px; font-weight: 700;
1793 + text-transform: uppercase; letter-spacing: 0.08em;
1794 + padding: 5px 10px; border-radius: 999px;
1795 + background: var(--accent); color: var(--on-accent);
1796 +}
1797 +.status-chip.st-pending { background: #b96a00; }
1798 +.status-chip.st-sold { background: var(--ink); color: var(--paper); }
1799 +.status-chip.st-withdrawn { background: var(--ink-3); color: var(--paper); }
1800 +.status-chip.st-coming { background: var(--accent-deep); }
1801 +
1802 +/* status badge on result cards */
1803 +.card-img .badge.status {
1804 + left: auto; right: 10px; bottom: 10px; top: auto;
1805 + background: var(--ink); color: var(--paper);
1806 +}
1807 +
1808 +.broker-agency { font-size: 13.5px; color: var(--ink-2); }
1809 +
1810 +/* ================= Admin (/admin) ================= */
1811 +.admin { padding-top: 30px; padding-bottom: 46px; }
1812 +.admin h1 { font-family: var(--font-display); font-size: 30px; margin: 4px 0 6px; }
1813 +.admin .sub { color: var(--ink-2); max-width: 620px; }
1814 +
1815 +.adm-token {
1816 + display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
1817 + margin: 18px 0 20px; padding: 12px 14px;
1818 + border: 1.5px dashed var(--ink-3); border-radius: var(--r-card);
1819 + background: var(--surface);
1820 +}
1821 +.adm-token label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }
1822 +.adm-token input {
1823 + flex: 1; min-width: 220px; padding: 9px 12px; font: inherit;
1824 + border: 1.5px solid var(--ink); border-radius: 8px; background: var(--paper);
1825 +}
1826 +
1827 +.adm-tiles { margin-bottom: 20px; }
1828 +.adm-tile-wide { grid-column: 1 / -1; }
1829 +.adm-status-row { display: flex; flex-wrap: wrap; gap: 8px; }
1830 +
1831 +.adm-tabs { display: flex; gap: 6px; margin: 6px 0 18px; border-bottom: 2px solid var(--ink); }
1832 +.adm-tabs a {
1833 + padding: 9px 16px; font-weight: 600; font-size: 14px; color: var(--ink-2);
1834 + border: 1.5px solid transparent; border-bottom: none;
1835 + border-radius: 10px 10px 0 0;
1836 +}
1837 +.adm-tabs a:hover { color: var(--ink); border-color: var(--ink-3); }
1838 +.adm-tabs a.on { background: var(--ink); color: var(--lime); border-color: var(--ink); }
1839 +
1840 +.adm-badge {
1841 + display: inline-flex; align-items: center; gap: 5px;
1842 + font-family: var(--font-mono); font-size: 10.5px; font-weight: 600;
1843 + text-transform: uppercase; letter-spacing: 0.05em;
1844 + padding: 2px 8px; border-radius: 999px;
1845 + border: 1px solid var(--ink-3); color: var(--ink-2); background: var(--surface);
1846 +}
1847 +.adm-badge.adm-ok { border-color: var(--accent); color: var(--accent-deep); background: var(--accent-soft); }
1848 +.adm-badge.adm-err { border-color: #b3261e; color: #b3261e; background: #fbe4e2; }
1849 +.adm-badge.adm-off { border-color: var(--ink-3); color: var(--ink-3); }
1850 +
1851 +.adm-sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); }
1852 +.adm-msg { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1853 +.adm-table { min-width: 900px; }
1854 +
1855 +.adm-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 14px; }
1856 +.adm-card {
1857 + background: var(--surface); border: 1.5px solid var(--ink);
1858 + border-radius: var(--r-card); padding: 14px 16px;
1859 + box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.1);
1860 +}
1861 +.adm-card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 4px; }
1862 +.adm-card p { margin: 8px 0 0; font-size: 13px; color: var(--ink-2); }
1863 +
1864 +.adm-controls { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 16px; align-items: center; }
1865 +.adm-controls input, .adm-controls select {
1866 + padding: 8px 10px; font: inherit; font-size: 13.5px;
1867 + border: 1.5px solid var(--ink); border-radius: 8px; background: var(--paper);
1868 +}
1869 +.adm-controls input:first-child { flex: 1; min-width: 200px; }
1870 +.adm-status-select {
1871 + padding: 5px 6px; font: inherit; font-size: 12.5px;
1872 + border: 1.5px solid var(--ink-3); border-radius: 7px; background: var(--paper);
1873 +}
1874 +.adm-inspect { padding: 5px 10px; font-size: 12.5px; }
added frontend/src/vite-env.d.ts +1 −0
@@ -0,0 +1 @@
1 +/// <reference types="vite/client" />
added frontend/tsconfig.json +20 −0
@@ -0,0 +1,20 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2020",
4 + "useDefineForClassFields": true,
5 + "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 + "module": "ESNext",
7 + "skipLibCheck": true,
8 + "moduleResolution": "bundler",
9 + "allowImportingTsExtensions": true,
10 + "resolveJsonModule": true,
11 + "isolatedModules": true,
12 + "noEmit": true,
13 + "jsx": "react-jsx",
14 + "strict": true,
15 + "noUnusedLocals": false,
16 + "noUnusedParameters": false,
17 + "noFallthroughCasesInSwitch": true
18 + },
19 + "include": ["src"]
20 +}
added frontend/vite.config.ts +19 −0
@@ -0,0 +1,19 @@
1 +// -----------------------------------------------------------------------------
2 +// Home-Ka — US real-estate aggregator (Groupe KA)
3 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +// vite.config.ts : Vite configuration (dev API proxy, build to dist/)
5 +// -----------------------------------------------------------------------------
6 +import { defineConfig } from "vite";
7 +import react from "@vitejs/plugin-react";
8 +
9 +export default defineConfig({
10 + plugins: [react()],
11 + // @groupe-ka/ka-maps is a file: dependency (../vendor/ka-maps) without its
12 + // own node_modules — keep the symlink so its imports (mapbox-gl, react)
13 + // resolve from this project's node_modules.
14 + resolve: { preserveSymlinks: true },
15 + server: {
16 + proxy: { "/api": "http://localhost:8090" },
17 + },
18 + build: { outDir: "dist", chunkSizeWarningLimit: 1200 },
19 +});
added homeka/__init__.py +4 −0
@@ -0,0 +1,4 @@
1 +# Home-Ka — US real-estate aggregator (Groupe KA)
2 +# US counterpart of Immo-Ka: same architecture (connectors → normalization →
3 +# property matching → dedup → canonical listings → API/frontend), RESO Data
4 +# Dictionary aligned, PROPERTY separated from LISTING.
added homeka/brokerages.py +88 −0
@@ -0,0 +1,88 @@
1 +# -----------------------------------------------------------------------------
2 +# Home-Ka — US real-estate aggregator (Groupe KA)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# brokerages.py : brokerage registry — seed loading + scoring refresh.
5 +# The seed (data/brokerages_seed.json) is the initial base of 500+ US
6 +# brokerages worth pursuing for direct feeds; the discovery pipeline
7 +# (discovery.py) then inspects, classifies and scores them.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import json
12 +import time
13 +from pathlib import Path
14 +
15 +from . import db
16 +from .discovery import priority
17 +
18 +SEED_PATH = Path(__file__).resolve().parent.parent / "data" / "brokerages_seed.json"
19 +
20 +
21 +def load_seed(path: Path | str | None = None) -> dict:
22 + """Upsert the seed file into the brokerages table (idempotent, keyed on
23 + website). Never overwrites discovery results or partnership statuses."""
24 + p = Path(path) if path else SEED_PATH
25 + data = json.loads(p.read_text(encoding="utf-8"))
26 + rows = data["brokerages"] if isinstance(data, dict) else data
27 + con = db.connect()
28 + now = time.time()
29 + added = updated = 0
30 + for b in rows:
31 + website = (b.get("website") or "").strip().rstrip("/")
32 + if not website or not b.get("name"):
33 + continue
34 + states = b.get("states") or []
35 + prio = priority(b.get("estimated_listings"), b.get("estimated_agents"),
36 + 30, len(states)) # 30 = unknown feed prob. before inspection
37 + existing = con.execute("SELECT id FROM brokerages WHERE website=?",
38 + (website,)).fetchone()
39 + if existing is None:
40 + con.execute(
41 + """INSERT INTO brokerages (name, website, states, cities,
42 + estimated_agents, estimated_listings, mls_affiliations,
43 + possible_feed_type, priority_score, partnership_status,
44 + created, updated)
45 + VALUES (?,?,?,?,?,?,?,?,?,'prospect',?,?)""",
46 + (b["name"], website,
47 + json.dumps(states, ensure_ascii=False),
48 + json.dumps(b.get("cities") or [], ensure_ascii=False),
49 + b.get("estimated_agents"), b.get("estimated_listings"),
50 + json.dumps(b.get("mls_affiliations") or [], ensure_ascii=False),
51 + b.get("possible_feed_type") or "crawl", prio, now, now))
52 + added += 1
53 + else:
54 + con.execute(
55 + """UPDATE brokerages SET name=?,
56 + states=CASE WHEN states='[]' THEN ? ELSE states END,
57 + cities=CASE WHEN cities='[]' THEN ? ELSE cities END,
58 + estimated_agents=COALESCE(estimated_agents, ?),
59 + estimated_listings=COALESCE(estimated_listings, ?),
60 + updated=? WHERE id=?""",
61 + (b["name"], json.dumps(states, ensure_ascii=False),
62 + json.dumps(b.get("cities") or [], ensure_ascii=False),
63 + b.get("estimated_agents"), b.get("estimated_listings"),
64 + now, existing["id"]))
65 + updated += 1
66 + con.commit()
67 + n = con.execute("SELECT COUNT(*) c FROM brokerages").fetchone()["c"]
68 + con.close()
69 + return {"added": added, "updated": updated, "total": n}
70 +
71 +
72 +def rescore(con=None) -> int:
73 + """Recompute priority scores from current inspection data."""
74 + own = con is None
75 + con = con or db.connect()
76 + n = 0
77 + for r in con.execute("SELECT id, estimated_listings, estimated_agents,"
78 + " feed_probability_score, states FROM brokerages"):
79 + states = json.loads(r["states"] or "[]")
80 + prio = priority(r["estimated_listings"], r["estimated_agents"],
81 + r["feed_probability_score"] or 30, len(states))
82 + con.execute("UPDATE brokerages SET priority_score=? WHERE id=?",
83 + (prio, r["id"]))
84 + n += 1
85 + con.commit()
86 + if own:
87 + con.close()
88 + return n
added homeka/connectors/__init__.py +67 −0
@@ -0,0 +1,67 @@
1 +# -----------------------------------------------------------------------------
2 +# Home-Ka — US real-estate aggregator (Groupe KA)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/__init__.py : AUTO-DISCOVERING connector registry (recursive).
5 +#
6 +# Same principle as immo-ka, extended with connector FAMILIES:
7 +# - FAMILIES: {"reso": RESOConnector, "xml": XMLFeedConnector, ...}
8 +# generic config-driven classes (class attribute `family`), instantiated
9 +# per source from the `sources` table — a new source of a known family
10 +# is a DB row, zero code.
11 +# - CUSTOM: {"acme_realty": AcmeRealtyConnector, ...}
12 +# one-site classes (class attribute `source_id`), like immo-ka.
13 +# Every module under this package (subpackages included) is imported; a broken
14 +# connector never blocks the others.
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import importlib
19 +import pkgutil
20 +import sys
21 +
22 +from .base import BaseConnector
23 +
24 +FAMILIES: dict[str, type[BaseConnector]] = {}
25 +CUSTOM: dict[str, type[BaseConnector]] = {}
26 +
27 +
28 +def _register(module) -> None:
29 + for obj in vars(module).values():
30 + if not (isinstance(obj, type) and issubclass(obj, BaseConnector)
31 + and obj is not BaseConnector):
32 + continue
33 + fam = getattr(obj, "family", "")
34 + sid = getattr(obj, "source_id", "")
35 + if fam and obj.__dict__.get("family"):
36 + FAMILIES[fam] = obj
37 + if sid and obj.__dict__.get("source_id"):
38 + CUSTOM[sid] = obj
39 +
40 +
41 +def _walk(package_name: str, path) -> None:
42 + for mod in pkgutil.iter_modules(path):
43 + if mod.name in ("base", "__init__"):
44 + continue
45 + full = f"{package_name}.{mod.name}"
46 + try:
47 + module = importlib.import_module(full)
48 + except Exception as exc: # a broken connector never blocks the others
49 + print(f"[home-ka] connector '{full}' skipped: {exc}", file=sys.stderr)
50 + continue
51 + _register(module)
52 + if mod.ispkg:
53 + _walk(full, module.__path__)
54 +
55 +
56 +_walk(__name__, __path__)
57 +
58 +
59 +def build(source_id: str, connector_type: str,
60 + config: dict | None = None) -> BaseConnector | None:
61 + """Instantiate the connector for a `sources` row (or a custom class)."""
62 + if source_id in CUSTOM:
63 + return CUSTOM[source_id]()
64 + cls = FAMILIES.get(connector_type)
65 + if cls is None:
66 + return None
67 + return cls(source_id, config or {})
added homeka/connectors/_maputil.py +69 −0
@@ -0,0 +1,69 @@
1 +# -----------------------------------------------------------------------------
2 +# Home-Ka — US real-estate aggregator (Groupe KA)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/_maputil.py : shared helpers for config-driven field mapping.
5 +#
6 +# Family connectors (json, xml, csv, sftp, reso) turn a raw record into a
7 +# Listing through a declarative mapping stored in the source config:
8 +# "mapping": {"external_id": "ListingId", "list_price": "price.amount",
9 +# "images": "photos[].url", "city": "address.city", ...}
10 +# Paths are dot-separated; "[]" fans out over a list. Unmapped raw fields are
11 +# preserved in Listing.details for the RESO passthrough in schema.finalize().
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +from ..schema import Listing
16 +
17 +__all__ = ["dig", "apply_mapping"]
18 +
19 +_LIST_FIELDS = {"images", "features"}
20 +
21 +
22 +def dig(record, path: str):
23 + """Resolve a dot path in a nested dict/list structure.
24 +
25 + "photos[].url" → [p["url"] for p in record["photos"]];
26 + "address.city" → record["address"]["city"]. Returns None when absent.
27 + """
28 + cur = record
29 + for part in str(path).split("."):
30 + if cur is None:
31 + return None
32 + fan = part.endswith("[]")
33 + key = part[:-2] if fan else part
34 + if key:
35 + if isinstance(cur, dict):
36 + cur = cur.get(key)
37 + elif isinstance(cur, list):
38 + cur = [c.get(key) if isinstance(c, dict) else None for c in cur]
39 + else:
40 + return None
41 + if fan and not isinstance(cur, list):
42 + cur = [cur] if cur is not None else []
43 + return cur
44 +
45 +
46 +def apply_mapping(source_id: str, record: dict, mapping: dict,
47 + static: dict | None = None) -> Listing | None:
48 + """Build a Listing from a raw record and a declarative mapping."""
49 + kw: dict = {"source": source_id, "external_id": "", "url": ""}
50 + for field, path in (mapping or {}).items():
51 + v = dig(record, path)
52 + if v is None:
53 + continue
54 + if field in _LIST_FIELDS:
55 + v = [x for x in (v if isinstance(v, list) else [v]) if x]
56 + kw[field] = v
57 + for field, v in (static or {}).items():
58 + kw.setdefault(field, v)
59 + if not kw.get("external_id"):
60 + return None
61 + kw["external_id"] = str(kw["external_id"])
62 + kw.setdefault("details", {})
63 + # keep flat raw scalars for the RESO passthrough / debugging
64 + if isinstance(record, dict):
65 + for k, v in record.items():
66 + if isinstance(v, (str, int, float, bool)) and k not in kw["details"]:
67 + kw["details"][k] = v
68 + allowed = set(Listing.__dataclass_fields__)
69 + return Listing(**{k: v for k, v in kw.items() if k in allowed})
added homeka/connectors/_resilient.py +318 −0
@@ -0,0 +1,318 @@
1 +# =============================================================================
2 +# Groupe KA — connecteurs : chaîne de fetch anti-bot RÉSILIENTE (commune)
3 +# Auteur : Simon-Pierre Boucher <contact@spboucher.ai>
4 +# Fichier : connectors/_resilient.py
5 +# -----------------------------------------------------------------------------
6 +# But : rendre les connecteurs durables dans le temps. Quand un site jusque-là
7 +# ouvert déploie un anti-bot (Cloudflare / Akamai / Incapsula / PerimeterX) ou
8 +# renvoie 403/429/503, la requête directe N'ÉCHOUE PLUS silencieusement : elle
9 +# ESCALADE automatiquement à travers une chaîne de secours :
10 +#
11 +# 1. Direct — la session du connecteur (curl_cffi impersonate si
12 +# dispo, sinon requests) : rapide et gratuit.
13 +# 2. Oxylabs (résid.) — proxy résidentiel Canada (-cc-CA) : IP propre.
14 +# 3. Scrapfly (ASP) — bypass anti-bot géré + rendu JS optionnel.
15 +# 4. Bright Data — Web Unlocker : déblocage premium, dernier recours.
16 +#
17 +# Le premier backend qui renvoie un 200 non vide gagne. Si TOUS échouent, on
18 +# renvoie la dernière réponse (avec son code d'erreur) pour que le connecteur
19 +# journalise l'échec comme avant — aucun changement de comportement en cas
20 +# d'échec total, aucun blocage silencieux.
21 +#
22 +# Conception :
23 +# - Aucun effet de bord à l'import ; toute brique non configurée est sautée.
24 +# - Les clés sont lues de os.environ, avec repli sur le .env de l'app puis
25 +# ~/.claude/.env, et acceptent les deux noms Scrapfly (SCRAPFLY_KEY /
26 +# SCRAPFLY_API_KEY). => aucune modif de .env nécessaire.
27 +# - `_ResilientResponse` imite requests.Response (.text/.content/.status_code/
28 +# .url/.headers/.json()/.ok/.raise_for_status()) : les connecteurs existants
29 +# continuent de fonctionner sans modification.
30 +# - Coupe-circuit par hôte : après plusieurs escalades totalement infructueuses
31 +# sur un même hôte, on saute l'escalade payante pendant un temps de repos
32 +# (évite de brûler du quota Scrapfly/Bright Data sur une source morte).
33 +# =============================================================================
34 +from __future__ import annotations
35 +
36 +import json as _json
37 +import os
38 +import time
39 +from pathlib import Path
40 +from urllib.parse import quote, urlsplit
41 +
42 +import requests
43 +
44 +# -- curl_cffi est OPTIONNEL (meilleur fingerprint TLS s'il est présent) ------
45 +try: # pragma: no cover
46 + from curl_cffi import requests as _cffi # type: ignore
47 + _HAS_CFFI = True
48 +except Exception: # noqa: BLE001
49 + _cffi = None
50 + _HAS_CFFI = False
51 +
52 +# Codes HTTP typiques d'un blocage anti-bot (≠ 401/404/410/500 « métier » :
53 +# 401 = auth manquante, 403/429 = bot bloqué, 5xx CF = challenge/edge).
54 +BLOCK_STATUS = {403, 429, 503, 520, 521, 522, 523, 524, 526, 1020}
55 +
56 +# Marqueurs de page-challenge (Cloudflare/Akamai/Incapsula/PerimeterX/DataDome).
57 +_CHALLENGE_MARKERS = (
58 + "just a moment", "cf-browser-verification", "cf-challenge",
59 + "attention required", "access denied", "request unsuccessful",
60 + "px-captcha", "perimeterx", "incapsula", "_incapsula_", "datadome",
61 + "captcha-delivery", "please enable javascript and cookies",
62 + "checking your browser", "ddos protection by",
63 +)
64 +
65 +_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
66 + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
67 +
68 +# Coupe-circuit en mémoire : hôte -> (timestamp_jusquà, échecs_consécutifs)
69 +_COOLDOWN: dict[str, tuple[float, int]] = {}
70 +_COOLDOWN_HITS = 3 # nb d'échecs totaux avant repos
71 +_COOLDOWN_SECONDS = 900.0 # 15 min de repos pour un hôte « mort »
72 +
73 +# -- chargement paresseux des secrets ----------------------------------------
74 +_ENV_CACHE: dict[str, str] | None = None
75 +
76 +
77 +def _load_env_files() -> dict[str, str]:
78 + """Parse les .env candidats une seule fois (repli si os.environ vide)."""
79 + global _ENV_CACHE
80 + if _ENV_CACHE is not None:
81 + return _ENV_CACHE
82 + out: dict[str, str] = {}
83 + candidates = []
84 + # .env de l'app (remonte quelques niveaux depuis ce module)
85 + here = Path(__file__).resolve()
86 + for up in range(2, 6):
87 + try:
88 + candidates.append(here.parents[up] / ".env")
89 + except IndexError:
90 + break
91 + candidates.append(Path.home() / ".claude" / ".env")
92 + for path in candidates:
93 + try:
94 + if not path.is_file():
95 + continue
96 + for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
97 + line = line.strip()
98 + if not line or line.startswith("#") or "=" not in line:
99 + continue
100 + k, _, v = line.partition("=")
101 + k, v = k.strip(), v.strip().strip('"').strip("'")
102 + # ne pas écraser une valeur déjà trouvée (priorité app > global)
103 + if k and k not in out:
104 + out[k] = v
105 + except Exception: # noqa: BLE001
106 + continue
107 + _ENV_CACHE = out
108 + return out
109 +
110 +
111 +def _secret(*names: str) -> str | None:
112 + """Cherche une clé dans os.environ puis dans les .env (par ordre de noms)."""
113 + for n in names:
114 + v = os.environ.get(n)
115 + if v:
116 + return v
117 + env = _load_env_files()
118 + for n in names:
119 + v = env.get(n)
120 + if v:
121 + return v
122 + return None
123 +
124 +
125 +# -- réponse compatible requests.Response ------------------------------------
126 +class _ResilientResponse:
127 + """Imite le minimum utile d'une requests.Response pour les connecteurs."""
128 +
129 + def __init__(self, url: str, status_code: int, text: str,
130 + headers: dict | None = None, via: str = "direct") -> None:
131 + self.url = url
132 + self.status_code = int(status_code or 0)
133 + self._text = text or ""
134 + self.headers = headers or {}
135 + self.encoding = "utf-8"
136 + self.via = via # backend gagnant (diagnostic)
137 +
138 + @property
139 + def text(self) -> str:
140 + return self._text
141 +
142 + @property
143 + def content(self) -> bytes:
144 + return self._text.encode("utf-8", errors="ignore")
145 +
146 + @property
147 + def ok(self) -> bool:
148 + return 200 <= self.status_code < 400
149 +
150 + def json(self, **kw):
151 + return _json.loads(self._text)
152 +
153 + def raise_for_status(self):
154 + if 400 <= self.status_code < 600:
155 + raise requests.HTTPError(
156 + f"{self.status_code} via {self.via} pour {self.url}",
157 + response=self) # type: ignore[arg-type]
158 + return None
159 +
160 + def __repr__(self) -> str: # pragma: no cover
161 + return f"<_ResilientResponse [{self.status_code}] via {self.via}>"
162 +
163 +
164 +# -- détection de blocage -----------------------------------------------------
165 +def is_blocked(resp) -> bool:
166 + """True si la réponse ressemble à un blocage anti-bot (≠ erreur métier)."""
167 + if resp is None:
168 + return True
169 + code = getattr(resp, "status_code", 0) or 0
170 + if code in BLOCK_STATUS:
171 + return True
172 + # 200 mais page-challenge servie
173 + if code == 200:
174 + try:
175 + body = (resp.text or "")[:4000].lower()
176 + except Exception: # noqa: BLE001
177 + return False
178 + server = str(resp.headers.get("Server", "")).lower() if getattr(resp, "headers", None) else ""
179 + if any(m in body for m in _CHALLENGE_MARKERS):
180 + return True
181 + if "cloudflare" in server and ("captcha" in body or "challenge" in body):
182 + return True
183 + return False
184 +
185 +
186 +def _host(url: str) -> str:
187 + try:
188 + return urlsplit(url).netloc.lower()
189 + except Exception: # noqa: BLE001
190 + return url
191 +
192 +
193 +def _cooling(host: str) -> bool:
194 + until, _ = _COOLDOWN.get(host, (0.0, 0))
195 + return time.time() < until
196 +
197 +
198 +def _note_failure(host: str) -> None:
199 + until, hits = _COOLDOWN.get(host, (0.0, 0))
200 + hits += 1
201 + if hits >= _COOLDOWN_HITS:
202 + _COOLDOWN[host] = (time.time() + _COOLDOWN_SECONDS, 0)
203 + else:
204 + _COOLDOWN[host] = (until, hits)
205 +
206 +
207 +def _note_success(host: str) -> None:
208 + _COOLDOWN.pop(host, None)
209 +
210 +
211 +# -- backends d'escalade ------------------------------------------------------
212 +def _try_oxylabs(url: str, timeout: int, country: str,
213 + headers: dict | None) -> _ResilientResponse | None:
214 + endpoint = _secret("OXYLABS_PROXY") # pr.oxylabs.io:7777
215 + user = _secret("OXYLABS_PROXY_USER") # customer-... (sans -cc-XX)
216 + pwd = _secret("OXYLABS_PROXY_PASS")
217 + if not (endpoint and user and pwd):
218 + return None
219 + cc = (country or "us").upper()
220 + puser = f"{user}-cc-{cc}"
221 + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}"
222 + proxies = {"http": proxy, "https": proxy}
223 + hdrs = {"User-Agent": _UA}
224 + if headers:
225 + hdrs.update(headers)
226 + try:
227 + r = requests.get(url, proxies=proxies, headers=hdrs, timeout=timeout,
228 + verify=False) # noqa: S501 (proxy MITM du CA Oxylabs)
229 + return _ResilientResponse(url, r.status_code, r.text,
230 + dict(r.headers), via="oxylabs")
231 + except Exception: # noqa: BLE001
232 + return None
233 +
234 +
235 +def _try_scrapfly(url: str, timeout: int, country: str, render_js: bool,
236 + headers: dict | None) -> _ResilientResponse | None:
237 + key = _secret("SCRAPFLY_KEY", "SCRAPFLY_API_KEY")
238 + if not key:
239 + return None
240 + params = {"key": key, "url": url, "country": country or "us",
241 + "asp": "true", "proxy_pool": "public_residential_pool"}
242 + if render_js:
243 + params["render_js"] = "true"
244 + if headers:
245 + for k, v in headers.items():
246 + params[f"headers[{k}]"] = v
247 + try:
248 + r = requests.get("https://api.scrapfly.io/scrape", params=params,
249 + timeout=max(timeout, 180))
250 + result = (r.json() or {}).get("result") or {}
251 + return _ResilientResponse(
252 + url, result.get("status_code") or 0, result.get("content") or "",
253 + (result.get("response_headers") or {}), via="scrapfly")
254 + except Exception: # noqa: BLE001
255 + return None
256 +
257 +
258 +def _try_brightdata(url: str, timeout: int,
259 + headers: dict | None) -> _ResilientResponse | None:
260 + key = _secret("BRIGHTDATA_API_KEY")
261 + zone = _secret("BRIGHTDATA_ZONE") or "web_unlocker1"
262 + if not key:
263 + return None
264 + try:
265 + r = requests.post(
266 + "https://api.brightdata.com/request",
267 + headers={"Authorization": f"Bearer {key}",
268 + "Content-Type": "application/json"},
269 + json={"zone": zone, "url": url, "format": "raw"},
270 + timeout=max(timeout, 120))
271 + return _ResilientResponse(url, r.status_code, r.text,
272 + dict(r.headers), via="brightdata")
273 + except Exception: # noqa: BLE001
274 + return None
275 +
276 +
277 +# -- API publique -------------------------------------------------------------
278 +def escalate(url: str, *, timeout: int = 30, country: str = "us",
279 + render_js: bool = False, headers: dict | None = None,
280 + original=None):
281 + """Tente la chaîne de secours et renvoie la meilleure réponse.
282 +
283 + Renvoie un `_ResilientResponse` 200 dès qu'un backend réussit ; sinon la
284 + dernière réponse tentée (ou `original`) pour préserver le comportement
285 + d'échec du connecteur. Respecte le coupe-circuit par hôte.
286 + """
287 + host = _host(url)
288 + if _cooling(host):
289 + return original # source au repos : on ne brûle pas de quota payant
290 +
291 + last = original
292 + for backend in (
293 + lambda: _try_oxylabs(url, timeout, country, headers),
294 + lambda: _try_scrapfly(url, timeout, country, render_js, headers),
295 + lambda: _try_brightdata(url, timeout, headers),
296 + ):
297 + resp = backend()
298 + if resp is None:
299 + continue
300 + last = resp
301 + if resp.status_code == 200 and resp.text and not is_blocked(resp):
302 + _note_success(host)
303 + return resp
304 + time.sleep(0.4)
305 +
306 + _note_failure(host)
307 + return last if last is not None else original
308 +
309 +
310 +def escalate_if_blocked(resp, url: str, *, timeout: int = 30,
311 + country: str = "us", render_js: bool = False,
312 + headers: dict | None = None):
313 + """Renvoie `resp` s'il est bon ; sinon lance l'escalade anti-bot."""
314 + if not is_blocked(resp):
315 + return resp
316 + better = escalate(url, timeout=timeout, country=country,
317 + render_js=render_js, headers=headers, original=resp)
318 + return better if better is not None else resp
added homeka/connectors/base.py +247 −0
@@ -0,0 +1,247 @@
1 +# -----------------------------------------------------------------------------
2 +# Home-Ka — US real-estate aggregator (Groupe KA)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/base.py : connector base class + fetch backends.
5 +#
6 +# Same philosophy as immo-ka: SOURCE → connector → raw ingestion →
7 +# normalization → property matching → deduplication → canonical listing →
8 +# search index → Home-Ka. One connector adapts ONE source; the pipeline
9 +# (ingest.py) handles the diff with the database.
10 +#
11 +# Two kinds of connectors:
12 +# - FAMILY connectors (reso/, rets/, xml/, json/, csv/, sftp/, public_data/):
13 +# generic, config-driven — one class serves many sources. Instantiated
14 +# with (source_id, config) from a row of the `sources` table. They carry
15 +# a class attribute `family` (e.g. "reso").
16 +# - CUSTOM connectors (custom_broker/*.py): one class per site, class
17 +# attribute `source_id` set, zero-arg constructible — exactly like
18 +# immo-ka's per-agency connectors.
19 +#
20 +# Data-sourcing policy (Home-Ka): favor the LOWEST-LEVEL source available —
21 +# direct feeds (RESO Web API, RETS, XML/JSON/CSV drops) over site crawling.
22 +# Crawling is a discovery/bootstrap tool, not the foundation; the proxy
23 +# backends (Firecrawl/Scrapfly) exist as a LAST RESORT only.
24 +# -----------------------------------------------------------------------------
25 +from __future__ import annotations
26 +
27 +import json
28 +import os
29 +import time
30 +
31 +import requests
32 +
33 +from ..schema import Listing
34 +
35 +USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
36 + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "
37 + "HomeKaBot/1.0 (+https://www.home-ka.com/bot; contact@spboucher.ai)")
38 +
39 +FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
40 +SCRAPFLY_API = "https://api.scrapfly.io/scrape"
41 +
42 +
43 +def expand_env(config: dict) -> dict:
44 + """Resolve '$ENV:NAME' placeholders in a source config — credentials live
45 + in the environment (.env), never in the database."""
46 + out = {}
47 + for k, v in (config or {}).items():
48 + if isinstance(v, str) and v.startswith("$ENV:"):
49 + out[k] = os.environ.get(v[5:], "")
50 + elif isinstance(v, dict):
51 + out[k] = expand_env(v)
52 + else:
53 + out[k] = v
54 + return out
55 +
56 +
57 +class BaseConnector:
58 + """A connector = one adapter for one listing source.
59 +
60 + Family subclasses: set `family`, accept (source_id, config) and implement
61 + `fetch()`. Custom subclasses: set `source_id` and implement `fetch()`.
62 + `fetch()` returns the complete list of listings currently visible at the
63 + source; the pipeline handles additions/changes/removals.
64 + """
65 +
66 + family: str = "" # set on generic, config-driven families
67 + source_id: str = "" # set on custom one-site connectors
68 + request_delay: float = 0.6 # politeness between requests
69 + timeout: int = 30
70 + use_detail_cache: bool = True # DB cache of detail pages
71 +
72 + def __init__(self, source_id: str | None = None,
73 + config: dict | None = None) -> None:
74 + if source_id:
75 + self.source_id = source_id
76 + self.config = expand_env(config or {})
77 + if self.config.get("request_delay"):
78 + self.request_delay = float(self.config["request_delay"])
79 + self.session = requests.Session()
80 + self.session.headers["User-Agent"] = USER_AGENT
81 + self._last_request = 0.0
82 + self._detail_con = None
83 +
84 + # -- backends -------------------------------------------------------------
85 + def _throttle(self) -> None:
86 + wait = self.request_delay - (time.time() - self._last_request)
87 + if wait > 0:
88 + time.sleep(wait)
89 +
90 + def get(self, url: str, **kw) -> requests.Response:
91 + """Polite direct GET."""
92 + self._throttle()
93 + resp = self.session.get(url, timeout=self.timeout, **kw)
94 + self._last_request = time.time()
95 + resp.raise_for_status()
96 + return resp
97 +
98 + def post(self, url: str, **kw) -> requests.Response:
99 + """Polite direct POST (internal search APIs)."""
100 + self._throttle()
101 + resp = self.session.post(url, timeout=self.timeout, **kw)
102 + self._last_request = time.time()
103 + resp.raise_for_status()
104 + return resp
105 +
106 + def get_rendered(self, url: str, wait_for: int = 0,
107 + proxy: str | None = None) -> str:
108 + """LAST RESORT — JS-rendered HTML via Firecrawl (FIRECRAWL_API_KEY)."""
109 + key = os.environ.get("FIRECRAWL_API_KEY")
110 + if not key:
111 + raise RuntimeError("FIRECRAWL_API_KEY missing (see .env)")
112 + payload: dict = {"url": url, "formats": ["html"], "timeout": 90000}
113 + if wait_for:
114 + payload["waitFor"] = wait_for
115 + if proxy:
116 + payload["proxy"] = proxy
117 + resp = requests.post(FIRECRAWL_API, json=payload,
118 + headers={"Authorization": f"Bearer {key}"},
119 + timeout=150)
120 + resp.raise_for_status()
121 + return (resp.json().get("data") or {}).get("html", "")
122 +
123 + def scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
124 + rendering_wait: int = 0, country: str = "us",
125 + wait_for_selector: str | None = None,
126 + js_scenario: list | str | None = None,
127 + proxy_pool: str | None = None, headers: dict | None = None,
128 + method: str = "GET", body: str | None = None) -> dict:
129 + """LAST RESORT — full Scrapfly call, returns the `result` dict.
130 + Home-Ka policy: only for sources with no lower-level path; convert
131 + every interesting source to a direct feed instead."""
132 + import base64
133 + key = os.environ.get("SCRAPFLY_KEY")
134 + if not key:
135 + raise RuntimeError("SCRAPFLY_KEY missing (see .env)")
136 + params: dict = {"key": key, "url": url, "country": country}
137 + if asp:
138 + params["asp"] = "true"
139 + if render_js:
140 + params["render_js"] = "true"
141 + if rendering_wait:
142 + params["rendering_wait"] = rendering_wait
143 + if wait_for_selector:
144 + params["wait_for_selector"] = wait_for_selector
145 + if proxy_pool:
146 + params["proxy_pool"] = proxy_pool
147 + if js_scenario is not None:
148 + js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario)
149 + params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode()
150 + if headers:
151 + for k, v in headers.items():
152 + params[f"headers[{k}]"] = v
153 + self._throttle()
154 + if method.upper() == "POST":
155 + resp = requests.post(SCRAPFLY_API, params=params,
156 + data=(body or ""), timeout=180)
157 + else:
158 + resp = requests.get(SCRAPFLY_API, params=params, timeout=180)
159 + self._last_request = time.time()
160 + try:
161 + return resp.json().get("result") or {}
162 + except ValueError:
163 + return {}
164 +
165 + def detail(self, external_id: str, key: str, fetch_fn) -> dict:
166 + """Detail-page payload with cache: `fetch_fn` only runs when the
167 + listing is new or its list-content hash changed."""
168 + if not self.use_detail_cache:
169 + return fetch_fn() or {}
170 + from .. import db
171 + if self._detail_con is None:
172 + self._detail_con = db.connect()
173 + cached = db.get_cached_detail(self._detail_con, self.source_id,
174 + str(external_id), key)
175 + if cached is not None:
176 + return cached
177 + payload = fetch_fn() or {}
178 + db.put_cached_detail(self._detail_con, self.source_id,
179 + str(external_id), key, payload)
180 + return payload
181 +
182 + # -- contract --------------------------------------------------------------
183 + def fetch(self) -> list[Listing]:
184 + raise NotImplementedError
185 +
186 +
187 +# =============================================================================
188 +# Anti-bot resilience (Groupe KA) — auto-escalation of get() without touching
189 +# connector bodies: an anti-bot block (403/429/503/challenge) or a network cut
190 +# triggers the fallback chain (Oxylabs residential -> Scrapfly ASP -> Bright
191 +# Data). See connectors/_resilient.py. Idempotent (_KA_RESILIENT_WRAPPED).
192 +# Direct fetch stays the default — proxies only fire when blocked.
193 +# =============================================================================
194 +if not getattr(BaseConnector, "_KA_RESILIENT_WRAPPED", False):
195 + import requests as _ka_requests # noqa: E402
196 + from . import _resilient as _kar # noqa: E402
197 +
198 + _ka_orig_get = BaseConnector.get
199 +
200 + def _ka_full_url(url, kw):
201 + try:
202 + return _ka_requests.Request("GET", url,
203 + params=kw.get("params")).prepare().url
204 + except Exception: # noqa: BLE001
205 + return url
206 +
207 + def _ka_resilient_get(self, url, **kw):
208 + timeout = getattr(self, "timeout", 30)
209 + headers = kw.get("headers")
210 + try:
211 + return _ka_orig_get(self, url, **kw)
212 + except _ka_requests.HTTPError as exc:
213 + r = getattr(exc, "response", None)
214 + if r is not None and _kar.is_blocked(r):
215 + target = getattr(r, "url", None) or _ka_full_url(url, kw)
216 + better = _kar.escalate_if_blocked(
217 + r, target, timeout=timeout, headers=headers)
218 + if better is not None and getattr(better, "status_code", 0) == 200:
219 + return better
220 + raise
221 + except (_ka_requests.ConnectionError, _ka_requests.Timeout):
222 + better = _kar.escalate(_ka_full_url(url, kw),
223 + timeout=timeout, headers=headers)
224 + if better is not None and getattr(better, "status_code", 0) == 200:
225 + return better
226 + raise
227 +
228 + def _ka_get_resilient(self, url, *, render_js=False, country="us", **kw):
229 + """Explicit anti-bot fetch: forces the fallback chain when needed."""
230 + timeout = getattr(self, "timeout", 30)
231 + headers = kw.get("headers")
232 + try:
233 + resp = _ka_orig_get(self, url, **kw)
234 + except _ka_requests.HTTPError as exc:
235 + resp = getattr(exc, "response", None)
236 + except (_ka_requests.ConnectionError, _ka_requests.Timeout):
237 + resp = None
238 + target = _ka_full_url(url, kw)
239 + if resp is not None and getattr(resp, "url", None):
240 + target = resp.url
241 + return _kar.escalate_if_blocked(resp, target, timeout=timeout,
242 + country=country, render_js=render_js,
243 + headers=headers)
244 +
245 + BaseConnector.get = _ka_resilient_get
246 + BaseConnector.get_resilient = _ka_get_resilient
247 + BaseConnector._KA_RESILIENT_WRAPPED = True
added homeka/connectors/csv/__init__.py +0 −0
added homeka/connectors/csv/feed.py +56 −0
@@ -0,0 +1,56 @@
1 +# -----------------------------------------------------------------------------
2 +# Home-Ka — US real-estate aggregator (Groupe KA)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/csv/feed.py : CSV feed connector — brokerage exports, scheduled
5 +# drops (also consumed by the sftp family after download).
6 +#
7 +# {"id": "acme_csv", "connector_type": "csv", "config": {
8 +# "url": "https://feeds.acme.com/export.csv", # or "path": local file
9 +# "delimiter": ",", "encoding": "utf-8-sig",
10 +# "mapping": {"external_id": "MLS #", "list_price": "Price",
11 +# "street_address": "Address", "city": "City", ...},
12 +# "images_split": "|", # column with delimiter-joined URLs
13 +# "static": {"state": "FL"}}}
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import csv
18 +import io
19 +
20 +from ..base import BaseConnector
21 +from .._maputil import apply_mapping
22 +from ...schema import Listing
23 +
24 +
25 +class CSVFeedConnector(BaseConnector):
26 + family = "csv"
27 + request_delay = 0.2
28 +
29 + def fetch(self) -> list[Listing]:
30 + cfg = self.config
31 + if cfg.get("path"):
32 + text = open(cfg["path"], encoding=cfg.get("encoding", "utf-8-sig"),
33 + newline="").read()
34 + else:
35 + resp = self.get(cfg["url"])
36 + resp.encoding = cfg.get("encoding", "utf-8-sig")
37 + text = resp.text
38 + return parse_csv(self.source_id, text, cfg)
39 +
40 +
41 +def parse_csv(source_id: str, text: str, cfg: dict) -> list[Listing]:
42 + """Shared with the sftp family."""
43 + mapping = cfg.get("mapping") or {}
44 + static = cfg.get("static") or {}
45 + split = cfg.get("images_split", "|")
46 + out: list[Listing] = []
47 + reader = csv.DictReader(io.StringIO(text),
48 + delimiter=cfg.get("delimiter", ","))
49 + for rec in reader:
50 + lst = apply_mapping(source_id, rec, mapping, static)
51 + if lst is None:
52 + continue
53 + if lst.images and len(lst.images) == 1 and split in (lst.images[0] or ""):
54 + lst.images = [u.strip() for u in lst.images[0].split(split) if u.strip()]
55 + out.append(lst)
56 + return out
added homeka/connectors/custom_broker/__init__.py +4 −0
@@ -0,0 +1,4 @@
1 +# Custom one-site connectors (one class per brokerage), auto-discovered by
2 +# connectors/__init__.py through the class attribute `source_id` — exactly
3 +# like immo-ka's per-agency connectors. Add a module here when a brokerage
4 +# needs bespoke parsing that the config-driven families can't express.
added homeka/connectors/json/__init__.py +0 −0
added homeka/connectors/json/feed.py +76 −0
@@ -0,0 +1,76 @@
1 +# -----------------------------------------------------------------------------
2 +# Home-Ka — US real-estate aggregator (Groupe KA)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/json/feed.py : generic JSON API connector — partner APIs,
5 +# brokerage internal search APIs, WordPress REST (wp-json) listing CPTs.
6 +# Config-driven; pagination by page parameter or by next-link path.
7 +#
8 +# {"id": "acme_api", "connector_type": "json", "config": {
9 +# "url": "https://www.acmerealty.com/api/listings?page={page}",
10 +# "method": "GET", "headers": {}, "body": null,
11 +# "items_path": "data.results", # dot path to the record list
12 +# "next_path": "data.next", # OR page-based: start_page/max_pages
13 +# "start_page": 1, "max_pages": 50, "stop_when_empty": true,
14 +# "mapping": {"external_id": "id", "url": "permalink",
15 +# "list_price": "price", "images": "photos[].url", ...},
16 +# "static": {"state": "CO"}}}
17 +# -----------------------------------------------------------------------------
18 +from __future__ import annotations
19 +
20 +from ..base import BaseConnector
21 +from .._maputil import apply_mapping, dig
22 +from ...schema import Listing
23 +
24 +
25 +class JSONFeedConnector(BaseConnector):
26 + family = "json"
27 + request_delay = 0.5
28 +
29 + def fetch(self) -> list[Listing]:
30 + cfg = self.config
31 + mapping = cfg.get("mapping") or {}
32 + static = cfg.get("static") or {}
33 + items_path = cfg.get("items_path", "")
34 + headers = cfg.get("headers") or {}
35 + out: list[Listing] = []
36 + seen: set[str] = set()
37 +
38 + url_tpl = cfg.get("url", "")
39 + page = int(cfg.get("start_page", 1))
40 + max_pages = int(cfg.get("max_pages", 100))
41 + next_url: str | None = None
42 +
43 + for _ in range(max_pages):
44 + url = next_url or url_tpl.format(page=page)
45 + if cfg.get("method", "GET").upper() == "POST":
46 + body = cfg.get("body")
47 + if isinstance(body, dict):
48 + body = {k: (str(v).format(page=page) if isinstance(v, str) else v)
49 + for k, v in body.items()}
50 + resp = self.post(url, json=body, headers=headers)
51 + else:
52 + resp = self.get(url, headers=headers)
53 + data = resp.json()
54 + items = dig(data, items_path) if items_path else data
55 + if not isinstance(items, list):
56 + items = [items] if isinstance(items, dict) else []
57 + new = 0
58 + for rec in items:
59 + if not isinstance(rec, dict):
60 + continue
61 + lst = apply_mapping(self.source_id, rec, mapping, static)
62 + if lst is not None and lst.uid not in seen:
63 + seen.add(lst.uid)
64 + out.append(lst)
65 + new += 1
66 + if cfg.get("next_path"):
67 + next_url = dig(data, cfg["next_path"])
68 + if not next_url:
69 + break
70 + else:
71 + page += 1
72 + if cfg.get("stop_when_empty", True) and new == 0:
73 + break
74 + if "{page}" not in url_tpl:
75 + break
76 + return out
added homeka/connectors/json/jsonld_site.py +226 −0
@@ -0,0 +1,226 @@
1 +# -----------------------------------------------------------------------------
2 +# Home-Ka — US real-estate aggregator (Groupe KA)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/json/jsonld_site.py : schema.org JSON-LD site connector.
5 +#
6 +# Many brokerage sites (Real Estate Webmasters and other server-rendered
7 +# platforms) publish each listing as JSON-LD (`Product` + `RealEstateListing`
8 +# / `SingleFamilyResidence` nodes) — clean, stable data with full address,
9 +# geo, price and photos, no proxy needed. This is the SECONDARY crawler path
10 +# (authority 3): use it to bootstrap coverage while converting the brokerage
11 +# to a direct feed. Ported from immo-ka connectors/jsonld.py.
12 +#
13 +# {"id": "michael_saunders", "connector_type": "jsonld", "config": {
14 +# "base_url": "https://www.michaelsaunders.com",
15 +# "url_include": "/listing/",
16 +# "external_id_regex": "/listing/([A-Z0-9-]+)/", # from the URL
17 +# "max_pages": 400, "static": {"state": "FL"}}}
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +import hashlib
22 +import json
23 +import re
24 +import time
25 +
26 +from ..base import BaseConnector
27 +from ...normalize import parse_price
28 +from ...schema import Listing
29 +
30 +_LD_RE = re.compile(r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
31 + re.S | re.I)
32 +_LOC_RE = re.compile(r"<loc>\s*(.*?)\s*</loc>", re.S | re.I)
33 +
34 +_LISTING_TYPES = {
35 + "realestatelisting", "singlefamilyresidence", "house", "residence",
36 + "apartment", "product", "accommodation", "condominium", "townhouse",
37 +}
38 +
39 +# URL slug fallback: ".../7039-s-straight-avenue-homosassa-fl-34446/"
40 +_SLUG_ADDR_RE = re.compile(r"/([\w-]+)-([a-z]{2})-(\d{5})/?$", re.I)
41 +
42 +
43 +def iter_ld(html: str):
44 + """Iterate JSON-LD objects of a page (each block, flattened from @graph)."""
45 + for block in _LD_RE.findall(html):
46 + block = block.strip()
47 + if not block:
48 + continue
49 + try:
50 + data = json.loads(block)
51 + except ValueError:
52 + continue
53 + nodes = data.get("@graph", [data]) if isinstance(data, dict) else data
54 + for node in (nodes if isinstance(nodes, list) else [nodes]):
55 + if isinstance(node, dict):
56 + yield node
57 +
58 +
59 +def _qv(value):
60 + """Unwrap schema.org QuantitativeValue ({'value': 2, ...} → 2)."""
61 + if isinstance(value, dict):
62 + return value.get("value")
63 + return value
64 +
65 +
66 +class JSONLDSiteConnector(BaseConnector):
67 + family = "jsonld"
68 + request_delay = 0.8
69 +
70 + # -- URL discovery -----------------------------------------------------
71 + def _listing_urls(self) -> list[str]:
72 + cfg = self.config
73 + include = re.compile(cfg.get("url_include", "/listing/|/property/"))
74 + urls: list[str] = []
75 + seen: set[str] = set()
76 +
77 + def add(u: str) -> None:
78 + if u not in seen and include.search(u):
79 + seen.add(u)
80 + urls.append(u)
81 +
82 + sitemaps = [cfg["sitemap_url"]] if cfg.get("sitemap_url") else []
83 + if not sitemaps and cfg.get("base_url"):
84 + sitemaps = [cfg["base_url"].rstrip("/") + "/sitemap.xml"]
85 + depth = 0
86 + max_pages = int(cfg.get("max_pages", 400))
87 + while sitemaps and depth < 3 and len(urls) < max_pages:
88 + nxt: list[str] = []
89 + for sm in sitemaps[:30]:
90 + if len(urls) >= max_pages:
91 + break
92 + try:
93 + body = self.get(sm).text
94 + except Exception:
95 + continue
96 + for loc in _LOC_RE.findall(body):
97 + if loc.endswith(".xml"):
98 + nxt.append(loc)
99 + else:
100 + add(loc)
101 + sitemaps = nxt
102 + depth += 1
103 + for lp in cfg.get("list_urls", []):
104 + try:
105 + body = self.get(lp).text
106 + except Exception:
107 + continue
108 + base = cfg.get("base_url", "").rstrip("/")
109 + for href in re.findall(r'href=["\']([^"\']+)["\']', body):
110 + u = href if href.startswith("http") else base + href
111 + add(u.split("#")[0])
112 + return urls[:max_pages]
113 +
114 + # -- fetch --------------------------------------------------------------
115 + def fetch(self) -> list[Listing]:
116 + static = self.config.get("static") or {}
117 + ext_re = re.compile(self.config["external_id_regex"]) \
118 + if self.config.get("external_id_regex") else None
119 + # weekly cache bucket: each detail page is re-fetched at most once a
120 + # week (price/status refresh) and never re-fetched inside a cycle
121 + week = int(time.time() // (7 * 86400))
122 + out: list[Listing] = []
123 + for url in self._listing_urls():
124 + ext = ""
125 + if ext_re:
126 + m = ext_re.search(url)
127 + if m:
128 + ext = m.group(1)
129 + if not ext:
130 + ext = hashlib.sha1(url.encode()).hexdigest()[:16]
131 +
132 + def _fetch(url=url):
133 + try:
134 + return {"ld": list(iter_ld(self.get(url).text))}
135 + except Exception:
136 + return {}
137 +
138 + payload = self.detail(ext, f"w{week}:{url}", _fetch)
139 + lst = self._from_nodes(payload.get("ld") or [], url, ext, static)
140 + if lst is not None:
141 + out.append(lst)
142 + return out
143 +
144 + # -- mapping --------------------------------------------------------------
145 + def _from_nodes(self, nodes: list[dict], url: str, ext: str,
146 + static: dict) -> Listing | None:
147 + """Merge every listing-typed JSON-LD node of the page (REW splits the
148 + data across Product + SingleFamilyResidence)."""
149 + picked = [n for n in nodes
150 + if str(n.get("@type") or "").lower() in _LISTING_TYPES]
151 + if not picked:
152 + return None
153 + merged: dict = {}
154 + addr: dict = {}
155 + geo: dict = {}
156 + offer: dict = {}
157 + subtype = ""
158 + for n in picked:
159 + t = str(n.get("@type") or "").lower()
160 + if t not in ("product",) and not subtype:
161 + subtype = str(n.get("@type") or "")
162 + for k, v in n.items():
163 + if v in (None, "", [], {}):
164 + continue
165 + if k == "address" and isinstance(v, dict):
166 + addr = {**v, **addr}
167 + elif k == "geo" and isinstance(v, dict):
168 + geo = {**v, **geo}
169 + elif k == "offers":
170 + o = v[0] if isinstance(v, list) and v else v
171 + if isinstance(o, dict):
172 + offer = {**o, **offer}
173 + else:
174 + merged.setdefault(k, v)
175 + if isinstance(merged.get("address"), str):
176 + addr.setdefault("streetAddress", merged["address"])
177 +
178 + imgs = merged.get("image") or merged.get("photo") or []
179 + if isinstance(imgs, str):
180 + imgs = [imgs]
181 + imgs = [i.get("url") if isinstance(i, dict) else i for i in imgs]
182 +
183 + price = parse_price(_qv(offer.get("price")))
184 + availability = str(offer.get("availability") or "")
185 + status = "active"
186 + if "soldout" in availability.lower().replace("_", ""):
187 + status = "sold"
188 +
189 + street = str(addr.get("streetAddress") or "")
190 + city = str(addr.get("addressLocality") or "")
191 + state = str(addr.get("addressRegion") or "")
192 + zip_code = str(addr.get("postalCode") or "")
193 + if not street: # slug fallback: .../123-main-street-austin-tx-78701/
194 + m = _SLUG_ADDR_RE.search(url)
195 + if m:
196 + state = state or m.group(2).upper()
197 + zip_code = zip_code or m.group(3)
198 +
199 + beds = _qv(merged.get("numberOfBedrooms") or merged.get("numberOfRooms"))
200 + baths = _qv(merged.get("numberOfBathroomsTotal")
201 + or merged.get("numberOfFullBathrooms"))
202 + floor = _qv(merged.get("floorSize"))
203 +
204 + kw = dict(
205 + source=self.source_id, external_id=ext, url=url,
206 + title=str(merged.get("name") or ""),
207 + street_address=street, city=city, state=state, zip_code=zip_code,
208 + list_price=price, status=status,
209 + mls_id=ext if not ext.startswith(("http", "sha")) and len(ext) <= 20 else "",
210 + property_type=subtype,
211 + property_subtype=subtype,
212 + bedrooms=beds, bathrooms=baths,
213 + living_area_sqft=floor,
214 + year_built=_qv(merged.get("yearBuilt")),
215 + description=str(merged.get("description") or ""),
216 + images=[i for i in imgs if isinstance(i, str)],
217 + lat=_qv(geo.get("latitude")), lng=_qv(geo.get("longitude")),
218 + details={"jsonld_types": [str(n.get("@type")) for n in picked],
219 + "availability": availability},
220 + )
221 + for k, v in static.items():
222 + if not kw.get(k):
223 + kw[k] = v
224 + if not kw["street_address"] and not kw["title"]:
225 + return None
226 + return Listing(**kw)
added homeka/connectors/public_data/__init__.py +0 −0
added homeka/connectors/public_data/arcgis.py +148 −0
@@ -0,0 +1,148 @@
1 +# -----------------------------------------------------------------------------
2 +# Home-Ka — US real-estate aggregator (Groupe KA)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/public_data/arcgis.py : county assessor / GIS parcel connector.
5 +#
6 +# US counties publish parcel/assessor layers on ArcGIS FeatureServers (open
7 +# data portals) — the lowest-level public source there is. This connector
8 +# feeds the PROPERTIES table (physical assets: APN, address, year built,
9 +# areas, assessed values), NOT the listings table: it enriches property
10 +# matching and keeps properties alive after listings go off-market.
11 +#
12 +# {"id": "county_travis_tx", "connector_type": "arcgis", "config": {
13 +# "layer_url": "https://services.arcgis.com/.../FeatureServer/0",
14 +# "where": "1=1", "out_fields": "*", "page_size": 2000,
15 +# "max_records": 200000,
16 +# "mapping": {"apn": "PROP_ID", "street_address": "SITUS_ADDR",
17 +# "city": "SITUS_CITY", "zip_code": "SITUS_ZIP",
18 +# "year_built": "YEAR_BUILT", "living_area_sqft": "LIVING_AREA",
19 +# "assessed_value": "ASSESSED_VAL"},
20 +# "static": {"state": "TX", "county": "Travis"}}}
21 +# -----------------------------------------------------------------------------
22 +from __future__ import annotations
23 +
24 +import json
25 +import time
26 +
27 +from ..base import BaseConnector
28 +from .._maputil import dig
29 +from ...schema import Listing
30 +
31 +# properties-table columns a mapping can target; everything else goes to details
32 +_PROP_FIELDS = {"apn", "street_address", "unit", "city", "state", "zip_code",
33 + "county", "property_type", "year_built", "living_area_sqft",
34 + "lot_size_sqft", "lat", "lng"}
35 +
36 +
37 +class ArcGISParcelConnector(BaseConnector):
38 + family = "arcgis"
39 + request_delay = 0.4
40 + is_public_records = True # ingest.py routes fetch_records() → properties
41 +
42 + def fetch(self) -> list[Listing]:
43 + return [] # public records never create listings
44 +
45 + def fetch_records(self) -> list[dict]:
46 + """Yield property records {column: value, 'details': {...}}."""
47 + cfg = self.config
48 + layer = (cfg.get("layer_url") or "").rstrip("/")
49 + if not layer:
50 + raise RuntimeError(f"{self.source_id}: layer_url missing")
51 + mapping = cfg.get("mapping") or {}
52 + static = cfg.get("static") or {}
53 + page = int(cfg.get("page_size", 2000))
54 + max_records = int(cfg.get("max_records", 200_000))
55 + offset = 0
56 + out: list[dict] = []
57 + while offset < max_records:
58 + params = {
59 + "f": "json", "where": cfg.get("where", "1=1"),
60 + "outFields": cfg.get("out_fields", "*"),
61 + "resultOffset": offset, "resultRecordCount": page,
62 + "returnGeometry": "true", "outSR": 4326,
63 + }
64 + data = self.get(f"{layer}/query", params=params).json()
65 + feats = data.get("features") or []
66 + if not feats:
67 + break
68 + for f in feats:
69 + attrs = f.get("attributes") or {}
70 + rec: dict = {"details": {}}
71 + for field, path in mapping.items():
72 + v = dig(attrs, path)
73 + if v in (None, ""):
74 + continue
75 + if field in _PROP_FIELDS:
76 + rec[field] = v
77 + else:
78 + rec["details"][field] = v
79 + for field, v in static.items():
80 + rec.setdefault(field, v)
81 + geom = f.get("geometry") or {}
82 + if "lat" not in rec and geom.get("y") is not None:
83 + rec["lat"], rec["lng"] = geom.get("y"), geom.get("x")
84 + if rec.get("apn") or rec.get("street_address"):
85 + out.append(rec)
86 + if not data.get("exceededTransferLimit") and len(feats) < page:
87 + break
88 + offset += len(feats)
89 + return out
90 +
91 +
92 +def sync_records(con, source_id: str, records: list[dict]) -> dict:
93 + """Upsert public-records rows into `properties` (match APN, then address)."""
94 + from ... import propertymatch
95 + now = time.time()
96 + added = updated = 0
97 + for rec in records:
98 + state = str(rec.get("state") or "")
99 + apn = str(rec.get("apn") or "").replace(" ", "")
100 + row = None
101 + if apn and state:
102 + row = con.execute("SELECT id, details FROM properties"
103 + " WHERE apn=? AND state=?", (apn, state)).fetchone()
104 + key = propertymatch.addr_key(str(rec.get("street_address") or ""),
105 + str(rec.get("unit") or ""),
106 + str(rec.get("city") or ""), state,
107 + str(rec.get("zip_code") or ""))
108 + if row is None and key:
109 + row = con.execute("SELECT id, details FROM properties"
110 + " WHERE addr_key=?", (key,)).fetchone()
111 + details = rec.get("details") or {}
112 + if row is None:
113 + if not key and not apn:
114 + continue
115 + con.execute(
116 + """INSERT OR IGNORE INTO properties (addr_key, apn,
117 + street_address, unit, city, state, zip_code, county,
118 + property_type, year_built, living_area_sqft, lot_size_sqft,
119 + lat, lng, details, first_seen, last_seen)
120 + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
121 + (key, apn or None, rec.get("street_address"), rec.get("unit"),
122 + rec.get("city"), state, rec.get("zip_code"), rec.get("county"),
123 + rec.get("property_type"), rec.get("year_built"),
124 + rec.get("living_area_sqft"), rec.get("lot_size_sqft"),
125 + rec.get("lat"), rec.get("lng"),
126 + json.dumps(details, ensure_ascii=False, default=str), now, now))
127 + added += 1
128 + else:
129 + old = json.loads(row["details"] or "{}")
130 + old.update(details)
131 + con.execute(
132 + """UPDATE properties SET
133 + apn=COALESCE(NULLIF(apn,''), ?),
134 + year_built=COALESCE(year_built, ?),
135 + living_area_sqft=COALESCE(living_area_sqft, ?),
136 + lot_size_sqft=COALESCE(lot_size_sqft, ?),
137 + lat=COALESCE(lat, ?), lng=COALESCE(lng, ?),
138 + details=?, last_seen=? WHERE id=?""",
139 + (apn or None, rec.get("year_built"),
140 + rec.get("living_area_sqft"), rec.get("lot_size_sqft"),
141 + rec.get("lat"), rec.get("lng"),
142 + json.dumps(old, ensure_ascii=False, default=str), now,
143 + row["id"]))
144 + updated += 1
145 + con.commit()
146 + return {"source": source_id, "found": len(records),
147 + "added": added, "updated": updated, "removed": 0,
148 + "kind": "public-records"}
added homeka/connectors/reso/__init__.py +0 −0
added homeka/connectors/reso/webapi.py +158 −0
@@ -0,0 +1,158 @@
1 +# -----------------------------------------------------------------------------
2 +# Home-Ka — US real-estate aggregator (Groupe KA)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/reso/webapi.py : RESO Web API (OData) connector — the PREFERRED
5 +# feed type. Works with MLS Grid, Trestle (CoreLogic), Bridge Interactive,
6 +# Spark/FBS, RMLS direct RESO endpoints, and any Data Dictionary compliant
7 +# server. One `sources` row per feed:
8 +#
9 +# {"id": "mlsgrid_actris", "connector_type": "reso", "config": {
10 +# "base_url": "https://api.mlsgrid.com/v2",
11 +# "token": "$ENV:MLSGRID_TOKEN", # or OAuth2 below
12 +# "token_url": "", "client_id": "", "client_secret": "",
13 +# "resource": "Property",
14 +# "filter": "StandardStatus eq 'Active' and PropertyType eq 'Residential'",
15 +# "expand": "Media",
16 +# "page_size": 1000, "max_records": 50000,
17 +# "originating_system": "ACTRIS"}}
18 +#
19 +# Field mapping is RESO Data Dictionary — schema.Listing understands the
20 +# standard names natively (details passthrough), with the explicit mapping
21 +# below for the core columns.
22 +# -----------------------------------------------------------------------------
23 +from __future__ import annotations
24 +
25 +import time
26 +
27 +import requests
28 +
29 +from ..base import BaseConnector
30 +from ...schema import Listing
31 +
32 +
33 +class RESOWebAPIConnector(BaseConnector):
34 + family = "reso"
35 + request_delay = 0.3
36 +
37 + # -- auth -------------------------------------------------------------
38 + def _token(self) -> str:
39 + cfg = self.config
40 + if cfg.get("token"):
41 + return cfg["token"]
42 + if cfg.get("token_url"):
43 + resp = requests.post(cfg["token_url"], data={
44 + "grant_type": "client_credentials",
45 + "client_id": cfg.get("client_id", ""),
46 + "client_secret": cfg.get("client_secret", ""),
47 + "scope": cfg.get("scope", "api"),
48 + }, timeout=30)
49 + resp.raise_for_status()
50 + return resp.json()["access_token"]
51 + raise RuntimeError(f"{self.source_id}: no RESO token/credentials configured")
52 +
53 + # -- fetch ------------------------------------------------------------
54 + def fetch(self) -> list[Listing]:
55 + cfg = self.config
56 + base = (cfg.get("base_url") or "").rstrip("/")
57 + if not base:
58 + raise RuntimeError(f"{self.source_id}: base_url missing")
59 + resource = cfg.get("resource", "Property")
60 + page_size = int(cfg.get("page_size", 1000))
61 + max_records = int(cfg.get("max_records", 100_000))
62 + params = {"$top": page_size}
63 + if cfg.get("filter"):
64 + params["$filter"] = cfg["filter"]
65 + if cfg.get("select"):
66 + params["$select"] = cfg["select"]
67 + if cfg.get("expand"):
68 + params["$expand"] = cfg["expand"]
69 + self.session.headers["Authorization"] = f"Bearer {self._token()}"
70 + self.session.headers["Accept"] = "application/json"
71 +
72 + out: list[Listing] = []
73 + url: str | None = f"{base}/{resource}"
74 + first = True
75 + while url and len(out) < max_records:
76 + resp = self.get(url, params=params if first else None)
77 + first = False
78 + data = resp.json()
79 + for rec in data.get("value", []):
80 + lst = self._to_listing(rec)
81 + if lst is not None:
82 + out.append(lst)
83 + url = data.get("@odata.nextLink")
84 + return out
85 +
86 + def _to_listing(self, rec: dict) -> Listing | None:
87 + key = rec.get("ListingKey") or rec.get("ListingId")
88 + if not key:
89 + return None
90 + media = rec.get("Media") or []
91 + images = [m.get("MediaURL") for m in media
92 + if isinstance(m, dict) and m.get("MediaURL")
93 + and (m.get("MediaCategory") in (None, "Photo"))]
94 + images.sort(key=lambda u: u or "")
95 + if media and all(isinstance(m, dict) for m in media):
96 + ordered = sorted(media, key=lambda m: m.get("Order") or 0)
97 + images = [m.get("MediaURL") for m in ordered if m.get("MediaURL")]
98 + details = {k: v for k, v in rec.items()
99 + if isinstance(v, (str, int, float, bool))}
100 + street = rec.get("UnparsedAddress") or " ".join(
101 + str(rec.get(k) or "") for k in
102 + ("StreetNumber", "StreetDirPrefix", "StreetName", "StreetSuffix")
103 + ).strip()
104 + return Listing(
105 + source=self.source_id,
106 + external_id=str(key),
107 + url=self.config.get("listing_url_template", "").format(**rec)
108 + if self.config.get("listing_url_template") else "",
109 + street_address=street,
110 + unit=str(rec.get("UnitNumber") or ""),
111 + city=str(rec.get("City") or ""),
112 + state=str(rec.get("StateOrProvince") or ""),
113 + zip_code=str(rec.get("PostalCode") or ""),
114 + county=str(rec.get("CountyOrParish") or ""),
115 + property_type=str(rec.get("PropertySubType")
116 + or rec.get("PropertyType") or ""),
117 + property_subtype=str(rec.get("PropertySubType") or ""),
118 + list_price=rec.get("ListPrice"),
119 + bedrooms=rec.get("BedroomsTotal"),
120 + bathrooms_full=rec.get("BathroomsFull"),
121 + bathrooms_half=rec.get("BathroomsHalf"),
122 + bathrooms=rec.get("BathroomsTotalInteger"),
123 + living_area_sqft=rec.get("LivingArea"),
124 + lot_size_sqft=rec.get("LotSizeSquareFeet"),
125 + year_built=rec.get("YearBuilt"),
126 + apn=str(rec.get("ParcelNumber") or ""),
127 + mls_id=str(rec.get("ListingId") or ""),
128 + mls_name=str(rec.get("OriginatingSystemName")
129 + or self.config.get("originating_system") or ""),
130 + status=str(rec.get("StandardStatus") or "Active"),
131 + listed_at=str(rec.get("ListingContractDate") or ""),
132 + brokerage_name=str(rec.get("ListOfficeName") or ""),
133 + office_name=str(rec.get("ListOfficeName") or ""),
134 + agent_name=str(rec.get("ListAgentFullName") or ""),
135 + agent_phone=str(rec.get("ListAgentPreferredPhone")
136 + or rec.get("ListAgentDirectPhone") or ""),
137 + description=str(rec.get("PublicRemarks") or ""),
138 + details=details,
139 + images=images,
140 + lat=rec.get("Latitude"),
141 + lng=rec.get("Longitude"),
142 + )
143 +
144 +
145 +def probe(base_url: str, token: str = "") -> dict:
146 + """Quick health probe of a RESO endpoint ($metadata reachability) — used
147 + by the admin and the brokerage discovery pipeline."""
148 + headers = {"Accept": "application/json"}
149 + if token:
150 + headers["Authorization"] = f"Bearer {token}"
151 + t0 = time.time()
152 + try:
153 + resp = requests.get(base_url.rstrip("/") + "/$metadata",
154 + headers=headers, timeout=20)
155 + return {"ok": resp.status_code < 500, "status": resp.status_code,
156 + "seconds": round(time.time() - t0, 2)}
157 + except requests.RequestException as exc:
158 + return {"ok": False, "error": str(exc)}
added homeka/connectors/rets/__init__.py +0 −0
added homeka/connectors/rets/client.py +128 −0
@@ -0,0 +1,128 @@
1 +# -----------------------------------------------------------------------------
2 +# Home-Ka — US real-estate aggregator (Groupe KA)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/rets/client.py : legacy RETS connector (login + DMQL2 search,
5 +# COMPACT-DECODED). Many small MLSs still hand out RETS credentials faster
6 +# than RESO Web API access — this keeps those feeds first-class.
7 +#
8 +# {"id": "rets_example", "connector_type": "rets", "config": {
9 +# "login_url": "https://rets.example-mls.com/rets/login.ashx",
10 +# "username": "$ENV:RETS_EXAMPLE_USER", "password": "$ENV:RETS_EXAMPLE_PASS",
11 +# "search_url": "", # discovered from login when empty
12 +# "resource": "Property", "class": "Residential",
13 +# "query": "(Status=|A)", "select": "",
14 +# "limit": 5000, "mapping": { ... Listing field -> RETS system name ... },
15 +# "user_agent": "HomeKa/1.0"}}
16 +# -----------------------------------------------------------------------------
17 +from __future__ import annotations
18 +
19 +import re
20 +import xml.etree.ElementTree as ET
21 +
22 +import requests
23 +from requests.auth import HTTPBasicAuth, HTTPDigestAuth
24 +
25 +from ..base import BaseConnector
26 +from .._maputil import apply_mapping
27 +from ...schema import Listing
28 +
29 +_CAP_RE = re.compile(r"<RETS-RESPONSE>(.*?)</RETS-RESPONSE>", re.S | re.I)
30 +
31 +
32 +class RETSConnector(BaseConnector):
33 + family = "rets"
34 + request_delay = 1.0
35 +
36 + def _login(self) -> str:
37 + """RETS Login transaction — returns the Search capability URL."""
38 + cfg = self.config
39 + if cfg.get("user_agent"):
40 + self.session.headers["User-Agent"] = cfg["user_agent"]
41 + self.session.headers["RETS-Version"] = cfg.get("rets_version", "RETS/1.7.2")
42 + auth_cls = HTTPDigestAuth if cfg.get("auth", "digest") == "digest" else HTTPBasicAuth
43 + self.session.auth = auth_cls(cfg.get("username", ""), cfg.get("password", ""))
44 + resp = self.get(cfg["login_url"])
45 + if cfg.get("search_url"):
46 + return cfg["search_url"]
47 + m = _CAP_RE.search(resp.text)
48 + caps = {}
49 + if m:
50 + for line in m.group(1).splitlines():
51 + if "=" in line:
52 + k, _, v = line.partition("=")
53 + caps[k.strip().lower()] = v.strip()
54 + search = caps.get("search")
55 + if not search:
56 + raise RuntimeError(f"{self.source_id}: no Search capability in RETS login")
57 + if search.startswith("/"):
58 + from urllib.parse import urlsplit
59 + u = urlsplit(cfg["login_url"])
60 + search = f"{u.scheme}://{u.netloc}{search}"
61 + return search
62 +
63 + def fetch(self) -> list[Listing]:
64 + cfg = self.config
65 + search_url = self._login()
66 + params = {
67 + "SearchType": cfg.get("resource", "Property"),
68 + "Class": cfg.get("class", "Residential"),
69 + "Query": cfg.get("query", "(Status=|A)"),
70 + "QueryType": "DMQL2",
71 + "Format": "COMPACT-DECODED",
72 + "Limit": str(cfg.get("limit", 5000)),
73 + "StandardNames": cfg.get("standard_names", "0"),
74 + }
75 + if cfg.get("select"):
76 + params["Select"] = cfg["select"]
77 + resp = self.get(search_url, params=params)
78 + return self._parse_compact(resp.text)
79 +
80 + def _parse_compact(self, body: str) -> list[Listing]:
81 + """Parse a COMPACT-DECODED RETS payload into Listings via the mapping."""
82 + try:
83 + root = ET.fromstring(body)
84 + except ET.ParseError as exc:
85 + raise RuntimeError(f"{self.source_id}: invalid RETS XML: {exc}")
86 + delim = "\t"
87 + d = root.find("DELIMITER")
88 + if d is not None and d.get("value"):
89 + delim = chr(int(d.get("value")))
90 + cols_el = root.find("COLUMNS")
91 + if cols_el is None or not cols_el.text:
92 + return []
93 + cols = cols_el.text.strip(delim).split(delim)
94 + mapping = self.config.get("mapping") or _DEFAULT_RETS_MAPPING
95 + static = self.config.get("static") or {}
96 + out: list[Listing] = []
97 + for row in root.findall("DATA"):
98 + vals = (row.text or "").strip("\n").strip(delim).split(delim)
99 + rec = {c: v for c, v in zip(cols, vals)}
100 + lst = apply_mapping(self.source_id, rec, mapping, static)
101 + if lst is not None:
102 + out.append(lst)
103 + return out
104 +
105 +
106 +# RETS StandardNames (StandardNames=1) default mapping; system-name feeds
107 +# override it entirely in the source config.
108 +_DEFAULT_RETS_MAPPING = {
109 + "external_id": "ListingID",
110 + "mls_id": "ListingID",
111 + "list_price": "ListPrice",
112 + "street_address": "StreetAddress",
113 + "city": "City",
114 + "state": "StateOrProvince",
115 + "zip_code": "PostalCode",
116 + "county": "County",
117 + "property_type": "PropertyType",
118 + "bedrooms": "Beds",
119 + "bathrooms": "Baths",
120 + "living_area_sqft": "LivingArea",
121 + "year_built": "YearBuilt",
122 + "status": "Status",
123 + "agent_name": "ListAgentName",
124 + "brokerage_name": "ListOfficeName",
125 + "description": "PublicRemarks",
126 + "lat": "Latitude",
127 + "lng": "Longitude",
128 +}
added homeka/connectors/sftp/__init__.py +0 −0
added homeka/connectors/sftp/drop.py +88 −0
@@ -0,0 +1,88 @@
1 +# -----------------------------------------------------------------------------
2 +# Home-Ka — US real-estate aggregator (Groupe KA)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/sftp/drop.py : SFTP drop connector — brokerages/MLS vendors that
5 +# push periodic CSV/XML exports to an SFTP folder. Downloads the newest
6 +# matching file then dispatches to the csv/xml parser.
7 +#
8 +# {"id": "acme_sftp", "connector_type": "sftp", "config": {
9 +# "host": "sftp.acmefeeds.com", "port": 22,
10 +# "username": "$ENV:ACME_SFTP_USER", "password": "$ENV:ACME_SFTP_PASS",
11 +# "key_path": "", # or private key file
12 +# "remote_dir": "/exports", "pattern": "listings_*.csv",
13 +# "format": "csv", # csv | xml
14 +# ... then the csv/xml family options (mapping, static, ...)}}
15 +#
16 +# Requires `paramiko` (in requirements.txt).
17 +# -----------------------------------------------------------------------------
18 +from __future__ import annotations
19 +
20 +import fnmatch
21 +import io
22 +
23 +from ..base import BaseConnector
24 +from ...schema import Listing
25 +
26 +
27 +class SFTPDropConnector(BaseConnector):
28 + family = "sftp"
29 +
30 + def _download(self) -> bytes:
31 + try:
32 + import paramiko
33 + except ImportError:
34 + raise RuntimeError("paramiko missing — pip install paramiko")
35 + cfg = self.config
36 + client = paramiko.SSHClient()
37 + client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
38 + kw: dict = {"username": cfg.get("username", "")}
39 + if cfg.get("key_path"):
40 + kw["key_filename"] = cfg["key_path"]
41 + else:
42 + kw["password"] = cfg.get("password", "")
43 + client.connect(cfg["host"], port=int(cfg.get("port", 22)),
44 + timeout=30, **kw)
45 + try:
46 + sftp = client.open_sftp()
47 + remote_dir = cfg.get("remote_dir", ".")
48 + pattern = cfg.get("pattern", "*")
49 + names = [n for n in sftp.listdir(remote_dir)
50 + if fnmatch.fnmatch(n, pattern)]
51 + if not names:
52 + raise RuntimeError(f"{self.source_id}: no file matching "
53 + f"{pattern} in {remote_dir}")
54 + # newest file by mtime
55 + newest = max(names, key=lambda n: sftp.stat(f"{remote_dir}/{n}").st_mtime)
56 + buf = io.BytesIO()
57 + sftp.getfo(f"{remote_dir}/{newest}", buf)
58 + return buf.getvalue()
59 + finally:
60 + client.close()
61 +
62 + def fetch(self) -> list[Listing]:
63 + data = self._download()
64 + fmt = self.config.get("format", "csv")
65 + if fmt == "csv":
66 + from ..csv.feed import parse_csv
67 + return parse_csv(self.source_id,
68 + data.decode(self.config.get("encoding", "utf-8-sig"),
69 + errors="replace"),
70 + self.config)
71 + if fmt == "xml":
72 + from ..xml.feed import XMLFeedConnector
73 + conn = XMLFeedConnector(self.source_id,
74 + {**self.config, "path": None})
75 + import xml.etree.ElementTree as ET
76 + root = ET.fromstring(data)
77 + item_tag = self.config.get("item_tag", "listing")
78 + mapping = self.config.get("mapping") or {}
79 + static = self.config.get("static") or {}
80 + out = []
81 + for el in root.iter():
82 + if el.tag.rsplit("}", 1)[-1] != item_tag:
83 + continue
84 + lst = conn._to_listing(el, mapping, static)
85 + if lst is not None:
86 + out.append(lst)
87 + return out
88 + raise RuntimeError(f"{self.source_id}: unknown sftp format {fmt!r}")
added homeka/connectors/xml/__init__.py +0 −0
added homeka/connectors/xml/feed.py +74 −0
@@ -0,0 +1,96 @@
1 +# -----------------------------------------------------------------------------
2 +# Home-Ka — US real-estate aggregator (Groupe KA)
3 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/xml/feed.py : generic XML feed connector (brokerage exports,
5 +# franchise syndication feeds, legacy XML drops). Config-driven:
6 +#
7 +# {"id": "acme_xml", "connector_type": "xml", "config": {
8 +# "url": "https://feeds.acmerealty.com/listings.xml",
9 +# "item_tag": "listing", # repeated element
10 +# "mapping": {"external_id": "mlsNumber", "list_price": "price",
11 +# "street_address": "address/street", "city": "address/city",
12 +# "state": "address/state", "zip_code": "address/zip",
13 +# "images": "photos/photo", ...}, # ElementTree paths
14 +# "static": {"state": "TX"},
15 +# "headers": {}, "auth_user": "", "auth_pass": ""}}
16 +# -----------------------------------------------------------------------------
17 +from __future__ import annotations
18 +
19 +import xml.etree.ElementTree as ET
20 +
21 +from ..base import BaseConnector
22 +from ...schema import Listing
23 +
24 +_LIST_FIELDS = {"images", "features"}
25 +
26 +
27 +def _strip_ns(tag: str) -> str:
28 + return tag.rsplit("}", 1)[-1]
29 +
30 +
31 +class XMLFeedConnector(BaseConnector):
32 + family = "xml"
33 + request_delay = 0.2
34 +
35 + def fetch(self) -> list[Listing]:
36 + cfg = self.config
37 + kw = {}
38 + if cfg.get("headers"):
39 + kw["headers"] = cfg["headers"]
40 + if cfg.get("auth_user"):
41 + kw["auth"] = (cfg["auth_user"], cfg.get("auth_pass", ""))
42 + if cfg.get("path"):
43 + body = open(cfg["path"], "rb").read()
44 + else:
45 + body = self.get(cfg["url"], **kw).content
46 + root = ET.fromstring(body)
47 + item_tag = cfg.get("item_tag", "listing")
48 + mapping = cfg.get("mapping") or {}
49 + static = cfg.get("static") or {}
50 + out: list[Listing] = []
51 + for el in root.iter():
52 + if _strip_ns(el.tag) != item_tag:
53 + continue
54 + lst = self._to_listing(el, mapping, static)
55 + if lst is not None:
56 + out.append(lst)
57 + return out
58 +
59 + def _find(self, el: ET.Element, path: str):
60 + """Namespace-tolerant lookup ('address/city' or '@attr' or 'tag@attr')."""
61 + if path.startswith("@"):
62 + return [el.get(path[1:])]
63 + attr = None
64 + if "@" in path:
65 + path, _, attr = path.partition("@")
66 + parts = path.split("/")
67 + nodes = [el]
68 + for p in parts:
69 + nxt = []
70 + for n in nodes:
71 + nxt.extend(c for c in n if _strip_ns(c.tag) == p)
72 + nodes = nxt
73 + if attr:
74 + return [n.get(attr) for n in nodes]

Diff truncated — file too large.