SPB Git

Repository Public

Commit

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 · Aug 28, 2026
60 changed files +20,001 −0 Browse files
60 changed files
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.

Menu

Theme