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>
60 changed files +20,001 −0
added
.env.example
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +# Home-Ka — credentials (copy to .env; NEVER commit .env) | |
| 2 | +# Admin API token (optional — /api/admin/* is open when unset) | |
| 3 | +HOMEKA_ADMIN_TOKEN= | |
| 4 | + | |
| 5 | +# Anti-bot fallback chain (optional, LAST RESORT — see connectors/_resilient.py) | |
| 6 | +SCRAPFLY_KEY= | |
| 7 | +FIRECRAWL_API_KEY= | |
| 8 | +OXYLABS_PROXY= | |
| 9 | +OXYLABS_PROXY_USER= | |
| 10 | +OXYLABS_PROXY_PASS= | |
| 11 | +BRIGHTDATA_API_KEY= | |
| 12 | +BRIGHTDATA_ZONE= | |
| 13 | + | |
| 14 | +# RESO / RETS feed credentials, referenced from source configs as $ENV:NAME | |
| 15 | +# MLSGRID_TOKEN= | |
| 16 | +# TRESTLE_CLIENT_ID= | |
| 17 | +# TRESTLE_CLIENT_SECRET= | |
added
.gitignore
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +__pycache__/ | |
| 2 | +*.pyc | |
| 3 | +.venv/ | |
| 4 | +node_modules/ | |
| 5 | +frontend/dist/ | |
| 6 | +frontend/*.tsbuildinfo | |
| 7 | +data/homeka.db* | |
| 8 | +.env | |
| 9 | +.DS_Store | |
added
README.md
+78 −0
@@ -0,0 +1,78 @@ | ||
| 1 | +<h1 align="center">Home·Ka</h1> | |
| 2 | + | |
| 3 | +<p align="center"><b>US real-estate aggregator — every home for sale, from direct feeds first.</b><br> | |
| 4 | +<a href="https://www.home-ka.com">www.home-ka.com</a> · Groupe KA · US counterpart of <a href="https://www.immo-ka.com">Immo·Ka</a> (Canada)</p> | |
| 5 | + | |
| 6 | +**Home-Ka** reuses the Immo-Ka architecture end to end — connectors, normalization, ingestion, property matching, deduplication, quality gate, search API, Ka Maps frontend — adapted to the US market: **RESO Data Dictionary** as the schema reference, USD/sq ft/USPS states, US Census geocoder, and a **brokerage partnership pipeline** designed to convert sources into direct feeds instead of crawling. | |
| 7 | + | |
| 8 | +## Pipeline (same philosophy as Immo-Ka) | |
| 9 | + | |
| 10 | +``` | |
| 11 | +SOURCE → connector → raw ingestion → normalization → property matching | |
| 12 | + → deduplication → canonical listing → search index → Home-Ka | |
| 13 | +``` | |
| 14 | + | |
| 15 | +- **PROPERTY ≠ LISTING** — a `Listing` is one publication by one source; a `Property` (`properties` table, keyed by APN or normalized address) is the physical asset and keeps existing after the listing goes off-market. Every listing is matched on ingest (`homeka/propertymatch.py`). | |
| 16 | +- **Change detection** — content hash per listing, price history (`price_log`), grace period (2 syncs) before deactivation, drift detection (volume/price collapse suspends removals). | |
| 17 | +- **Dedup** — precomputed (`dup_hidden`/`dup_of`): same MLS number across sources, or same property at ±1% price; most authoritative source wins (`sources.authority`: direct feed < partner API < site < classifieds). | |
| 18 | +- **Quality gate** — completeness score, quarantine below threshold (`published=0`). | |
| 19 | +- **Geocoding** — US Census batch geocoder (free, 5k/req) + budgeted Nominatim fallback, persistent cache. | |
| 20 | + | |
| 21 | +## Connector families (`homeka/connectors/`) | |
| 22 | + | |
| 23 | +| Family | Module | Use | | |
| 24 | +|---|---|---| | |
| 25 | +| `reso` | `reso/webapi.py` | **RESO Web API (OData)** — MLS Grid, Trestle, Bridge, Spark… the preferred feed | | |
| 26 | +| `rets` | `rets/client.py` | legacy RETS (login + DMQL2, COMPACT-DECODED) | | |
| 27 | +| `xml` | `xml/feed.py` | XML exports / syndication feeds | | |
| 28 | +| `json` | `json/feed.py` | JSON partner APIs, wp-json, internal search APIs | | |
| 29 | +| `jsonld` | `json/jsonld_site.py` | schema.org JSON-LD site crawler (bootstrap only) | | |
| 30 | +| `csv` | `csv/feed.py` | CSV exports | | |
| 31 | +| `sftp` | `sftp/drop.py` | SFTP drops (csv/xml), newest file by mtime | | |
| 32 | +| `arcgis` | `public_data/arcgis.py` | county assessor/GIS parcels → feeds **properties**, not listings | | |
| 33 | +| custom | `custom_broker/*.py` | one class per site when a family can't express it | | |
| 34 | + | |
| 35 | +A new source of a known family = **one row in the `sources` table** (`config` JSON: URL, field mapping, `$ENV:NAME` credentials), zero code. Custom connectors auto-register via `source_id`, exactly like Immo-Ka. Anti-bot policy: direct requests first; auto-escalation chain (Oxylabs → Scrapfly ASP → Bright Data, `connectors/_resilient.py`) only when blocked. | |
| 36 | + | |
| 37 | +## Brokerage pipeline (`/admin/brokerages`) | |
| 38 | + | |
| 39 | +``` | |
| 40 | +discover brokerage → inspect website → detect IDX/feed/provider | |
| 41 | +→ find public contacts → classify connector → score → admin | |
| 42 | +``` | |
| 43 | + | |
| 44 | +- Seed: **630 real US brokerages** (`data/brokerages_seed.json`, `python run.py seed`). | |
| 45 | +- `homeka/discovery.py` inspects each website (direct GET): IDX platform fingerprints (kvCORE, iHomefinder, IDX Broker, Sierra, REW, Delta Media, Ylopo…), RESO/RETS hints, MLS affiliations, contact/partnership emails; then scores `feed_probability_score` and `priority_score` (size × feed probability × coverage). | |
| 46 | +- Partnership statuses: prospect → to-contact → contacted → in-discussion → feed-received → **live** (linked to a `sources` row). | |
| 47 | + | |
| 48 | +## Run | |
| 49 | + | |
| 50 | +```bash | |
| 51 | +python run.py serve 8099 # API + frontend | |
| 52 | +python run.py sync [source ...] # one-shot ingestion | |
| 53 | +python run.py watch 60 # sync loop + geocode + img audit + discovery | |
| 54 | +python run.py seed # load brokerage seed | |
| 55 | +python run.py discover 25 # inspect next prospects | |
| 56 | +python run.py list | geocode | quality | dedup | rescore | |
| 57 | +``` | |
| 58 | + | |
| 59 | +Admin: `/admin` (overview), `/admin/sources`, `/admin/connectors`, `/admin/brokerages` — optional `HOMEKA_ADMIN_TOKEN` (.env) required as `X-Admin-Token`. | |
| 60 | + | |
| 61 | +## Deployment | |
| 62 | + | |
| 63 | +- Node **M4M36** (`~/apps/home-ka`), port **8099**, PM2 `home-ka-web` + `home-ka-sync` + `home-ka-ngrok` (`--url=www.home-ka.com`). | |
| 64 | +- Frontend: Vite build (`frontend/`, ka-maps vendored in `vendor/ka-maps`). | |
| 65 | +- Git: `origin` = spbgit (`gitsrv:srv/git/home-ka.git`). | |
| 66 | + | |
| 67 | +## Adding a RESO feed (example) | |
| 68 | + | |
| 69 | +```python | |
| 70 | +from homeka import db | |
| 71 | +con = db.connect() | |
| 72 | +db.upsert_source(con, "mlsgrid_actris", "MLS Grid — ACTRIS", "reso", config={ | |
| 73 | + "base_url": "https://api.mlsgrid.com/v2", | |
| 74 | + "token": "$ENV:MLSGRID_TOKEN", | |
| 75 | + "filter": "StandardStatus eq 'Active'", | |
| 76 | + "expand": "Media", "originating_system": "ACTRIS", | |
| 77 | +}, authority=0, states=["TX"]) | |
| 78 | +``` | |
added
data/brokerages_seed.json
+10059 −0
@@ -0,0 +1,10059 @@ | ||
| 1 | +{ | |
| 2 | + "updated": "2026-08-28", | |
| 3 | + "count": 630, | |
| 4 | + "brokerages": [ | |
| 5 | + { | |
| 6 | + "name": "Compass", | |
| 7 | + "website": "www.compass.com", | |
| 8 | + "states": [ | |
| 9 | + "NY", | |
| 10 | + "CA", | |
| 11 | + "FL", | |
| 12 | + "TX", | |
| 13 | + "MA", | |
| 14 | + "DC", | |
| 15 | + "WA", | |
| 16 | + "IL", | |
| 17 | + "CO", | |
| 18 | + "NJ", | |
| 19 | + "CT", | |
| 20 | + "PA", | |
| 21 | + "GA", | |
| 22 | + "AZ", | |
| 23 | + "NV", | |
| 24 | + "TN", | |
| 25 | + "NC", | |
| 26 | + "MD", | |
| 27 | + "VA", | |
| 28 | + "HI" | |
| 29 | + ], | |
| 30 | + "cities": [ | |
| 31 | + "New York", | |
| 32 | + "Los Angeles", | |
| 33 | + "Miami", | |
| 34 | + "San Francisco", | |
| 35 | + "Washington" | |
| 36 | + ], | |
| 37 | + "estimated_agents": 28000, | |
| 38 | + "estimated_listings": 45000, | |
| 39 | + "mls_affiliations": [] | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + "name": "eXp Realty", | |
| 43 | + "website": "www.exprealty.com", | |
| 44 | + "states": [], | |
| 45 | + "cities": [], | |
| 46 | + "estimated_agents": 82000, | |
| 47 | + "estimated_listings": 120000, | |
| 48 | + "mls_affiliations": [] | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + "name": "Redfin", | |
| 52 | + "website": "www.redfin.com", | |
| 53 | + "states": [ | |
| 54 | + "WA", | |
| 55 | + "CA", | |
| 56 | + "TX", | |
| 57 | + "FL", | |
| 58 | + "IL", | |
| 59 | + "CO", | |
| 60 | + "GA", | |
| 61 | + "AZ", | |
| 62 | + "MD", | |
| 63 | + "VA", | |
| 64 | + "OR", | |
| 65 | + "NC" | |
| 66 | + ], | |
| 67 | + "cities": [ | |
| 68 | + "Seattle" | |
| 69 | + ], | |
| 70 | + "estimated_agents": 1900, | |
| 71 | + "estimated_listings": 8000, | |
| 72 | + "mls_affiliations": [] | |
| 73 | + }, | |
| 74 | + { | |
| 75 | + "name": "HomeSmart", | |
| 76 | + "website": "www.homesmart.com", | |
| 77 | + "states": [ | |
| 78 | + "AZ", | |
| 79 | + "CA", | |
| 80 | + "CO", | |
| 81 | + "FL", | |
| 82 | + "TX", | |
| 83 | + "NV", | |
| 84 | + "GA", | |
| 85 | + "WA", | |
| 86 | + "NM", | |
| 87 | + "UT" | |
| 88 | + ], | |
| 89 | + "cities": [ | |
| 90 | + "Phoenix", | |
| 91 | + "Scottsdale" | |
| 92 | + ], | |
| 93 | + "estimated_agents": 25000, | |
| 94 | + "estimated_listings": 30000, | |
| 95 | + "mls_affiliations": [ | |
| 96 | + "ARMLS" | |
| 97 | + ] | |
| 98 | + }, | |
| 99 | + { | |
| 100 | + "name": "Realty ONE Group", | |
| 101 | + "website": "www.realtyonegroup.com", | |
| 102 | + "states": [ | |
| 103 | + "NV", | |
| 104 | + "AZ", | |
| 105 | + "CA", | |
| 106 | + "FL", | |
| 107 | + "TX", | |
| 108 | + "CO", | |
| 109 | + "WA", | |
| 110 | + "NC", | |
| 111 | + "TN" | |
| 112 | + ], | |
| 113 | + "cities": [ | |
| 114 | + "Las Vegas", | |
| 115 | + "Irvine" | |
| 116 | + ], | |
| 117 | + "estimated_agents": 19000, | |
| 118 | + "estimated_listings": 26000, | |
| 119 | + "mls_affiliations": [] | |
| 120 | + }, | |
| 121 | + { | |
| 122 | + "name": "Fathom Realty", | |
| 123 | + "website": "www.fathomrealty.com", | |
| 124 | + "states": [ | |
| 125 | + "TX", | |
| 126 | + "NC", | |
| 127 | + "VA", | |
| 128 | + "GA", | |
| 129 | + "FL", | |
| 130 | + "AZ", | |
| 131 | + "CO", | |
| 132 | + "WA", | |
| 133 | + "TN", | |
| 134 | + "MO" | |
| 135 | + ], | |
| 136 | + "cities": [], | |
| 137 | + "estimated_agents": 11000, | |
| 138 | + "estimated_listings": 15000, | |
| 139 | + "mls_affiliations": [] | |
| 140 | + }, | |
| 141 | + { | |
| 142 | + "name": "United Real Estate", | |
| 143 | + "website": "www.unitedrealestate.com", | |
| 144 | + "states": [ | |
| 145 | + "TX", | |
| 146 | + "KS", | |
| 147 | + "MO", | |
| 148 | + "IL", | |
| 149 | + "PA", | |
| 150 | + "NC", | |
| 151 | + "FL", | |
| 152 | + "GA", | |
| 153 | + "OH", | |
| 154 | + "NJ" | |
| 155 | + ], | |
| 156 | + "cities": [ | |
| 157 | + "Dallas", | |
| 158 | + "Kansas City", | |
| 159 | + "Chicago", | |
| 160 | + "Philadelphia" | |
| 161 | + ], | |
| 162 | + "estimated_agents": 21000, | |
| 163 | + "estimated_listings": 24000, | |
| 164 | + "mls_affiliations": [] | |
| 165 | + }, | |
| 166 | + { | |
| 167 | + "name": "The Real Brokerage", | |
| 168 | + "website": "www.onereal.com", | |
| 169 | + "states": [], | |
| 170 | + "cities": [], | |
| 171 | + "estimated_agents": 24000, | |
| 172 | + "estimated_listings": 30000, | |
| 173 | + "mls_affiliations": [] | |
| 174 | + }, | |
| 175 | + { | |
| 176 | + "name": "Weichert Realtors", | |
| 177 | + "website": "www.weichert.com", | |
| 178 | + "states": [ | |
| 179 | + "NJ", | |
| 180 | + "NY", | |
| 181 | + "PA", | |
| 182 | + "CT", | |
| 183 | + "MD", | |
| 184 | + "VA", | |
| 185 | + "DC", | |
| 186 | + "DE", | |
| 187 | + "FL", | |
| 188 | + "SC", | |
| 189 | + "NC", | |
| 190 | + "GA", | |
| 191 | + "TX" | |
| 192 | + ], | |
| 193 | + "cities": [ | |
| 194 | + "Morris Plains" | |
| 195 | + ], | |
| 196 | + "estimated_agents": 10000, | |
| 197 | + "estimated_listings": 15000, | |
| 198 | + "mls_affiliations": [] | |
| 199 | + }, | |
| 200 | + { | |
| 201 | + "name": "Douglas Elliman", | |
| 202 | + "website": "www.elliman.com", | |
| 203 | + "states": [ | |
| 204 | + "NY", | |
| 205 | + "FL", | |
| 206 | + "CA", | |
| 207 | + "CT", | |
| 208 | + "NJ", | |
| 209 | + "MA", | |
| 210 | + "CO", | |
| 211 | + "TX", | |
| 212 | + "NV" | |
| 213 | + ], | |
| 214 | + "cities": [ | |
| 215 | + "New York", | |
| 216 | + "Miami", | |
| 217 | + "Los Angeles" | |
| 218 | + ], | |
| 219 | + "estimated_agents": 6600, | |
| 220 | + "estimated_listings": 12000, | |
| 221 | + "mls_affiliations": [ | |
| 222 | + "OneKey", | |
| 223 | + "MiamiMLS" | |
| 224 | + ] | |
| 225 | + }, | |
| 226 | + { | |
| 227 | + "name": "Corcoran", | |
| 228 | + "website": "www.corcoran.com", | |
| 229 | + "states": [ | |
| 230 | + "NY", | |
| 231 | + "FL", | |
| 232 | + "CA", | |
| 233 | + "MA", | |
| 234 | + "NJ", | |
| 235 | + "CT", | |
| 236 | + "SC", | |
| 237 | + "GA" | |
| 238 | + ], | |
| 239 | + "cities": [ | |
| 240 | + "New York", | |
| 241 | + "Palm Beach" | |
| 242 | + ], | |
| 243 | + "estimated_agents": 5100, | |
| 244 | + "estimated_listings": 9000, | |
| 245 | + "mls_affiliations": [ | |
| 246 | + "OneKey" | |
| 247 | + ] | |
| 248 | + }, | |
| 249 | + { | |
| 250 | + "name": "Coldwell Banker Realty", | |
| 251 | + "website": "www.coldwellbankerhomes.com", | |
| 252 | + "states": [ | |
| 253 | + "CA", | |
| 254 | + "FL", | |
| 255 | + "TX", | |
| 256 | + "IL", | |
| 257 | + "MN", | |
| 258 | + "MA", | |
| 259 | + "CT", | |
| 260 | + "NJ", | |
| 261 | + "NY", | |
| 262 | + "MD", | |
| 263 | + "VA", | |
| 264 | + "GA", | |
| 265 | + "CO", | |
| 266 | + "AZ", | |
| 267 | + "WA", | |
| 268 | + "OH", | |
| 269 | + "MI", | |
| 270 | + "WI", | |
| 271 | + "PA", | |
| 272 | + "NC", | |
| 273 | + "SC", | |
| 274 | + "UT", | |
| 275 | + "NV" | |
| 276 | + ], | |
| 277 | + "cities": [], | |
| 278 | + "estimated_agents": 52000, | |
| 279 | + "estimated_listings": 90000, | |
| 280 | + "mls_affiliations": [] | |
| 281 | + }, | |
| 282 | + { | |
| 283 | + "name": "Berkshire Hathaway HomeServices (network)", | |
| 284 | + "website": "www.bhhs.com", | |
| 285 | + "states": [], | |
| 286 | + "cities": [], | |
| 287 | + "estimated_agents": 50000, | |
| 288 | + "estimated_listings": 80000, | |
| 289 | + "mls_affiliations": [] | |
| 290 | + }, | |
| 291 | + { | |
| 292 | + "name": "Sotheby's International Realty", | |
| 293 | + "website": "www.sothebysrealty.com", | |
| 294 | + "states": [ | |
| 295 | + "NY", | |
| 296 | + "CA", | |
| 297 | + "FL", | |
| 298 | + "CO", | |
| 299 | + "TX", | |
| 300 | + "MA", | |
| 301 | + "AZ", | |
| 302 | + "NV", | |
| 303 | + "HI", | |
| 304 | + "WA", | |
| 305 | + "CT", | |
| 306 | + "NJ" | |
| 307 | + ], | |
| 308 | + "cities": [], | |
| 309 | + "estimated_agents": 26000, | |
| 310 | + "estimated_listings": 40000, | |
| 311 | + "mls_affiliations": [] | |
| 312 | + }, | |
| 313 | + { | |
| 314 | + "name": "Christie's International Real Estate", | |
| 315 | + "website": "www.christiesrealestate.com", | |
| 316 | + "states": [ | |
| 317 | + "NY", | |
| 318 | + "CA", | |
| 319 | + "FL", | |
| 320 | + "IL", | |
| 321 | + "TX", | |
| 322 | + "NJ" | |
| 323 | + ], | |
| 324 | + "cities": [], | |
| 325 | + "estimated_agents": 10000, | |
| 326 | + "estimated_listings": 15000, | |
| 327 | + "mls_affiliations": [] | |
| 328 | + }, | |
| 329 | + { | |
| 330 | + "name": "Engel & Völkers Americas", | |
| 331 | + "website": "www.evrealestate.com", | |
| 332 | + "states": [ | |
| 333 | + "FL", | |
| 334 | + "NY", | |
| 335 | + "CA", | |
| 336 | + "TX", | |
| 337 | + "CO", | |
| 338 | + "GA", | |
| 339 | + "MN", | |
| 340 | + "AZ", | |
| 341 | + "SC", | |
| 342 | + "NV" | |
| 343 | + ], | |
| 344 | + "cities": [], | |
| 345 | + "estimated_agents": 6500, | |
| 346 | + "estimated_listings": 10000, | |
| 347 | + "mls_affiliations": [] | |
| 348 | + }, | |
| 349 | + { | |
| 350 | + "name": "Keller Williams Realty International", | |
| 351 | + "website": "www.kw.com", | |
| 352 | + "states": [], | |
| 353 | + "cities": [ | |
| 354 | + "Austin" | |
| 355 | + ], | |
| 356 | + "estimated_agents": 160000, | |
| 357 | + "estimated_listings": 200000, | |
| 358 | + "mls_affiliations": [] | |
| 359 | + }, | |
| 360 | + { | |
| 361 | + "name": "RE/MAX", | |
| 362 | + "website": "www.remax.com", | |
| 363 | + "states": [], | |
| 364 | + "cities": [ | |
| 365 | + "Denver" | |
| 366 | + ], | |
| 367 | + "estimated_agents": 60000, | |
| 368 | + "estimated_listings": 110000, | |
| 369 | + "mls_affiliations": [] | |
| 370 | + }, | |
| 371 | + { | |
| 372 | + "name": "Century 21 Real Estate", | |
| 373 | + "website": "www.century21.com", | |
| 374 | + "states": [], | |
| 375 | + "cities": [], | |
| 376 | + "estimated_agents": 55000, | |
| 377 | + "estimated_listings": 90000, | |
| 378 | + "mls_affiliations": [] | |
| 379 | + }, | |
| 380 | + { | |
| 381 | + "name": "ERA Real Estate", | |
| 382 | + "website": "www.era.com", | |
| 383 | + "states": [], | |
| 384 | + "cities": [], | |
| 385 | + "estimated_agents": 15000, | |
| 386 | + "estimated_listings": 25000, | |
| 387 | + "mls_affiliations": [] | |
| 388 | + }, | |
| 389 | + { | |
| 390 | + "name": "Better Homes and Gardens Real Estate", | |
| 391 | + "website": "www.bhgre.com", | |
| 392 | + "states": [], | |
| 393 | + "cities": [], | |
| 394 | + "estimated_agents": 13000, | |
| 395 | + "estimated_listings": 20000, | |
| 396 | + "mls_affiliations": [] | |
| 397 | + }, | |
| 398 | + { | |
| 399 | + "name": "EXIT Realty Corp International", | |
| 400 | + "website": "www.exitrealty.com", | |
| 401 | + "states": [], | |
| 402 | + "cities": [], | |
| 403 | + "estimated_agents": 14000, | |
| 404 | + "estimated_listings": 20000, | |
| 405 | + "mls_affiliations": [] | |
| 406 | + }, | |
| 407 | + { | |
| 408 | + "name": "NextHome", | |
| 409 | + "website": "www.nexthome.com", | |
| 410 | + "states": [], | |
| 411 | + "cities": [], | |
| 412 | + "estimated_agents": 5600, | |
| 413 | + "estimated_listings": 9000, | |
| 414 | + "mls_affiliations": [] | |
| 415 | + }, | |
| 416 | + { | |
| 417 | + "name": "HomeServices of America", | |
| 418 | + "website": "www.homeservices.com", | |
| 419 | + "states": [ | |
| 420 | + "MN", | |
| 421 | + "CA", | |
| 422 | + "TX", | |
| 423 | + "GA", | |
| 424 | + "NC", | |
| 425 | + "VA", | |
| 426 | + "PA", | |
| 427 | + "NJ", | |
| 428 | + "DE", | |
| 429 | + "IA", | |
| 430 | + "NE", | |
| 431 | + "KS", | |
| 432 | + "MO", | |
| 433 | + "AZ", | |
| 434 | + "OR", | |
| 435 | + "WA" | |
| 436 | + ], | |
| 437 | + "cities": [ | |
| 438 | + "Minneapolis" | |
| 439 | + ], | |
| 440 | + "estimated_agents": 40000, | |
| 441 | + "estimated_listings": 70000, | |
| 442 | + "mls_affiliations": [] | |
| 443 | + }, | |
| 444 | + { | |
| 445 | + "name": "Howard Hanna Real Estate Services", | |
| 446 | + "website": "www.howardhanna.com", | |
| 447 | + "states": [ | |
| 448 | + "PA", | |
| 449 | + "OH", | |
| 450 | + "NY", | |
| 451 | + "VA", | |
| 452 | + "NC", | |
| 453 | + "MI", | |
| 454 | + "WV", | |
| 455 | + "MD" | |
| 456 | + ], | |
| 457 | + "cities": [ | |
| 458 | + "Pittsburgh", | |
| 459 | + "Cleveland" | |
| 460 | + ], | |
| 461 | + "estimated_agents": 11000, | |
| 462 | + "estimated_listings": 22000, | |
| 463 | + "mls_affiliations": [] | |
| 464 | + }, | |
| 465 | + { | |
| 466 | + "name": "William Raveis Real Estate", | |
| 467 | + "website": "www.raveis.com", | |
| 468 | + "states": [ | |
| 469 | + "CT", | |
| 470 | + "MA", | |
| 471 | + "NY", | |
| 472 | + "FL", | |
| 473 | + "RI", | |
| 474 | + "NH", | |
| 475 | + "ME", | |
| 476 | + "VT", | |
| 477 | + "NJ", | |
| 478 | + "SC" | |
| 479 | + ], | |
| 480 | + "cities": [ | |
| 481 | + "Shelton", | |
| 482 | + "Boston" | |
| 483 | + ], | |
| 484 | + "estimated_agents": 4500, | |
| 485 | + "estimated_listings": 9000, | |
| 486 | + "mls_affiliations": [ | |
| 487 | + "MLSPIN" | |
| 488 | + ] | |
| 489 | + }, | |
| 490 | + { | |
| 491 | + "name": "@properties Christie's International", | |
| 492 | + "website": "www.atproperties.com", | |
| 493 | + "states": [ | |
| 494 | + "IL", | |
| 495 | + "IN", | |
| 496 | + "WI", | |
| 497 | + "MI" | |
| 498 | + ], | |
| 499 | + "cities": [ | |
| 500 | + "Chicago" | |
| 501 | + ], | |
| 502 | + "estimated_agents": 4000, | |
| 503 | + "estimated_listings": 8000, | |
| 504 | + "mls_affiliations": [ | |
| 505 | + "MRED" | |
| 506 | + ] | |
| 507 | + }, | |
| 508 | + { | |
| 509 | + "name": "John L. Scott Real Estate", | |
| 510 | + "website": "www.johnlscott.com", | |
| 511 | + "states": [ | |
| 512 | + "WA", | |
| 513 | + "OR", | |
| 514 | + "ID", | |
| 515 | + "CA" | |
| 516 | + ], | |
| 517 | + "cities": [ | |
| 518 | + "Seattle", | |
| 519 | + "Bellevue", | |
| 520 | + "Portland" | |
| 521 | + ], | |
| 522 | + "estimated_agents": 3000, | |
| 523 | + "estimated_listings": 7000, | |
| 524 | + "mls_affiliations": [ | |
| 525 | + "NWMLS", | |
| 526 | + "RMLS" | |
| 527 | + ] | |
| 528 | + }, | |
| 529 | + { | |
| 530 | + "name": "Windermere Real Estate", | |
| 531 | + "website": "www.windermere.com", | |
| 532 | + "states": [ | |
| 533 | + "WA", | |
| 534 | + "OR", | |
| 535 | + "CA", | |
| 536 | + "AZ", | |
| 537 | + "CO", | |
| 538 | + "ID", | |
| 539 | + "MT", | |
| 540 | + "NV", | |
| 541 | + "UT", | |
| 542 | + "HI" | |
| 543 | + ], | |
| 544 | + "cities": [ | |
| 545 | + "Seattle" | |
| 546 | + ], | |
| 547 | + "estimated_agents": 6500, | |
| 548 | + "estimated_listings": 12000, | |
| 549 | + "mls_affiliations": [ | |
| 550 | + "NWMLS" | |
| 551 | + ] | |
| 552 | + }, | |
| 553 | + { | |
| 554 | + "name": "Long & Foster Real Estate", | |
| 555 | + "website": "www.longandfoster.com", | |
| 556 | + "states": [ | |
| 557 | + "VA", | |
| 558 | + "MD", | |
| 559 | + "DC", | |
| 560 | + "PA", | |
| 561 | + "NJ", | |
| 562 | + "DE", | |
| 563 | + "WV", | |
| 564 | + "NC" | |
| 565 | + ], | |
| 566 | + "cities": [ | |
| 567 | + "Chantilly" | |
| 568 | + ], | |
| 569 | + "estimated_agents": 9000, | |
| 570 | + "estimated_listings": 16000, | |
| 571 | + "mls_affiliations": [ | |
| 572 | + "BRIGHT" | |
| 573 | + ] | |
| 574 | + }, | |
| 575 | + { | |
| 576 | + "name": "Baird & Warner", | |
| 577 | + "website": "www.bairdwarner.com", | |
| 578 | + "states": [ | |
| 579 | + "IL" | |
| 580 | + ], | |
| 581 | + "cities": [ | |
| 582 | + "Chicago" | |
| 583 | + ], | |
| 584 | + "estimated_agents": 2400, | |
| 585 | + "estimated_listings": 5000, | |
| 586 | + "mls_affiliations": [ | |
| 587 | + "MRED" | |
| 588 | + ] | |
| 589 | + }, | |
| 590 | + { | |
| 591 | + "name": "Edina Realty", | |
| 592 | + "website": "www.edinarealty.com", | |
| 593 | + "states": [ | |
| 594 | + "MN", | |
| 595 | + "WI" | |
| 596 | + ], | |
| 597 | + "cities": [ | |
| 598 | + "Minneapolis", | |
| 599 | + "Saint Paul" | |
| 600 | + ], | |
| 601 | + "estimated_agents": 2300, | |
| 602 | + "estimated_listings": 5500, | |
| 603 | + "mls_affiliations": [ | |
| 604 | + "NorthstarMLS" | |
| 605 | + ] | |
| 606 | + }, | |
| 607 | + { | |
| 608 | + "name": "Ebby Halliday Realtors", | |
| 609 | + "website": "www.ebby.com", | |
| 610 | + "states": [ | |
| 611 | + "TX" | |
| 612 | + ], | |
| 613 | + "cities": [ | |
| 614 | + "Dallas", | |
| 615 | + "Plano", | |
| 616 | + "Frisco" | |
| 617 | + ], | |
| 618 | + "estimated_agents": 1800, | |
| 619 | + "estimated_listings": 4000, | |
| 620 | + "mls_affiliations": [ | |
| 621 | + "NTREIS" | |
| 622 | + ] | |
| 623 | + }, | |
| 624 | + { | |
| 625 | + "name": "Allen Tate Realtors", | |
| 626 | + "website": "www.allentate.com", | |
| 627 | + "states": [ | |
| 628 | + "NC", | |
| 629 | + "SC" | |
| 630 | + ], | |
| 631 | + "cities": [ | |
| 632 | + "Charlotte", | |
| 633 | + "Raleigh", | |
| 634 | + "Greensboro" | |
| 635 | + ], | |
| 636 | + "estimated_agents": 1800, | |
| 637 | + "estimated_listings": 4200, | |
| 638 | + "mls_affiliations": [ | |
| 639 | + "Canopy" | |
| 640 | + ] | |
| 641 | + }, | |
| 642 | + { | |
| 643 | + "name": "Crye-Leike Real Estate Services", | |
| 644 | + "website": "www.crye-leike.com", | |
| 645 | + "states": [ | |
| 646 | + "TN", | |
| 647 | + "AR", | |
| 648 | + "MS", | |
| 649 | + "GA", | |
| 650 | + "AL", | |
| 651 | + "KY", | |
| 652 | + "FL" | |
| 653 | + ], | |
| 654 | + "cities": [ | |
| 655 | + "Memphis", | |
| 656 | + "Nashville" | |
| 657 | + ], | |
| 658 | + "estimated_agents": 3200, | |
| 659 | + "estimated_listings": 7000, | |
| 660 | + "mls_affiliations": [ | |
| 661 | + "Realtracs" | |
| 662 | + ] | |
| 663 | + }, | |
| 664 | + { | |
| 665 | + "name": "Michael Saunders & Company", | |
| 666 | + "website": "www.michaelsaunders.com", | |
| 667 | + "states": [ | |
| 668 | + "FL" | |
| 669 | + ], | |
| 670 | + "cities": [ | |
| 671 | + "Sarasota" | |
| 672 | + ], | |
| 673 | + "estimated_agents": 700, | |
| 674 | + "estimated_listings": 2000, | |
| 675 | + "mls_affiliations": [ | |
| 676 | + "Stellar" | |
| 677 | + ] | |
| 678 | + }, | |
| 679 | + { | |
| 680 | + "name": "Watson Realty Corp", | |
| 681 | + "website": "www.watsonrealtycorp.com", | |
| 682 | + "states": [ | |
| 683 | + "FL", | |
| 684 | + "GA" | |
| 685 | + ], | |
| 686 | + "cities": [ | |
| 687 | + "Jacksonville", | |
| 688 | + "Orlando" | |
| 689 | + ], | |
| 690 | + "estimated_agents": 1400, | |
| 691 | + "estimated_listings": 3200, | |
| 692 | + "mls_affiliations": [ | |
| 693 | + "Stellar" | |
| 694 | + ] | |
| 695 | + }, | |
| 696 | + { | |
| 697 | + "name": "BHHS Fox & Roach Realtors", | |
| 698 | + "website": "www.foxroach.com", | |
| 699 | + "states": [ | |
| 700 | + "PA", | |
| 701 | + "NJ", | |
| 702 | + "DE" | |
| 703 | + ], | |
| 704 | + "cities": [ | |
| 705 | + "Philadelphia" | |
| 706 | + ], | |
| 707 | + "estimated_agents": 5500, | |
| 708 | + "estimated_listings": 9000, | |
| 709 | + "mls_affiliations": [ | |
| 710 | + "BRIGHT" | |
| 711 | + ] | |
| 712 | + }, | |
| 713 | + { | |
| 714 | + "name": "Houlihan Lawrence", | |
| 715 | + "website": "www.houlihanlawrence.com", | |
| 716 | + "states": [ | |
| 717 | + "NY", | |
| 718 | + "CT" | |
| 719 | + ], | |
| 720 | + "cities": [ | |
| 721 | + "Rye", | |
| 722 | + "Greenwich" | |
| 723 | + ], | |
| 724 | + "estimated_agents": 1300, | |
| 725 | + "estimated_listings": 2800, | |
| 726 | + "mls_affiliations": [ | |
| 727 | + "OneKey" | |
| 728 | + ] | |
| 729 | + }, | |
| 730 | + { | |
| 731 | + "name": "Daniel Gale Sotheby's International Realty", | |
| 732 | + "website": "www.danielgale.com", | |
| 733 | + "states": [ | |
| 734 | + "NY" | |
| 735 | + ], | |
| 736 | + "cities": [ | |
| 737 | + "Long Island" | |
| 738 | + ], | |
| 739 | + "estimated_agents": 950, | |
| 740 | + "estimated_listings": 2200, | |
| 741 | + "mls_affiliations": [ | |
| 742 | + "OneKey" | |
| 743 | + ] | |
| 744 | + }, | |
| 745 | + { | |
| 746 | + "name": "Brown Harris Stevens", | |
| 747 | + "website": "www.bhsusa.com", | |
| 748 | + "states": [ | |
| 749 | + "NY", | |
| 750 | + "FL", | |
| 751 | + "CT", | |
| 752 | + "NJ" | |
| 753 | + ], | |
| 754 | + "cities": [ | |
| 755 | + "New York", | |
| 756 | + "Palm Beach" | |
| 757 | + ], | |
| 758 | + "estimated_agents": 2500, | |
| 759 | + "estimated_listings": 5000, | |
| 760 | + "mls_affiliations": [] | |
| 761 | + }, | |
| 762 | + { | |
| 763 | + "name": "SERHANT.", | |
| 764 | + "website": "www.serhant.com", | |
| 765 | + "states": [ | |
| 766 | + "NY", | |
| 767 | + "NJ", | |
| 768 | + "CT", | |
| 769 | + "FL" | |
| 770 | + ], | |
| 771 | + "cities": [ | |
| 772 | + "New York" | |
| 773 | + ], | |
| 774 | + "estimated_agents": 500, | |
| 775 | + "estimated_listings": 1500, | |
| 776 | + "mls_affiliations": [] | |
| 777 | + }, | |
| 778 | + { | |
| 779 | + "name": "First Team Real Estate", | |
| 780 | + "website": "www.firstteam.com", | |
| 781 | + "states": [ | |
| 782 | + "CA" | |
| 783 | + ], | |
| 784 | + "cities": [ | |
| 785 | + "Irvine", | |
| 786 | + "Newport Beach" | |
| 787 | + ], | |
| 788 | + "estimated_agents": 2000, | |
| 789 | + "estimated_listings": 3500, | |
| 790 | + "mls_affiliations": [ | |
| 791 | + "CRMLS" | |
| 792 | + ] | |
| 793 | + }, | |
| 794 | + { | |
| 795 | + "name": "HomeSmart Evergreen Realty", | |
| 796 | + "website": "www.homesmartevergreen.com", | |
| 797 | + "states": [ | |
| 798 | + "CA" | |
| 799 | + ], | |
| 800 | + "cities": [ | |
| 801 | + "Irvine" | |
| 802 | + ], | |
| 803 | + "estimated_agents": 1500, | |
| 804 | + "estimated_listings": 2200, | |
| 805 | + "mls_affiliations": [ | |
| 806 | + "CRMLS" | |
| 807 | + ] | |
| 808 | + }, | |
| 809 | + { | |
| 810 | + "name": "Anywhere Real Estate (Coldwell Banker parent)", | |
| 811 | + "website": "www.anywhere.re", | |
| 812 | + "states": [], | |
| 813 | + "cities": [], | |
| 814 | + "estimated_agents": 180000, | |
| 815 | + "estimated_listings": 250000, | |
| 816 | + "mls_affiliations": [] | |
| 817 | + }, | |
| 818 | + { | |
| 819 | + "name": "Weichert, Realtors - Capital Properties", | |
| 820 | + "website": "www.weichertcp.com", | |
| 821 | + "states": [ | |
| 822 | + "DC", | |
| 823 | + "MD", | |
| 824 | + "VA" | |
| 825 | + ], | |
| 826 | + "cities": [ | |
| 827 | + "Washington" | |
| 828 | + ], | |
| 829 | + "estimated_agents": 300, | |
| 830 | + "estimated_listings": 700, | |
| 831 | + "mls_affiliations": [ | |
| 832 | + "BRIGHT" | |
| 833 | + ] | |
| 834 | + }, | |
| 835 | + { | |
| 836 | + "name": "RedKey Realty Leaders", | |
| 837 | + "website": "www.redkeystl.com", | |
| 838 | + "states": [ | |
| 839 | + "MO" | |
| 840 | + ], | |
| 841 | + "cities": [ | |
| 842 | + "Saint Louis" | |
| 843 | + ], | |
| 844 | + "estimated_agents": 150, | |
| 845 | + "estimated_listings": 400, | |
| 846 | + "mls_affiliations": [ | |
| 847 | + "MARIS" | |
| 848 | + ] | |
| 849 | + }, | |
| 850 | + { | |
| 851 | + "name": "JB Goodwin Realtors", | |
| 852 | + "website": "www.jbgoodwin.com", | |
| 853 | + "states": [ | |
| 854 | + "TX" | |
| 855 | + ], | |
| 856 | + "cities": [ | |
| 857 | + "Austin", | |
| 858 | + "San Antonio" | |
| 859 | + ], | |
| 860 | + "estimated_agents": 700, | |
| 861 | + "estimated_listings": 1500, | |
| 862 | + "mls_affiliations": [ | |
| 863 | + "ACTRIS", | |
| 864 | + "SABOR" | |
| 865 | + ] | |
| 866 | + }, | |
| 867 | + { | |
| 868 | + "name": "Kuper Sotheby's International Realty", | |
| 869 | + "website": "www.kuperrealty.com", | |
| 870 | + "states": [ | |
| 871 | + "TX" | |
| 872 | + ], | |
| 873 | + "cities": [ | |
| 874 | + "San Antonio", | |
| 875 | + "Austin" | |
| 876 | + ], | |
| 877 | + "estimated_agents": 425, | |
| 878 | + "estimated_listings": 1100, | |
| 879 | + "mls_affiliations": [ | |
| 880 | + "SABOR", | |
| 881 | + "ACTRIS" | |
| 882 | + ] | |
| 883 | + }, | |
| 884 | + { | |
| 885 | + "name": "Phyllis Browning Company", | |
| 886 | + "website": "www.phyllisbrowning.com", | |
| 887 | + "states": [ | |
| 888 | + "TX" | |
| 889 | + ], | |
| 890 | + "cities": [ | |
| 891 | + "San Antonio" | |
| 892 | + ], | |
| 893 | + "estimated_agents": 200, | |
| 894 | + "estimated_listings": 500, | |
| 895 | + "mls_affiliations": [ | |
| 896 | + "SABOR" | |
| 897 | + ] | |
| 898 | + }, | |
| 899 | + { | |
| 900 | + "name": "Better Homes and Gardens RE Gary Greene", | |
| 901 | + "website": "www.garygreene.com", | |
| 902 | + "states": [ | |
| 903 | + "TX" | |
| 904 | + ], | |
| 905 | + "cities": [ | |
| 906 | + "Houston", | |
| 907 | + "The Woodlands" | |
| 908 | + ], | |
| 909 | + "estimated_agents": 1100, | |
| 910 | + "estimated_listings": 2600, | |
| 911 | + "mls_affiliations": [ | |
| 912 | + "HAR" | |
| 913 | + ] | |
| 914 | + }, | |
| 915 | + { | |
| 916 | + "name": "Greenwood King Properties", | |
| 917 | + "website": "www.greenwoodking.com", | |
| 918 | + "states": [ | |
| 919 | + "TX" | |
| 920 | + ], | |
| 921 | + "cities": [ | |
| 922 | + "Houston" | |
| 923 | + ], | |
| 924 | + "estimated_agents": 180, | |
| 925 | + "estimated_listings": 450, | |
| 926 | + "mls_affiliations": [ | |
| 927 | + "HAR" | |
| 928 | + ] | |
| 929 | + }, | |
| 930 | + { | |
| 931 | + "name": "Martha Turner Sotheby's International Realty", | |
| 932 | + "website": "www.marthaturner.com", | |
| 933 | + "states": [ | |
| 934 | + "TX" | |
| 935 | + ], | |
| 936 | + "cities": [ | |
| 937 | + "Houston" | |
| 938 | + ], | |
| 939 | + "estimated_agents": 300, | |
| 940 | + "estimated_listings": 700, | |
| 941 | + "mls_affiliations": [ | |
| 942 | + "HAR" | |
| 943 | + ] | |
| 944 | + }, | |
| 945 | + { | |
| 946 | + "name": "John Daugherty Realtors", | |
| 947 | + "website": "www.johndaugherty.com", | |
| 948 | + "states": [ | |
| 949 | + "TX" | |
| 950 | + ], | |
| 951 | + "cities": [ | |
| 952 | + "Houston" | |
| 953 | + ], | |
| 954 | + "estimated_agents": 120, | |
| 955 | + "estimated_listings": 300, | |
| 956 | + "mls_affiliations": [ | |
| 957 | + "HAR" | |
| 958 | + ] | |
| 959 | + }, | |
| 960 | + { | |
| 961 | + "name": "Heritage Texas Properties", | |
| 962 | + "website": "www.heritagetexas.com", | |
| 963 | + "states": [ | |
| 964 | + "TX" | |
| 965 | + ], | |
| 966 | + "cities": [ | |
| 967 | + "Houston" | |
| 968 | + ], | |
| 969 | + "estimated_agents": 200, | |
| 970 | + "estimated_listings": 450, | |
| 971 | + "mls_affiliations": [ | |
| 972 | + "HAR" | |
| 973 | + ] | |
| 974 | + }, | |
| 975 | + { | |
| 976 | + "name": "Compass RE Texas", | |
| 977 | + "website": "www.compass.com/tx", | |
| 978 | + "states": [ | |
| 979 | + "TX" | |
| 980 | + ], | |
| 981 | + "cities": [ | |
| 982 | + "Houston", | |
| 983 | + "Dallas", | |
| 984 | + "Austin" | |
| 985 | + ], | |
| 986 | + "estimated_agents": 2200, | |
| 987 | + "estimated_listings": 5000, | |
| 988 | + "mls_affiliations": [ | |
| 989 | + "HAR", | |
| 990 | + "NTREIS", | |
| 991 | + "ACTRIS" | |
| 992 | + ] | |
| 993 | + }, | |
| 994 | + { | |
| 995 | + "name": "Briggs Freeman Sotheby's International Realty", | |
| 996 | + "website": "www.briggsfreeman.com", | |
| 997 | + "states": [ | |
| 998 | + "TX" | |
| 999 | + ], | |
| 1000 | + "cities": [ | |
| 1001 | + "Dallas", | |
| 1002 | + "Fort Worth" | |
| 1003 | + ], | |
| 1004 | + "estimated_agents": 380, | |
| 1005 | + "estimated_listings": 900, | |
| 1006 | + "mls_affiliations": [ | |
| 1007 | + "NTREIS" | |
| 1008 | + ] | |
| 1009 | + }, | |
| 1010 | + { | |
| 1011 | + "name": "Allie Beth Allman & Associates", | |
| 1012 | + "website": "www.alliebeth.com", | |
| 1013 | + "states": [ | |
| 1014 | + "TX" | |
| 1015 | + ], | |
| 1016 | + "cities": [ | |
| 1017 | + "Dallas" | |
| 1018 | + ], | |
| 1019 | + "estimated_agents": 400, | |
| 1020 | + "estimated_listings": 900, | |
| 1021 | + "mls_affiliations": [ | |
| 1022 | + "NTREIS" | |
| 1023 | + ] | |
| 1024 | + }, | |
| 1025 | + { | |
| 1026 | + "name": "Dave Perry-Miller Real Estate", | |
| 1027 | + "website": "www.daveperrymiller.com", | |
| 1028 | + "states": [ | |
| 1029 | + "TX" | |
| 1030 | + ], | |
| 1031 | + "cities": [ | |
| 1032 | + "Dallas" | |
| 1033 | + ], | |
| 1034 | + "estimated_agents": 350, | |
| 1035 | + "estimated_listings": 800, | |
| 1036 | + "mls_affiliations": [ | |
| 1037 | + "NTREIS" | |
| 1038 | + ] | |
| 1039 | + }, | |
| 1040 | + { | |
| 1041 | + "name": "Williams Trew Real Estate", | |
| 1042 | + "website": "www.williamstrew.com", | |
| 1043 | + "states": [ | |
| 1044 | + "TX" | |
| 1045 | + ], | |
| 1046 | + "cities": [ | |
| 1047 | + "Fort Worth" | |
| 1048 | + ], | |
| 1049 | + "estimated_agents": 200, | |
| 1050 | + "estimated_listings": 500, | |
| 1051 | + "mls_affiliations": [ | |
| 1052 | + "NTREIS" | |
| 1053 | + ] | |
| 1054 | + }, | |
| 1055 | + { | |
| 1056 | + "name": "Coldwell Banker Apex Realtors", | |
| 1057 | + "website": "www.cbapex.com", | |
| 1058 | + "states": [ | |
| 1059 | + "TX" | |
| 1060 | + ], | |
| 1061 | + "cities": [ | |
| 1062 | + "Dallas", | |
| 1063 | + "Rockwall" | |
| 1064 | + ], | |
| 1065 | + "estimated_agents": 900, | |
| 1066 | + "estimated_listings": 2000, | |
| 1067 | + "mls_affiliations": [ | |
| 1068 | + "NTREIS" | |
| 1069 | + ] | |
| 1070 | + }, | |
| 1071 | + { | |
| 1072 | + "name": "Monument Realty", | |
| 1073 | + "website": "www.monumentrealty.com", | |
| 1074 | + "states": [ | |
| 1075 | + "TX" | |
| 1076 | + ], | |
| 1077 | + "cities": [ | |
| 1078 | + "Frisco", | |
| 1079 | + "Dallas" | |
| 1080 | + ], | |
| 1081 | + "estimated_agents": 700, | |
| 1082 | + "estimated_listings": 1400, | |
| 1083 | + "mls_affiliations": [ | |
| 1084 | + "NTREIS" | |
| 1085 | + ] | |
| 1086 | + }, | |
| 1087 | + { | |
| 1088 | + "name": "Keller Williams Realty Austin", | |
| 1089 | + "website": "www.kwaustin.com", | |
| 1090 | + "states": [ | |
| 1091 | + "TX" | |
| 1092 | + ], | |
| 1093 | + "cities": [ | |
| 1094 | + "Austin" | |
| 1095 | + ], | |
| 1096 | + "estimated_agents": 2200, | |
| 1097 | + "estimated_listings": 3500, | |
| 1098 | + "mls_affiliations": [ | |
| 1099 | + "ACTRIS" | |
| 1100 | + ] | |
| 1101 | + }, | |
| 1102 | + { | |
| 1103 | + "name": "Moreland Properties", | |
| 1104 | + "website": "www.moreland.com", | |
| 1105 | + "states": [ | |
| 1106 | + "TX" | |
| 1107 | + ], | |
| 1108 | + "cities": [ | |
| 1109 | + "Austin" | |
| 1110 | + ], | |
| 1111 | + "estimated_agents": 110, | |
| 1112 | + "estimated_listings": 300, | |
| 1113 | + "mls_affiliations": [ | |
| 1114 | + "ACTRIS" | |
| 1115 | + ] | |
| 1116 | + }, | |
| 1117 | + { | |
| 1118 | + "name": "Realty Austin (Compass)", | |
| 1119 | + "website": "www.realtyaustin.com", | |
| 1120 | + "states": [ | |
| 1121 | + "TX" | |
| 1122 | + ], | |
| 1123 | + "cities": [ | |
| 1124 | + "Austin" | |
| 1125 | + ], | |
| 1126 | + "estimated_agents": 600, | |
| 1127 | + "estimated_listings": 1400, | |
| 1128 | + "mls_affiliations": [ | |
| 1129 | + "ACTRIS" | |
| 1130 | + ] | |
| 1131 | + }, | |
| 1132 | + { | |
| 1133 | + "name": "All City Real Estate", | |
| 1134 | + "website": "www.allcityagents.com", | |
| 1135 | + "states": [ | |
| 1136 | + "TX" | |
| 1137 | + ], | |
| 1138 | + "cities": [ | |
| 1139 | + "Austin" | |
| 1140 | + ], | |
| 1141 | + "estimated_agents": 900, | |
| 1142 | + "estimated_listings": 1500, | |
| 1143 | + "mls_affiliations": [ | |
| 1144 | + "ACTRIS" | |
| 1145 | + ] | |
| 1146 | + }, | |
| 1147 | + { | |
| 1148 | + "name": "Spyglass Realty", | |
| 1149 | + "website": "www.spyglassrealty.com", | |
| 1150 | + "states": [ | |
| 1151 | + "TX" | |
| 1152 | + ], | |
| 1153 | + "cities": [ | |
| 1154 | + "Austin" | |
| 1155 | + ], | |
| 1156 | + "estimated_agents": 130, | |
| 1157 | + "estimated_listings": 350, | |
| 1158 | + "mls_affiliations": [ | |
| 1159 | + "ACTRIS" | |
| 1160 | + ] | |
| 1161 | + }, | |
| 1162 | + { | |
| 1163 | + "name": "Orchard", | |
| 1164 | + "website": "www.orchard.com", | |
| 1165 | + "states": [ | |
| 1166 | + "TX", | |
| 1167 | + "CO", | |
| 1168 | + "GA", | |
| 1169 | + "NC" | |
| 1170 | + ], | |
| 1171 | + "cities": [ | |
| 1172 | + "Austin", | |
| 1173 | + "Denver", | |
| 1174 | + "Atlanta" | |
| 1175 | + ], | |
| 1176 | + "estimated_agents": 500, | |
| 1177 | + "estimated_listings": 1200, | |
| 1178 | + "mls_affiliations": [] | |
| 1179 | + }, | |
| 1180 | + { | |
| 1181 | + "name": "Redfin Texas", | |
| 1182 | + "website": "www.redfin.com/state/texas", | |
| 1183 | + "states": [ | |
| 1184 | + "TX" | |
| 1185 | + ], | |
| 1186 | + "cities": [], | |
| 1187 | + "estimated_agents": 250, | |
| 1188 | + "estimated_listings": 1500, | |
| 1189 | + "mls_affiliations": [ | |
| 1190 | + "NTREIS", | |
| 1191 | + "HAR", | |
| 1192 | + "ACTRIS" | |
| 1193 | + ] | |
| 1194 | + }, | |
| 1195 | + { | |
| 1196 | + "name": "RE/MAX DFW Associates", | |
| 1197 | + "website": "www.rmdfw.com", | |
| 1198 | + "states": [ | |
| 1199 | + "TX" | |
| 1200 | + ], | |
| 1201 | + "cities": [ | |
| 1202 | + "Dallas", | |
| 1203 | + "Coppell" | |
| 1204 | + ], | |
| 1205 | + "estimated_agents": 400, | |
| 1206 | + "estimated_listings": 900, | |
| 1207 | + "mls_affiliations": [ | |
| 1208 | + "NTREIS" | |
| 1209 | + ] | |
| 1210 | + }, | |
| 1211 | + { | |
| 1212 | + "name": "Ultima Real Estate", | |
| 1213 | + "website": "www.ultimarealestate.com", | |
| 1214 | + "states": [ | |
| 1215 | + "TX" | |
| 1216 | + ], | |
| 1217 | + "cities": [ | |
| 1218 | + "Dallas" | |
| 1219 | + ], | |
| 1220 | + "estimated_agents": 600, | |
| 1221 | + "estimated_listings": 1000, | |
| 1222 | + "mls_affiliations": [ | |
| 1223 | + "NTREIS" | |
| 1224 | + ] | |
| 1225 | + }, | |
| 1226 | + { | |
| 1227 | + "name": "Central Metro Realty", | |
| 1228 | + "website": "www.centralmetrorealty.com", | |
| 1229 | + "states": [ | |
| 1230 | + "TX" | |
| 1231 | + ], | |
| 1232 | + "cities": [ | |
| 1233 | + "Austin", | |
| 1234 | + "San Antonio", | |
| 1235 | + "Houston" | |
| 1236 | + ], | |
| 1237 | + "estimated_agents": 1300, | |
| 1238 | + "estimated_listings": 1800, | |
| 1239 | + "mls_affiliations": [ | |
| 1240 | + "ACTRIS" | |
| 1241 | + ] | |
| 1242 | + }, | |
| 1243 | + { | |
| 1244 | + "name": "Texas United Realty", | |
| 1245 | + "website": "www.texasunitedrealty.com", | |
| 1246 | + "states": [ | |
| 1247 | + "TX" | |
| 1248 | + ], | |
| 1249 | + "cities": [ | |
| 1250 | + "Houston" | |
| 1251 | + ], | |
| 1252 | + "estimated_agents": 800, | |
| 1253 | + "estimated_listings": 1200, | |
| 1254 | + "mls_affiliations": [ | |
| 1255 | + "HAR" | |
| 1256 | + ] | |
| 1257 | + }, | |
| 1258 | + { | |
| 1259 | + "name": "Norhill Realty", | |
| 1260 | + "website": "www.norhillrealty.com", | |
| 1261 | + "states": [ | |
| 1262 | + "TX" | |
| 1263 | + ], | |
| 1264 | + "cities": [ | |
| 1265 | + "Houston" | |
| 1266 | + ], | |
| 1267 | + "estimated_agents": 60, | |
| 1268 | + "estimated_listings": 180, | |
| 1269 | + "mls_affiliations": [ | |
| 1270 | + "HAR" | |
| 1271 | + ] | |
| 1272 | + }, | |
| 1273 | + { | |
| 1274 | + "name": "Fathom Realty Texas", | |
| 1275 | + "website": "www.fathomrealty.com/tx", | |
| 1276 | + "states": [ | |
| 1277 | + "TX" | |
| 1278 | + ], | |
| 1279 | + "cities": [], | |
| 1280 | + "estimated_agents": 2500, | |
| 1281 | + "estimated_listings": 3500, | |
| 1282 | + "mls_affiliations": [ | |
| 1283 | + "NTREIS", | |
| 1284 | + "HAR" | |
| 1285 | + ] | |
| 1286 | + }, | |
| 1287 | + { | |
| 1288 | + "name": "Rogers Healy and Associates", | |
| 1289 | + "website": "www.rogershealy.com", | |
| 1290 | + "states": [ | |
| 1291 | + "TX" | |
| 1292 | + ], | |
| 1293 | + "cities": [ | |
| 1294 | + "Dallas" | |
| 1295 | + ], | |
| 1296 | + "estimated_agents": 700, | |
| 1297 | + "estimated_listings": 1300, | |
| 1298 | + "mls_affiliations": [ | |
| 1299 | + "NTREIS" | |
| 1300 | + ] | |
| 1301 | + }, | |
| 1302 | + { | |
| 1303 | + "name": "The Agency Texas", | |
| 1304 | + "website": "www.theagencyre.com/tx", | |
| 1305 | + "states": [ | |
| 1306 | + "TX" | |
| 1307 | + ], | |
| 1308 | + "cities": [ | |
| 1309 | + "Austin", | |
| 1310 | + "Dallas" | |
| 1311 | + ], | |
| 1312 | + "estimated_agents": 150, | |
| 1313 | + "estimated_listings": 400, | |
| 1314 | + "mls_affiliations": [] | |
| 1315 | + }, | |
| 1316 | + { | |
| 1317 | + "name": "The Agency", | |
| 1318 | + "website": "www.theagencyre.com", | |
| 1319 | + "states": [ | |
| 1320 | + "CA", | |
| 1321 | + "NY", | |
| 1322 | + "FL", | |
| 1323 | + "CO", | |
| 1324 | + "AZ", | |
| 1325 | + "TX", | |
| 1326 | + "NV", | |
| 1327 | + "WA" | |
| 1328 | + ], | |
| 1329 | + "cities": [ | |
| 1330 | + "Beverly Hills", | |
| 1331 | + "Los Angeles" | |
| 1332 | + ], | |
| 1333 | + "estimated_agents": 2100, | |
| 1334 | + "estimated_listings": 4500, | |
| 1335 | + "mls_affiliations": [ | |
| 1336 | + "CRMLS" | |
| 1337 | + ] | |
| 1338 | + }, | |
| 1339 | + { | |
| 1340 | + "name": "Hilton & Hyland", | |
| 1341 | + "website": "www.hiltonhyland.com", | |
| 1342 | + "states": [ | |
| 1343 | + "CA" | |
| 1344 | + ], | |
| 1345 | + "cities": [ | |
| 1346 | + "Beverly Hills" | |
| 1347 | + ], | |
| 1348 | + "estimated_agents": 120, | |
| 1349 | + "estimated_listings": 300, | |
| 1350 | + "mls_affiliations": [ | |
| 1351 | + "CRMLS" | |
| 1352 | + ] | |
| 1353 | + }, | |
| 1354 | + { | |
| 1355 | + "name": "Carolwood Estates", | |
| 1356 | + "website": "www.carolwoodre.com", | |
| 1357 | + "states": [ | |
| 1358 | + "CA" | |
| 1359 | + ], | |
| 1360 | + "cities": [ | |
| 1361 | + "Beverly Hills" | |
| 1362 | + ], | |
| 1363 | + "estimated_agents": 90, | |
| 1364 | + "estimated_listings": 220, | |
| 1365 | + "mls_affiliations": [ | |
| 1366 | + "CRMLS" | |
| 1367 | + ] | |
| 1368 | + }, | |
| 1369 | + { | |
| 1370 | + "name": "Westside Estate Agency", | |
| 1371 | + "website": "www.weahomes.com", | |
| 1372 | + "states": [ | |
| 1373 | + "CA" | |
| 1374 | + ], | |
| 1375 | + "cities": [ | |
| 1376 | + "Beverly Hills" | |
| 1377 | + ], | |
| 1378 | + "estimated_agents": 60, | |
| 1379 | + "estimated_listings": 150, | |
| 1380 | + "mls_affiliations": [ | |
| 1381 | + "CRMLS" | |
| 1382 | + ] | |
| 1383 | + }, | |
| 1384 | + { | |
| 1385 | + "name": "Rodeo Realty", | |
| 1386 | + "website": "www.rodeore.com", | |
| 1387 | + "states": [ | |
| 1388 | + "CA" | |
| 1389 | + ], | |
| 1390 | + "cities": [ | |
| 1391 | + "Los Angeles", | |
| 1392 | + "Beverly Hills" | |
| 1393 | + ], | |
| 1394 | + "estimated_agents": 1100, | |
| 1395 | + "estimated_listings": 2200, | |
| 1396 | + "mls_affiliations": [ | |
| 1397 | + "CRMLS" | |
| 1398 | + ] | |
| 1399 | + }, | |
| 1400 | + { | |
| 1401 | + "name": "John Aaroe Group / Pacific Union LA", | |
| 1402 | + "website": "www.aaroe.com", | |
| 1403 | + "states": [ | |
| 1404 | + "CA" | |
| 1405 | + ], | |
| 1406 | + "cities": [ | |
| 1407 | + "Los Angeles" | |
| 1408 | + ], | |
| 1409 | + "estimated_agents": 300, | |
| 1410 | + "estimated_listings": 600, | |
| 1411 | + "mls_affiliations": [ | |
| 1412 | + "CRMLS" | |
| 1413 | + ] | |
| 1414 | + }, | |
| 1415 | + { | |
| 1416 | + "name": "Vanguard Properties", | |
| 1417 | + "website": "www.vanguardproperties.com", | |
| 1418 | + "states": [ | |
| 1419 | + "CA" | |
| 1420 | + ], | |
| 1421 | + "cities": [ | |
| 1422 | + "San Francisco", | |
| 1423 | + "Sonoma" | |
| 1424 | + ], | |
| 1425 | + "estimated_agents": 450, | |
| 1426 | + "estimated_listings": 900, | |
| 1427 | + "mls_affiliations": [ | |
| 1428 | + "SFAR" | |
| 1429 | + ] | |
| 1430 | + }, | |
| 1431 | + { | |
| 1432 | + "name": "Zephyr Real Estate (Corcoran Icon)", | |
| 1433 | + "website": "www.corcoranicon.com", | |
| 1434 | + "states": [ | |
| 1435 | + "CA" | |
| 1436 | + ], | |
| 1437 | + "cities": [ | |
| 1438 | + "San Francisco", | |
| 1439 | + "Oakland" | |
| 1440 | + ], | |
| 1441 | + "estimated_agents": 900, | |
| 1442 | + "estimated_listings": 1600, | |
| 1443 | + "mls_affiliations": [ | |
| 1444 | + "SFAR" | |
| 1445 | + ] | |
| 1446 | + }, | |
| 1447 | + { | |
| 1448 | + "name": "Golden Gate Sotheby's International Realty", | |
| 1449 | + "website": "www.goldengatesir.com", | |
| 1450 | + "states": [ | |
| 1451 | + "CA" | |
| 1452 | + ], | |
| 1453 | + "cities": [ | |
| 1454 | + "San Francisco", | |
| 1455 | + "Marin" | |
| 1456 | + ], | |
| 1457 | + "estimated_agents": 500, | |
| 1458 | + "estimated_listings": 1100, | |
| 1459 | + "mls_affiliations": [ | |
| 1460 | + "SFAR", | |
| 1461 | + "BAREIS" | |
| 1462 | + ] | |
| 1463 | + }, | |
| 1464 | + { | |
| 1465 | + "name": "Intero Real Estate Services", | |
| 1466 | + "website": "www.intero.com", | |
| 1467 | + "states": [ | |
| 1468 | + "CA" | |
| 1469 | + ], | |
| 1470 | + "cities": [ | |
| 1471 | + "San Jose", | |
| 1472 | + "Cupertino" | |
| 1473 | + ], | |
| 1474 | + "estimated_agents": 1600, | |
| 1475 | + "estimated_listings": 2600, | |
| 1476 | + "mls_affiliations": [ | |
| 1477 | + "MLSListings" | |
| 1478 | + ] | |
| 1479 | + }, | |
| 1480 | + { | |
| 1481 | + "name": "Sereno (Christie's)", | |
| 1482 | + "website": "www.sereno.com", | |
| 1483 | + "states": [ | |
| 1484 | + "CA" | |
| 1485 | + ], | |
| 1486 | + "cities": [ | |
| 1487 | + "Los Gatos", | |
| 1488 | + "Palo Alto" | |
| 1489 | + ], | |
| 1490 | + "estimated_agents": 600, | |
| 1491 | + "estimated_listings": 1100, | |
| 1492 | + "mls_affiliations": [ | |
| 1493 | + "MLSListings" | |
| 1494 | + ] | |
| 1495 | + }, | |
| 1496 | + { | |
| 1497 | + "name": "Compass California", | |
| 1498 | + "website": "www.compass.com/ca", | |
| 1499 | + "states": [ | |
| 1500 | + "CA" | |
| 1501 | + ], | |
| 1502 | + "cities": [ | |
| 1503 | + "San Francisco", | |
| 1504 | + "Los Angeles", | |
| 1505 | + "San Diego" | |
| 1506 | + ], | |
| 1507 | + "estimated_agents": 6000, | |
| 1508 | + "estimated_listings": 10000, | |
| 1509 | + "mls_affiliations": [ | |
| 1510 | + "CRMLS", | |
| 1511 | + "SFAR", | |
| 1512 | + "MLSListings", | |
| 1513 | + "SDMLS" | |
| 1514 | + ] | |
| 1515 | + }, | |
| 1516 | + { | |
| 1517 | + "name": "Berkshire Hathaway HomeServices California Properties", | |
| 1518 | + "website": "www.bhhscalifornia.com", | |
| 1519 | + "states": [ | |
| 1520 | + "CA" | |
| 1521 | + ], | |
| 1522 | + "cities": [ | |
| 1523 | + "San Diego", | |
| 1524 | + "Los Angeles", | |
| 1525 | + "Santa Barbara" | |
| 1526 | + ], | |
| 1527 | + "estimated_agents": 3000, | |
| 1528 | + "estimated_listings": 5500, | |
| 1529 | + "mls_affiliations": [ | |
| 1530 | + "CRMLS", | |
| 1531 | + "SDMLS" | |
| 1532 | + ] | |
| 1533 | + }, | |
| 1534 | + { | |
| 1535 | + "name": "Willis Allen Real Estate", | |
| 1536 | + "website": "www.willisallen.com", | |
| 1537 | + "states": [ | |
| 1538 | + "CA" | |
| 1539 | + ], | |
| 1540 | + "cities": [ | |
| 1541 | + "La Jolla", | |
| 1542 | + "San Diego" | |
| 1543 | + ], | |
| 1544 | + "estimated_agents": 200, | |
| 1545 | + "estimated_listings": 450, | |
| 1546 | + "mls_affiliations": [ | |
| 1547 | + "SDMLS" | |
| 1548 | + ] | |
| 1549 | + }, | |
| 1550 | + { | |
| 1551 | + "name": "Pacific Sotheby's International Realty", | |
| 1552 | + "website": "www.pacificsir.com", | |
| 1553 | + "states": [ | |
| 1554 | + "CA" | |
| 1555 | + ], | |
| 1556 | + "cities": [ | |
| 1557 | + "San Diego" | |
| 1558 | + ], | |
| 1559 | + "estimated_agents": 750, | |
| 1560 | + "estimated_listings": 1500, | |
| 1561 | + "mls_affiliations": [ | |
| 1562 | + "SDMLS" | |
| 1563 | + ] | |
| 1564 | + }, | |
| 1565 | + { | |
| 1566 | + "name": "Big Block Realty", | |
| 1567 | + "website": "www.bigblockrealty.com", | |
| 1568 | + "states": [ | |
| 1569 | + "CA" | |
| 1570 | + ], | |
| 1571 | + "cities": [ | |
| 1572 | + "San Diego" | |
| 1573 | + ], | |
| 1574 | + "estimated_agents": 1200, | |
| 1575 | + "estimated_listings": 1800, | |
| 1576 | + "mls_affiliations": [ | |
| 1577 | + "SDMLS" | |
| 1578 | + ] | |
| 1579 | + }, | |
| 1580 | + { | |
| 1581 | + "name": "eXp Realty of California", | |
| 1582 | + "website": "www.exprealty.com/ca", | |
| 1583 | + "states": [ | |
| 1584 | + "CA" | |
| 1585 | + ], | |
| 1586 | + "cities": [], | |
| 1587 | + "estimated_agents": 8000, | |
| 1588 | + "estimated_listings": 11000, | |
| 1589 | + "mls_affiliations": [ | |
| 1590 | + "CRMLS" | |
| 1591 | + ] | |
| 1592 | + }, | |
| 1593 | + { | |
| 1594 | + "name": "Realty Masters & Associates", | |
| 1595 | + "website": "www.realtymasters.com", | |
| 1596 | + "states": [ | |
| 1597 | + "CA" | |
| 1598 | + ], | |
| 1599 | + "cities": [ | |
| 1600 | + "Riverside", | |
| 1601 | + "Chino" | |
| 1602 | + ], | |
| 1603 | + "estimated_agents": 1800, | |
| 1604 | + "estimated_listings": 2500, | |
| 1605 | + "mls_affiliations": [ | |
| 1606 | + "CRMLS" | |
| 1607 | + ] | |
| 1608 | + }, | |
| 1609 | + { | |
| 1610 | + "name": "KW Advisors Bay Area", | |
| 1611 | + "website": "www.kwadvisorsbayarea.com", | |
| 1612 | + "states": [ | |
| 1613 | + "CA" | |
| 1614 | + ], | |
| 1615 | + "cities": [ | |
| 1616 | + "San Francisco", | |
| 1617 | + "Oakland" | |
| 1618 | + ], | |
| 1619 | + "estimated_agents": 900, | |
| 1620 | + "estimated_listings": 1400, | |
| 1621 | + "mls_affiliations": [ | |
| 1622 | + "SFAR" | |
| 1623 | + ] | |
| 1624 | + }, | |
| 1625 | + { | |
| 1626 | + "name": "Lyon Real Estate", | |
| 1627 | + "website": "www.golyon.com", | |
| 1628 | + "states": [ | |
| 1629 | + "CA" | |
| 1630 | + ], | |
| 1631 | + "cities": [ | |
| 1632 | + "Sacramento" | |
| 1633 | + ], | |
| 1634 | + "estimated_agents": 800, | |
| 1635 | + "estimated_listings": 1600, | |
| 1636 | + "mls_affiliations": [ | |
| 1637 | + "Metrolist" | |
| 1638 | + ] | |
| 1639 | + }, | |
| 1640 | + { | |
| 1641 | + "name": "Nick Sadek Sotheby's International Realty", | |
| 1642 | + "website": "www.nicksadeksir.com", | |
| 1643 | + "states": [ | |
| 1644 | + "CA" | |
| 1645 | + ], | |
| 1646 | + "cities": [ | |
| 1647 | + "Sacramento", | |
| 1648 | + "Roseville" | |
| 1649 | + ], | |
| 1650 | + "estimated_agents": 250, | |
| 1651 | + "estimated_listings": 500, | |
| 1652 | + "mls_affiliations": [ | |
| 1653 | + "Metrolist" | |
| 1654 | + ] | |
| 1655 | + }, | |
| 1656 | + { | |
| 1657 | + "name": "Coldwell Banker Grass Roots Realty", | |
| 1658 | + "website": "www.cbgrassroots.com", | |
| 1659 | + "states": [ | |
| 1660 | + "CA" | |
| 1661 | + ], | |
| 1662 | + "cities": [ | |
| 1663 | + "Grass Valley" | |
| 1664 | + ], | |
| 1665 | + "estimated_agents": 90, | |
| 1666 | + "estimated_listings": 220, | |
| 1667 | + "mls_affiliations": [ | |
| 1668 | + "Metrolist" | |
| 1669 | + ] | |
| 1670 | + }, | |
| 1671 | + { | |
| 1672 | + "name": "Village Properties", | |
| 1673 | + "website": "www.villagesite.com", | |
| 1674 | + "states": [ | |
| 1675 | + "CA" | |
| 1676 | + ], | |
| 1677 | + "cities": [ | |
| 1678 | + "Santa Barbara", | |
| 1679 | + "Montecito" | |
| 1680 | + ], | |
| 1681 | + "estimated_agents": 200, | |
| 1682 | + "estimated_listings": 400, | |
| 1683 | + "mls_affiliations": [ | |
| 1684 | + "SBMLS" | |
| 1685 | + ] | |
| 1686 | + }, | |
| 1687 | + { | |
| 1688 | + "name": "Bennion Deville Homes", | |
| 1689 | + "website": "www.bdhomes.com", | |
| 1690 | + "states": [ | |
| 1691 | + "CA" | |
| 1692 | + ], | |
| 1693 | + "cities": [ | |
| 1694 | + "Palm Springs", | |
| 1695 | + "Rancho Mirage" | |
| 1696 | + ], | |
| 1697 | + "estimated_agents": 900, | |
| 1698 | + "estimated_listings": 1600, | |
| 1699 | + "mls_affiliations": [ | |
| 1700 | + "CRMLS" | |
| 1701 | + ] | |
| 1702 | + }, | |
| 1703 | + { | |
| 1704 | + "name": "Monterey Coast Realty", | |
| 1705 | + "website": "www.montereycoastrealty.com", | |
| 1706 | + "states": [ | |
| 1707 | + "CA" | |
| 1708 | + ], | |
| 1709 | + "cities": [ | |
| 1710 | + "Carmel" | |
| 1711 | + ], | |
| 1712 | + "estimated_agents": 70, | |
| 1713 | + "estimated_listings": 180, | |
| 1714 | + "mls_affiliations": [ | |
| 1715 | + "MLSListings" | |
| 1716 | + ] | |
| 1717 | + }, | |
| 1718 | + { | |
| 1719 | + "name": "Alain Pinel / Golden Gate (legacy)", | |
| 1720 | + "website": "www.apr.com", | |
| 1721 | + "states": [ | |
| 1722 | + "CA" | |
| 1723 | + ], | |
| 1724 | + "cities": [ | |
| 1725 | + "Palo Alto" | |
| 1726 | + ], | |
| 1727 | + "estimated_agents": 300, | |
| 1728 | + "estimated_listings": 600, | |
| 1729 | + "mls_affiliations": [ | |
| 1730 | + "MLSListings" | |
| 1731 | + ] | |
| 1732 | + }, | |
| 1733 | + { | |
| 1734 | + "name": "Keller Williams Larchmont LA", | |
| 1735 | + "website": "www.kwlarchmont.com", | |
| 1736 | + "states": [ | |
| 1737 | + "CA" | |
| 1738 | + ], | |
| 1739 | + "cities": [ | |
| 1740 | + "Los Angeles" | |
| 1741 | + ], | |
| 1742 | + "estimated_agents": 600, | |
| 1743 | + "estimated_listings": 900, | |
| 1744 | + "mls_affiliations": [ | |
| 1745 | + "CRMLS" | |
| 1746 | + ] | |
| 1747 | + }, | |
| 1748 | + { | |
| 1749 | + "name": "Harcourts Auctions USA", | |
| 1750 | + "website": "www.harcourtsusa.com", | |
| 1751 | + "states": [ | |
| 1752 | + "CA" | |
| 1753 | + ], | |
| 1754 | + "cities": [ | |
| 1755 | + "San Diego", | |
| 1756 | + "Orange County" | |
| 1757 | + ], | |
| 1758 | + "estimated_agents": 500, | |
| 1759 | + "estimated_listings": 900, | |
| 1760 | + "mls_affiliations": [ | |
| 1761 | + "CRMLS" | |
| 1762 | + ] | |
| 1763 | + }, | |
| 1764 | + { | |
| 1765 | + "name": "Realty World Northern California & Nevada", | |
| 1766 | + "website": "www.realtyworld-nca.com", | |
| 1767 | + "states": [ | |
| 1768 | + "CA", | |
| 1769 | + "NV" | |
| 1770 | + ], | |
| 1771 | + "cities": [ | |
| 1772 | + "Sacramento" | |
| 1773 | + ], | |
| 1774 | + "estimated_agents": 1200, | |
| 1775 | + "estimated_listings": 1900, | |
| 1776 | + "mls_affiliations": [ | |
| 1777 | + "Metrolist" | |
| 1778 | + ] | |
| 1779 | + }, | |
| 1780 | + { | |
| 1781 | + "name": "PLG Estates", | |
| 1782 | + "website": "www.plgestates.com", | |
| 1783 | + "states": [ | |
| 1784 | + "CA" | |
| 1785 | + ], | |
| 1786 | + "cities": [ | |
| 1787 | + "Beverly Hills" | |
| 1788 | + ], | |
| 1789 | + "estimated_agents": 250, | |
| 1790 | + "estimated_listings": 400, | |
| 1791 | + "mls_affiliations": [ | |
| 1792 | + "CRMLS" | |
| 1793 | + ] | |
| 1794 | + }, | |
| 1795 | + { | |
| 1796 | + "name": "The Keyes Company", | |
| 1797 | + "website": "www.keyes.com", | |
| 1798 | + "states": [ | |
| 1799 | + "FL" | |
| 1800 | + ], | |
| 1801 | + "cities": [ | |
| 1802 | + "Miami", | |
| 1803 | + "Fort Lauderdale", | |
| 1804 | + "West Palm Beach" | |
| 1805 | + ], | |
| 1806 | + "estimated_agents": 3800, | |
| 1807 | + "estimated_listings": 7000, | |
| 1808 | + "mls_affiliations": [ | |
| 1809 | + "MiamiMLS", | |
| 1810 | + "BeachesMLS" | |
| 1811 | + ] | |
| 1812 | + }, | |
| 1813 | + { | |
| 1814 | + "name": "Coldwell Banker Florida (Realty)", | |
| 1815 | + "website": "www.coldwellbankerhomes.com/fl", | |
| 1816 | + "states": [ | |
| 1817 | + "FL" | |
| 1818 | + ], | |
| 1819 | + "cities": [ | |
| 1820 | + "Orlando", | |
| 1821 | + "Tampa", | |
| 1822 | + "Miami" | |
| 1823 | + ], | |
| 1824 | + "estimated_agents": 6000, | |
| 1825 | + "estimated_listings": 11000, | |
| 1826 | + "mls_affiliations": [ | |
| 1827 | + "Stellar", | |
| 1828 | + "MiamiMLS" | |
| 1829 | + ] | |
| 1830 | + }, | |
| 1831 | + { | |
| 1832 | + "name": "Lang Realty", | |
| 1833 | + "website": "www.langrealty.com", | |
| 1834 | + "states": [ | |
| 1835 | + "FL" | |
| 1836 | + ], | |
| 1837 | + "cities": [ | |
| 1838 | + "Boca Raton", | |
| 1839 | + "Palm Beach Gardens" | |
| 1840 | + ], | |
| 1841 | + "estimated_agents": 450, | |
| 1842 | + "estimated_listings": 1000, | |
| 1843 | + "mls_affiliations": [ | |
| 1844 | + "BeachesMLS" | |
| 1845 | + ] | |
| 1846 | + }, | |
| 1847 | + { | |
| 1848 | + "name": "Illustrated Properties", | |
| 1849 | + "website": "www.ipre.com", | |
| 1850 | + "states": [ | |
| 1851 | + "FL" | |
| 1852 | + ], | |
| 1853 | + "cities": [ | |
| 1854 | + "Palm Beach Gardens" | |
| 1855 | + ], | |
| 1856 | + "estimated_agents": 600, | |
| 1857 | + "estimated_listings": 1300, | |
| 1858 | + "mls_affiliations": [ | |
| 1859 | + "BeachesMLS" | |
| 1860 | + ] | |
| 1861 | + }, | |
| 1862 | + { | |
| 1863 | + "name": "The Corcoran Group Florida", | |
| 1864 | + "website": "www.corcoran.com/fl", | |
| 1865 | + "states": [ | |
| 1866 | + "FL" | |
| 1867 | + ], | |
| 1868 | + "cities": [ | |
| 1869 | + "Palm Beach", | |
| 1870 | + "Miami" | |
| 1871 | + ], | |
| 1872 | + "estimated_agents": 700, | |
| 1873 | + "estimated_listings": 1400, | |
| 1874 | + "mls_affiliations": [ | |
| 1875 | + "MiamiMLS", | |
| 1876 | + "BeachesMLS" | |
| 1877 | + ] | |
| 1878 | + }, | |
| 1879 | + { | |
| 1880 | + "name": "ONE Sotheby's International Realty", | |
| 1881 | + "website": "www.onesothebysrealty.com", | |
| 1882 | + "states": [ | |
| 1883 | + "FL" | |
| 1884 | + ], | |
| 1885 | + "cities": [ | |
| 1886 | + "Miami", | |
| 1887 | + "Fort Lauderdale" | |
| 1888 | + ], | |
| 1889 | + "estimated_agents": 1300, | |
| 1890 | + "estimated_listings": 2800, | |
| 1891 | + "mls_affiliations": [ | |
| 1892 | + "MiamiMLS" | |
| 1893 | + ] | |
| 1894 | + }, | |
| 1895 | + { | |
| 1896 | + "name": "Premier Sotheby's International Realty", | |
| 1897 | + "website": "www.premiersothebysrealty.com", | |
| 1898 | + "states": [ | |
| 1899 | + "FL", | |
| 1900 | + "NC" | |
| 1901 | + ], | |
| 1902 | + "cities": [ | |
| 1903 | + "Naples", | |
| 1904 | + "Sarasota" | |
| 1905 | + ], | |
| 1906 | + "estimated_agents": 1400, | |
| 1907 | + "estimated_listings": 3200, | |
| 1908 | + "mls_affiliations": [ | |
| 1909 | + "Stellar", | |
| 1910 | + "NABOR" | |
| 1911 | + ] | |
| 1912 | + }, | |
| 1913 | + { | |
| 1914 | + "name": "John R. Wood Christie's", | |
| 1915 | + "website": "www.johnrwood.com", | |
| 1916 | + "states": [ | |
| 1917 | + "FL" | |
| 1918 | + ], | |
| 1919 | + "cities": [ | |
| 1920 | + "Naples", | |
| 1921 | + "Fort Myers" | |
| 1922 | + ], | |
| 1923 | + "estimated_agents": 800, | |
| 1924 | + "estimated_listings": 1900, | |
| 1925 | + "mls_affiliations": [ | |
| 1926 | + "NABOR" | |
| 1927 | + ] | |
| 1928 | + }, | |
| 1929 | + { | |
| 1930 | + "name": "Royal Shell Real Estate", | |
| 1931 | + "website": "www.royalshellrealestate.com", | |
| 1932 | + "states": [ | |
| 1933 | + "FL", | |
| 1934 | + "NC" | |
| 1935 | + ], | |
| 1936 | + "cities": [ | |
| 1937 | + "Fort Myers", | |
| 1938 | + "Sanibel" | |
| 1939 | + ], | |
| 1940 | + "estimated_agents": 500, | |
| 1941 | + "estimated_listings": 1100, | |
| 1942 | + "mls_affiliations": [ | |
| 1943 | + "NABOR" | |
| 1944 | + ] | |
| 1945 | + }, | |
| 1946 | + { | |
| 1947 | + "name": "Smith & Associates Real Estate", | |
| 1948 | + "website": "www.smithandassociates.com", | |
| 1949 | + "states": [ | |
| 1950 | + "FL" | |
| 1951 | + ], | |
| 1952 | + "cities": [ | |
| 1953 | + "Tampa", | |
| 1954 | + "Saint Petersburg" | |
| 1955 | + ], | |
| 1956 | + "estimated_agents": 300, | |
| 1957 | + "estimated_listings": 800, | |
| 1958 | + "mls_affiliations": [ | |
| 1959 | + "Stellar" | |
| 1960 | + ] | |
| 1961 | + }, | |
| 1962 | + { | |
| 1963 | + "name": "Berkshire Hathaway HomeServices Florida Properties Group", | |
| 1964 | + "website": "www.bhhsfloridaproperties.com", | |
| 1965 | + "states": [ | |
| 1966 | + "FL" | |
| 1967 | + ], | |
| 1968 | + "cities": [ | |
| 1969 | + "Tampa" | |
| 1970 | + ], | |
| 1971 | + "estimated_agents": 650, | |
| 1972 | + "estimated_listings": 1400, | |
| 1973 | + "mls_affiliations": [ | |
| 1974 | + "Stellar" | |
| 1975 | + ] | |
| 1976 | + }, | |
| 1977 | + { | |
| 1978 | + "name": "Charles Rutenberg Realty Orlando", | |
| 1979 | + "website": "www.orlandocrr.com", | |
| 1980 | + "states": [ | |
| 1981 | + "FL" | |
| 1982 | + ], | |
| 1983 | + "cities": [ | |
| 1984 | + "Orlando" | |
| 1985 | + ], | |
| 1986 | + "estimated_agents": 2000, | |
| 1987 | + "estimated_listings": 2800, | |
| 1988 | + "mls_affiliations": [ | |
| 1989 | + "Stellar" | |
| 1990 | + ] | |
| 1991 | + }, | |
| 1992 | + { | |
| 1993 | + "name": "Charles Rutenberg Realty Clearwater", | |
| 1994 | + "website": "www.crrfl.com", | |
| 1995 | + "states": [ | |
| 1996 | + "FL" | |
| 1997 | + ], | |
| 1998 | + "cities": [ | |
| 1999 | + "Clearwater", | |
| 2000 | + "Tampa" | |
| 2001 | + ], | |
| 2002 | + "estimated_agents": 2200, | |
| 2003 | + "estimated_listings": 3200, | |
| 2004 | + "mls_affiliations": [ | |
| 2005 | + "Stellar" | |
| 2006 | + ] | |
| 2007 | + }, | |
| 2008 | + { | |
| 2009 | + "name": "La Rosa Realty", | |
| 2010 | + "website": "www.larosarealty.com", | |
| 2011 | + "states": [ | |
| 2012 | + "FL" | |
| 2013 | + ], | |
| 2014 | + "cities": [ | |
| 2015 | + "Orlando", | |
| 2016 | + "Kissimmee" | |
| 2017 | + ], | |
| 2018 | + "estimated_agents": 2500, | |
| 2019 | + "estimated_listings": 3500, | |
| 2020 | + "mls_affiliations": [ | |
| 2021 | + "Stellar" | |
| 2022 | + ] | |
| 2023 | + }, | |
| 2024 | + { | |
| 2025 | + "name": "Olympus Executive Realty", | |
| 2026 | + "website": "www.olympusexecutiverealty.com", | |
| 2027 | + "states": [ | |
| 2028 | + "FL" | |
| 2029 | + ], | |
| 2030 | + "cities": [ | |
| 2031 | + "Orlando" | |
| 2032 | + ], | |
| 2033 | + "estimated_agents": 800, | |
| 2034 | + "estimated_listings": 1200, | |
| 2035 | + "mls_affiliations": [ | |
| 2036 | + "Stellar" | |
| 2037 | + ] | |
| 2038 | + }, | |
| 2039 | + { | |
| 2040 | + "name": "Dalton Wade Real Estate Group", | |
| 2041 | + "website": "www.daltonwade.com", | |
| 2042 | + "states": [ | |
| 2043 | + "FL" | |
| 2044 | + ], | |
| 2045 | + "cities": [ | |
| 2046 | + "Saint Petersburg" | |
| 2047 | + ], | |
| 2048 | + "estimated_agents": 2200, | |
| 2049 | + "estimated_listings": 3000, | |
| 2050 | + "mls_affiliations": [ | |
| 2051 | + "Stellar" | |
| 2052 | + ] | |
| 2053 | + }, | |
| 2054 | + { | |
| 2055 | + "name": "LPT Realty", | |
| 2056 | + "website": "www.lptrealty.com", | |
| 2057 | + "states": [ | |
| 2058 | + "FL", | |
| 2059 | + "TX", | |
| 2060 | + "GA", | |
| 2061 | + "NC", | |
| 2062 | + "SC", | |
| 2063 | + "TN", | |
| 2064 | + "AL", | |
| 2065 | + "CO", | |
| 2066 | + "AZ", | |
| 2067 | + "NV" | |
| 2068 | + ], | |
| 2069 | + "cities": [ | |
| 2070 | + "Lake Mary" | |
| 2071 | + ], | |
| 2072 | + "estimated_agents": 12000, | |
| 2073 | + "estimated_listings": 16000, | |
| 2074 | + "mls_affiliations": [ | |
| 2075 | + "Stellar" | |
| 2076 | + ] | |
| 2077 | + }, | |
| 2078 | + { | |
| 2079 | + "name": "Robert Slack LLC", | |
| 2080 | + "website": "www.robertslack.com", | |
| 2081 | + "states": [ | |
| 2082 | + "FL" | |
| 2083 | + ], | |
| 2084 | + "cities": [ | |
| 2085 | + "Ocala", | |
| 2086 | + "Orlando" | |
| 2087 | + ], | |
| 2088 | + "estimated_agents": 600, | |
| 2089 | + "estimated_listings": 1400, | |
| 2090 | + "mls_affiliations": [ | |
| 2091 | + "Stellar" | |
| 2092 | + ] | |
| 2093 | + }, | |
| 2094 | + { | |
| 2095 | + "name": "Florida Homes Realty & Mortgage", | |
| 2096 | + "website": "www.floridahomesrealty.com", | |
| 2097 | + "states": [ | |
| 2098 | + "FL" | |
| 2099 | + ], | |
| 2100 | + "cities": [ | |
| 2101 | + "Jacksonville" | |
| 2102 | + ], | |
| 2103 | + "estimated_agents": 1500, | |
| 2104 | + "estimated_listings": 2200, | |
| 2105 | + "mls_affiliations": [ | |
| 2106 | + "realMLS" | |
| 2107 | + ] | |
| 2108 | + }, | |
| 2109 | + { | |
| 2110 | + "name": "Berkshire Hathaway HomeServices Florida Network Realty", | |
| 2111 | + "website": "www.floridanetworkrealty.com", | |
| 2112 | + "states": [ | |
| 2113 | + "FL" | |
| 2114 | + ], | |
| 2115 | + "cities": [ | |
| 2116 | + "Jacksonville" | |
| 2117 | + ], | |
| 2118 | + "estimated_agents": 500, | |
| 2119 | + "estimated_listings": 1000, | |
| 2120 | + "mls_affiliations": [ | |
| 2121 | + "realMLS" | |
| 2122 | + ] | |
| 2123 | + }, | |
| 2124 | + { | |
| 2125 | + "name": "Ponte Vedra Club Realty", | |
| 2126 | + "website": "www.pvclubrealty.com", | |
| 2127 | + "states": [ | |
| 2128 | + "FL" | |
| 2129 | + ], | |
| 2130 | + "cities": [ | |
| 2131 | + "Ponte Vedra Beach" | |
| 2132 | + ], | |
| 2133 | + "estimated_agents": 90, | |
| 2134 | + "estimated_listings": 220, | |
| 2135 | + "mls_affiliations": [ | |
| 2136 | + "realMLS" | |
| 2137 | + ] | |
| 2138 | + }, | |
| 2139 | + { | |
| 2140 | + "name": "Berkshire Hathaway HomeServices EWM Realty", | |
| 2141 | + "website": "www.ewm.com", | |
| 2142 | + "states": [ | |
| 2143 | + "FL" | |
| 2144 | + ], | |
| 2145 | + "cities": [ | |
| 2146 | + "Miami", | |
| 2147 | + "Coral Gables" | |
| 2148 | + ], | |
| 2149 | + "estimated_agents": 800, | |
| 2150 | + "estimated_listings": 1600, | |
| 2151 | + "mls_affiliations": [ | |
| 2152 | + "MiamiMLS" | |
| 2153 | + ] | |
| 2154 | + }, | |
| 2155 | + { | |
| 2156 | + "name": "Fortune International Realty", | |
| 2157 | + "website": "www.fir.com", | |
| 2158 | + "states": [ | |
| 2159 | + "FL" | |
| 2160 | + ], | |
| 2161 | + "cities": [ | |
| 2162 | + "Miami" | |
| 2163 | + ], | |
| 2164 | + "estimated_agents": 1000, | |
| 2165 | + "estimated_listings": 1800, | |
| 2166 | + "mls_affiliations": [ | |
| 2167 | + "MiamiMLS" | |
| 2168 | + ] | |
| 2169 | + }, | |
| 2170 | + { | |
| 2171 | + "name": "Cervera Real Estate", | |
| 2172 | + "website": "www.cervera.com", | |
| 2173 | + "states": [ | |
| 2174 | + "FL" | |
| 2175 | + ], | |
| 2176 | + "cities": [ | |
| 2177 | + "Miami" | |
| 2178 | + ], | |
| 2179 | + "estimated_agents": 300, | |
| 2180 | + "estimated_listings": 700, | |
| 2181 | + "mls_affiliations": [ | |
| 2182 | + "MiamiMLS" | |
| 2183 | + ] | |
| 2184 | + }, | |
| 2185 | + { | |
| 2186 | + "name": "Related ISG Realty", | |
| 2187 | + "website": "www.relatedisg.com", | |
| 2188 | + "states": [ | |
| 2189 | + "FL" | |
| 2190 | + ], | |
| 2191 | + "cities": [ | |
| 2192 | + "Miami", | |
| 2193 | + "Aventura" | |
| 2194 | + ], | |
| 2195 | + "estimated_agents": 900, | |
| 2196 | + "estimated_listings": 1500, | |
| 2197 | + "mls_affiliations": [ | |
| 2198 | + "MiamiMLS" | |
| 2199 | + ] | |
| 2200 | + }, | |
| 2201 | + { | |
| 2202 | + "name": "Luxe Properties", | |
| 2203 | + "website": "www.luxeknows.com", | |
| 2204 | + "states": [ | |
| 2205 | + "FL" | |
| 2206 | + ], | |
| 2207 | + "cities": [ | |
| 2208 | + "Miami" | |
| 2209 | + ], | |
| 2210 | + "estimated_agents": 500, | |
| 2211 | + "estimated_listings": 900, | |
| 2212 | + "mls_affiliations": [ | |
| 2213 | + "MiamiMLS" | |
| 2214 | + ] | |
| 2215 | + }, | |
| 2216 | + { | |
| 2217 | + "name": "Avanti Way Realty", | |
| 2218 | + "website": "www.avantiway.com", | |
| 2219 | + "states": [ | |
| 2220 | + "FL" | |
| 2221 | + ], | |
| 2222 | + "cities": [ | |
| 2223 | + "Miami" | |
| 2224 | + ], | |
| 2225 | + "estimated_agents": 1200, | |
| 2226 | + "estimated_listings": 1800, | |
| 2227 | + "mls_affiliations": [ | |
| 2228 | + "MiamiMLS" | |
| 2229 | + ] | |
| 2230 | + }, | |
| 2231 | + { | |
| 2232 | + "name": "United Realty Group", | |
| 2233 | + "website": "www.urgfl.com", | |
| 2234 | + "states": [ | |
| 2235 | + "FL" | |
| 2236 | + ], | |
| 2237 | + "cities": [ | |
| 2238 | + "Fort Lauderdale" | |
| 2239 | + ], | |
| 2240 | + "estimated_agents": 2500, | |
| 2241 | + "estimated_listings": 3400, | |
| 2242 | + "mls_affiliations": [ | |
| 2243 | + "BeachesMLS" | |
| 2244 | + ] | |
| 2245 | + }, | |
| 2246 | + { | |
| 2247 | + "name": "RE/MAX Fine Properties Florida", | |
| 2248 | + "website": "www.remaxfineproperties.com", | |
| 2249 | + "states": [ | |
| 2250 | + "FL" | |
| 2251 | + ], | |
| 2252 | + "cities": [ | |
| 2253 | + "Sarasota" | |
| 2254 | + ], | |
| 2255 | + "estimated_agents": 200, | |
| 2256 | + "estimated_listings": 500, | |
| 2257 | + "mls_affiliations": [ | |
| 2258 | + "Stellar" | |
| 2259 | + ] | |
| 2260 | + }, | |
| 2261 | + { | |
| 2262 | + "name": "Michael Saunders Rentals & New Homes", | |
| 2263 | + "website": "www.msandc.com", | |
| 2264 | + "states": [ | |
| 2265 | + "FL" | |
| 2266 | + ], | |
| 2267 | + "cities": [ | |
| 2268 | + "Sarasota" | |
| 2269 | + ], | |
| 2270 | + "estimated_agents": 150, | |
| 2271 | + "estimated_listings": 350, | |
| 2272 | + "mls_affiliations": [ | |
| 2273 | + "Stellar" | |
| 2274 | + ] | |
| 2275 | + }, | |
| 2276 | + { | |
| 2277 | + "name": "Coastal Properties Group (Christie's)", | |
| 2278 | + "website": "www.coastalpgi.com", | |
| 2279 | + "states": [ | |
| 2280 | + "FL" | |
| 2281 | + ], | |
| 2282 | + "cities": [ | |
| 2283 | + "Tampa", | |
| 2284 | + "Clearwater" | |
| 2285 | + ], | |
| 2286 | + "estimated_agents": 400, | |
| 2287 | + "estimated_listings": 900, | |
| 2288 | + "mls_affiliations": [ | |
| 2289 | + "Stellar" | |
| 2290 | + ] | |
| 2291 | + }, | |
| 2292 | + { | |
| 2293 | + "name": "Engel & Völkers Florida", | |
| 2294 | + "website": "www.florida.evrealestate.com", | |
| 2295 | + "states": [ | |
| 2296 | + "FL" | |
| 2297 | + ], | |
| 2298 | + "cities": [ | |
| 2299 | + "Naples", | |
| 2300 | + "Miami" | |
| 2301 | + ], | |
| 2302 | + "estimated_agents": 700, | |
| 2303 | + "estimated_listings": 1400, | |
| 2304 | + "mls_affiliations": [ | |
| 2305 | + "NABOR", | |
| 2306 | + "MiamiMLS" | |
| 2307 | + ] | |
| 2308 | + }, | |
| 2309 | + { | |
| 2310 | + "name": "Coldwell Banker Warburg", | |
| 2311 | + "website": "www.cbwarburg.com", | |
| 2312 | + "states": [ | |
| 2313 | + "NY" | |
| 2314 | + ], | |
| 2315 | + "cities": [ | |
| 2316 | + "New York" | |
| 2317 | + ], | |
| 2318 | + "estimated_agents": 130, | |
| 2319 | + "estimated_listings": 300, | |
| 2320 | + "mls_affiliations": [] | |
| 2321 | + }, | |
| 2322 | + { | |
| 2323 | + "name": "Halstead / BHS merged", | |
| 2324 | + "website": "www.halstead.com", | |
| 2325 | + "states": [ | |
| 2326 | + "NY", | |
| 2327 | + "NJ", | |
| 2328 | + "CT" | |
| 2329 | + ], | |
| 2330 | + "cities": [ | |
| 2331 | + "New York" | |
| 2332 | + ], | |
| 2333 | + "estimated_agents": 1200, | |
| 2334 | + "estimated_listings": 2400, | |
| 2335 | + "mls_affiliations": [] | |
| 2336 | + }, | |
| 2337 | + { | |
| 2338 | + "name": "Bond New York", | |
| 2339 | + "website": "www.bondnewyork.com", | |
| 2340 | + "states": [ | |
| 2341 | + "NY" | |
| 2342 | + ], | |
| 2343 | + "cities": [ | |
| 2344 | + "New York" | |
| 2345 | + ], | |
| 2346 | + "estimated_agents": 500, | |
| 2347 | + "estimated_listings": 900, | |
| 2348 | + "mls_affiliations": [] | |
| 2349 | + }, | |
| 2350 | + { | |
| 2351 | + "name": "Level Group", | |
| 2352 | + "website": "www.levelgroup.com", | |
| 2353 | + "states": [ | |
| 2354 | + "NY" | |
| 2355 | + ], | |
| 2356 | + "cities": [ | |
| 2357 | + "New York" | |
| 2358 | + ], | |
| 2359 | + "estimated_agents": 900, | |
| 2360 | + "estimated_listings": 1300, | |
| 2361 | + "mls_affiliations": [] | |
| 2362 | + }, | |
| 2363 | + { | |
| 2364 | + "name": "Nest Seekers International", | |
| 2365 | + "website": "www.nestseekers.com", | |
| 2366 | + "states": [ | |
| 2367 | + "NY", | |
| 2368 | + "NJ", | |
| 2369 | + "FL", | |
| 2370 | + "CA" | |
| 2371 | + ], | |
| 2372 | + "cities": [ | |
| 2373 | + "New York", | |
| 2374 | + "Hamptons" | |
| 2375 | + ], | |
| 2376 | + "estimated_agents": 1400, | |
| 2377 | + "estimated_listings": 2500, | |
| 2378 | + "mls_affiliations": [] | |
| 2379 | + }, | |
| 2380 | + { | |
| 2381 | + "name": "The Modlin Group", | |
| 2382 | + "website": "www.modlingroup.com", | |
| 2383 | + "states": [ | |
| 2384 | + "NY" | |
| 2385 | + ], | |
| 2386 | + "cities": [ | |
| 2387 | + "New York" | |
| 2388 | + ], | |
| 2389 | + "estimated_agents": 40, | |
| 2390 | + "estimated_listings": 100, | |
| 2391 | + "mls_affiliations": [] | |
| 2392 | + }, | |
| 2393 | + { | |
| 2394 | + "name": "Saunders & Associates", | |
| 2395 | + "website": "www.saunders.com", | |
| 2396 | + "states": [ | |
| 2397 | + "NY" | |
| 2398 | + ], | |
| 2399 | + "cities": [ | |
| 2400 | + "Bridgehampton", | |
| 2401 | + "Southampton" | |
| 2402 | + ], | |
| 2403 | + "estimated_agents": 120, | |
| 2404 | + "estimated_listings": 400, | |
| 2405 | + "mls_affiliations": [] | |
| 2406 | + }, | |
| 2407 | + { | |
| 2408 | + "name": "Town & Country Real Estate Hamptons", | |
| 2409 | + "website": "www.townandcountryhamptons.com", | |
| 2410 | + "states": [ | |
| 2411 | + "NY" | |
| 2412 | + ], | |
| 2413 | + "cities": [ | |
| 2414 | + "East Hampton" | |
| 2415 | + ], | |
| 2416 | + "estimated_agents": 90, | |
| 2417 | + "estimated_listings": 260, | |
| 2418 | + "mls_affiliations": [] | |
| 2419 | + }, | |
| 2420 | + { | |
| 2421 | + "name": "Julia B. Fee Sotheby's International Realty", | |
| 2422 | + "website": "www.juliabfee.com", | |
| 2423 | + "states": [ | |
| 2424 | + "NY" | |
| 2425 | + ], | |
| 2426 | + "cities": [ | |
| 2427 | + "Rye", | |
| 2428 | + "Scarsdale" | |
| 2429 | + ], | |
| 2430 | + "estimated_agents": 200, | |
| 2431 | + "estimated_listings": 450, | |
| 2432 | + "mls_affiliations": [ | |
| 2433 | + "OneKey" | |
| 2434 | + ] | |
| 2435 | + }, | |
| 2436 | + { | |
| 2437 | + "name": "William Pitt Sotheby's International Realty", | |
| 2438 | + "website": "www.williampitt.com", | |
| 2439 | + "states": [ | |
| 2440 | + "CT", | |
| 2441 | + "NY", | |
| 2442 | + "MA" | |
| 2443 | + ], | |
| 2444 | + "cities": [ | |
| 2445 | + "Stamford" | |
| 2446 | + ], | |
| 2447 | + "estimated_agents": 1100, | |
| 2448 | + "estimated_listings": 2300, | |
| 2449 | + "mls_affiliations": [ | |
| 2450 | + "SmartMLS" | |
| 2451 | + ] | |
| 2452 | + }, | |
| 2453 | + { | |
| 2454 | + "name": "Berkshire Hathaway HomeServices New England Properties", | |
| 2455 | + "website": "www.bhhsneproperties.com", | |
| 2456 | + "states": [ | |
| 2457 | + "CT", | |
| 2458 | + "RI", | |
| 2459 | + "NY" | |
| 2460 | + ], | |
| 2461 | + "cities": [ | |
| 2462 | + "Hartford" | |
| 2463 | + ], | |
| 2464 | + "estimated_agents": 1500, | |
| 2465 | + "estimated_listings": 2900, | |
| 2466 | + "mls_affiliations": [ | |
| 2467 | + "SmartMLS" | |
| 2468 | + ] | |
| 2469 | + }, | |
| 2470 | + { | |
| 2471 | + "name": "The Riverside Realty Group", | |
| 2472 | + "website": "www.riversiderealtyct.com", | |
| 2473 | + "states": [ | |
| 2474 | + "CT" | |
| 2475 | + ], | |
| 2476 | + "cities": [ | |
| 2477 | + "Westport" | |
| 2478 | + ], | |
| 2479 | + "estimated_agents": 60, | |
| 2480 | + "estimated_listings": 160, | |
| 2481 | + "mls_affiliations": [ | |
| 2482 | + "SmartMLS" | |
| 2483 | + ] | |
| 2484 | + }, | |
| 2485 | + { | |
| 2486 | + "name": "Higgins Group Private Brokerage", | |
| 2487 | + "website": "www.higginsgroup.com", | |
| 2488 | + "states": [ | |
| 2489 | + "CT" | |
| 2490 | + ], | |
| 2491 | + "cities": [ | |
| 2492 | + "Fairfield" | |
| 2493 | + ], | |
| 2494 | + "estimated_agents": 400, | |
| 2495 | + "estimated_listings": 800, | |
| 2496 | + "mls_affiliations": [ | |
| 2497 | + "SmartMLS" | |
| 2498 | + ] | |
| 2499 | + }, | |
| 2500 | + { | |
| 2501 | + "name": "Gibson Sotheby's International Realty", | |
| 2502 | + "website": "www.gibsonsothebysrealty.com", | |
| 2503 | + "states": [ | |
| 2504 | + "MA" | |
| 2505 | + ], | |
| 2506 | + "cities": [ | |
| 2507 | + "Boston" | |
| 2508 | + ], | |
| 2509 | + "estimated_agents": 400, | |
| 2510 | + "estimated_listings": 900, | |
| 2511 | + "mls_affiliations": [ | |
| 2512 | + "MLSPIN" | |
| 2513 | + ] | |
| 2514 | + }, | |
| 2515 | + { | |
| 2516 | + "name": "Jack Conway & Company", | |
| 2517 | + "website": "www.jackconway.com", | |
| 2518 | + "states": [ | |
| 2519 | + "MA" | |
| 2520 | + ], | |
| 2521 | + "cities": [ | |
| 2522 | + "Norwell", | |
| 2523 | + "Boston South Shore" | |
| 2524 | + ], | |
| 2525 | + "estimated_agents": 700, | |
| 2526 | + "estimated_listings": 1300, | |
| 2527 | + "mls_affiliations": [ | |
| 2528 | + "MLSPIN" | |
| 2529 | + ] | |
| 2530 | + }, | |
| 2531 | + { | |
| 2532 | + "name": "Lamacchia Realty", | |
| 2533 | + "website": "www.lamacchiarealty.com", | |
| 2534 | + "states": [ | |
| 2535 | + "MA", | |
| 2536 | + "NH", | |
| 2537 | + "CT", | |
| 2538 | + "RI", | |
| 2539 | + "FL" | |
| 2540 | + ], | |
| 2541 | + "cities": [ | |
| 2542 | + "Waltham" | |
| 2543 | + ], | |
| 2544 | + "estimated_agents": 500, | |
| 2545 | + "estimated_listings": 1100, | |
| 2546 | + "mls_affiliations": [ | |
| 2547 | + "MLSPIN" | |
| 2548 | + ] | |
| 2549 | + }, | |
| 2550 | + { | |
| 2551 | + "name": "William Raveis Boston", | |
| 2552 | + "website": "www.raveis.com/ma", | |
| 2553 | + "states": [ | |
| 2554 | + "MA" | |
| 2555 | + ], | |
| 2556 | + "cities": [ | |
| 2557 | + "Boston" | |
| 2558 | + ], | |
| 2559 | + "estimated_agents": 700, | |
| 2560 | + "estimated_listings": 1300, | |
| 2561 | + "mls_affiliations": [ | |
| 2562 | + "MLSPIN" | |
| 2563 | + ] | |
| 2564 | + }, | |
| 2565 | + { | |
| 2566 | + "name": "Compass Massachusetts", | |
| 2567 | + "website": "www.compass.com/ma", | |
| 2568 | + "states": [ | |
| 2569 | + "MA" | |
| 2570 | + ], | |
| 2571 | + "cities": [ | |
| 2572 | + "Boston", | |
| 2573 | + "Cambridge" | |
| 2574 | + ], | |
| 2575 | + "estimated_agents": 1300, | |
| 2576 | + "estimated_listings": 2300, | |
| 2577 | + "mls_affiliations": [ | |
| 2578 | + "MLSPIN" | |
| 2579 | + ] | |
| 2580 | + }, | |
| 2581 | + { | |
| 2582 | + "name": "Berkshire Hathaway HomeServices Commonwealth Real Estate", | |
| 2583 | + "website": "www.commonmoves.com", | |
| 2584 | + "states": [ | |
| 2585 | + "MA" | |
| 2586 | + ], | |
| 2587 | + "cities": [ | |
| 2588 | + "Natick" | |
| 2589 | + ], | |
| 2590 | + "estimated_agents": 900, | |
| 2591 | + "estimated_listings": 1700, | |
| 2592 | + "mls_affiliations": [ | |
| 2593 | + "MLSPIN" | |
| 2594 | + ] | |
| 2595 | + }, | |
| 2596 | + { | |
| 2597 | + "name": "Barrett Sotheby's International Realty", | |
| 2598 | + "website": "www.barrettsothebysrealty.com", | |
| 2599 | + "states": [ | |
| 2600 | + "MA" | |
| 2601 | + ], | |
| 2602 | + "cities": [ | |
| 2603 | + "Concord", | |
| 2604 | + "Lexington" | |
| 2605 | + ], | |
| 2606 | + "estimated_agents": 150, | |
| 2607 | + "estimated_listings": 350, | |
| 2608 | + "mls_affiliations": [ | |
| 2609 | + "MLSPIN" | |
| 2610 | + ] | |
| 2611 | + }, | |
| 2612 | + { | |
| 2613 | + "name": "Residential Properties Ltd", | |
| 2614 | + "website": "www.residentialproperties.com", | |
| 2615 | + "states": [ | |
| 2616 | + "RI" | |
| 2617 | + ], | |
| 2618 | + "cities": [ | |
| 2619 | + "Providence", | |
| 2620 | + "Barrington" | |
| 2621 | + ], | |
| 2622 | + "estimated_agents": 200, | |
| 2623 | + "estimated_listings": 500, | |
| 2624 | + "mls_affiliations": [ | |
| 2625 | + "RIMLS" | |
| 2626 | + ] | |
| 2627 | + }, | |
| 2628 | + { | |
| 2629 | + "name": "Mott & Chace Sotheby's International Realty", | |
| 2630 | + "website": "www.mottandchace.com", | |
| 2631 | + "states": [ | |
| 2632 | + "RI" | |
| 2633 | + ], | |
| 2634 | + "cities": [ | |
| 2635 | + "Providence", | |
| 2636 | + "Newport" | |
| 2637 | + ], | |
| 2638 | + "estimated_agents": 130, | |
| 2639 | + "estimated_listings": 320, | |
| 2640 | + "mls_affiliations": [ | |
| 2641 | + "RIMLS" | |
| 2642 | + ] | |
| 2643 | + }, | |
| 2644 | + { | |
| 2645 | + "name": "Lila Delman Compass", | |
| 2646 | + "website": "www.liladelman.com", | |
| 2647 | + "states": [ | |
| 2648 | + "RI" | |
| 2649 | + ], | |
| 2650 | + "cities": [ | |
| 2651 | + "Newport" | |
| 2652 | + ], | |
| 2653 | + "estimated_agents": 100, | |
| 2654 | + "estimated_listings": 260, | |
| 2655 | + "mls_affiliations": [ | |
| 2656 | + "RIMLS" | |
| 2657 | + ] | |
| 2658 | + }, | |
| 2659 | + { | |
| 2660 | + "name": "Badger Peabody & Smith Realty", | |
| 2661 | + "website": "www.peabodysmith.com", | |
| 2662 | + "states": [ | |
| 2663 | + "NH" | |
| 2664 | + ], | |
| 2665 | + "cities": [ | |
| 2666 | + "Franconia", | |
| 2667 | + "Plymouth" | |
| 2668 | + ], | |
| 2669 | + "estimated_agents": 90, | |
| 2670 | + "estimated_listings": 260, | |
| 2671 | + "mls_affiliations": [ | |
| 2672 | + "PrimeMLS" | |
| 2673 | + ] | |
| 2674 | + }, | |
| 2675 | + { | |
| 2676 | + "name": "Bean Group", | |
| 2677 | + "website": "www.beangroup.com", | |
| 2678 | + "states": [ | |
| 2679 | + "NH", | |
| 2680 | + "ME", | |
| 2681 | + "MA", | |
| 2682 | + "VT" | |
| 2683 | + ], | |
| 2684 | + "cities": [ | |
| 2685 | + "Portsmouth" | |
| 2686 | + ], | |
| 2687 | + "estimated_agents": 300, | |
| 2688 | + "estimated_listings": 700, | |
| 2689 | + "mls_affiliations": [ | |
| 2690 | + "PrimeMLS" | |
| 2691 | + ] | |
| 2692 | + }, | |
| 2693 | + { | |
| 2694 | + "name": "KW Coastal and Lakes & Mountains Realty", | |
| 2695 | + "website": "www.kwlakesandmountains.com", | |
| 2696 | + "states": [ | |
| 2697 | + "NH" | |
| 2698 | + ], | |
| 2699 | + "cities": [ | |
| 2700 | + "Portsmouth", | |
| 2701 | + "Wolfeboro" | |
| 2702 | + ], | |
| 2703 | + "estimated_agents": 500, | |
| 2704 | + "estimated_listings": 900, | |
| 2705 | + "mls_affiliations": [ | |
| 2706 | + "PrimeMLS" | |
| 2707 | + ] | |
| 2708 | + }, | |
| 2709 | + { | |
| 2710 | + "name": "Legacy Properties Sotheby's International Realty", | |
| 2711 | + "website": "www.legacysir.com", | |
| 2712 | + "states": [ | |
| 2713 | + "ME" | |
| 2714 | + ], | |
| 2715 | + "cities": [ | |
| 2716 | + "Portland", | |
| 2717 | + "Kennebunk" | |
| 2718 | + ], | |
| 2719 | + "estimated_agents": 130, | |
| 2720 | + "estimated_listings": 350, | |
| 2721 | + "mls_affiliations": [ | |
| 2722 | + "MREIS" | |
| 2723 | + ] | |
| 2724 | + }, | |
| 2725 | + { | |
| 2726 | + "name": "Portside Real Estate Group", | |
| 2727 | + "website": "www.portsiderealestategroup.com", | |
| 2728 | + "states": [ | |
| 2729 | + "ME" | |
| 2730 | + ], | |
| 2731 | + "cities": [ | |
| 2732 | + "Portland" | |
| 2733 | + ], | |
| 2734 | + "estimated_agents": 90, | |
| 2735 | + "estimated_listings": 240, | |
| 2736 | + "mls_affiliations": [ | |
| 2737 | + "MREIS" | |
| 2738 | + ] | |
| 2739 | + }, | |
| 2740 | + { | |
| 2741 | + "name": "Camden Real Estate Company", | |
| 2742 | + "website": "www.camdenre.com", | |
| 2743 | + "states": [ | |
| 2744 | + "ME" | |
| 2745 | + ], | |
| 2746 | + "cities": [ | |
| 2747 | + "Camden" | |
| 2748 | + ], | |
| 2749 | + "estimated_agents": 30, | |
| 2750 | + "estimated_listings": 90, | |
| 2751 | + "mls_affiliations": [ | |
| 2752 | + "MREIS" | |
| 2753 | + ] | |
| 2754 | + }, | |
| 2755 | + { | |
| 2756 | + "name": "Four Seasons Sotheby's International Realty", | |
| 2757 | + "website": "www.fourseasonssir.com", | |
| 2758 | + "states": [ | |
| 2759 | + "VT", | |
| 2760 | + "NH" | |
| 2761 | + ], | |
| 2762 | + "cities": [ | |
| 2763 | + "Stowe", | |
| 2764 | + "Manchester" | |
| 2765 | + ], | |
| 2766 | + "estimated_agents": 250, | |
| 2767 | + "estimated_listings": 600, | |
| 2768 | + "mls_affiliations": [ | |
| 2769 | + "PrimeMLS" | |
| 2770 | + ] | |
| 2771 | + }, | |
| 2772 | + { | |
| 2773 | + "name": "Coldwell Banker Hickok & Boardman", | |
| 2774 | + "website": "www.hickokandboardman.com", | |
| 2775 | + "states": [ | |
| 2776 | + "VT" | |
| 2777 | + ], | |
| 2778 | + "cities": [ | |
| 2779 | + "Burlington" | |
| 2780 | + ], | |
| 2781 | + "estimated_agents": 90, | |
| 2782 | + "estimated_listings": 240, | |
| 2783 | + "mls_affiliations": [ | |
| 2784 | + "NEREN" | |
| 2785 | + ] | |
| 2786 | + }, | |
| 2787 | + { | |
| 2788 | + "name": "McEnearney Associates", | |
| 2789 | + "website": "www.mcenearney.com", | |
| 2790 | + "states": [ | |
| 2791 | + "VA", | |
| 2792 | + "DC", | |
| 2793 | + "MD" | |
| 2794 | + ], | |
| 2795 | + "cities": [ | |
| 2796 | + "Alexandria", | |
| 2797 | + "McLean" | |
| 2798 | + ], | |
| 2799 | + "estimated_agents": 400, | |
| 2800 | + "estimated_listings": 900, | |
| 2801 | + "mls_affiliations": [ | |
| 2802 | + "BRIGHT" | |
| 2803 | + ] | |
| 2804 | + }, | |
| 2805 | + { | |
| 2806 | + "name": "TTR Sotheby's International Realty", | |
| 2807 | + "website": "www.ttrsir.com", | |
| 2808 | + "states": [ | |
| 2809 | + "DC", | |
| 2810 | + "VA", | |
| 2811 | + "MD" | |
| 2812 | + ], | |
| 2813 | + "cities": [ | |
| 2814 | + "Washington", | |
| 2815 | + "Georgetown" | |
| 2816 | + ], | |
| 2817 | + "estimated_agents": 500, | |
| 2818 | + "estimated_listings": 1100, | |
| 2819 | + "mls_affiliations": [ | |
| 2820 | + "BRIGHT" | |
| 2821 | + ] | |
| 2822 | + }, | |
| 2823 | + { | |
| 2824 | + "name": "Washington Fine Properties", | |
| 2825 | + "website": "www.wfp.com", | |
| 2826 | + "states": [ | |
| 2827 | + "DC", | |
| 2828 | + "VA", | |
| 2829 | + "MD" | |
| 2830 | + ], | |
| 2831 | + "cities": [ | |
| 2832 | + "Washington" | |
| 2833 | + ], | |
| 2834 | + "estimated_agents": 200, | |
| 2835 | + "estimated_listings": 450, | |
| 2836 | + "mls_affiliations": [ | |
| 2837 | + "BRIGHT" | |
| 2838 | + ] | |
| 2839 | + }, | |
| 2840 | + { | |
| 2841 | + "name": "Samson Properties", | |
| 2842 | + "website": "www.samsonproperties.net", | |
| 2843 | + "states": [ | |
| 2844 | + "VA", | |
| 2845 | + "MD", | |
| 2846 | + "DC", | |
| 2847 | + "WV", | |
| 2848 | + "NC" | |
| 2849 | + ], | |
| 2850 | + "cities": [ | |
| 2851 | + "Chantilly" | |
| 2852 | + ], | |
| 2853 | + "estimated_agents": 5500, | |
| 2854 | + "estimated_listings": 7500, | |
| 2855 | + "mls_affiliations": [ | |
| 2856 | + "BRIGHT" | |
| 2857 | + ] | |
| 2858 | + }, | |
| 2859 | + { | |
| 2860 | + "name": "Pearson Smith Realty", | |
| 2861 | + "website": "www.pearsonsmithrealty.com", | |
| 2862 | + "states": [ | |
| 2863 | + "VA", | |
| 2864 | + "MD", | |
| 2865 | + "DC", | |
| 2866 | + "WV" | |
| 2867 | + ], | |
| 2868 | + "cities": [ | |
| 2869 | + "Ashburn" | |
| 2870 | + ], | |
| 2871 | + "estimated_agents": 1200, | |
| 2872 | + "estimated_listings": 2000, | |
| 2873 | + "mls_affiliations": [ | |
| 2874 | + "BRIGHT" | |
| 2875 | + ] | |
| 2876 | + }, | |
| 2877 | + { | |
| 2878 | + "name": "KW Metro Center", | |
| 2879 | + "website": "www.kwmetrocenter.com", | |
| 2880 | + "states": [ | |
| 2881 | + "VA", | |
| 2882 | + "DC" | |
| 2883 | + ], | |
| 2884 | + "cities": [ | |
| 2885 | + "Alexandria", | |
| 2886 | + "Arlington" | |
| 2887 | + ], | |
| 2888 | + "estimated_agents": 900, | |
| 2889 | + "estimated_listings": 1400, | |
| 2890 | + "mls_affiliations": [ | |
| 2891 | + "BRIGHT" | |
| 2892 | + ] | |
| 2893 | + }, | |
| 2894 | + { | |
| 2895 | + "name": "Joyner Fine Properties", | |
| 2896 | + "website": "www.joynerfineproperties.com", | |
| 2897 | + "states": [ | |
| 2898 | + "VA" | |
| 2899 | + ], | |
| 2900 | + "cities": [ | |
| 2901 | + "Richmond" | |
| 2902 | + ], | |
| 2903 | + "estimated_agents": 200, | |
| 2904 | + "estimated_listings": 500, | |
| 2905 | + "mls_affiliations": [ | |
| 2906 | + "CVRMLS" | |
| 2907 | + ] | |
| 2908 | + }, | |
| 2909 | + { | |
| 2910 | + "name": "Shaheen, Ruth, Martin & Fonville Real Estate", | |
| 2911 | + "website": "www.srmfre.com", | |
| 2912 | + "states": [ | |
| 2913 | + "VA" | |
| 2914 | + ], | |
| 2915 | + "cities": [ | |
| 2916 | + "Richmond" | |
| 2917 | + ], | |
| 2918 | + "estimated_agents": 300, | |
| 2919 | + "estimated_listings": 700, | |
| 2920 | + "mls_affiliations": [ | |
| 2921 | + "CVRMLS" | |
| 2922 | + ] | |
| 2923 | + }, | |
| 2924 | + { | |
| 2925 | + "name": "Howard Hanna William E. Wood", | |
| 2926 | + "website": "www.howardhanna.com/va", | |
| 2927 | + "states": [ | |
| 2928 | + "VA", | |
| 2929 | + "NC" | |
| 2930 | + ], | |
| 2931 | + "cities": [ | |
| 2932 | + "Virginia Beach", | |
| 2933 | + "Norfolk" | |
| 2934 | + ], | |
| 2935 | + "estimated_agents": 900, | |
| 2936 | + "estimated_listings": 2000, | |
| 2937 | + "mls_affiliations": [ | |
| 2938 | + "REIN" | |
| 2939 | + ] | |
| 2940 | + }, | |
| 2941 | + { | |
| 2942 | + "name": "BHHS RW Towne Realty", | |
| 2943 | + "website": "www.rwtowne.com", | |
| 2944 | + "states": [ | |
| 2945 | + "VA", | |
| 2946 | + "NC" | |
| 2947 | + ], | |
| 2948 | + "cities": [ | |
| 2949 | + "Virginia Beach" | |
| 2950 | + ], | |
| 2951 | + "estimated_agents": 500, | |
| 2952 | + "estimated_listings": 1100, | |
| 2953 | + "mls_affiliations": [ | |
| 2954 | + "REIN" | |
| 2955 | + ] | |
| 2956 | + }, | |
| 2957 | + { | |
| 2958 | + "name": "Cummings & Co Realtors", | |
| 2959 | + "website": "www.cummingsrealtors.com", | |
| 2960 | + "states": [ | |
| 2961 | + "MD" | |
| 2962 | + ], | |
| 2963 | + "cities": [ | |
| 2964 | + "Baltimore", | |
| 2965 | + "Towson" | |
| 2966 | + ], | |
| 2967 | + "estimated_agents": 800, | |
| 2968 | + "estimated_listings": 1400, | |
| 2969 | + "mls_affiliations": [ | |
| 2970 | + "BRIGHT" | |
| 2971 | + ] | |
| 2972 | + }, | |
| 2973 | + { | |
| 2974 | + "name": "Northrop Realty", | |
| 2975 | + "website": "www.northroprealty.com", | |
| 2976 | + "states": [ | |
| 2977 | + "MD", | |
| 2978 | + "DE" | |
| 2979 | + ], | |
| 2980 | + "cities": [ | |
| 2981 | + "Columbia", | |
| 2982 | + "Annapolis" | |
| 2983 | + ], | |
| 2984 | + "estimated_agents": 400, | |
| 2985 | + "estimated_listings": 900, | |
| 2986 | + "mls_affiliations": [ | |
| 2987 | + "BRIGHT" | |
| 2988 | + ] | |
| 2989 | + }, | |
| 2990 | + { | |
| 2991 | + "name": "Monument Sotheby's International Realty", | |
| 2992 | + "website": "www.monumentsothebysrealty.com", | |
| 2993 | + "states": [ | |
| 2994 | + "MD", | |
| 2995 | + "DE" | |
| 2996 | + ], | |
| 2997 | + "cities": [ | |
| 2998 | + "Baltimore", | |
| 2999 | + "Annapolis" | |
| 3000 | + ], | |
| 3001 | + "estimated_agents": 300, | |
| 3002 | + "estimated_listings": 700, | |
| 3003 | + "mls_affiliations": [ | |
| 3004 | + "BRIGHT" | |
| 3005 | + ] | |
| 3006 | + }, | |
| 3007 | + { | |
| 3008 | + "name": "Patterson-Schwartz Real Estate", | |
| 3009 | + "website": "www.pattersonschwartz.com", | |
| 3010 | + "states": [ | |
| 3011 | + "DE", | |
| 3012 | + "PA", | |
| 3013 | + "MD" | |
| 3014 | + ], | |
| 3015 | + "cities": [ | |
| 3016 | + "Wilmington", | |
| 3017 | + "Hockessin" | |
| 3018 | + ], | |
| 3019 | + "estimated_agents": 400, | |
| 3020 | + "estimated_listings": 900, | |
| 3021 | + "mls_affiliations": [ | |
| 3022 | + "BRIGHT" | |
| 3023 | + ] | |
| 3024 | + }, | |
| 3025 | + { | |
| 3026 | + "name": "Jack Lingo Realtor", | |
| 3027 | + "website": "www.jacklingo.com", | |
| 3028 | + "states": [ | |
| 3029 | + "DE" | |
| 3030 | + ], | |
| 3031 | + "cities": [ | |
| 3032 | + "Rehoboth Beach", | |
| 3033 | + "Lewes" | |
| 3034 | + ], | |
| 3035 | + "estimated_agents": 150, | |
| 3036 | + "estimated_listings": 450, | |
| 3037 | + "mls_affiliations": [ | |
| 3038 | + "BRIGHT" | |
| 3039 | + ] | |
| 3040 | + }, | |
| 3041 | + { | |
| 3042 | + "name": "Berkshire Hathaway HomeServices Homesale Realty", | |
| 3043 | + "website": "www.homesale.com", | |
| 3044 | + "states": [ | |
| 3045 | + "PA", | |
| 3046 | + "MD" | |
| 3047 | + ], | |
| 3048 | + "cities": [ | |
| 3049 | + "Lancaster", | |
| 3050 | + "York" | |
| 3051 | + ], | |
| 3052 | + "estimated_agents": 1300, | |
| 3053 | + "estimated_listings": 2600, | |
| 3054 | + "mls_affiliations": [ | |
| 3055 | + "BRIGHT" | |
| 3056 | + ] | |
| 3057 | + }, | |
| 3058 | + { | |
| 3059 | + "name": "RE/MAX of Reading", | |
| 3060 | + "website": "www.gopennsylvania.com", | |
| 3061 | + "states": [ | |
| 3062 | + "PA" | |
| 3063 | + ], | |
| 3064 | + "cities": [ | |
| 3065 | + "Reading", | |
| 3066 | + "Wyomissing" | |
| 3067 | + ], | |
| 3068 | + "estimated_agents": 130, | |
| 3069 | + "estimated_listings": 350, | |
| 3070 | + "mls_affiliations": [ | |
| 3071 | + "BRIGHT" | |
| 3072 | + ] | |
| 3073 | + }, | |
| 3074 | + { | |
| 3075 | + "name": "Kurfiss Sotheby's International Realty", | |
| 3076 | + "website": "www.kurfiss.com", | |
| 3077 | + "states": [ | |
| 3078 | + "PA", | |
| 3079 | + "NJ" | |
| 3080 | + ], | |
| 3081 | + "cities": [ | |
| 3082 | + "Philadelphia", | |
| 3083 | + "New Hope" | |
| 3084 | + ], | |
| 3085 | + "estimated_agents": 200, | |
| 3086 | + "estimated_listings": 450, | |
| 3087 | + "mls_affiliations": [ | |
| 3088 | + "BRIGHT" | |
| 3089 | + ] | |
| 3090 | + }, | |
| 3091 | + { | |
| 3092 | + "name": "Elfant Wissahickon Realtors", | |
| 3093 | + "website": "www.elfantwissahickon.com", | |
| 3094 | + "states": [ | |
| 3095 | + "PA" | |
| 3096 | + ], | |
| 3097 | + "cities": [ | |
| 3098 | + "Philadelphia" | |
| 3099 | + ], | |
| 3100 | + "estimated_agents": 250, | |
| 3101 | + "estimated_listings": 500, | |
| 3102 | + "mls_affiliations": [ | |
| 3103 | + "BRIGHT" | |
| 3104 | + ] | |
| 3105 | + }, | |
| 3106 | + { | |
| 3107 | + "name": "Compass Pennsylvania", | |
| 3108 | + "website": "www.compass.com/pa", | |
| 3109 | + "states": [ | |
| 3110 | + "PA" | |
| 3111 | + ], | |
| 3112 | + "cities": [ | |
| 3113 | + "Philadelphia" | |
| 3114 | + ], | |
| 3115 | + "estimated_agents": 700, | |
| 3116 | + "estimated_listings": 1300, | |
| 3117 | + "mls_affiliations": [ | |
| 3118 | + "BRIGHT" | |
| 3119 | + ] | |
| 3120 | + }, | |
| 3121 | + { | |
| 3122 | + "name": "RE/MAX Main Line", | |
| 3123 | + "website": "www.remaxmainline.com", | |
| 3124 | + "states": [ | |
| 3125 | + "PA" | |
| 3126 | + ], | |
| 3127 | + "cities": [ | |
| 3128 | + "Paoli", | |
| 3129 | + "Wayne" | |
| 3130 | + ], | |
| 3131 | + "estimated_agents": 200, | |
| 3132 | + "estimated_listings": 500, | |
| 3133 | + "mls_affiliations": [ | |
| 3134 | + "BRIGHT" | |
| 3135 | + ] | |
| 3136 | + }, | |
| 3137 | + { | |
| 3138 | + "name": "Piatt Sotheby's International Realty", | |
| 3139 | + "website": "www.piattsothebysrealty.com", | |
| 3140 | + "states": [ | |
| 3141 | + "PA" | |
| 3142 | + ], | |
| 3143 | + "cities": [ | |
| 3144 | + "Pittsburgh" | |
| 3145 | + ], | |
| 3146 | + "estimated_agents": 150, | |
| 3147 | + "estimated_listings": 400, | |
| 3148 | + "mls_affiliations": [ | |
| 3149 | + "WestPennMLS" | |
| 3150 | + ] | |
| 3151 | + }, | |
| 3152 | + { | |
| 3153 | + "name": "Realty ONE Group Gold Standard", | |
| 3154 | + "website": "www.roggoldstandard.com", | |
| 3155 | + "states": [ | |
| 3156 | + "PA" | |
| 3157 | + ], | |
| 3158 | + "cities": [ | |
| 3159 | + "Pittsburgh" | |
| 3160 | + ], | |
| 3161 | + "estimated_agents": 250, | |
| 3162 | + "estimated_listings": 450, | |
| 3163 | + "mls_affiliations": [ | |
| 3164 | + "WestPennMLS" | |
| 3165 | + ] | |
| 3166 | + }, | |
| 3167 | + { | |
| 3168 | + "name": "Coldwell Banker Realty Pittsburgh", | |
| 3169 | + "website": "www.coldwellbankerhomes.com/pa/pittsburgh", | |
| 3170 | + "states": [ | |
| 3171 | + "PA" | |
| 3172 | + ], | |
| 3173 | + "cities": [ | |
| 3174 | + "Pittsburgh" | |
| 3175 | + ], | |
| 3176 | + "estimated_agents": 700, | |
| 3177 | + "estimated_listings": 1500, | |
| 3178 | + "mls_affiliations": [ | |
| 3179 | + "WestPennMLS" | |
| 3180 | + ] | |
| 3181 | + }, | |
| 3182 | + { | |
| 3183 | + "name": "Prominent Properties Sotheby's International Realty", | |
| 3184 | + "website": "www.prominentproperties.com", | |
| 3185 | + "states": [ | |
| 3186 | + "NJ" | |
| 3187 | + ], | |
| 3188 | + "cities": [ | |
| 3189 | + "Tenafly", | |
| 3190 | + "Ridgewood" | |
| 3191 | + ], | |
| 3192 | + "estimated_agents": 700, | |
| 3193 | + "estimated_listings": 1500, | |
| 3194 | + "mls_affiliations": [ | |
| 3195 | + "NJMLS" | |
| 3196 | + ] | |
| 3197 | + }, | |
| 3198 | + { | |
| 3199 | + "name": "Christie's International Real Estate Northern New Jersey", | |
| 3200 | + "website": "www.specialproperties.com", | |
| 3201 | + "states": [ | |
| 3202 | + "NJ" | |
| 3203 | + ], | |
| 3204 | + "cities": [ | |
| 3205 | + "Saddle River" | |
| 3206 | + ], | |
| 3207 | + "estimated_agents": 300, | |
| 3208 | + "estimated_listings": 700, | |
| 3209 | + "mls_affiliations": [ | |
| 3210 | + "NJMLS" | |
| 3211 | + ] | |
| 3212 | + }, | |
| 3213 | + { | |
| 3214 | + "name": "Coldwell Banker Realty New Jersey", | |
| 3215 | + "website": "www.coldwellbankerhomes.com/nj", | |
| 3216 | + "states": [ | |
| 3217 | + "NJ" | |
| 3218 | + ], | |
| 3219 | + "cities": [ | |
| 3220 | + "Madison", | |
| 3221 | + "Princeton" | |
| 3222 | + ], | |
| 3223 | + "estimated_agents": 3000, | |
| 3224 | + "estimated_listings": 5500, | |
| 3225 | + "mls_affiliations": [ | |
| 3226 | + "GSMLS", | |
| 3227 | + "BRIGHT" | |
| 3228 | + ] | |
| 3229 | + }, | |
| 3230 | + { | |
| 3231 | + "name": "Keller Williams NJ Metro Group", | |
| 3232 | + "website": "www.kwnjmetro.com", | |
| 3233 | + "states": [ | |
| 3234 | + "NJ" | |
| 3235 | + ], | |
| 3236 | + "cities": [ | |
| 3237 | + "Montclair" | |
| 3238 | + ], | |
| 3239 | + "estimated_agents": 400, | |
| 3240 | + "estimated_listings": 800, | |
| 3241 | + "mls_affiliations": [ | |
| 3242 | + "GSMLS" | |
| 3243 | + ] | |
| 3244 | + }, | |
| 3245 | + { | |
| 3246 | + "name": "Halo Realty", | |
| 3247 | + "website": "www.halorealtynj.com", | |
| 3248 | + "states": [ | |
| 3249 | + "NJ" | |
| 3250 | + ], | |
| 3251 | + "cities": [ | |
| 3252 | + "Edison" | |
| 3253 | + ], | |
| 3254 | + "estimated_agents": 400, | |
| 3255 | + "estimated_listings": 700, | |
| 3256 | + "mls_affiliations": [ | |
| 3257 | + "GSMLS" | |
| 3258 | + ] | |
| 3259 | + }, | |
| 3260 | + { | |
| 3261 | + "name": "Diane Turton, Realtors", | |
| 3262 | + "website": "www.dianeturton.com", | |
| 3263 | + "states": [ | |
| 3264 | + "NJ" | |
| 3265 | + ], | |
| 3266 | + "cities": [ | |
| 3267 | + "Point Pleasant", | |
| 3268 | + "Jersey Shore" | |
| 3269 | + ], | |
| 3270 | + "estimated_agents": 400, | |
| 3271 | + "estimated_listings": 900, | |
| 3272 | + "mls_affiliations": [ | |
| 3273 | + "MOMLS" | |
| 3274 | + ] | |
| 3275 | + }, | |
| 3276 | + { | |
| 3277 | + "name": "Berkshire Hathaway HomeServices Fox & Roach NJ Shore", | |
| 3278 | + "website": "www.foxroach.com/nj", | |
| 3279 | + "states": [ | |
| 3280 | + "NJ" | |
| 3281 | + ], | |
| 3282 | + "cities": [ | |
| 3283 | + "Avalon", | |
| 3284 | + "Margate" | |
| 3285 | + ], | |
| 3286 | + "estimated_agents": 700, | |
| 3287 | + "estimated_listings": 1500, | |
| 3288 | + "mls_affiliations": [ | |
| 3289 | + "SJSRMLS" | |
| 3290 | + ] | |
| 3291 | + }, | |
| 3292 | + { | |
| 3293 | + "name": "Harry Norman Realtors", | |
| 3294 | + "website": "www.harrynorman.com", | |
| 3295 | + "states": [ | |
| 3296 | + "GA" | |
| 3297 | + ], | |
| 3298 | + "cities": [ | |
| 3299 | + "Atlanta", | |
| 3300 | + "Buckhead" | |
| 3301 | + ], | |
| 3302 | + "estimated_agents": 1000, | |
| 3303 | + "estimated_listings": 2100, | |
| 3304 | + "mls_affiliations": [ | |
| 3305 | + "FMLS", | |
| 3306 | + "GAMLS" | |
| 3307 | + ] | |
| 3308 | + }, | |
| 3309 | + { | |
| 3310 | + "name": "Atlanta Fine Homes Sotheby's International Realty", | |
| 3311 | + "website": "www.atlantafinehomes.com", | |
| 3312 | + "states": [ | |
| 3313 | + "GA" | |
| 3314 | + ], | |
| 3315 | + "cities": [ | |
| 3316 | + "Atlanta" | |
| 3317 | + ], | |
| 3318 | + "estimated_agents": 500, | |
| 3319 | + "estimated_listings": 1100, | |
| 3320 | + "mls_affiliations": [ | |
| 3321 | + "FMLS" | |
| 3322 | + ] | |
| 3323 | + }, | |
| 3324 | + { | |
| 3325 | + "name": "Ansley Real Estate Christie's", | |
| 3326 | + "website": "www.ansleyre.com", | |
| 3327 | + "states": [ | |
| 3328 | + "GA" | |
| 3329 | + ], | |
| 3330 | + "cities": [ | |
| 3331 | + "Atlanta", | |
| 3332 | + "Alpharetta" | |
| 3333 | + ], | |
| 3334 | + "estimated_agents": 500, | |
| 3335 | + "estimated_listings": 1100, | |
| 3336 | + "mls_affiliations": [ | |
| 3337 | + "FMLS" | |
| 3338 | + ] | |
| 3339 | + }, | |
| 3340 | + { | |
| 3341 | + "name": "Dorsey Alston Realtors", | |
| 3342 | + "website": "www.dorseyalston.com", | |
| 3343 | + "states": [ | |
| 3344 | + "GA" | |
| 3345 | + ], | |
| 3346 | + "cities": [ | |
| 3347 | + "Atlanta" | |
| 3348 | + ], | |
| 3349 | + "estimated_agents": 250, | |
| 3350 | + "estimated_listings": 550, | |
| 3351 | + "mls_affiliations": [ | |
| 3352 | + "FMLS" | |
| 3353 | + ] | |
| 3354 | + }, | |
| 3355 | + { | |
| 3356 | + "name": "Palmer House Properties", | |
| 3357 | + "website": "www.palmerhouseproperties.com", | |
| 3358 | + "states": [ | |
| 3359 | + "GA" | |
| 3360 | + ], | |
| 3361 | + "cities": [ | |
| 3362 | + "Atlanta" | |
| 3363 | + ], | |
| 3364 | + "estimated_agents": 1800, | |
| 3365 | + "estimated_listings": 2500, | |
| 3366 | + "mls_affiliations": [ | |
| 3367 | + "FMLS", | |
| 3368 | + "GAMLS" | |
| 3369 | + ] | |
| 3370 | + }, | |
| 3371 | + { | |
| 3372 | + "name": "Maximum One Realty", | |
| 3373 | + "website": "www.maximumonerealty.com", | |
| 3374 | + "states": [ | |
| 3375 | + "GA" | |
| 3376 | + ], | |
| 3377 | + "cities": [ | |
| 3378 | + "Atlanta", | |
| 3379 | + "Marietta" | |
| 3380 | + ], | |
| 3381 | + "estimated_agents": 3000, | |
| 3382 | + "estimated_listings": 4200, | |
| 3383 | + "mls_affiliations": [ | |
| 3384 | + "GAMLS" | |
| 3385 | + ] | |
| 3386 | + }, | |
| 3387 | + { | |
| 3388 | + "name": "Virtual Properties Realty", | |
| 3389 | + "website": "www.virtualpropertiesrealty.com", | |
| 3390 | + "states": [ | |
| 3391 | + "GA" | |
| 3392 | + ], | |
| 3393 | + "cities": [ | |
| 3394 | + "Duluth" | |
| 3395 | + ], | |
| 3396 | + "estimated_agents": 3500, | |
| 3397 | + "estimated_listings": 5000, | |
| 3398 | + "mls_affiliations": [ | |
| 3399 | + "GAMLS" | |
| 3400 | + ] | |
| 3401 | + }, | |
| 3402 | + { | |
| 3403 | + "name": "BHGRE Metro Brokers Atlanta", | |
| 3404 | + "website": "www.metrobrokers.com", | |
| 3405 | + "states": [ | |
| 3406 | + "GA" | |
| 3407 | + ], | |
| 3408 | + "cities": [ | |
| 3409 | + "Atlanta" | |
| 3410 | + ], | |
| 3411 | + "estimated_agents": 2200, | |
| 3412 | + "estimated_listings": 3400, | |
| 3413 | + "mls_affiliations": [ | |
| 3414 | + "GAMLS", | |
| 3415 | + "FMLS" | |
| 3416 | + ] | |
| 3417 | + }, | |
| 3418 | + { | |
| 3419 | + "name": "Seabolt Real Estate", | |
| 3420 | + "website": "www.seaboltrealestate.com", | |
| 3421 | + "states": [ | |
| 3422 | + "GA" | |
| 3423 | + ], | |
| 3424 | + "cities": [ | |
| 3425 | + "Savannah" | |
| 3426 | + ], | |
| 3427 | + "estimated_agents": 80, | |
| 3428 | + "estimated_listings": 220, | |
| 3429 | + "mls_affiliations": [ | |
| 3430 | + "SavannahMLS" | |
| 3431 | + ] | |
| 3432 | + }, | |
| 3433 | + { | |
| 3434 | + "name": "Daniel Ravenel Sotheby's International Realty", | |
| 3435 | + "website": "www.danielravenelsir.com", | |
| 3436 | + "states": [ | |
| 3437 | + "SC", | |
| 3438 | + "GA" | |
| 3439 | + ], | |
| 3440 | + "cities": [ | |
| 3441 | + "Charleston", | |
| 3442 | + "Savannah" | |
| 3443 | + ], | |
| 3444 | + "estimated_agents": 120, | |
| 3445 | + "estimated_listings": 320, | |
| 3446 | + "mls_affiliations": [ | |
| 3447 | + "CharlestonMLS" | |
| 3448 | + ] | |
| 3449 | + }, | |
| 3450 | + { | |
| 3451 | + "name": "Carolina One Real Estate", | |
| 3452 | + "website": "www.carolinaone.com", | |
| 3453 | + "states": [ | |
| 3454 | + "SC" | |
| 3455 | + ], | |
| 3456 | + "cities": [ | |
| 3457 | + "Charleston" | |
| 3458 | + ], | |
| 3459 | + "estimated_agents": 900, | |
| 3460 | + "estimated_listings": 2000, | |
| 3461 | + "mls_affiliations": [ | |
| 3462 | + "CharlestonMLS" | |
| 3463 | + ] | |
| 3464 | + }, | |
| 3465 | + { | |
| 3466 | + "name": "The Boulevard Company", | |
| 3467 | + "website": "www.theboulevardcompany.com", | |
| 3468 | + "states": [ | |
| 3469 | + "SC" | |
| 3470 | + ], | |
| 3471 | + "cities": [ | |
| 3472 | + "Charleston" | |
| 3473 | + ], | |
| 3474 | + "estimated_agents": 200, | |
| 3475 | + "estimated_listings": 450, | |
| 3476 | + "mls_affiliations": [ | |
| 3477 | + "CharlestonMLS" | |
| 3478 | + ] | |
| 3479 | + }, | |
| 3480 | + { | |
| 3481 | + "name": "Dunes Properties of Charleston", | |
| 3482 | + "website": "www.dunesproperties.com", | |
| 3483 | + "states": [ | |
| 3484 | + "SC" | |
| 3485 | + ], | |
| 3486 | + "cities": [ | |
| 3487 | + "Charleston", | |
| 3488 | + "Isle of Palms" | |
| 3489 | + ], | |
| 3490 | + "estimated_agents": 100, | |
| 3491 | + "estimated_listings": 280, | |
| 3492 | + "mls_affiliations": [ | |
| 3493 | + "CharlestonMLS" | |
| 3494 | + ] | |
| 3495 | + }, | |
| 3496 | + { | |
| 3497 | + "name": "ERA Wilder Realty", | |
| 3498 | + "website": "www.erawilder.com", | |
| 3499 | + "states": [ | |
| 3500 | + "SC" | |
| 3501 | + ], | |
| 3502 | + "cities": [ | |
| 3503 | + "Columbia" | |
| 3504 | + ], | |
| 3505 | + "estimated_agents": 300, | |
| 3506 | + "estimated_listings": 700, | |
| 3507 | + "mls_affiliations": [ | |
| 3508 | + "ColumbiaMLS" | |
| 3509 | + ] | |
| 3510 | + }, | |
| 3511 | + { | |
| 3512 | + "name": "Coldwell Banker Caine", | |
| 3513 | + "website": "www.cbcaine.com", | |
| 3514 | + "states": [ | |
| 3515 | + "SC" | |
| 3516 | + ], | |
| 3517 | + "cities": [ | |
| 3518 | + "Greenville" | |
| 3519 | + ], | |
| 3520 | + "estimated_agents": 400, | |
| 3521 | + "estimated_listings": 850, | |
| 3522 | + "mls_affiliations": [ | |
| 3523 | + "GreenvilleMLS" | |
| 3524 | + ] | |
| 3525 | + }, | |
| 3526 | + { | |
| 3527 | + "name": "Berkshire Hathaway HomeServices C Dan Joyner Realtors", | |
| 3528 | + "website": "www.cdanjoyner.com", | |
| 3529 | + "states": [ | |
| 3530 | + "SC" | |
| 3531 | + ], | |
| 3532 | + "cities": [ | |
| 3533 | + "Greenville" | |
| 3534 | + ], | |
| 3535 | + "estimated_agents": 450, | |
| 3536 | + "estimated_listings": 950, | |
| 3537 | + "mls_affiliations": [ | |
| 3538 | + "GreenvilleMLS" | |
| 3539 | + ] | |
| 3540 | + }, | |
| 3541 | + { | |
| 3542 | + "name": "CENTURY 21 Boling & Associates", | |
| 3543 | + "website": "www.century21boling.com", | |
| 3544 | + "states": [ | |
| 3545 | + "SC" | |
| 3546 | + ], | |
| 3547 | + "cities": [ | |
| 3548 | + "Myrtle Beach" | |
| 3549 | + ], | |
| 3550 | + "estimated_agents": 150, | |
| 3551 | + "estimated_listings": 420, | |
| 3552 | + "mls_affiliations": [ | |
| 3553 | + "CCARMLS" | |
| 3554 | + ] | |
| 3555 | + }, | |
| 3556 | + { | |
| 3557 | + "name": "Howard Perry & Walston (Corcoran HPW)", | |
| 3558 | + "website": "www.hpw.com", | |
| 3559 | + "states": [ | |
| 3560 | + "NC" | |
| 3561 | + ], | |
| 3562 | + "cities": [ | |
| 3563 | + "Raleigh", | |
| 3564 | + "Durham" | |
| 3565 | + ], | |
| 3566 | + "estimated_agents": 900, | |
| 3567 | + "estimated_listings": 1900, | |
| 3568 | + "mls_affiliations": [ | |
| 3569 | + "TriangleMLS" | |
| 3570 | + ] | |
| 3571 | + }, | |
| 3572 | + { | |
| 3573 | + "name": "Fonville Morisey Realty", | |
| 3574 | + "website": "www.fmrealty.com", | |
| 3575 | + "states": [ | |
| 3576 | + "NC" | |
| 3577 | + ], | |
| 3578 | + "cities": [ | |
| 3579 | + "Raleigh" | |
| 3580 | + ], | |
| 3581 | + "estimated_agents": 700, | |
| 3582 | + "estimated_listings": 1400, | |
| 3583 | + "mls_affiliations": [ | |
| 3584 | + "TriangleMLS" | |
| 3585 | + ] | |
| 3586 | + }, | |
| 3587 | + { | |
| 3588 | + "name": "Hodge & Kittrell Sotheby's International Realty", | |
| 3589 | + "website": "www.hodgekittrell.com", | |
| 3590 | + "states": [ | |
| 3591 | + "NC" | |
| 3592 | + ], | |
| 3593 | + "cities": [ | |
| 3594 | + "Raleigh" | |
| 3595 | + ], | |
| 3596 | + "estimated_agents": 130, | |
| 3597 | + "estimated_listings": 320, | |
| 3598 | + "mls_affiliations": [ | |
| 3599 | + "TriangleMLS" | |
| 3600 | + ] | |
| 3601 | + }, | |
| 3602 | + { | |
| 3603 | + "name": "Dickens Mitchener", | |
| 3604 | + "website": "www.dickensmitchener.com", | |
| 3605 | + "states": [ | |
| 3606 | + "NC" | |
| 3607 | + ], | |
| 3608 | + "cities": [ | |
| 3609 | + "Charlotte" | |
| 3610 | + ], | |
| 3611 | + "estimated_agents": 130, | |
| 3612 | + "estimated_listings": 320, | |
| 3613 | + "mls_affiliations": [ | |
| 3614 | + "Canopy" | |
| 3615 | + ] | |
| 3616 | + }, | |
| 3617 | + { | |
| 3618 | + "name": "Cottingham Chalk", | |
| 3619 | + "website": "www.cottinghamchalk.com", | |
| 3620 | + "states": [ | |
| 3621 | + "NC" | |
| 3622 | + ], | |
| 3623 | + "cities": [ | |
| 3624 | + "Charlotte" | |
| 3625 | + ], | |
| 3626 | + "estimated_agents": 70, | |
| 3627 | + "estimated_listings": 180, | |
| 3628 | + "mls_affiliations": [ | |
| 3629 | + "Canopy" | |
| 3630 | + ] | |
| 3631 | + }, | |
| 3632 | + { | |
| 3633 | + "name": "Helen Adams Realty", | |
| 3634 | + "website": "www.helenadamsrealty.com", | |
| 3635 | + "states": [ | |
| 3636 | + "NC", | |
| 3637 | + "SC" | |
| 3638 | + ], | |
| 3639 | + "cities": [ | |
| 3640 | + "Charlotte" | |
| 3641 | + ], | |
| 3642 | + "estimated_agents": 250, | |
| 3643 | + "estimated_listings": 550, | |
| 3644 | + "mls_affiliations": [ | |
| 3645 | + "Canopy" | |
| 3646 | + ] | |
| 3647 | + }, | |
| 3648 | + { | |
| 3649 | + "name": "Premier South (KW)", | |
| 3650 | + "website": "www.premiersouth.com", | |
| 3651 | + "states": [ | |
| 3652 | + "NC", | |
| 3653 | + "SC" | |
| 3654 | + ], | |
| 3655 | + "cities": [ | |
| 3656 | + "Charlotte" | |
| 3657 | + ], | |
| 3658 | + "estimated_agents": 400, | |
| 3659 | + "estimated_listings": 800, | |
| 3660 | + "mls_affiliations": [ | |
| 3661 | + "Canopy" | |
| 3662 | + ] | |
| 3663 | + }, | |
| 3664 | + { | |
| 3665 | + "name": "Intracoastal Realty", | |
| 3666 | + "website": "www.intracoastalrealty.com", | |
| 3667 | + "states": [ | |
| 3668 | + "NC" | |
| 3669 | + ], | |
| 3670 | + "cities": [ | |
| 3671 | + "Wilmington", | |
| 3672 | + "Wrightsville Beach" | |
| 3673 | + ], | |
| 3674 | + "estimated_agents": 400, | |
| 3675 | + "estimated_listings": 950, | |
| 3676 | + "mls_affiliations": [ | |
| 3677 | + "CapeFearMLS" | |
| 3678 | + ] | |
| 3679 | + }, | |
| 3680 | + { | |
| 3681 | + "name": "Landmark Sotheby's International Realty", | |
| 3682 | + "website": "www.landmarksir.com", | |
| 3683 | + "states": [ | |
| 3684 | + "NC" | |
| 3685 | + ], | |
| 3686 | + "cities": [ | |
| 3687 | + "Wilmington" | |
| 3688 | + ], | |
| 3689 | + "estimated_agents": 90, | |
| 3690 | + "estimated_listings": 240, | |
| 3691 | + "mls_affiliations": [ | |
| 3692 | + "CapeFearMLS" | |
| 3693 | + ] | |
| 3694 | + }, | |
| 3695 | + { | |
| 3696 | + "name": "Premier Sotheby's International Realty NC Mountains", | |
| 3697 | + "website": "www.premiersir.com/nc", | |
| 3698 | + "states": [ | |
| 3699 | + "NC" | |
| 3700 | + ], | |
| 3701 | + "cities": [ | |
| 3702 | + "Asheville" | |
| 3703 | + ], | |
| 3704 | + "estimated_agents": 200, | |
| 3705 | + "estimated_listings": 500, | |
| 3706 | + "mls_affiliations": [ | |
| 3707 | + "CanopyMLS" | |
| 3708 | + ] | |
| 3709 | + }, | |
| 3710 | + { | |
| 3711 | + "name": "Beverly-Hanks Allen Tate Realtors", | |
| 3712 | + "website": "www.beverly-hanks.com", | |
| 3713 | + "states": [ | |
| 3714 | + "NC" | |
| 3715 | + ], | |
| 3716 | + "cities": [ | |
| 3717 | + "Asheville" | |
| 3718 | + ], | |
| 3719 | + "estimated_agents": 400, | |
| 3720 | + "estimated_listings": 900, | |
| 3721 | + "mls_affiliations": [ | |
| 3722 | + "CanopyMLS" | |
| 3723 | + ] | |
| 3724 | + }, | |
| 3725 | + { | |
| 3726 | + "name": "RE/MAX Executive Carolinas", | |
| 3727 | + "website": "www.remaxexec.com", | |
| 3728 | + "states": [ | |
| 3729 | + "NC", | |
| 3730 | + "SC" | |
| 3731 | + ], | |
| 3732 | + "cities": [ | |
| 3733 | + "Charlotte" | |
| 3734 | + ], | |
| 3735 | + "estimated_agents": 500, | |
| 3736 | + "estimated_listings": 1000, | |
| 3737 | + "mls_affiliations": [ | |
| 3738 | + "Canopy" | |
| 3739 | + ] | |
| 3740 | + }, | |
| 3741 | + { | |
| 3742 | + "name": "Village Realty Outer Banks", | |
| 3743 | + "website": "www.villagerealtyobx.com", | |
| 3744 | + "states": [ | |
| 3745 | + "NC" | |
| 3746 | + ], | |
| 3747 | + "cities": [ | |
| 3748 | + "Nags Head" | |
| 3749 | + ], | |
| 3750 | + "estimated_agents": 60, | |
| 3751 | + "estimated_listings": 200, | |
| 3752 | + "mls_affiliations": [ | |
| 3753 | + "OBXMLS" | |
| 3754 | + ] | |
| 3755 | + }, | |
| 3756 | + { | |
| 3757 | + "name": "Parks Compass Nashville", | |
| 3758 | + "website": "www.parksathome.com", | |
| 3759 | + "states": [ | |
| 3760 | + "TN" | |
| 3761 | + ], | |
| 3762 | + "cities": [ | |
| 3763 | + "Nashville" | |
| 3764 | + ], | |
| 3765 | + "estimated_agents": 1400, | |
| 3766 | + "estimated_listings": 2600, | |
| 3767 | + "mls_affiliations": [ | |
| 3768 | + "Realtracs" | |
| 3769 | + ] | |
| 3770 | + }, | |
| 3771 | + { | |
| 3772 | + "name": "Benchmark Realty", | |
| 3773 | + "website": "www.benchmarkrealtytn.com", | |
| 3774 | + "states": [ | |
| 3775 | + "TN" | |
| 3776 | + ], | |
| 3777 | + "cities": [ | |
| 3778 | + "Nashville", | |
| 3779 | + "Franklin" | |
| 3780 | + ], | |
| 3781 | + "estimated_agents": 1600, | |
| 3782 | + "estimated_listings": 2600, | |
| 3783 | + "mls_affiliations": [ | |
| 3784 | + "Realtracs" | |
| 3785 | + ] | |
| 3786 | + }, | |
| 3787 | + { | |
| 3788 | + "name": "Zeitlin Sotheby's International Realty", | |
| 3789 | + "website": "www.zeitlin.com", | |
| 3790 | + "states": [ | |
| 3791 | + "TN" | |
| 3792 | + ], | |
| 3793 | + "cities": [ | |
| 3794 | + "Nashville" | |
| 3795 | + ], | |
| 3796 | + "estimated_agents": 200, | |
| 3797 | + "estimated_listings": 450, | |
| 3798 | + "mls_affiliations": [ | |
| 3799 | + "Realtracs" | |
| 3800 | + ] | |
| 3801 | + }, | |
| 3802 | + { | |
| 3803 | + "name": "Fridrich & Clark Realty", | |
| 3804 | + "website": "www.fridrichandclark.com", | |
| 3805 | + "states": [ | |
| 3806 | + "TN" | |
| 3807 | + ], | |
| 3808 | + "cities": [ | |
| 3809 | + "Nashville" | |
| 3810 | + ], | |
| 3811 | + "estimated_agents": 200, | |
| 3812 | + "estimated_listings": 450, | |
| 3813 | + "mls_affiliations": [ | |
| 3814 | + "Realtracs" | |
| 3815 | + ] | |
| 3816 | + }, | |
| 3817 | + { | |
| 3818 | + "name": "simpliHOM", | |
| 3819 | + "website": "www.simplihom.com", | |
| 3820 | + "states": [ | |
| 3821 | + "TN" | |
| 3822 | + ], | |
| 3823 | + "cities": [ | |
| 3824 | + "Nashville" | |
| 3825 | + ], | |
| 3826 | + "estimated_agents": 700, | |
| 3827 | + "estimated_listings": 1100, | |
| 3828 | + "mls_affiliations": [ | |
| 3829 | + "Realtracs" | |
| 3830 | + ] | |
| 3831 | + }, | |
| 3832 | + { | |
| 3833 | + "name": "Marx-Bensdorf Realtors", | |
| 3834 | + "website": "www.marx-bensdorf.com", | |
| 3835 | + "states": [ | |
| 3836 | + "TN" | |
| 3837 | + ], | |
| 3838 | + "cities": [ | |
| 3839 | + "Memphis" | |
| 3840 | + ], | |
| 3841 | + "estimated_agents": 70, | |
| 3842 | + "estimated_listings": 180, | |
| 3843 | + "mls_affiliations": [ | |
| 3844 | + "MAARMLS" | |
| 3845 | + ] | |
| 3846 | + }, | |
| 3847 | + { | |
| 3848 | + "name": "Alliance Sotheby's International Realty", | |
| 3849 | + "website": "www.alliancesir.com", | |
| 3850 | + "states": [ | |
| 3851 | + "TN" | |
| 3852 | + ], | |
| 3853 | + "cities": [ | |
| 3854 | + "Knoxville" | |
| 3855 | + ], | |
| 3856 | + "estimated_agents": 90, | |
| 3857 | + "estimated_listings": 240, | |
| 3858 | + "mls_affiliations": [ | |
| 3859 | + "KnoxvilleMLS" | |
| 3860 | + ] | |
| 3861 | + }, | |
| 3862 | + { | |
| 3863 | + "name": "Wallace Real Estate", | |
| 3864 | + "website": "www.wallacetn.com", | |
| 3865 | + "states": [ | |
| 3866 | + "TN" | |
| 3867 | + ], | |
| 3868 | + "cities": [ | |
| 3869 | + "Knoxville" | |
| 3870 | + ], | |
| 3871 | + "estimated_agents": 400, | |
| 3872 | + "estimated_listings": 900, | |
| 3873 | + "mls_affiliations": [ | |
| 3874 | + "KnoxvilleMLS" | |
| 3875 | + ] | |
| 3876 | + }, | |
| 3877 | + { | |
| 3878 | + "name": "RE/MAX Advantage Alabama", | |
| 3879 | + "website": "www.remaxadvantagesouth.com", | |
| 3880 | + "states": [ | |
| 3881 | + "AL" | |
| 3882 | + ], | |
| 3883 | + "cities": [ | |
| 3884 | + "Birmingham" | |
| 3885 | + ], | |
| 3886 | + "estimated_agents": 200, | |
| 3887 | + "estimated_listings": 500, | |
| 3888 | + "mls_affiliations": [ | |
| 3889 | + "GALMLS" | |
| 3890 | + ] | |
| 3891 | + }, | |
| 3892 | + { | |
| 3893 | + "name": "ARC Realty", | |
| 3894 | + "website": "www.arcrealtyco.com", | |
| 3895 | + "states": [ | |
| 3896 | + "AL" | |
| 3897 | + ], | |
| 3898 | + "cities": [ | |
| 3899 | + "Birmingham" | |
| 3900 | + ], | |
| 3901 | + "estimated_agents": 400, | |
| 3902 | + "estimated_listings": 900, | |
| 3903 | + "mls_affiliations": [ | |
| 3904 | + "GALMLS" | |
| 3905 | + ] | |
| 3906 | + }, | |
| 3907 | + { | |
| 3908 | + "name": "RealtySouth", | |
| 3909 | + "website": "www.realtysouth.com", | |
| 3910 | + "states": [ | |
| 3911 | + "AL" | |
| 3912 | + ], | |
| 3913 | + "cities": [ | |
| 3914 | + "Birmingham" | |
| 3915 | + ], | |
| 3916 | + "estimated_agents": 800, | |
| 3917 | + "estimated_listings": 1700, | |
| 3918 | + "mls_affiliations": [ | |
| 3919 | + "GALMLS" | |
| 3920 | + ] | |
| 3921 | + }, | |
| 3922 | + { | |
| 3923 | + "name": "LAH Sotheby's International Realty", | |
| 3924 | + "website": "www.lahsothebysrealty.com", | |
| 3925 | + "states": [ | |
| 3926 | + "AL" | |
| 3927 | + ], | |
| 3928 | + "cities": [ | |
| 3929 | + "Birmingham" | |
| 3930 | + ], | |
| 3931 | + "estimated_agents": 150, | |
| 3932 | + "estimated_listings": 380, | |
| 3933 | + "mls_affiliations": [ | |
| 3934 | + "GALMLS" | |
| 3935 | + ] | |
| 3936 | + }, | |
| 3937 | + { | |
| 3938 | + "name": "Bellator Real Estate & Development", | |
| 3939 | + "website": "www.gobellator.com", | |
| 3940 | + "states": [ | |
| 3941 | + "AL" | |
| 3942 | + ], | |
| 3943 | + "cities": [ | |
| 3944 | + "Mobile", | |
| 3945 | + "Gulf Shores" | |
| 3946 | + ], | |
| 3947 | + "estimated_agents": 250, | |
| 3948 | + "estimated_listings": 600, | |
| 3949 | + "mls_affiliations": [ | |
| 3950 | + "BaldwinMLS" | |
| 3951 | + ] | |
| 3952 | + }, | |
| 3953 | + { | |
| 3954 | + "name": "Semonin Realtors", | |
| 3955 | + "website": "www.semonin.com", | |
| 3956 | + "states": [ | |
| 3957 | + "KY", | |
| 3958 | + "IN" | |
| 3959 | + ], | |
| 3960 | + "cities": [ | |
| 3961 | + "Louisville" | |
| 3962 | + ], | |
| 3963 | + "estimated_agents": 500, | |
| 3964 | + "estimated_listings": 1100, | |
| 3965 | + "mls_affiliations": [ | |
| 3966 | + "GLARMLS" | |
| 3967 | + ] | |
| 3968 | + }, | |
| 3969 | + { | |
| 3970 | + "name": "Lenihan Sotheby's International Realty", | |
| 3971 | + "website": "www.lsir.com", | |
| 3972 | + "states": [ | |
| 3973 | + "KY" | |
| 3974 | + ], | |
| 3975 | + "cities": [ | |
| 3976 | + "Louisville" | |
| 3977 | + ], | |
| 3978 | + "estimated_agents": 90, | |
| 3979 | + "estimated_listings": 240, | |
| 3980 | + "mls_affiliations": [ | |
| 3981 | + "GLARMLS" | |
| 3982 | + ] | |
| 3983 | + }, | |
| 3984 | + { | |
| 3985 | + "name": "Kentucky Select Properties", | |
| 3986 | + "website": "www.kyselectproperties.com", | |
| 3987 | + "states": [ | |
| 3988 | + "KY" | |
| 3989 | + ], | |
| 3990 | + "cities": [ | |
| 3991 | + "Louisville" | |
| 3992 | + ], | |
| 3993 | + "estimated_agents": 90, | |
| 3994 | + "estimated_listings": 230, | |
| 3995 | + "mls_affiliations": [ | |
| 3996 | + "GLARMLS" | |
| 3997 | + ] | |
| 3998 | + }, | |
| 3999 | + { | |
| 4000 | + "name": "The Brokerage / Bluegrass Sotheby's", | |
| 4001 | + "website": "www.bluegrasssir.com", | |
| 4002 | + "states": [ | |
| 4003 | + "KY" | |
| 4004 | + ], | |
| 4005 | + "cities": [ | |
| 4006 | + "Lexington" | |
| 4007 | + ], | |
| 4008 | + "estimated_agents": 100, | |
| 4009 | + "estimated_listings": 260, | |
| 4010 | + "mls_affiliations": [ | |
| 4011 | + "LBARMLS" | |
| 4012 | + ] | |
| 4013 | + }, | |
| 4014 | + { | |
| 4015 | + "name": "Latter & Blum (Compass)", | |
| 4016 | + "website": "www.latter-blum.com", | |
| 4017 | + "states": [ | |
| 4018 | + "LA", | |
| 4019 | + "MS", | |
| 4020 | + "TX" | |
| 4021 | + ], | |
| 4022 | + "cities": [ | |
| 4023 | + "New Orleans", | |
| 4024 | + "Baton Rouge" | |
| 4025 | + ], | |
| 4026 | + "estimated_agents": 2500, | |
| 4027 | + "estimated_listings": 5000, | |
| 4028 | + "mls_affiliations": [ | |
| 4029 | + "GSREIN" | |
| 4030 | + ] | |
| 4031 | + }, | |
| 4032 | + { | |
| 4033 | + "name": "Gardner Realtors", | |
| 4034 | + "website": "www.gardnerrealtors.com", | |
| 4035 | + "states": [ | |
| 4036 | + "LA", | |
| 4037 | + "MS" | |
| 4038 | + ], | |
| 4039 | + "cities": [ | |
| 4040 | + "New Orleans", | |
| 4041 | + "Metairie" | |
| 4042 | + ], | |
| 4043 | + "estimated_agents": 700, | |
| 4044 | + "estimated_listings": 1500, | |
| 4045 | + "mls_affiliations": [ | |
| 4046 | + "GSREIN" | |
| 4047 | + ] | |
| 4048 | + }, | |
| 4049 | + { | |
| 4050 | + "name": "Keller Williams Realty New Orleans", | |
| 4051 | + "website": "www.kwneworleans.com", | |
| 4052 | + "states": [ | |
| 4053 | + "LA" | |
| 4054 | + ], | |
| 4055 | + "cities": [ | |
| 4056 | + "New Orleans" | |
| 4057 | + ], | |
| 4058 | + "estimated_agents": 400, | |
| 4059 | + "estimated_listings": 800, | |
| 4060 | + "mls_affiliations": [ | |
| 4061 | + "GSREIN" | |
| 4062 | + ] | |
| 4063 | + }, | |
| 4064 | + { | |
| 4065 | + "name": "Burns & Co. Realtors", | |
| 4066 | + "website": "www.burnsandco.com", | |
| 4067 | + "states": [ | |
| 4068 | + "MS" | |
| 4069 | + ], | |
| 4070 | + "cities": [ | |
| 4071 | + "Jackson", | |
| 4072 | + "Ridgeland" | |
| 4073 | + ], | |
| 4074 | + "estimated_agents": 60, | |
| 4075 | + "estimated_listings": 160, | |
| 4076 | + "mls_affiliations": [ | |
| 4077 | + "MGCMLS" | |
| 4078 | + ] | |
| 4079 | + }, | |
| 4080 | + { | |
| 4081 | + "name": "Coldwell Banker Smith Homes Realtors", | |
| 4082 | + "website": "www.cbsmithhomes.com", | |
| 4083 | + "states": [ | |
| 4084 | + "MS" | |
| 4085 | + ], | |
| 4086 | + "cities": [ | |
| 4087 | + "Gulfport", | |
| 4088 | + "Biloxi" | |
| 4089 | + ], | |
| 4090 | + "estimated_agents": 90, | |
| 4091 | + "estimated_listings": 240, | |
| 4092 | + "mls_affiliations": [ | |
| 4093 | + "MGCMLS" | |
| 4094 | + ] | |
| 4095 | + }, | |
| 4096 | + { | |
| 4097 | + "name": "Jameson Sotheby's International Realty", | |
| 4098 | + "website": "www.jamesonsir.com", | |
| 4099 | + "states": [ | |
| 4100 | + "IL" | |
| 4101 | + ], | |
| 4102 | + "cities": [ | |
| 4103 | + "Chicago" | |
| 4104 | + ], | |
| 4105 | + "estimated_agents": 500, | |
| 4106 | + "estimated_listings": 1100, | |
| 4107 | + "mls_affiliations": [ | |
| 4108 | + "MRED" | |
| 4109 | + ] | |
| 4110 | + }, | |
| 4111 | + { | |
| 4112 | + "name": "Dream Town Real Estate", | |
| 4113 | + "website": "www.dreamtown.com", | |
| 4114 | + "states": [ | |
| 4115 | + "IL" | |
| 4116 | + ], | |
| 4117 | + "cities": [ | |
| 4118 | + "Chicago" | |
| 4119 | + ], | |
| 4120 | + "estimated_agents": 500, | |
| 4121 | + "estimated_listings": 1000, | |
| 4122 | + "mls_affiliations": [ | |
| 4123 | + "MRED" | |
| 4124 | + ] | |
| 4125 | + }, | |
| 4126 | + { | |
| 4127 | + "name": "Berkshire Hathaway HomeServices Chicago", | |
| 4128 | + "website": "www.bhhschicago.com", | |
| 4129 | + "states": [ | |
| 4130 | + "IL", | |
| 4131 | + "WI", | |
| 4132 | + "IN", | |
| 4133 | + "MI" | |
| 4134 | + ], | |
| 4135 | + "cities": [ | |
| 4136 | + "Chicago" | |
| 4137 | + ], | |
| 4138 | + "estimated_agents": 1500, | |
| 4139 | + "estimated_listings": 3000, | |
| 4140 | + "mls_affiliations": [ | |
| 4141 | + "MRED" | |
| 4142 | + ] | |
| 4143 | + }, | |
| 4144 | + { | |
| 4145 | + "name": "Redco Realty (Chicago)", | |
| 4146 | + "website": "www.redcorealty.com", | |
| 4147 | + "states": [ | |
| 4148 | + "IL" | |
| 4149 | + ], | |
| 4150 | + "cities": [ | |
| 4151 | + "Chicago" | |
| 4152 | + ], | |
| 4153 | + "estimated_agents": 150, | |
| 4154 | + "estimated_listings": 300, | |
| 4155 | + "mls_affiliations": [ | |
| 4156 | + "MRED" | |
| 4157 | + ] | |
| 4158 | + }, | |
| 4159 | + { | |
| 4160 | + "name": "John Greene Realtor", | |
| 4161 | + "website": "www.johngreenerealtor.com", | |
| 4162 | + "states": [ | |
| 4163 | + "IL" | |
| 4164 | + ], | |
| 4165 | + "cities": [ | |
| 4166 | + "Naperville" | |
| 4167 | + ], | |
| 4168 | + "estimated_agents": 200, | |
| 4169 | + "estimated_listings": 480, | |
| 4170 | + "mls_affiliations": [ | |
| 4171 | + "MRED" | |
| 4172 | + ] | |
| 4173 | + }, | |
| 4174 | + { | |
| 4175 | + "name": "Charles Rutenberg Realty of Illinois", | |
| 4176 | + "website": "www.crrealtyil.com", | |
| 4177 | + "states": [ | |
| 4178 | + "IL" | |
| 4179 | + ], | |
| 4180 | + "cities": [ | |
| 4181 | + "Naperville", | |
| 4182 | + "Chicago" | |
| 4183 | + ], | |
| 4184 | + "estimated_agents": 1300, | |
| 4185 | + "estimated_listings": 1900, | |
| 4186 | + "mls_affiliations": [ | |
| 4187 | + "MRED" | |
| 4188 | + ] | |
| 4189 | + }, | |
| 4190 | + { | |
| 4191 | + "name": "RE/MAX Suburban Chicago", | |
| 4192 | + "website": "www.remaxsuburban.com", | |
| 4193 | + "states": [ | |
| 4194 | + "IL" | |
| 4195 | + ], | |
| 4196 | + "cities": [ | |
| 4197 | + "Arlington Heights" | |
| 4198 | + ], | |
| 4199 | + "estimated_agents": 200, | |
| 4200 | + "estimated_listings": 480, | |
| 4201 | + "mls_affiliations": [ | |
| 4202 | + "MRED" | |
| 4203 | + ] | |
| 4204 | + }, | |
| 4205 | + { | |
| 4206 | + "name": "F.C. Tucker Company", | |
| 4207 | + "website": "www.talktotucker.com", | |
| 4208 | + "states": [ | |
| 4209 | + "IN" | |
| 4210 | + ], | |
| 4211 | + "cities": [ | |
| 4212 | + "Indianapolis" | |
| 4213 | + ], | |
| 4214 | + "estimated_agents": 1500, | |
| 4215 | + "estimated_listings": 3200, | |
| 4216 | + "mls_affiliations": [ | |
| 4217 | + "MIBOR" | |
| 4218 | + ] | |
| 4219 | + }, | |
| 4220 | + { | |
| 4221 | + "name": "Carpenter Realtors", | |
| 4222 | + "website": "www.callcarpenter.com", | |
| 4223 | + "states": [ | |
| 4224 | + "IN" | |
| 4225 | + ], | |
| 4226 | + "cities": [ | |
| 4227 | + "Indianapolis" | |
| 4228 | + ], | |
| 4229 | + "estimated_agents": 600, | |
| 4230 | + "estimated_listings": 1300, | |
| 4231 | + "mls_affiliations": [ | |
| 4232 | + "MIBOR" | |
| 4233 | + ] | |
| 4234 | + }, | |
| 4235 | + { | |
| 4236 | + "name": "Encore Sotheby's International Realty", | |
| 4237 | + "website": "www.encoresothebysrealty.com", | |
| 4238 | + "states": [ | |
| 4239 | + "IN" | |
| 4240 | + ], | |
| 4241 | + "cities": [ | |
| 4242 | + "Indianapolis", | |
| 4243 | + "Carmel" | |
| 4244 | + ], | |
| 4245 | + "estimated_agents": 130, | |
| 4246 | + "estimated_listings": 320, | |
| 4247 | + "mls_affiliations": [ | |
| 4248 | + "MIBOR" | |
| 4249 | + ] | |
| 4250 | + }, | |
| 4251 | + { | |
| 4252 | + "name": "Berkshire Hathaway HomeServices Indiana Realty", | |
| 4253 | + "website": "www.bhhsin.com", | |
| 4254 | + "states": [ | |
| 4255 | + "IN" | |
| 4256 | + ], | |
| 4257 | + "cities": [ | |
| 4258 | + "Indianapolis", | |
| 4259 | + "Bloomington" | |
| 4260 | + ], | |
| 4261 | + "estimated_agents": 500, | |
| 4262 | + "estimated_listings": 1100, | |
| 4263 | + "mls_affiliations": [ | |
| 4264 | + "MIBOR" | |
| 4265 | + ] | |
| 4266 | + }, | |
| 4267 | + { | |
| 4268 | + "name": "Sibcy Cline Realtors", | |
| 4269 | + "website": "www.sibcycline.com", | |
| 4270 | + "states": [ | |
| 4271 | + "OH", | |
| 4272 | + "KY", | |
| 4273 | + "IN" | |
| 4274 | + ], | |
| 4275 | + "cities": [ | |
| 4276 | + "Cincinnati" | |
| 4277 | + ], | |
| 4278 | + "estimated_agents": 1100, | |
| 4279 | + "estimated_listings": 2300, | |
| 4280 | + "mls_affiliations": [ | |
| 4281 | + "CincyMLS" | |
| 4282 | + ] | |
| 4283 | + }, | |
| 4284 | + { | |
| 4285 | + "name": "Comey & Shepherd Realtors", | |
| 4286 | + "website": "www.comey.com", | |
| 4287 | + "states": [ | |
| 4288 | + "OH", | |
| 4289 | + "KY" | |
| 4290 | + ], | |
| 4291 | + "cities": [ | |
| 4292 | + "Cincinnati" | |
| 4293 | + ], | |
| 4294 | + "estimated_agents": 400, | |
| 4295 | + "estimated_listings": 850, | |
| 4296 | + "mls_affiliations": [ | |
| 4297 | + "CincyMLS" | |
| 4298 | + ] | |
| 4299 | + }, | |
| 4300 | + { | |
| 4301 | + "name": "Cutler Real Estate", | |
| 4302 | + "website": "www.cutlerhomes.com", | |
| 4303 | + "states": [ | |
| 4304 | + "OH" | |
| 4305 | + ], | |
| 4306 | + "cities": [ | |
| 4307 | + "Canton", | |
| 4308 | + "Columbus" | |
| 4309 | + ], | |
| 4310 | + "estimated_agents": 500, | |
| 4311 | + "estimated_listings": 1100, | |
| 4312 | + "mls_affiliations": [ | |
| 4313 | + "ColumbusMLS", | |
| 4314 | + "YesMLS" | |
| 4315 | + ] | |
| 4316 | + }, | |
| 4317 | + { | |
| 4318 | + "name": "Berkshire Hathaway HomeServices Professional Realty", | |
| 4319 | + "website": "www.bhhspro.com", | |
| 4320 | + "states": [ | |
| 4321 | + "OH", | |
| 4322 | + "KY", | |
| 4323 | + "IN" | |
| 4324 | + ], | |
| 4325 | + "cities": [ | |
| 4326 | + "Columbus", | |
| 4327 | + "Dayton" | |
| 4328 | + ], | |
| 4329 | + "estimated_agents": 700, | |
| 4330 | + "estimated_listings": 1500, | |
| 4331 | + "mls_affiliations": [ | |
| 4332 | + "ColumbusMLS" | |
| 4333 | + ] | |
| 4334 | + }, | |
| 4335 | + { | |
| 4336 | + "name": "Coldwell Banker Realty Columbus", | |
| 4337 | + "website": "www.coldwellbankerhomes.com/oh/columbus", | |
| 4338 | + "states": [ | |
| 4339 | + "OH" | |
| 4340 | + ], | |
| 4341 | + "cities": [ | |
| 4342 | + "Columbus" | |
| 4343 | + ], | |
| 4344 | + "estimated_agents": 600, | |
| 4345 | + "estimated_listings": 1300, | |
| 4346 | + "mls_affiliations": [ | |
| 4347 | + "ColumbusMLS" | |
| 4348 | + ] | |
| 4349 | + }, | |
| 4350 | + { | |
| 4351 | + "name": "Street Sotheby's International Realty", | |
| 4352 | + "website": "www.streetsir.com", | |
| 4353 | + "states": [ | |
| 4354 | + "OH" | |
| 4355 | + ], | |
| 4356 | + "cities": [ | |
| 4357 | + "Columbus", | |
| 4358 | + "New Albany" | |
| 4359 | + ], | |
| 4360 | + "estimated_agents": 100, | |
| 4361 | + "estimated_listings": 260, | |
| 4362 | + "mls_affiliations": [ | |
| 4363 | + "ColumbusMLS" | |
| 4364 | + ] | |
| 4365 | + }, | |
| 4366 | + { | |
| 4367 | + "name": "Keller Williams Greater Metropolitan Cleveland", | |
| 4368 | + "website": "www.clevelandrealestateexperts.com", | |
| 4369 | + "states": [ | |
| 4370 | + "OH" | |
| 4371 | + ], | |
| 4372 | + "cities": [ | |
| 4373 | + "Cleveland" | |
| 4374 | + ], | |
| 4375 | + "estimated_agents": 400, | |
| 4376 | + "estimated_listings": 800, | |
| 4377 | + "mls_affiliations": [ | |
| 4378 | + "YesMLS" | |
| 4379 | + ] | |
| 4380 | + }, | |
| 4381 | + { | |
| 4382 | + "name": "Chestnut Hill Realty (Cleveland)", | |
| 4383 | + "website": "www.chestnuthillrealty.com", | |
| 4384 | + "states": [ | |
| 4385 | + "OH" | |
| 4386 | + ], | |
| 4387 | + "cities": [ | |
| 4388 | + "Cleveland" | |
| 4389 | + ], | |
| 4390 | + "estimated_agents": 60, | |
| 4391 | + "estimated_listings": 150, | |
| 4392 | + "mls_affiliations": [ | |
| 4393 | + "YesMLS" | |
| 4394 | + ] | |
| 4395 | + }, | |
| 4396 | + { | |
| 4397 | + "name": "Real Estate One", | |
| 4398 | + "website": "www.realestateone.com", | |
| 4399 | + "states": [ | |
| 4400 | + "MI" | |
| 4401 | + ], | |
| 4402 | + "cities": [ | |
| 4403 | + "Detroit", | |
| 4404 | + "Southfield" | |
| 4405 | + ], | |
| 4406 | + "estimated_agents": 2000, | |
| 4407 | + "estimated_listings": 4200, | |
| 4408 | + "mls_affiliations": [ | |
| 4409 | + "Realcomp" | |
| 4410 | + ] | |
| 4411 | + }, | |
| 4412 | + { | |
| 4413 | + "name": "Max Broock Realtors", | |
| 4414 | + "website": "www.maxbroock.com", | |
| 4415 | + "states": [ | |
| 4416 | + "MI" | |
| 4417 | + ], | |
| 4418 | + "cities": [ | |
| 4419 | + "Birmingham", | |
| 4420 | + "Detroit" | |
| 4421 | + ], | |
| 4422 | + "estimated_agents": 600, | |
| 4423 | + "estimated_listings": 1300, | |
| 4424 | + "mls_affiliations": [ | |
| 4425 | + "Realcomp" | |
| 4426 | + ] | |
| 4427 | + }, | |
| 4428 | + { | |
| 4429 | + "name": "The Charles Reinhart Company", | |
| 4430 | + "website": "www.reinhartrealtors.com", | |
| 4431 | + "states": [ | |
| 4432 | + "MI" | |
| 4433 | + ], | |
| 4434 | + "cities": [ | |
| 4435 | + "Ann Arbor" | |
| 4436 | + ], | |
| 4437 | + "estimated_agents": 200, | |
| 4438 | + "estimated_listings": 500, | |
| 4439 | + "mls_affiliations": [ | |
| 4440 | + "AnnArborMLS" | |
| 4441 | + ] | |
| 4442 | + }, | |
| 4443 | + { | |
| 4444 | + "name": "@properties Detroit (Christie's)", | |
| 4445 | + "website": "www.atproperties.com/detroit", | |
| 4446 | + "states": [ | |
| 4447 | + "MI" | |
| 4448 | + ], | |
| 4449 | + "cities": [ | |
| 4450 | + "Detroit", | |
| 4451 | + "Birmingham" | |
| 4452 | + ], | |
| 4453 | + "estimated_agents": 300, | |
| 4454 | + "estimated_listings": 600, | |
| 4455 | + "mls_affiliations": [ | |
| 4456 | + "Realcomp" | |
| 4457 | + ] | |
| 4458 | + }, | |
| 4459 | + { | |
| 4460 | + "name": "Coldwell Banker Schmidt Family of Companies", | |
| 4461 | + "website": "www.cbgreatlakes.com", | |
| 4462 | + "states": [ | |
| 4463 | + "MI", | |
| 4464 | + "OH", | |
| 4465 | + "FL" | |
| 4466 | + ], | |
| 4467 | + "cities": [ | |
| 4468 | + "Traverse City" | |
| 4469 | + ], | |
| 4470 | + "estimated_agents": 2000, | |
| 4471 | + "estimated_listings": 4500, | |
| 4472 | + "mls_affiliations": [ | |
| 4473 | + "NGLRMLS" | |
| 4474 | + ] | |
| 4475 | + }, | |
| 4476 | + { | |
| 4477 | + "name": "Shorewest Realtors", | |
| 4478 | + "website": "www.shorewest.com", | |
| 4479 | + "states": [ | |
| 4480 | + "WI" | |
| 4481 | + ], | |
| 4482 | + "cities": [ | |
| 4483 | + "Milwaukee", | |
| 4484 | + "Brookfield" | |
| 4485 | + ], | |
| 4486 | + "estimated_agents": 1200, | |
| 4487 | + "estimated_listings": 2600, | |
| 4488 | + "mls_affiliations": [ | |
| 4489 | + "Metro MLS" | |
| 4490 | + ] | |
| 4491 | + }, | |
| 4492 | + { | |
| 4493 | + "name": "First Weber Realtors", | |
| 4494 | + "website": "www.firstweber.com", | |
| 4495 | + "states": [ | |
| 4496 | + "WI" | |
| 4497 | + ], | |
| 4498 | + "cities": [ | |
| 4499 | + "Madison", | |
| 4500 | + "Milwaukee" | |
| 4501 | + ], | |
| 4502 | + "estimated_agents": 1400, | |
| 4503 | + "estimated_listings": 3000, | |
| 4504 | + "mls_affiliations": [ | |
| 4505 | + "SCWMLS" | |
| 4506 | + ] | |
| 4507 | + }, | |
| 4508 | + { | |
| 4509 | + "name": "Stark Company Realtors", | |
| 4510 | + "website": "www.starkhomes.com", | |
| 4511 | + "states": [ | |
| 4512 | + "WI" | |
| 4513 | + ], | |
| 4514 | + "cities": [ | |
| 4515 | + "Madison" | |
| 4516 | + ], | |
| 4517 | + "estimated_agents": 450, | |
| 4518 | + "estimated_listings": 950, | |
| 4519 | + "mls_affiliations": [ | |
| 4520 | + "SCWMLS" | |
| 4521 | + ] | |
| 4522 | + }, | |
| 4523 | + { | |
| 4524 | + "name": "Mahler Sotheby's International Realty", | |
| 4525 | + "website": "www.mahlersir.com", | |
| 4526 | + "states": [ | |
| 4527 | + "WI" | |
| 4528 | + ], | |
| 4529 | + "cities": [ | |
| 4530 | + "Milwaukee" | |
| 4531 | + ], | |
| 4532 | + "estimated_agents": 80, | |
| 4533 | + "estimated_listings": 210, | |
| 4534 | + "mls_affiliations": [ | |
| 4535 | + "Metro MLS" | |
| 4536 | + ] | |
| 4537 | + }, | |
| 4538 | + { | |
| 4539 | + "name": "Lakes Sotheby's International Realty", | |
| 4540 | + "website": "www.lakessir.com", | |
| 4541 | + "states": [ | |
| 4542 | + "MN" | |
| 4543 | + ], | |
| 4544 | + "cities": [ | |
| 4545 | + "Edina", | |
| 4546 | + "Minneapolis" | |
| 4547 | + ], | |
| 4548 | + "estimated_agents": 150, | |
| 4549 | + "estimated_listings": 380, | |
| 4550 | + "mls_affiliations": [ | |
| 4551 | + "NorthstarMLS" | |
| 4552 | + ] | |
| 4553 | + }, | |
| 4554 | + { | |
| 4555 | + "name": "Coldwell Banker Realty Minneapolis", | |
| 4556 | + "website": "www.coldwellbankerhomes.com/mn", | |
| 4557 | + "states": [ | |
| 4558 | + "MN" | |
| 4559 | + ], | |
| 4560 | + "cities": [ | |
| 4561 | + "Minneapolis" | |
| 4562 | + ], | |
| 4563 | + "estimated_agents": 1500, | |
| 4564 | + "estimated_listings": 3200, | |
| 4565 | + "mls_affiliations": [ | |
| 4566 | + "NorthstarMLS" | |
| 4567 | + ] | |
| 4568 | + }, | |
| 4569 | + { | |
| 4570 | + "name": "RE/MAX Results Minnesota", | |
| 4571 | + "website": "www.results.net", | |
| 4572 | + "states": [ | |
| 4573 | + "MN", | |
| 4574 | + "WI" | |
| 4575 | + ], | |
| 4576 | + "cities": [ | |
| 4577 | + "Eden Prairie", | |
| 4578 | + "Minneapolis" | |
| 4579 | + ], | |
| 4580 | + "estimated_agents": 1200, | |
| 4581 | + "estimated_listings": 2600, | |
| 4582 | + "mls_affiliations": [ | |
| 4583 | + "NorthstarMLS" | |
| 4584 | + ] | |
| 4585 | + }, | |
| 4586 | + { | |
| 4587 | + "name": "Kris Lindahl Real Estate", | |
| 4588 | + "website": "www.krislindahl.com", | |
| 4589 | + "states": [ | |
| 4590 | + "MN", | |
| 4591 | + "WI" | |
| 4592 | + ], | |
| 4593 | + "cities": [ | |
| 4594 | + "Blaine", | |
| 4595 | + "Minneapolis" | |
| 4596 | + ], | |
| 4597 | + "estimated_agents": 200, | |
| 4598 | + "estimated_listings": 550, | |
| 4599 | + "mls_affiliations": [ | |
| 4600 | + "NorthstarMLS" | |
| 4601 | + ] | |
| 4602 | + }, | |
| 4603 | + { | |
| 4604 | + "name": "Reece Nichols Real Estate", | |
| 4605 | + "website": "www.reecenichols.com", | |
| 4606 | + "states": [ | |
| 4607 | + "KS", | |
| 4608 | + "MO" | |
| 4609 | + ], | |
| 4610 | + "cities": [ | |
| 4611 | + "Kansas City", | |
| 4612 | + "Overland Park" | |
| 4613 | + ], | |
| 4614 | + "estimated_agents": 2600, | |
| 4615 | + "estimated_listings": 5200, | |
| 4616 | + "mls_affiliations": [ | |
| 4617 | + "Heartland" | |
| 4618 | + ] | |
| 4619 | + }, | |
| 4620 | + { | |
| 4621 | + "name": "Keller Williams Realty Partners KC", | |
| 4622 | + "website": "www.kwkc.com", | |
| 4623 | + "states": [ | |
| 4624 | + "KS", | |
| 4625 | + "MO" | |
| 4626 | + ], | |
| 4627 | + "cities": [ | |
| 4628 | + "Overland Park" | |
| 4629 | + ], | |
| 4630 | + "estimated_agents": 700, | |
| 4631 | + "estimated_listings": 1400, | |
| 4632 | + "mls_affiliations": [ | |
| 4633 | + "Heartland" | |
| 4634 | + ] | |
| 4635 | + }, | |
| 4636 | + { | |
| 4637 | + "name": "Compass Realty Group KC", | |
| 4638 | + "website": "www.compassrealtygroupkc.com", | |
| 4639 | + "states": [ | |
| 4640 | + "MO", | |
| 4641 | + "KS" | |
| 4642 | + ], | |
| 4643 | + "cities": [ | |
| 4644 | + "Kansas City" | |
| 4645 | + ], | |
| 4646 | + "estimated_agents": 300, | |
| 4647 | + "estimated_listings": 650, | |
| 4648 | + "mls_affiliations": [ | |
| 4649 | + "Heartland" | |
| 4650 | + ] | |
| 4651 | + }, | |
| 4652 | + { | |
| 4653 | + "name": "Berkshire Hathaway HomeServices Kansas City Realty", | |
| 4654 | + "website": "www.bhhskcrealty.com", | |
| 4655 | + "states": [ | |
| 4656 | + "KS", | |
| 4657 | + "MO" | |
| 4658 | + ], | |
| 4659 | + "cities": [ | |
| 4660 | + "Kansas City" | |
| 4661 | + ], | |
| 4662 | + "estimated_agents": 300, | |
| 4663 | + "estimated_listings": 650, | |
| 4664 | + "mls_affiliations": [ | |
| 4665 | + "Heartland" | |
| 4666 | + ] | |
| 4667 | + }, | |
| 4668 | + { | |
| 4669 | + "name": "Coldwell Banker Gundaker (Realty STL)", | |
| 4670 | + "website": "www.cbgundaker.com", | |
| 4671 | + "states": [ | |
| 4672 | + "MO", | |
| 4673 | + "IL" | |
| 4674 | + ], | |
| 4675 | + "cities": [ | |
| 4676 | + "Saint Louis" | |
| 4677 | + ], | |
| 4678 | + "estimated_agents": 2000, | |
| 4679 | + "estimated_listings": 3800, | |
| 4680 | + "mls_affiliations": [ | |
| 4681 | + "MARIS" | |
| 4682 | + ] | |
| 4683 | + }, | |
| 4684 | + { | |
| 4685 | + "name": "Janet McAfee Real Estate", | |
| 4686 | + "website": "www.janetmcafee.com", | |
| 4687 | + "states": [ | |
| 4688 | + "MO" | |
| 4689 | + ], | |
| 4690 | + "cities": [ | |
| 4691 | + "Saint Louis" | |
| 4692 | + ], | |
| 4693 | + "estimated_agents": 130, | |
| 4694 | + "estimated_listings": 320, | |
| 4695 | + "mls_affiliations": [ | |
| 4696 | + "MARIS" | |
| 4697 | + ] | |
| 4698 | + }, | |
| 4699 | + { | |
| 4700 | + "name": "Dielmann Sotheby's International Realty", | |
| 4701 | + "website": "www.dielmannsothebysrealty.com", | |
| 4702 | + "states": [ | |
| 4703 | + "MO" | |
| 4704 | + ], | |
| 4705 | + "cities": [ | |
| 4706 | + "Saint Louis" | |
| 4707 | + ], | |
| 4708 | + "estimated_agents": 100, | |
| 4709 | + "estimated_listings": 260, | |
| 4710 | + "mls_affiliations": [ | |
| 4711 | + "MARIS" | |
| 4712 | + ] | |
| 4713 | + }, | |
| 4714 | + { | |
| 4715 | + "name": "Murney Associates Realtors", | |
| 4716 | + "website": "www.murney.com", | |
| 4717 | + "states": [ | |
| 4718 | + "MO" | |
| 4719 | + ], | |
| 4720 | + "cities": [ | |
| 4721 | + "Springfield" | |
| 4722 | + ], | |
| 4723 | + "estimated_agents": 700, | |
| 4724 | + "estimated_listings": 1500, | |
| 4725 | + "mls_affiliations": [ | |
| 4726 | + "SOMOMLS" | |
| 4727 | + ] | |
| 4728 | + }, | |
| 4729 | + { | |
| 4730 | + "name": "Berkshire Hathaway HomeServices Ambassador Real Estate", | |
| 4731 | + "website": "www.bhhsamb.com", | |
| 4732 | + "states": [ | |
| 4733 | + "NE", | |
| 4734 | + "IA" | |
| 4735 | + ], | |
| 4736 | + "cities": [ | |
| 4737 | + "Omaha" | |
| 4738 | + ], | |
| 4739 | + "estimated_agents": 800, | |
| 4740 | + "estimated_listings": 1600, | |
| 4741 | + "mls_affiliations": [ | |
| 4742 | + "GPRMLS" | |
| 4743 | + ] | |
| 4744 | + }, | |
| 4745 | + { | |
| 4746 | + "name": "NP Dodge Real Estate", | |
| 4747 | + "website": "www.npdodge.com", | |
| 4748 | + "states": [ | |
| 4749 | + "NE", | |
| 4750 | + "IA" | |
| 4751 | + ], | |
| 4752 | + "cities": [ | |
| 4753 | + "Omaha" | |
| 4754 | + ], | |
| 4755 | + "estimated_agents": 800, | |
| 4756 | + "estimated_listings": 1700, | |
| 4757 | + "mls_affiliations": [ | |
| 4758 | + "GPRMLS" | |
| 4759 | + ] | |
| 4760 | + }, | |
| 4761 | + { | |
| 4762 | + "name": "Nebraska Realty", | |
| 4763 | + "website": "www.nebraskarealty.com", | |
| 4764 | + "states": [ | |
| 4765 | + "NE", | |
| 4766 | + "IA" | |
| 4767 | + ], | |
| 4768 | + "cities": [ | |
| 4769 | + "Omaha", | |
| 4770 | + "Lincoln" | |
| 4771 | + ], | |
| 4772 | + "estimated_agents": 1000, | |
| 4773 | + "estimated_listings": 1900, | |
| 4774 | + "mls_affiliations": [ | |
| 4775 | + "GPRMLS" | |
| 4776 | + ] | |
| 4777 | + }, | |
| 4778 | + { | |
| 4779 | + "name": "Iowa Realty", | |
| 4780 | + "website": "www.iowarealty.com", | |
| 4781 | + "states": [ | |
| 4782 | + "IA" | |
| 4783 | + ], | |
| 4784 | + "cities": [ | |
| 4785 | + "Des Moines", | |
| 4786 | + "West Des Moines" | |
| 4787 | + ], | |
| 4788 | + "estimated_agents": 900, | |
| 4789 | + "estimated_listings": 2000, | |
| 4790 | + "mls_affiliations": [ | |
| 4791 | + "DMAAR" | |
| 4792 | + ] | |
| 4793 | + }, | |
| 4794 | + { | |
| 4795 | + "name": "RE/MAX Concepts Iowa", | |
| 4796 | + "website": "www.remax-concepts.com", | |
| 4797 | + "states": [ | |
| 4798 | + "IA" | |
| 4799 | + ], | |
| 4800 | + "cities": [ | |
| 4801 | + "Des Moines" | |
| 4802 | + ], | |
| 4803 | + "estimated_agents": 300, | |
| 4804 | + "estimated_listings": 700, | |
| 4805 | + "mls_affiliations": [ | |
| 4806 | + "DMAAR" | |
| 4807 | + ] | |
| 4808 | + }, | |
| 4809 | + { | |
| 4810 | + "name": "Ruhl & Ruhl Realtors", | |
| 4811 | + "website": "www.ruhlhomes.com", | |
| 4812 | + "states": [ | |
| 4813 | + "IA", | |
| 4814 | + "IL", | |
| 4815 | + "WI" | |
| 4816 | + ], | |
| 4817 | + "cities": [ | |
| 4818 | + "Davenport", | |
| 4819 | + "Quad Cities" | |
| 4820 | + ], | |
| 4821 | + "estimated_agents": 400, | |
| 4822 | + "estimated_listings": 950, | |
| 4823 | + "mls_affiliations": [ | |
| 4824 | + "QCARMLS" | |
| 4825 | + ] | |
| 4826 | + }, | |
| 4827 | + { | |
| 4828 | + "name": "Skogman Realty", | |
| 4829 | + "website": "www.skogman.com", | |
| 4830 | + "states": [ | |
| 4831 | + "IA" | |
| 4832 | + ], | |
| 4833 | + "cities": [ | |
| 4834 | + "Cedar Rapids" | |
| 4835 | + ], | |
| 4836 | + "estimated_agents": 200, | |
| 4837 | + "estimated_listings": 500, | |
| 4838 | + "mls_affiliations": [ | |
| 4839 | + "CRMLS-IA" | |
| 4840 | + ] | |
| 4841 | + }, | |
| 4842 | + { | |
| 4843 | + "name": "Fischer Rounds Real Estate (SD)", | |
| 4844 | + "website": "www.fischerrounds.com", | |
| 4845 | + "states": [ | |
| 4846 | + "SD" | |
| 4847 | + ], | |
| 4848 | + "cities": [ | |
| 4849 | + "Pierre", | |
| 4850 | + "Rapid City" | |
| 4851 | + ], | |
| 4852 | + "estimated_agents": 40, | |
| 4853 | + "estimated_listings": 120, | |
| 4854 | + "mls_affiliations": [ | |
| 4855 | + "MtPlainsMLS" | |
| 4856 | + ] | |
| 4857 | + }, | |
| 4858 | + { | |
| 4859 | + "name": "Hegg Realtors", | |
| 4860 | + "website": "www.hegg.com", | |
| 4861 | + "states": [ | |
| 4862 | + "SD" | |
| 4863 | + ], | |
| 4864 | + "cities": [ | |
| 4865 | + "Sioux Falls" | |
| 4866 | + ], | |
| 4867 | + "estimated_agents": 200, | |
| 4868 | + "estimated_listings": 450, | |
| 4869 | + "mls_affiliations": [ | |
| 4870 | + "RASE MLS" | |
| 4871 | + ] | |
| 4872 | + }, | |
| 4873 | + { | |
| 4874 | + "name": "Century 21 Morrison Realty", | |
| 4875 | + "website": "www.century21morrison.com", | |
| 4876 | + "states": [ | |
| 4877 | + "ND" | |
| 4878 | + ], | |
| 4879 | + "cities": [ | |
| 4880 | + "Bismarck" | |
| 4881 | + ], | |
| 4882 | + "estimated_agents": 90, | |
| 4883 | + "estimated_listings": 240, | |
| 4884 | + "mls_affiliations": [ | |
| 4885 | + "BisManMLS" | |
| 4886 | + ] | |
| 4887 | + }, | |
| 4888 | + { | |
| 4889 | + "name": "Park Co. Realtors", | |
| 4890 | + "website": "www.parkcompany.com", | |
| 4891 | + "states": [ | |
| 4892 | + "ND", | |
| 4893 | + "MN" | |
| 4894 | + ], | |
| 4895 | + "cities": [ | |
| 4896 | + "Fargo" | |
| 4897 | + ], | |
| 4898 | + "estimated_agents": 130, | |
| 4899 | + "estimated_listings": 320, | |
| 4900 | + "mls_affiliations": [ | |
| 4901 | + "FMAAR" | |
| 4902 | + ] | |
| 4903 | + }, | |
| 4904 | + { | |
| 4905 | + "name": "Berkshire Hathaway HomeServices First Realty Montana", | |
| 4906 | + "website": "www.bhhsmt.com", | |
| 4907 | + "states": [ | |
| 4908 | + "MT" | |
| 4909 | + ], | |
| 4910 | + "cities": [ | |
| 4911 | + "Bozeman", | |
| 4912 | + "Billings" | |
| 4913 | + ], | |
| 4914 | + "estimated_agents": 300, | |
| 4915 | + "estimated_listings": 700, | |
| 4916 | + "mls_affiliations": [ | |
| 4917 | + "MontanaMLS" | |
| 4918 | + ] | |
| 4919 | + }, | |
| 4920 | + { | |
| 4921 | + "name": "PureWest Christie's International Real Estate", | |
| 4922 | + "website": "www.purewestrealestate.com", | |
| 4923 | + "states": [ | |
| 4924 | + "MT" | |
| 4925 | + ], | |
| 4926 | + "cities": [ | |
| 4927 | + "Whitefish", | |
| 4928 | + "Bozeman" | |
| 4929 | + ], | |
| 4930 | + "estimated_agents": 400, | |
| 4931 | + "estimated_listings": 1000, | |
| 4932 | + "mls_affiliations": [ | |
| 4933 | + "MontanaMLS" | |
| 4934 | + ] | |
| 4935 | + }, | |
| 4936 | + { | |
| 4937 | + "name": "ERA Landmark Real Estate", | |
| 4938 | + "website": "www.eralandmark.com", | |
| 4939 | + "states": [ | |
| 4940 | + "MT" | |
| 4941 | + ], | |
| 4942 | + "cities": [ | |
| 4943 | + "Bozeman" | |
| 4944 | + ], | |
| 4945 | + "estimated_agents": 90, | |
| 4946 | + "estimated_listings": 260, | |
| 4947 | + "mls_affiliations": [ | |
| 4948 | + "GallatinMLS" | |
| 4949 | + ] | |
| 4950 | + }, | |
| 4951 | + { | |
| 4952 | + "name": "Jackson Hole Sotheby's International Realty", | |
| 4953 | + "website": "www.jhsir.com", | |
| 4954 | + "states": [ | |
| 4955 | + "WY", | |
| 4956 | + "ID" | |
| 4957 | + ], | |
| 4958 | + "cities": [ | |
| 4959 | + "Jackson" | |
| 4960 | + ], | |
| 4961 | + "estimated_agents": 130, | |
| 4962 | + "estimated_listings": 350, | |
| 4963 | + "mls_affiliations": [ | |
| 4964 | + "TetonMLS" | |
| 4965 | + ] | |
| 4966 | + }, | |
| 4967 | + { | |
| 4968 | + "name": "Live Water Properties", | |
| 4969 | + "website": "www.livewaterproperties.com", | |
| 4970 | + "states": [ | |
| 4971 | + "WY", | |
| 4972 | + "MT", | |
| 4973 | + "ID" | |
| 4974 | + ], | |
| 4975 | + "cities": [ | |
| 4976 | + "Jackson" | |
| 4977 | + ], | |
| 4978 | + "estimated_agents": 40, | |
| 4979 | + "estimated_listings": 140, | |
| 4980 | + "mls_affiliations": [ | |
| 4981 | + "TetonMLS" | |
| 4982 | + ] | |
| 4983 | + }, | |
| 4984 | + { | |
| 4985 | + "name": "Coldwell Banker Antlers Realty", | |
| 4986 | + "website": "www.codyrealestate.info", | |
| 4987 | + "states": [ | |
| 4988 | + "WY" | |
| 4989 | + ], | |
| 4990 | + "cities": [ | |
| 4991 | + "Cody" | |
| 4992 | + ], | |
| 4993 | + "estimated_agents": 20, | |
| 4994 | + "estimated_listings": 70, | |
| 4995 | + "mls_affiliations": [ | |
| 4996 | + "ParkCountyMLS" | |
| 4997 | + ] | |
| 4998 | + }, | |
| 4999 | + { | |
| 5000 | + "name": "The Group Real Estate (Idaho Falls)", | |
| 5001 | + "website": "www.thegrouprealestate.com", | |
| 5002 | + "states": [ | |
| 5003 | + "ID" | |
| 5004 | + ], | |
| 5005 | + "cities": [ | |
| 5006 | + "Idaho Falls" | |
| 5007 | + ], | |
| 5008 | + "estimated_agents": 90, | |
| 5009 | + "estimated_listings": 240, | |
| 5010 | + "mls_affiliations": [ | |
| 5011 | + "SnakeRiverMLS" | |
| 5012 | + ] | |
| 5013 | + }, | |
| 5014 | + { | |
| 5015 | + "name": "Amherst Madison Real Estate Advisors", | |
| 5016 | + "website": "www.amherstmadison.com", | |
| 5017 | + "states": [ | |
| 5018 | + "ID" | |
| 5019 | + ], | |
| 5020 | + "cities": [ | |
| 5021 | + "Boise" | |
| 5022 | + ], | |
| 5023 | + "estimated_agents": 500, | |
| 5024 | + "estimated_listings": 950, | |
| 5025 | + "mls_affiliations": [ | |
| 5026 | + "IntermountainMLS" | |
| 5027 | + ] | |
| 5028 | + }, | |
| 5029 | + { | |
| 5030 | + "name": "Boise Premier Real Estate", | |
| 5031 | + "website": "www.boisepremier.com", | |
| 5032 | + "states": [ | |
| 5033 | + "ID" | |
| 5034 | + ], | |
| 5035 | + "cities": [ | |
| 5036 | + "Boise" | |
| 5037 | + ], | |
| 5038 | + "estimated_agents": 300, | |
| 5039 | + "estimated_listings": 550, | |
| 5040 | + "mls_affiliations": [ | |
| 5041 | + "IntermountainMLS" | |
| 5042 | + ] | |
| 5043 | + }, | |
| 5044 | + { | |
| 5045 | + "name": "Silvercreek Realty Group", | |
| 5046 | + "website": "www.silvercreekrealty.com", | |
| 5047 | + "states": [ | |
| 5048 | + "ID" | |
| 5049 | + ], | |
| 5050 | + "cities": [ | |
| 5051 | + "Boise", | |
| 5052 | + "Meridian" | |
| 5053 | + ], | |
| 5054 | + "estimated_agents": 1800, | |
| 5055 | + "estimated_listings": 2800, | |
| 5056 | + "mls_affiliations": [ | |
| 5057 | + "IntermountainMLS" | |
| 5058 | + ] | |
| 5059 | + }, | |
| 5060 | + { | |
| 5061 | + "name": "Windermere Coeur d'Alene Realty", | |
| 5062 | + "website": "www.cdarealty.com", | |
| 5063 | + "states": [ | |
| 5064 | + "ID" | |
| 5065 | + ], | |
| 5066 | + "cities": [ | |
| 5067 | + "Coeur d'Alene" | |
| 5068 | + ], | |
| 5069 | + "estimated_agents": 130, | |
| 5070 | + "estimated_listings": 350, | |
| 5071 | + "mls_affiliations": [ | |
| 5072 | + "CDAMLS" | |
| 5073 | + ] | |
| 5074 | + }, | |
| 5075 | + { | |
| 5076 | + "name": "LIV Sotheby's International Realty", | |
| 5077 | + "website": "www.livsothebysrealty.com", | |
| 5078 | + "states": [ | |
| 5079 | + "CO" | |
| 5080 | + ], | |
| 5081 | + "cities": [ | |
| 5082 | + "Denver", | |
| 5083 | + "Vail", | |
| 5084 | + "Boulder" | |
| 5085 | + ], | |
| 5086 | + "estimated_agents": 800, | |
| 5087 | + "estimated_listings": 1800, | |
| 5088 | + "mls_affiliations": [ | |
| 5089 | + "REcolorado" | |
| 5090 | + ] | |
| 5091 | + }, | |
| 5092 | + { | |
| 5093 | + "name": "Kentwood Real Estate", | |
| 5094 | + "website": "www.denverrealestate.com", | |
| 5095 | + "states": [ | |
| 5096 | + "CO" | |
| 5097 | + ], | |
| 5098 | + "cities": [ | |
| 5099 | + "Denver" | |
| 5100 | + ], | |
| 5101 | + "estimated_agents": 200, | |
| 5102 | + "estimated_listings": 500, | |
| 5103 | + "mls_affiliations": [ | |
| 5104 | + "REcolorado" | |
| 5105 | + ] | |
| 5106 | + }, | |
| 5107 | + { | |
| 5108 | + "name": "Milehimodern", | |
| 5109 | + "website": "www.milehimodern.com", | |
| 5110 | + "states": [ | |
| 5111 | + "CO" | |
| 5112 | + ], | |
| 5113 | + "cities": [ | |
| 5114 | + "Denver", | |
| 5115 | + "Boulder" | |
| 5116 | + ], | |
| 5117 | + "estimated_agents": 250, | |
| 5118 | + "estimated_listings": 550, | |
| 5119 | + "mls_affiliations": [ | |
| 5120 | + "REcolorado" | |
| 5121 | + ] | |
| 5122 | + }, | |
| 5123 | + { | |
| 5124 | + "name": "RE/MAX Alliance Colorado", | |
| 5125 | + "website": "www.homesincolorado.com", | |
| 5126 | + "states": [ | |
| 5127 | + "CO" | |
| 5128 | + ], | |
| 5129 | + "cities": [ | |
| 5130 | + "Denver", | |
| 5131 | + "Boulder", | |
| 5132 | + "Fort Collins" | |
| 5133 | + ], | |
| 5134 | + "estimated_agents": 900, | |
| 5135 | + "estimated_listings": 1900, | |
| 5136 | + "mls_affiliations": [ | |
| 5137 | + "REcolorado", | |
| 5138 | + "IRES" | |
| 5139 | + ] | |
| 5140 | + }, | |
| 5141 | + { | |
| 5142 | + "name": "The Group Inc Real Estate", | |
| 5143 | + "website": "www.thegroupinc.com", | |
| 5144 | + "states": [ | |
| 5145 | + "CO" | |
| 5146 | + ], | |
| 5147 | + "cities": [ | |
| 5148 | + "Fort Collins", | |
| 5149 | + "Loveland" | |
| 5150 | + ], | |
| 5151 | + "estimated_agents": 200, | |
| 5152 | + "estimated_listings": 500, | |
| 5153 | + "mls_affiliations": [ | |
| 5154 | + "IRES" | |
| 5155 | + ] | |
| 5156 | + }, | |
| 5157 | + { | |
| 5158 | + "name": "Slifer Smith & Frampton Real Estate", | |
| 5159 | + "website": "www.slifersmithandframpton.com", | |
| 5160 | + "states": [ | |
| 5161 | + "CO" | |
| 5162 | + ], | |
| 5163 | + "cities": [ | |
| 5164 | + "Vail", | |
| 5165 | + "Beaver Creek" | |
| 5166 | + ], | |
| 5167 | + "estimated_agents": 300, | |
| 5168 | + "estimated_listings": 700, | |
| 5169 | + "mls_affiliations": [ | |
| 5170 | + "VailMLS" | |
| 5171 | + ] | |
| 5172 | + }, | |
| 5173 | + { | |
| 5174 | + "name": "Aspen Snowmass Sotheby's International Realty", | |
| 5175 | + "website": "www.aspensnowmasssir.com", | |
| 5176 | + "states": [ | |
| 5177 | + "CO" | |
| 5178 | + ], | |
| 5179 | + "cities": [ | |
| 5180 | + "Aspen" | |
| 5181 | + ], | |
| 5182 | + "estimated_agents": 200, | |
| 5183 | + "estimated_listings": 500, | |
| 5184 | + "mls_affiliations": [ | |
| 5185 | + "AspenGlenwoodMLS" | |
| 5186 | + ] | |
| 5187 | + }, | |
| 5188 | + { | |
| 5189 | + "name": "Douglas Elliman Colorado", | |
| 5190 | + "website": "www.elliman.com/colorado", | |
| 5191 | + "states": [ | |
| 5192 | + "CO" | |
| 5193 | + ], | |
| 5194 | + "cities": [ | |
| 5195 | + "Aspen", | |
| 5196 | + "Denver" | |
| 5197 | + ], | |
| 5198 | + "estimated_agents": 130, | |
| 5199 | + "estimated_listings": 320, | |
| 5200 | + "mls_affiliations": [ | |
| 5201 | + "AspenGlenwoodMLS" | |
| 5202 | + ] | |
| 5203 | + }, | |
| 5204 | + { | |
| 5205 | + "name": "ERA Shields Real Estate", | |
| 5206 | + "website": "www.erashields.com", | |
| 5207 | + "states": [ | |
| 5208 | + "CO" | |
| 5209 | + ], | |
| 5210 | + "cities": [ | |
| 5211 | + "Colorado Springs" | |
| 5212 | + ], | |
| 5213 | + "estimated_agents": 200, | |
| 5214 | + "estimated_listings": 480, | |
| 5215 | + "mls_affiliations": [ | |
| 5216 | + "PPMLS" | |
| 5217 | + ] | |
| 5218 | + }, | |
| 5219 | + { | |
| 5220 | + "name": "The Platinum Group Realtors", | |
| 5221 | + "website": "www.platinumgrouprealtors.com", | |
| 5222 | + "states": [ | |
| 5223 | + "CO" | |
| 5224 | + ], | |
| 5225 | + "cities": [ | |
| 5226 | + "Colorado Springs" | |
| 5227 | + ], | |
| 5228 | + "estimated_agents": 130, | |
| 5229 | + "estimated_listings": 340, | |
| 5230 | + "mls_affiliations": [ | |
| 5231 | + "PPMLS" | |
| 5232 | + ] | |
| 5233 | + }, | |
| 5234 | + { | |
| 5235 | + "name": "Summit Sotheby's International Realty", | |
| 5236 | + "website": "www.summitsothebysrealty.com", | |
| 5237 | + "states": [ | |
| 5238 | + "UT" | |
| 5239 | + ], | |
| 5240 | + "cities": [ | |
| 5241 | + "Park City", | |
| 5242 | + "Salt Lake City" | |
| 5243 | + ], | |
| 5244 | + "estimated_agents": 350, | |
| 5245 | + "estimated_listings": 850, | |
| 5246 | + "mls_affiliations": [ | |
| 5247 | + "ParkCityMLS", | |
| 5248 | + "UtahRealEstate" | |
| 5249 | + ] | |
| 5250 | + }, | |
| 5251 | + { | |
| 5252 | + "name": "Windermere Real Estate Utah", | |
| 5253 | + "website": "www.windermereutah.com", | |
| 5254 | + "states": [ | |
| 5255 | + "UT" | |
| 5256 | + ], | |
| 5257 | + "cities": [ | |
| 5258 | + "Salt Lake City" | |
| 5259 | + ], | |
| 5260 | + "estimated_agents": 300, | |
| 5261 | + "estimated_listings": 650, | |
| 5262 | + "mls_affiliations": [ | |
| 5263 | + "UtahRealEstate" | |
| 5264 | + ] | |
| 5265 | + }, | |
| 5266 | + { | |
| 5267 | + "name": "Presidio Real Estate", | |
| 5268 | + "website": "www.presidiorealestate.com", | |
| 5269 | + "states": [ | |
| 5270 | + "UT" | |
| 5271 | + ], | |
| 5272 | + "cities": [ | |
| 5273 | + "South Jordan" | |
| 5274 | + ], | |
| 5275 | + "estimated_agents": 900, | |
| 5276 | + "estimated_listings": 1400, | |
| 5277 | + "mls_affiliations": [ | |
| 5278 | + "UtahRealEstate" | |
| 5279 | + ] | |
| 5280 | + }, | |
| 5281 | + { | |
| 5282 | + "name": "Equity Real Estate Utah", | |
| 5283 | + "website": "www.equityrealestate.com", | |
| 5284 | + "states": [ | |
| 5285 | + "UT" | |
| 5286 | + ], | |
| 5287 | + "cities": [ | |
| 5288 | + "Murray" | |
| 5289 | + ], | |
| 5290 | + "estimated_agents": 1500, | |
| 5291 | + "estimated_listings": 2200, | |
| 5292 | + "mls_affiliations": [ | |
| 5293 | + "UtahRealEstate" | |
| 5294 | + ] | |
| 5295 | + }, | |
| 5296 | + { | |
| 5297 | + "name": "ERA Brokers Consolidated", | |
| 5298 | + "website": "www.erabrokers.com", | |
| 5299 | + "states": [ | |
| 5300 | + "UT", | |
| 5301 | + "NV", | |
| 5302 | + "AZ", | |
| 5303 | + "ID" | |
| 5304 | + ], | |
| 5305 | + "cities": [ | |
| 5306 | + "Saint George" | |
| 5307 | + ], | |
| 5308 | + "estimated_agents": 400, | |
| 5309 | + "estimated_listings": 950, | |
| 5310 | + "mls_affiliations": [ | |
| 5311 | + "WCBRMLS" | |
| 5312 | + ] | |
| 5313 | + }, | |
| 5314 | + { | |
| 5315 | + "name": "Russ Lyon Sotheby's International Realty", | |
| 5316 | + "website": "www.russlyon.com", | |
| 5317 | + "states": [ | |
| 5318 | + "AZ" | |
| 5319 | + ], | |
| 5320 | + "cities": [ | |
| 5321 | + "Scottsdale", | |
| 5322 | + "Phoenix", | |
| 5323 | + "Sedona" | |
| 5324 | + ], | |
| 5325 | + "estimated_agents": 900, | |
| 5326 | + "estimated_listings": 2100, | |
| 5327 | + "mls_affiliations": [ | |
| 5328 | + "ARMLS" | |
| 5329 | + ] | |
| 5330 | + }, | |
| 5331 | + { | |
| 5332 | + "name": "Launch Real Estate", | |
| 5333 | + "website": "www.launchrealestate.com", | |
| 5334 | + "states": [ | |
| 5335 | + "AZ" | |
| 5336 | + ], | |
| 5337 | + "cities": [ | |
| 5338 | + "Scottsdale" | |
| 5339 | + ], | |
| 5340 | + "estimated_agents": 400, | |
| 5341 | + "estimated_listings": 850, | |
| 5342 | + "mls_affiliations": [ | |
| 5343 | + "ARMLS" | |
| 5344 | + ] | |
| 5345 | + }, | |
| 5346 | + { | |
| 5347 | + "name": "The Brokery", | |
| 5348 | + "website": "www.thebrokery.com", | |
| 5349 | + "states": [ | |
| 5350 | + "AZ" | |
| 5351 | + ], | |
| 5352 | + "cities": [ | |
| 5353 | + "Phoenix" | |
| 5354 | + ], | |
| 5355 | + "estimated_agents": 90, | |
| 5356 | + "estimated_listings": 240, | |
| 5357 | + "mls_affiliations": [ | |
| 5358 | + "ARMLS" | |
| 5359 | + ] | |
| 5360 | + }, | |
| 5361 | + { | |
| 5362 | + "name": "My Home Group", | |
| 5363 | + "website": "www.myhomegroup.com", | |
| 5364 | + "states": [ | |
| 5365 | + "AZ", | |
| 5366 | + "WA" | |
| 5367 | + ], | |
| 5368 | + "cities": [ | |
| 5369 | + "Scottsdale", | |
| 5370 | + "Phoenix" | |
| 5371 | + ], | |
| 5372 | + "estimated_agents": 3200, | |
| 5373 | + "estimated_listings": 4600, | |
| 5374 | + "mls_affiliations": [ | |
| 5375 | + "ARMLS" | |
| 5376 | + ] | |
| 5377 | + }, | |
| 5378 | + { | |
| 5379 | + "name": "West USA Realty", | |
| 5380 | + "website": "www.westusa.com", | |
| 5381 | + "states": [ | |
| 5382 | + "AZ" | |
| 5383 | + ], | |
| 5384 | + "cities": [ | |
| 5385 | + "Phoenix" | |
| 5386 | + ], | |
| 5387 | + "estimated_agents": 2900, | |
| 5388 | + "estimated_listings": 4200, | |
| 5389 | + "mls_affiliations": [ | |
| 5390 | + "ARMLS" | |
| 5391 | + ] | |
| 5392 | + }, | |
| 5393 | + { | |
| 5394 | + "name": "Realty Executives Arizona Territory", | |
| 5395 | + "website": "www.realtyexecutivesaz.com", | |
| 5396 | + "states": [ | |
| 5397 | + "AZ" | |
| 5398 | + ], | |
| 5399 | + "cities": [ | |
| 5400 | + "Tucson" | |
| 5401 | + ], | |
| 5402 | + "estimated_agents": 500, | |
| 5403 | + "estimated_listings": 1100, | |
| 5404 | + "mls_affiliations": [ | |
| 5405 | + "MLSSAZ" | |
| 5406 | + ] | |
| 5407 | + }, | |
| 5408 | + { | |
| 5409 | + "name": "Long Realty Company", | |
| 5410 | + "website": "www.longrealty.com", | |
| 5411 | + "states": [ | |
| 5412 | + "AZ" | |
| 5413 | + ], | |
| 5414 | + "cities": [ | |
| 5415 | + "Tucson" | |
| 5416 | + ], | |
| 5417 | + "estimated_agents": 1200, | |
| 5418 | + "estimated_listings": 2600, | |
| 5419 | + "mls_affiliations": [ | |
| 5420 | + "MLSSAZ" | |
| 5421 | + ] | |
| 5422 | + }, | |
| 5423 | + { | |
| 5424 | + "name": "Tierra Antigua Realty", | |
| 5425 | + "website": "www.tierraantigua.com", | |
| 5426 | + "states": [ | |
| 5427 | + "AZ" | |
| 5428 | + ], | |
| 5429 | + "cities": [ | |
| 5430 | + "Tucson" | |
| 5431 | + ], | |
| 5432 | + "estimated_agents": 1400, | |
| 5433 | + "estimated_listings": 2200, | |
| 5434 | + "mls_affiliations": [ | |
| 5435 | + "MLSSAZ" | |
| 5436 | + ] | |
| 5437 | + }, | |
| 5438 | + { | |
| 5439 | + "name": "Berkshire Hathaway HomeServices Arizona Properties", | |
| 5440 | + "website": "www.bhhsaz.com", | |
| 5441 | + "states": [ | |
| 5442 | + "AZ" | |
| 5443 | + ], | |
| 5444 | + "cities": [ | |
| 5445 | + "Scottsdale" | |
| 5446 | + ], | |
| 5447 | + "estimated_agents": 700, | |
| 5448 | + "estimated_listings": 1500, | |
| 5449 | + "mls_affiliations": [ | |
| 5450 | + "ARMLS" | |
| 5451 | + ] | |
| 5452 | + }, | |
| 5453 | + { | |
| 5454 | + "name": "HomeSmart Arizona", | |
| 5455 | + "website": "www.homesmart.com/az", | |
| 5456 | + "states": [ | |
| 5457 | + "AZ" | |
| 5458 | + ], | |
| 5459 | + "cities": [ | |
| 5460 | + "Phoenix" | |
| 5461 | + ], | |
| 5462 | + "estimated_agents": 7000, | |
| 5463 | + "estimated_listings": 9000, | |
| 5464 | + "mls_affiliations": [ | |
| 5465 | + "ARMLS" | |
| 5466 | + ] | |
| 5467 | + }, | |
| 5468 | + { | |
| 5469 | + "name": "Coldwell Banker Legacy", | |
| 5470 | + "website": "www.cblegacy.com", | |
| 5471 | + "states": [ | |
| 5472 | + "NM" | |
| 5473 | + ], | |
| 5474 | + "cities": [ | |
| 5475 | + "Albuquerque" | |
| 5476 | + ], | |
| 5477 | + "estimated_agents": 400, | |
| 5478 | + "estimated_listings": 950, | |
| 5479 | + "mls_affiliations": [ | |
| 5480 | + "SWMLS" | |
| 5481 | + ] | |
| 5482 | + }, | |
| 5483 | + { | |
| 5484 | + "name": "Berkshire Hathaway HomeServices New Mexico Properties", | |
| 5485 | + "website": "www.bhhsnm.com", | |
| 5486 | + "states": [ | |
| 5487 | + "NM" | |
| 5488 | + ], | |
| 5489 | + "cities": [ | |
| 5490 | + "Albuquerque" | |
| 5491 | + ], | |
| 5492 | + "estimated_agents": 300, | |
| 5493 | + "estimated_listings": 700, | |
| 5494 | + "mls_affiliations": [ | |
| 5495 | + "SWMLS" | |
| 5496 | + ] | |
| 5497 | + }, | |
| 5498 | + { | |
| 5499 | + "name": "Sotheby's International Realty Santa Fe", | |
| 5500 | + "website": "www.santafesothebysrealty.com", | |
| 5501 | + "states": [ | |
| 5502 | + "NM" | |
| 5503 | + ], | |
| 5504 | + "cities": [ | |
| 5505 | + "Santa Fe" | |
| 5506 | + ], | |
| 5507 | + "estimated_agents": 200, | |
| 5508 | + "estimated_listings": 550, | |
| 5509 | + "mls_affiliations": [ | |
| 5510 | + "SantaFeMLS" | |
| 5511 | + ] | |
| 5512 | + }, | |
| 5513 | + { | |
| 5514 | + "name": "Barker Realty (Santa Fe)", | |
| 5515 | + "website": "www.santaferealestate.com", | |
| 5516 | + "states": [ | |
| 5517 | + "NM" | |
| 5518 | + ], | |
| 5519 | + "cities": [ | |
| 5520 | + "Santa Fe" | |
| 5521 | + ], | |
| 5522 | + "estimated_agents": 90, | |
| 5523 | + "estimated_listings": 260, | |
| 5524 | + "mls_affiliations": [ | |
| 5525 | + "SantaFeMLS" | |
| 5526 | + ] | |
| 5527 | + }, | |
| 5528 | + { | |
| 5529 | + "name": "Berkshire Hathaway HomeServices Nevada Properties", | |
| 5530 | + "website": "www.bhhsnv.com", | |
| 5531 | + "states": [ | |
| 5532 | + "NV" | |
| 5533 | + ], | |
| 5534 | + "cities": [ | |
| 5535 | + "Las Vegas" | |
| 5536 | + ], | |
| 5537 | + "estimated_agents": 2600, | |
| 5538 | + "estimated_listings": 3900, | |
| 5539 | + "mls_affiliations": [ | |
| 5540 | + "LVR" | |
| 5541 | + ] | |
| 5542 | + }, | |
| 5543 | + { | |
| 5544 | + "name": "Simply Vegas Real Estate", | |
| 5545 | + "website": "www.simplyvegas.com", | |
| 5546 | + "states": [ | |
| 5547 | + "NV" | |
| 5548 | + ], | |
| 5549 | + "cities": [ | |
| 5550 | + "Las Vegas" | |
| 5551 | + ], | |
| 5552 | + "estimated_agents": 500, | |
| 5553 | + "estimated_listings": 1000, | |
| 5554 | + "mls_affiliations": [ | |
| 5555 | + "LVR" | |
| 5556 | + ] | |
| 5557 | + }, | |
| 5558 | + { | |
| 5559 | + "name": "Urban Nest Realty", | |
| 5560 | + "website": "www.urbannestlv.com", | |
| 5561 | + "states": [ | |
| 5562 | + "NV" | |
| 5563 | + ], | |
| 5564 | + "cities": [ | |
| 5565 | + "Las Vegas" | |
| 5566 | + ], | |
| 5567 | + "estimated_agents": 400, | |
| 5568 | + "estimated_listings": 800, | |
| 5569 | + "mls_affiliations": [ | |
| 5570 | + "LVR" | |
| 5571 | + ] | |
| 5572 | + }, | |
| 5573 | + { | |
| 5574 | + "name": "Chase International", | |
| 5575 | + "website": "www.chaseinternational.com", | |
| 5576 | + "states": [ | |
| 5577 | + "NV", | |
| 5578 | + "CA" | |
| 5579 | + ], | |
| 5580 | + "cities": [ | |
| 5581 | + "Lake Tahoe", | |
| 5582 | + "Reno" | |
| 5583 | + ], | |
| 5584 | + "estimated_agents": 350, | |
| 5585 | + "estimated_listings": 800, | |
| 5586 | + "mls_affiliations": [ | |
| 5587 | + "NNRMLS" | |
| 5588 | + ] | |
| 5589 | + }, | |
| 5590 | + { | |
| 5591 | + "name": "Dickson Realty", | |
| 5592 | + "website": "www.dicksonrealty.com", | |
| 5593 | + "states": [ | |
| 5594 | + "NV", | |
| 5595 | + "CA" | |
| 5596 | + ], | |
| 5597 | + "cities": [ | |
| 5598 | + "Reno" | |
| 5599 | + ], | |
| 5600 | + "estimated_agents": 300, | |
| 5601 | + "estimated_listings": 700, | |
| 5602 | + "mls_affiliations": [ | |
| 5603 | + "NNRMLS" | |
| 5604 | + ] | |
| 5605 | + }, | |
| 5606 | + { | |
| 5607 | + "name": "Ferrari-Lund Real Estate", | |
| 5608 | + "website": "www.ferrari-lund.com", | |
| 5609 | + "states": [ | |
| 5610 | + "NV" | |
| 5611 | + ], | |
| 5612 | + "cities": [ | |
| 5613 | + "Reno" | |
| 5614 | + ], | |
| 5615 | + "estimated_agents": 200, | |
| 5616 | + "estimated_listings": 450, | |
| 5617 | + "mls_affiliations": [ | |
| 5618 | + "NNRMLS" | |
| 5619 | + ] | |
| 5620 | + }, | |
| 5621 | + { | |
| 5622 | + "name": "Realogics Sotheby's International Realty", | |
| 5623 | + "website": "www.rsir.com", | |
| 5624 | + "states": [ | |
| 5625 | + "WA" | |
| 5626 | + ], | |
| 5627 | + "cities": [ | |
| 5628 | + "Seattle", | |
| 5629 | + "Bellevue" | |
| 5630 | + ], | |
| 5631 | + "estimated_agents": 300, | |
| 5632 | + "estimated_listings": 700, | |
| 5633 | + "mls_affiliations": [ | |
| 5634 | + "NWMLS" | |
| 5635 | + ] | |
| 5636 | + }, | |
| 5637 | + { | |
| 5638 | + "name": "COMPASS Washington", | |
| 5639 | + "website": "www.compass.com/wa", | |
| 5640 | + "states": [ | |
| 5641 | + "WA" | |
| 5642 | + ], | |
| 5643 | + "cities": [ | |
| 5644 | + "Seattle" | |
| 5645 | + ], | |
| 5646 | + "estimated_agents": 1300, | |
| 5647 | + "estimated_listings": 2400, | |
| 5648 | + "mls_affiliations": [ | |
| 5649 | + "NWMLS" | |
| 5650 | + ] | |
| 5651 | + }, | |
| 5652 | + { | |
| 5653 | + "name": "Coldwell Banker Bain", | |
| 5654 | + "website": "www.coldwellbankerbain.com", | |
| 5655 | + "states": [ | |
| 5656 | + "WA", | |
| 5657 | + "OR" | |
| 5658 | + ], | |
| 5659 | + "cities": [ | |
| 5660 | + "Seattle", | |
| 5661 | + "Bellevue", | |
| 5662 | + "Portland" | |
| 5663 | + ], | |
| 5664 | + "estimated_agents": 1100, | |
| 5665 | + "estimated_listings": 2400, | |
| 5666 | + "mls_affiliations": [ | |
| 5667 | + "NWMLS", | |
| 5668 | + "RMLS" | |
| 5669 | + ] | |
| 5670 | + }, | |
| 5671 | + { | |
| 5672 | + "name": "RE/MAX Northwest Realtors", | |
| 5673 | + "website": "www.remaxnorthwest.com", | |
| 5674 | + "states": [ | |
| 5675 | + "WA" | |
| 5676 | + ], | |
| 5677 | + "cities": [ | |
| 5678 | + "Seattle" | |
| 5679 | + ], | |
| 5680 | + "estimated_agents": 300, | |
| 5681 | + "estimated_listings": 650, | |
| 5682 | + "mls_affiliations": [ | |
| 5683 | + "NWMLS" | |
| 5684 | + ] | |
| 5685 | + }, | |
| 5686 | + { | |
| 5687 | + "name": "Skyline Properties", | |
| 5688 | + "website": "www.skylineproperties.com", | |
| 5689 | + "states": [ | |
| 5690 | + "WA" | |
| 5691 | + ], | |
| 5692 | + "cities": [ | |
| 5693 | + "Bellevue" | |
| 5694 | + ], | |
| 5695 | + "estimated_agents": 900, | |
| 5696 | + "estimated_listings": 1400, | |
| 5697 | + "mls_affiliations": [ | |
| 5698 | + "NWMLS" | |
| 5699 | + ] | |
| 5700 | + }, | |
| 5701 | + { | |
| 5702 | + "name": "Marketplace Sotheby's International Realty", | |
| 5703 | + "website": "www.marketplacesir.com", | |
| 5704 | + "states": [ | |
| 5705 | + "WA" | |
| 5706 | + ], | |
| 5707 | + "cities": [ | |
| 5708 | + "Redmond" | |
| 5709 | + ], | |
| 5710 | + "estimated_agents": 90, | |
| 5711 | + "estimated_listings": 240, | |
| 5712 | + "mls_affiliations": [ | |
| 5713 | + "NWMLS" | |
| 5714 | + ] | |
| 5715 | + }, | |
| 5716 | + { | |
| 5717 | + "name": "Lake & Company Real Estate", | |
| 5718 | + "website": "www.lakere.com", | |
| 5719 | + "states": [ | |
| 5720 | + "WA" | |
| 5721 | + ], | |
| 5722 | + "cities": [ | |
| 5723 | + "Seattle" | |
| 5724 | + ], | |
| 5725 | + "estimated_agents": 90, | |
| 5726 | + "estimated_listings": 220, | |
| 5727 | + "mls_affiliations": [ | |
| 5728 | + "NWMLS" | |
| 5729 | + ] | |
| 5730 | + }, | |
| 5731 | + { | |
| 5732 | + "name": "Berkshire Hathaway HomeServices Northwest Real Estate", | |
| 5733 | + "website": "www.bhhsnw.com", | |
| 5734 | + "states": [ | |
| 5735 | + "WA", | |
| 5736 | + "OR" | |
| 5737 | + ], | |
| 5738 | + "cities": [ | |
| 5739 | + "Portland", | |
| 5740 | + "Tacoma" | |
| 5741 | + ], | |
| 5742 | + "estimated_agents": 800, | |
| 5743 | + "estimated_listings": 1700, | |
| 5744 | + "mls_affiliations": [ | |
| 5745 | + "RMLS", | |
| 5746 | + "NWMLS" | |
| 5747 | + ] | |
| 5748 | + }, | |
| 5749 | + { | |
| 5750 | + "name": "Where Real Estate (Portland)", | |
| 5751 | + "website": "www.wherepdx.com", | |
| 5752 | + "states": [ | |
| 5753 | + "OR" | |
| 5754 | + ], | |
| 5755 | + "cities": [ | |
| 5756 | + "Portland" | |
| 5757 | + ], | |
| 5758 | + "estimated_agents": 90, | |
| 5759 | + "estimated_listings": 220, | |
| 5760 | + "mls_affiliations": [ | |
| 5761 | + "RMLS" | |
| 5762 | + ] | |
| 5763 | + }, | |
| 5764 | + { | |
| 5765 | + "name": "Living Room Realty", | |
| 5766 | + "website": "www.livingroomre.com", | |
| 5767 | + "states": [ | |
| 5768 | + "OR", | |
| 5769 | + "WA" | |
| 5770 | + ], | |
| 5771 | + "cities": [ | |
| 5772 | + "Portland" | |
| 5773 | + ], | |
| 5774 | + "estimated_agents": 200, | |
| 5775 | + "estimated_listings": 480, | |
| 5776 | + "mls_affiliations": [ | |
| 5777 | + "RMLS" | |
| 5778 | + ] | |
| 5779 | + }, | |
| 5780 | + { | |
| 5781 | + "name": "Cascade Hasson Sotheby's International Realty", | |
| 5782 | + "website": "www.cascadehasson.com", | |
| 5783 | + "states": [ | |
| 5784 | + "OR", | |
| 5785 | + "WA" | |
| 5786 | + ], | |
| 5787 | + "cities": [ | |
| 5788 | + "Portland", | |
| 5789 | + "Lake Oswego" | |
| 5790 | + ], | |
| 5791 | + "estimated_agents": 500, | |
| 5792 | + "estimated_listings": 1200, | |
| 5793 | + "mls_affiliations": [ | |
| 5794 | + "RMLS" | |
| 5795 | + ] | |
| 5796 | + }, | |
| 5797 | + { | |
| 5798 | + "name": "Duke Warner Realty", | |
| 5799 | + "website": "www.dukewarner.com", | |
| 5800 | + "states": [ | |
| 5801 | + "OR" | |
| 5802 | + ], | |
| 5803 | + "cities": [ | |
| 5804 | + "Bend" | |
| 5805 | + ], | |
| 5806 | + "estimated_agents": 60, | |
| 5807 | + "estimated_listings": 170, | |
| 5808 | + "mls_affiliations": [ | |
| 5809 | + "COAR" | |
| 5810 | + ] | |
| 5811 | + }, | |
| 5812 | + { | |
| 5813 | + "name": "Cascade Hasson SIR Bend", | |
| 5814 | + "website": "www.cascadehasson.com/bend", | |
| 5815 | + "states": [ | |
| 5816 | + "OR" | |
| 5817 | + ], | |
| 5818 | + "cities": [ | |
| 5819 | + "Bend" | |
| 5820 | + ], | |
| 5821 | + "estimated_agents": 90, | |
| 5822 | + "estimated_listings": 260, | |
| 5823 | + "mls_affiliations": [ | |
| 5824 | + "COAR" | |
| 5825 | + ] | |
| 5826 | + }, | |
| 5827 | + { | |
| 5828 | + "name": "Windermere Central Oregon Real Estate", | |
| 5829 | + "website": "www.windermerecentraloregon.com", | |
| 5830 | + "states": [ | |
| 5831 | + "OR" | |
| 5832 | + ], | |
| 5833 | + "cities": [ | |
| 5834 | + "Bend" | |
| 5835 | + ], | |
| 5836 | + "estimated_agents": 90, | |
| 5837 | + "estimated_listings": 250, | |
| 5838 | + "mls_affiliations": [ | |
| 5839 | + "COAR" | |
| 5840 | + ] | |
| 5841 | + }, | |
| 5842 | + { | |
| 5843 | + "name": "John L Scott Medford", | |
| 5844 | + "website": "www.johnlscott.com/medford", | |
| 5845 | + "states": [ | |
| 5846 | + "OR" | |
| 5847 | + ], | |
| 5848 | + "cities": [ | |
| 5849 | + "Medford" | |
| 5850 | + ], | |
| 5851 | + "estimated_agents": 90, | |
| 5852 | + "estimated_listings": 240, | |
| 5853 | + "mls_affiliations": [ | |
| 5854 | + "SOMLS" | |
| 5855 | + ] | |
| 5856 | + }, | |
| 5857 | + { | |
| 5858 | + "name": "RE/MAX Dynamic Properties", | |
| 5859 | + "website": "www.dynamicak.com", | |
| 5860 | + "states": [ | |
| 5861 | + "AK" | |
| 5862 | + ], | |
| 5863 | + "cities": [ | |
| 5864 | + "Anchorage" | |
| 5865 | + ], | |
| 5866 | + "estimated_agents": 200, | |
| 5867 | + "estimated_listings": 480, | |
| 5868 | + "mls_affiliations": [ | |
| 5869 | + "AlaskaMLS" | |
| 5870 | + ] | |
| 5871 | + }, | |
| 5872 | + { | |
| 5873 | + "name": "Keller Williams Realty Alaska Group", | |
| 5874 | + "website": "www.kwalaska.com", | |
| 5875 | + "states": [ | |
| 5876 | + "AK" | |
| 5877 | + ], | |
| 5878 | + "cities": [ | |
| 5879 | + "Anchorage" | |
| 5880 | + ], | |
| 5881 | + "estimated_agents": 200, | |
| 5882 | + "estimated_listings": 420, | |
| 5883 | + "mls_affiliations": [ | |
| 5884 | + "AlaskaMLS" | |
| 5885 | + ] | |
| 5886 | + }, | |
| 5887 | + { | |
| 5888 | + "name": "Jack White Real Estate", | |
| 5889 | + "website": "www.jackwhite.com", | |
| 5890 | + "states": [ | |
| 5891 | + "AK" | |
| 5892 | + ], | |
| 5893 | + "cities": [ | |
| 5894 | + "Anchorage", | |
| 5895 | + "Wasilla" | |
| 5896 | + ], | |
| 5897 | + "estimated_agents": 300, | |
| 5898 | + "estimated_listings": 650, | |
| 5899 | + "mls_affiliations": [ | |
| 5900 | + "AlaskaMLS" | |
| 5901 | + ] | |
| 5902 | + }, | |
| 5903 | + { | |
| 5904 | + "name": "Hawaii Life Real Estate Brokers", | |
| 5905 | + "website": "www.hawaiilife.com", | |
| 5906 | + "states": [ | |
| 5907 | + "HI" | |
| 5908 | + ], | |
| 5909 | + "cities": [ | |
| 5910 | + "Honolulu", | |
| 5911 | + "Kailua-Kona", | |
| 5912 | + "Princeville" | |
| 5913 | + ], | |
| 5914 | + "estimated_agents": 350, | |
| 5915 | + "estimated_listings": 900, | |
| 5916 | + "mls_affiliations": [ | |
| 5917 | + "HiCentralMLS" | |
| 5918 | + ] | |
| 5919 | + }, | |
| 5920 | + { | |
| 5921 | + "name": "Locations Hawaii", | |
| 5922 | + "website": "www.locationshawaii.com", | |
| 5923 | + "states": [ | |
| 5924 | + "HI" | |
| 5925 | + ], | |
| 5926 | + "cities": [ | |
| 5927 | + "Honolulu" | |
| 5928 | + ], | |
| 5929 | + "estimated_agents": 300, | |
| 5930 | + "estimated_listings": 700, | |
| 5931 | + "mls_affiliations": [ | |
| 5932 | + "HiCentralMLS" | |
| 5933 | + ] | |
| 5934 | + }, | |
| 5935 | + { | |
| 5936 | + "name": "List Sotheby's International Realty Hawaii", | |
| 5937 | + "website": "www.listsothebysrealty.com", | |
| 5938 | + "states": [ | |
| 5939 | + "HI" | |
| 5940 | + ], | |
| 5941 | + "cities": [ | |
| 5942 | + "Honolulu" | |
| 5943 | + ], | |
| 5944 | + "estimated_agents": 200, | |
| 5945 | + "estimated_listings": 480, | |
| 5946 | + "mls_affiliations": [ | |
| 5947 | + "HiCentralMLS" | |
| 5948 | + ] | |
| 5949 | + }, | |
| 5950 | + { | |
| 5951 | + "name": "Coldwell Banker Island Properties", | |
| 5952 | + "website": "www.cbislandproperties.com", | |
| 5953 | + "states": [ | |
| 5954 | + "HI" | |
| 5955 | + ], | |
| 5956 | + "cities": [ | |
| 5957 | + "Maui", | |
| 5958 | + "Wailea" | |
| 5959 | + ], | |
| 5960 | + "estimated_agents": 200, | |
| 5961 | + "estimated_listings": 500, | |
| 5962 | + "mls_affiliations": [ | |
| 5963 | + "REALTORS ACM" | |
| 5964 | + ] | |
| 5965 | + }, | |
| 5966 | + { | |
| 5967 | + "name": "Elite Pacific Properties (Compass Hawaii)", | |
| 5968 | + "website": "www.elitepacific.com", | |
| 5969 | + "states": [ | |
| 5970 | + "HI" | |
| 5971 | + ], | |
| 5972 | + "cities": [ | |
| 5973 | + "Honolulu", | |
| 5974 | + "Maui" | |
| 5975 | + ], | |
| 5976 | + "estimated_agents": 200, | |
| 5977 | + "estimated_listings": 450, | |
| 5978 | + "mls_affiliations": [ | |
| 5979 | + "HiCentralMLS" | |
| 5980 | + ] | |
| 5981 | + }, | |
| 5982 | + { | |
| 5983 | + "name": "Chinowth & Cohen Realtors", | |
| 5984 | + "website": "www.ccoklahoma.com", | |
| 5985 | + "states": [ | |
| 5986 | + "OK" | |
| 5987 | + ], | |
| 5988 | + "cities": [ | |
| 5989 | + "Tulsa", | |
| 5990 | + "Oklahoma City" | |
| 5991 | + ], | |
| 5992 | + "estimated_agents": 900, | |
| 5993 | + "estimated_listings": 1900, | |
| 5994 | + "mls_affiliations": [ | |
| 5995 | + "MLSOK", | |
| 5996 | + "NORES" | |
| 5997 | + ] | |
| 5998 | + }, | |
| 5999 | + { | |
| 6000 | + "name": "McGraw Realtors", | |
| 6001 | + "website": "www.mcgrawrealtors.com", | |
| 6002 | + "states": [ | |
| 6003 | + "OK" | |
| 6004 | + ], | |
| 6005 | + "cities": [ | |
| 6006 | + "Tulsa" | |
| 6007 | + ], | |
| 6008 | + "estimated_agents": 700, | |
| 6009 | + "estimated_listings": 1500, | |
| 6010 | + "mls_affiliations": [ | |
| 6011 | + "NORES" | |
| 6012 | + ] | |
| 6013 | + }, | |
| 6014 | + { | |
| 6015 | + "name": "Keller Williams Realty Elite OKC", | |
| 6016 | + "website": "www.kwokc.com", | |
| 6017 | + "states": [ | |
| 6018 | + "OK" | |
| 6019 | + ], | |
| 6020 | + "cities": [ | |
| 6021 | + "Oklahoma City" | |
| 6022 | + ], | |
| 6023 | + "estimated_agents": 500, | |
| 6024 | + "estimated_listings": 1000, | |
| 6025 | + "mls_affiliations": [ | |
| 6026 | + "MLSOK" | |
| 6027 | + ] | |
| 6028 | + }, | |
| 6029 | + { | |
| 6030 | + "name": "Sage Sotheby's International Realty", | |
| 6031 | + "website": "www.sagesir.com", | |
| 6032 | + "states": [ | |
| 6033 | + "OK" | |
| 6034 | + ], | |
| 6035 | + "cities": [ | |
| 6036 | + "Oklahoma City" | |
| 6037 | + ], | |
| 6038 | + "estimated_agents": 60, | |
| 6039 | + "estimated_listings": 160, | |
| 6040 | + "mls_affiliations": [ | |
| 6041 | + "MLSOK" | |
| 6042 | + ] | |
| 6043 | + }, | |
| 6044 | + { | |
| 6045 | + "name": "Verbode", | |
| 6046 | + "website": "www.verbode.com", | |
| 6047 | + "states": [ | |
| 6048 | + "OK" | |
| 6049 | + ], | |
| 6050 | + "cities": [ | |
| 6051 | + "Oklahoma City" | |
| 6052 | + ], | |
| 6053 | + "estimated_agents": 40, | |
| 6054 | + "estimated_listings": 110, | |
| 6055 | + "mls_affiliations": [ | |
| 6056 | + "MLSOK" | |
| 6057 | + ] | |
| 6058 | + }, | |
| 6059 | + { | |
| 6060 | + "name": "Crye-Leike Arkansas", | |
| 6061 | + "website": "www.crye-leike.com/ar", | |
| 6062 | + "states": [ | |
| 6063 | + "AR" | |
| 6064 | + ], | |
| 6065 | + "cities": [ | |
| 6066 | + "Little Rock" | |
| 6067 | + ], | |
| 6068 | + "estimated_agents": 400, | |
| 6069 | + "estimated_listings": 900, | |
| 6070 | + "mls_affiliations": [ | |
| 6071 | + "CARMLS" | |
| 6072 | + ] | |
| 6073 | + }, | |
| 6074 | + { | |
| 6075 | + "name": "The Charlotte John Company", | |
| 6076 | + "website": "www.charlottejohn.com", | |
| 6077 | + "states": [ | |
| 6078 | + "AR" | |
| 6079 | + ], | |
| 6080 | + "cities": [ | |
| 6081 | + "Little Rock" | |
| 6082 | + ], | |
| 6083 | + "estimated_agents": 90, | |
| 6084 | + "estimated_listings": 230, | |
| 6085 | + "mls_affiliations": [ | |
| 6086 | + "CARMLS" | |
| 6087 | + ] | |
| 6088 | + }, | |
| 6089 | + { | |
| 6090 | + "name": "The Janet Jones Company", | |
| 6091 | + "website": "www.janetjones.com", | |
| 6092 | + "states": [ | |
| 6093 | + "AR" | |
| 6094 | + ], | |
| 6095 | + "cities": [ | |
| 6096 | + "Little Rock" | |
| 6097 | + ], | |
| 6098 | + "estimated_agents": 90, | |
| 6099 | + "estimated_listings": 230, | |
| 6100 | + "mls_affiliations": [ | |
| 6101 | + "CARMLS" | |
| 6102 | + ] | |
| 6103 | + }, | |
| 6104 | + { | |
| 6105 | + "name": "Lindsey & Associates", | |
| 6106 | + "website": "www.lindsey.com", | |
| 6107 | + "states": [ | |
| 6108 | + "AR" | |
| 6109 | + ], | |
| 6110 | + "cities": [ | |
| 6111 | + "Fayetteville", | |
| 6112 | + "Rogers" | |
| 6113 | + ], | |
| 6114 | + "estimated_agents": 500, | |
| 6115 | + "estimated_listings": 1100, | |
| 6116 | + "mls_affiliations": [ | |
| 6117 | + "NWAMLS" | |
| 6118 | + ] | |
| 6119 | + }, | |
| 6120 | + { | |
| 6121 | + "name": "Collier & Associates NWA", | |
| 6122 | + "website": "www.collierandassociates.com", | |
| 6123 | + "states": [ | |
| 6124 | + "AR" | |
| 6125 | + ], | |
| 6126 | + "cities": [ | |
| 6127 | + "Bentonville" | |
| 6128 | + ], | |
| 6129 | + "estimated_agents": 130, | |
| 6130 | + "estimated_listings": 340, | |
| 6131 | + "mls_affiliations": [ | |
| 6132 | + "NWAMLS" | |
| 6133 | + ] | |
| 6134 | + }, | |
| 6135 | + { | |
| 6136 | + "name": "Old Colony Realtors", | |
| 6137 | + "website": "www.oldcolony.com", | |
| 6138 | + "states": [ | |
| 6139 | + "WV" | |
| 6140 | + ], | |
| 6141 | + "cities": [ | |
| 6142 | + "Charleston" | |
| 6143 | + ], | |
| 6144 | + "estimated_agents": 200, | |
| 6145 | + "estimated_listings": 480, | |
| 6146 | + "mls_affiliations": [ | |
| 6147 | + "KVBRMLS" | |
| 6148 | + ] | |
| 6149 | + }, | |
| 6150 | + { | |
| 6151 | + "name": "Real Estate Central WV", | |
| 6152 | + "website": "www.recentralwv.com", | |
| 6153 | + "states": [ | |
| 6154 | + "WV" | |
| 6155 | + ], | |
| 6156 | + "cities": [ | |
| 6157 | + "Morgantown" | |
| 6158 | + ], | |
| 6159 | + "estimated_agents": 60, | |
| 6160 | + "estimated_listings": 160, | |
| 6161 | + "mls_affiliations": [ | |
| 6162 | + "NCWVMLS" | |
| 6163 | + ] | |
| 6164 | + }, | |
| 6165 | + { | |
| 6166 | + "name": "Ben Kinney Real Estate Team (PLACE)", | |
| 6167 | + "website": "www.benkinneyteam.com", | |
| 6168 | + "states": [ | |
| 6169 | + "WA" | |
| 6170 | + ], | |
| 6171 | + "cities": [ | |
| 6172 | + "Bellingham", | |
| 6173 | + "Seattle" | |
| 6174 | + ], | |
| 6175 | + "estimated_agents": 100, | |
| 6176 | + "estimated_listings": 400, | |
| 6177 | + "mls_affiliations": [ | |
| 6178 | + "NWMLS" | |
| 6179 | + ] | |
| 6180 | + }, | |
| 6181 | + { | |
| 6182 | + "name": "Mark Spain Real Estate", | |
| 6183 | + "website": "www.markspain.com", | |
| 6184 | + "states": [ | |
| 6185 | + "GA", | |
| 6186 | + "NC", | |
| 6187 | + "TN", | |
| 6188 | + "FL", | |
| 6189 | + "TX", | |
| 6190 | + "AL", | |
| 6191 | + "SC" | |
| 6192 | + ], | |
| 6193 | + "cities": [ | |
| 6194 | + "Atlanta" | |
| 6195 | + ], | |
| 6196 | + "estimated_agents": 400, | |
| 6197 | + "estimated_listings": 1200, | |
| 6198 | + "mls_affiliations": [ | |
| 6199 | + "FMLS" | |
| 6200 | + ] | |
| 6201 | + }, | |
| 6202 | + { | |
| 6203 | + "name": "The Loken Group", | |
| 6204 | + "website": "www.thelokengroup.com", | |
| 6205 | + "states": [ | |
| 6206 | + "TX" | |
| 6207 | + ], | |
| 6208 | + "cities": [ | |
| 6209 | + "Houston" | |
| 6210 | + ], | |
| 6211 | + "estimated_agents": 150, | |
| 6212 | + "estimated_listings": 500, | |
| 6213 | + "mls_affiliations": [ | |
| 6214 | + "HAR" | |
| 6215 | + ] | |
| 6216 | + }, | |
| 6217 | + { | |
| 6218 | + "name": "JPAR Real Estate", | |
| 6219 | + "website": "www.jpar.com", | |
| 6220 | + "states": [ | |
| 6221 | + "TX", | |
| 6222 | + "FL", | |
| 6223 | + "GA", | |
| 6224 | + "NC", | |
| 6225 | + "SC", | |
| 6226 | + "AZ", | |
| 6227 | + "CO", | |
| 6228 | + "LA", | |
| 6229 | + "OK", | |
| 6230 | + "TN", | |
| 6231 | + "MD", | |
| 6232 | + "NJ", | |
| 6233 | + "NM" | |
| 6234 | + ], | |
| 6235 | + "cities": [ | |
| 6236 | + "Plano" | |
| 6237 | + ], | |
| 6238 | + "estimated_agents": 4000, | |
| 6239 | + "estimated_listings": 6500, | |
| 6240 | + "mls_affiliations": [ | |
| 6241 | + "NTREIS" | |
| 6242 | + ] | |
| 6243 | + }, | |
| 6244 | + { | |
| 6245 | + "name": "Side Inc (platform brokerages)", | |
| 6246 | + "website": "www.side.com", | |
| 6247 | + "states": [ | |
| 6248 | + "CA", | |
| 6249 | + "TX", | |
| 6250 | + "FL", | |
| 6251 | + "NY", | |
| 6252 | + "WA", | |
| 6253 | + "AZ", | |
| 6254 | + "CO", | |
| 6255 | + "GA" | |
| 6256 | + ], | |
| 6257 | + "cities": [ | |
| 6258 | + "San Francisco" | |
| 6259 | + ], | |
| 6260 | + "estimated_agents": 3000, | |
| 6261 | + "estimated_listings": 6000, | |
| 6262 | + "mls_affiliations": [] | |
| 6263 | + }, | |
| 6264 | + { | |
| 6265 | + "name": "Radius Agent Realty", | |
| 6266 | + "website": "www.radiusagent.com", | |
| 6267 | + "states": [ | |
| 6268 | + "CA", | |
| 6269 | + "TX", | |
| 6270 | + "FL", | |
| 6271 | + "CO", | |
| 6272 | + "GA", | |
| 6273 | + "WA", | |
| 6274 | + "OR", | |
| 6275 | + "AZ" | |
| 6276 | + ], | |
| 6277 | + "cities": [ | |
| 6278 | + "San Francisco" | |
| 6279 | + ], | |
| 6280 | + "estimated_agents": 900, | |
| 6281 | + "estimated_listings": 1500, | |
| 6282 | + "mls_affiliations": [] | |
| 6283 | + }, | |
| 6284 | + { | |
| 6285 | + "name": "Homie", | |
| 6286 | + "website": "www.homie.com", | |
| 6287 | + "states": [ | |
| 6288 | + "UT", | |
| 6289 | + "AZ", | |
| 6290 | + "NV", | |
| 6291 | + "ID", | |
| 6292 | + "CO" | |
| 6293 | + ], | |
| 6294 | + "cities": [ | |
| 6295 | + "Salt Lake City" | |
| 6296 | + ], | |
| 6297 | + "estimated_agents": 150, | |
| 6298 | + "estimated_listings": 450, | |
| 6299 | + "mls_affiliations": [ | |
| 6300 | + "UtahRealEstate" | |
| 6301 | + ] | |
| 6302 | + }, | |
| 6303 | + { | |
| 6304 | + "name": "Houwzer (rebranded)", | |
| 6305 | + "website": "www.houwzer.com", | |
| 6306 | + "states": [ | |
| 6307 | + "PA", | |
| 6308 | + "NJ", | |
| 6309 | + "MD", | |
| 6310 | + "DC", | |
| 6311 | + "VA", | |
| 6312 | + "FL" | |
| 6313 | + ], | |
| 6314 | + "cities": [ | |
| 6315 | + "Philadelphia" | |
| 6316 | + ], | |
| 6317 | + "estimated_agents": 150, | |
| 6318 | + "estimated_listings": 400, | |
| 6319 | + "mls_affiliations": [ | |
| 6320 | + "BRIGHT" | |
| 6321 | + ] | |
| 6322 | + }, | |
| 6323 | + { | |
| 6324 | + "name": "Prevu Real Estate", | |
| 6325 | + "website": "www.prevu.com", | |
| 6326 | + "states": [ | |
| 6327 | + "NY", | |
| 6328 | + "CT", | |
| 6329 | + "MA", | |
| 6330 | + "PA", | |
| 6331 | + "CA", | |
| 6332 | + "CO", | |
| 6333 | + "WA", | |
| 6334 | + "TX", | |
| 6335 | + "FL" | |
| 6336 | + ], | |
| 6337 | + "cities": [ | |
| 6338 | + "New York" | |
| 6339 | + ], | |
| 6340 | + "estimated_agents": 100, | |
| 6341 | + "estimated_listings": 300, | |
| 6342 | + "mls_affiliations": [] | |
| 6343 | + }, | |
| 6344 | + { | |
| 6345 | + "name": "Homecoin", | |
| 6346 | + "website": "www.homecoin.com", | |
| 6347 | + "states": [ | |
| 6348 | + "CA", | |
| 6349 | + "FL", | |
| 6350 | + "GA", | |
| 6351 | + "MI", | |
| 6352 | + "MO", | |
| 6353 | + "NY", | |
| 6354 | + "NC", | |
| 6355 | + "SC", | |
| 6356 | + "TN", | |
| 6357 | + "TX", | |
| 6358 | + "VA", | |
| 6359 | + "WA", | |
| 6360 | + "WI" | |
| 6361 | + ], | |
| 6362 | + "cities": [ | |
| 6363 | + "San Diego" | |
| 6364 | + ], | |
| 6365 | + "estimated_agents": 20, | |
| 6366 | + "estimated_listings": 900, | |
| 6367 | + "mls_affiliations": [] | |
| 6368 | + }, | |
| 6369 | + { | |
| 6370 | + "name": "beycome", | |
| 6371 | + "website": "www.beycome.com", | |
| 6372 | + "states": [ | |
| 6373 | + "FL", | |
| 6374 | + "GA", | |
| 6375 | + "AL", | |
| 6376 | + "TX", | |
| 6377 | + "CA", | |
| 6378 | + "IL", | |
| 6379 | + "NC", | |
| 6380 | + "SC", | |
| 6381 | + "MN" | |
| 6382 | + ], | |
| 6383 | + "cities": [ | |
| 6384 | + "Miami" | |
| 6385 | + ], | |
| 6386 | + "estimated_agents": 30, | |
| 6387 | + "estimated_listings": 1200, | |
| 6388 | + "mls_affiliations": [] | |
| 6389 | + }, | |
| 6390 | + { | |
| 6391 | + "name": "HomeLister", | |
| 6392 | + "website": "www.homelister.com", | |
| 6393 | + "states": [ | |
| 6394 | + "CA", | |
| 6395 | + "WA", | |
| 6396 | + "OR", | |
| 6397 | + "CO", | |
| 6398 | + "TX", | |
| 6399 | + "FL", | |
| 6400 | + "GA", | |
| 6401 | + "NC", | |
| 6402 | + "SC", | |
| 6403 | + "PA", | |
| 6404 | + "NJ", | |
| 6405 | + "NY", | |
| 6406 | + "MD", | |
| 6407 | + "VA", | |
| 6408 | + "MI", | |
| 6409 | + "MN", | |
| 6410 | + "MO", | |
| 6411 | + "TN" | |
| 6412 | + ], | |
| 6413 | + "cities": [ | |
| 6414 | + "Los Angeles" | |
| 6415 | + ], | |
| 6416 | + "estimated_agents": 25, | |
| 6417 | + "estimated_listings": 700, | |
| 6418 | + "mls_affiliations": [] | |
| 6419 | + }, | |
| 6420 | + { | |
| 6421 | + "name": "Flat Fee Group", | |
| 6422 | + "website": "www.flatfeegroup.com", | |
| 6423 | + "states": [], | |
| 6424 | + "cities": [], | |
| 6425 | + "estimated_agents": 50, | |
| 6426 | + "estimated_listings": 900, | |
| 6427 | + "mls_affiliations": [] | |
| 6428 | + }, | |
| 6429 | + { | |
| 6430 | + "name": "ISoldMyHouse.com", | |
| 6431 | + "website": "www.isoldmyhouse.com", | |
| 6432 | + "states": [], | |
| 6433 | + "cities": [], | |
| 6434 | + "estimated_agents": 20, | |
| 6435 | + "estimated_listings": 600, | |
| 6436 | + "mls_affiliations": [] | |
| 6437 | + }, | |
| 6438 | + { | |
| 6439 | + "name": "Redefy Real Estate", | |
| 6440 | + "website": "www.redefy.com", | |
| 6441 | + "states": [ | |
| 6442 | + "CO", | |
| 6443 | + "TX", | |
| 6444 | + "FL", | |
| 6445 | + "GA", | |
| 6446 | + "NC", | |
| 6447 | + "SC", | |
| 6448 | + "TN", | |
| 6449 | + "VA" | |
| 6450 | + ], | |
| 6451 | + "cities": [ | |
| 6452 | + "Denver" | |
| 6453 | + ], | |
| 6454 | + "estimated_agents": 100, | |
| 6455 | + "estimated_listings": 300, | |
| 6456 | + "mls_affiliations": [ | |
| 6457 | + "REcolorado" | |
| 6458 | + ] | |
| 6459 | + }, | |
| 6460 | + { | |
| 6461 | + "name": "Door.com", | |
| 6462 | + "website": "www.door.com", | |
| 6463 | + "states": [ | |
| 6464 | + "TX" | |
| 6465 | + ], | |
| 6466 | + "cities": [ | |
| 6467 | + "Dallas" | |
| 6468 | + ], | |
| 6469 | + "estimated_agents": 60, | |
| 6470 | + "estimated_listings": 200, | |
| 6471 | + "mls_affiliations": [ | |
| 6472 | + "NTREIS" | |
| 6473 | + ] | |
| 6474 | + }, | |
| 6475 | + { | |
| 6476 | + "name": "Central Texas flat-fee: Creekview Realty", | |
| 6477 | + "website": "www.creekviewrealty.com", | |
| 6478 | + "states": [ | |
| 6479 | + "TX" | |
| 6480 | + ], | |
| 6481 | + "cities": [ | |
| 6482 | + "Dallas", | |
| 6483 | + "Austin", | |
| 6484 | + "Houston" | |
| 6485 | + ], | |
| 6486 | + "estimated_agents": 10, | |
| 6487 | + "estimated_listings": 400, | |
| 6488 | + "mls_affiliations": [ | |
| 6489 | + "NTREIS" | |
| 6490 | + ] | |
| 6491 | + }, | |
| 6492 | + { | |
| 6493 | + "name": "Coldwell Banker Howard Perry and Walston Builder Services", | |
| 6494 | + "website": "www.hpwnewhomes.com", | |
| 6495 | + "states": [ | |
| 6496 | + "NC" | |
| 6497 | + ], | |
| 6498 | + "cities": [ | |
| 6499 | + "Raleigh" | |
| 6500 | + ], | |
| 6501 | + "estimated_agents": 100, | |
| 6502 | + "estimated_listings": 300, | |
| 6503 | + "mls_affiliations": [ | |
| 6504 | + "TriangleMLS" | |
| 6505 | + ] | |
| 6506 | + }, | |
| 6507 | + { | |
| 6508 | + "name": "Coach House Realty (Long Island)", | |
| 6509 | + "website": "www.coachrealtors.com", | |
| 6510 | + "states": [ | |
| 6511 | + "NY" | |
| 6512 | + ], | |
| 6513 | + "cities": [ | |
| 6514 | + "Northport", | |
| 6515 | + "Long Island" | |
| 6516 | + ], | |
| 6517 | + "estimated_agents": 600, | |
| 6518 | + "estimated_listings": 1300, | |
| 6519 | + "mls_affiliations": [ | |
| 6520 | + "OneKey" | |
| 6521 | + ] | |
| 6522 | + }, | |
| 6523 | + { | |
| 6524 | + "name": "Signature Premier Properties", | |
| 6525 | + "website": "www.signaturepremier.com", | |
| 6526 | + "states": [ | |
| 6527 | + "NY" | |
| 6528 | + ], | |
| 6529 | + "cities": [ | |
| 6530 | + "Long Island" | |
| 6531 | + ], | |
| 6532 | + "estimated_agents": 1300, | |
| 6533 | + "estimated_listings": 2400, | |
| 6534 | + "mls_affiliations": [ | |
| 6535 | + "OneKey" | |
| 6536 | + ] | |
| 6537 | + }, | |
| 6538 | + { | |
| 6539 | + "name": "Douglas Elliman Long Island", | |
| 6540 | + "website": "www.elliman.com/longisland", | |
| 6541 | + "states": [ | |
| 6542 | + "NY" | |
| 6543 | + ], | |
| 6544 | + "cities": [ | |
| 6545 | + "Long Island" | |
| 6546 | + ], | |
| 6547 | + "estimated_agents": 1300, | |
| 6548 | + "estimated_listings": 2600, | |
| 6549 | + "mls_affiliations": [ | |
| 6550 | + "OneKey" | |
| 6551 | + ] | |
| 6552 | + }, | |
| 6553 | + { | |
| 6554 | + "name": "Howard Hanna Rand Realty", | |
| 6555 | + "website": "www.randrealty.com", | |
| 6556 | + "states": [ | |
| 6557 | + "NY", | |
| 6558 | + "NJ", | |
| 6559 | + "CT" | |
| 6560 | + ], | |
| 6561 | + "cities": [ | |
| 6562 | + "White Plains", | |
| 6563 | + "Nanuet" | |
| 6564 | + ], | |
| 6565 | + "estimated_agents": 1100, | |
| 6566 | + "estimated_listings": 2300, | |
| 6567 | + "mls_affiliations": [ | |
| 6568 | + "OneKey" | |
| 6569 | + ] | |
| 6570 | + }, | |
| 6571 | + { | |
| 6572 | + "name": "Hunt Real Estate ERA", | |
| 6573 | + "website": "www.huntrealestate.com", | |
| 6574 | + "states": [ | |
| 6575 | + "NY", | |
| 6576 | + "AZ" | |
| 6577 | + ], | |
| 6578 | + "cities": [ | |
| 6579 | + "Buffalo", | |
| 6580 | + "Rochester", | |
| 6581 | + "Syracuse" | |
| 6582 | + ], | |
| 6583 | + "estimated_agents": 1400, | |
| 6584 | + "estimated_listings": 3000, | |
| 6585 | + "mls_affiliations": [ | |
| 6586 | + "NYSAMLSs" | |
| 6587 | + ] | |
| 6588 | + }, | |
| 6589 | + { | |
| 6590 | + "name": "Howard Hanna WNY", | |
| 6591 | + "website": "www.howardhanna.com/ny", | |
| 6592 | + "states": [ | |
| 6593 | + "NY" | |
| 6594 | + ], | |
| 6595 | + "cities": [ | |
| 6596 | + "Buffalo", | |
| 6597 | + "Rochester" | |
| 6598 | + ], | |
| 6599 | + "estimated_agents": 900, | |
| 6600 | + "estimated_listings": 2100, | |
| 6601 | + "mls_affiliations": [ | |
| 6602 | + "NYSAMLSs" | |
| 6603 | + ] | |
| 6604 | + }, | |
| 6605 | + { | |
| 6606 | + "name": "Keller Williams Realty Greater Rochester", | |
| 6607 | + "website": "www.kwrochester.com", | |
| 6608 | + "states": [ | |
| 6609 | + "NY" | |
| 6610 | + ], | |
| 6611 | + "cities": [ | |
| 6612 | + "Rochester" | |
| 6613 | + ], | |
| 6614 | + "estimated_agents": 300, | |
| 6615 | + "estimated_listings": 650, | |
| 6616 | + "mls_affiliations": [ | |
| 6617 | + "GENRIS" | |
| 6618 | + ] | |
| 6619 | + }, | |
| 6620 | + { | |
| 6621 | + "name": "Berkshire Hathaway HomeServices CNY Realty", | |
| 6622 | + "website": "www.bhhscnyrealty.com", | |
| 6623 | + "states": [ | |
| 6624 | + "NY" | |
| 6625 | + ], | |
| 6626 | + "cities": [ | |
| 6627 | + "Syracuse" | |
| 6628 | + ], | |
| 6629 | + "estimated_agents": 130, | |
| 6630 | + "estimated_listings": 320, | |
| 6631 | + "mls_affiliations": [ | |
| 6632 | + "CNYIS" | |
| 6633 | + ] | |
| 6634 | + }, | |
| 6635 | + { | |
| 6636 | + "name": "Select Sotheby's International Realty (ADK/Saratoga)", | |
| 6637 | + "website": "www.selectsothebysrealty.com", | |
| 6638 | + "states": [ | |
| 6639 | + "NY" | |
| 6640 | + ], | |
| 6641 | + "cities": [ | |
| 6642 | + "Saratoga Springs", | |
| 6643 | + "Lake Placid" | |
| 6644 | + ], | |
| 6645 | + "estimated_agents": 90, | |
| 6646 | + "estimated_listings": 260, | |
| 6647 | + "mls_affiliations": [ | |
| 6648 | + "GlobalMLS" | |
| 6649 | + ] | |
| 6650 | + }, | |
| 6651 | + { | |
| 6652 | + "name": "Miranda Real Estate Group", | |
| 6653 | + "website": "www.mrgteam.com", | |
| 6654 | + "states": [ | |
| 6655 | + "NY" | |
| 6656 | + ], | |
| 6657 | + "cities": [ | |
| 6658 | + "Albany", | |
| 6659 | + "Clifton Park" | |
| 6660 | + ], | |
| 6661 | + "estimated_agents": 200, | |
| 6662 | + "estimated_listings": 480, | |
| 6663 | + "mls_affiliations": [ | |
| 6664 | + "GlobalMLS" | |
| 6665 | + ] | |
| 6666 | + }, | |
| 6667 | + { | |
| 6668 | + "name": "Howard Hanna Capital Region", | |
| 6669 | + "website": "www.howardhanna.com/albany", | |
| 6670 | + "states": [ | |
| 6671 | + "NY" | |
| 6672 | + ], | |
| 6673 | + "cities": [ | |
| 6674 | + "Albany" | |
| 6675 | + ], | |
| 6676 | + "estimated_agents": 200, | |
| 6677 | + "estimated_listings": 500, | |
| 6678 | + "mls_affiliations": [ | |
| 6679 | + "GlobalMLS" | |
| 6680 | + ] | |
| 6681 | + }, | |
| 6682 | + { | |
| 6683 | + "name": "Vylla Home", | |
| 6684 | + "website": "www.vylla.com", | |
| 6685 | + "states": [], | |
| 6686 | + "cities": [], | |
| 6687 | + "estimated_agents": 2000, | |
| 6688 | + "estimated_listings": 3500, | |
| 6689 | + "mls_affiliations": [] | |
| 6690 | + }, | |
| 6691 | + { | |
| 6692 | + "name": "Radius Realty Group DMV", | |
| 6693 | + "website": "www.radiusrealtygroup.com", | |
| 6694 | + "states": [ | |
| 6695 | + "DC", | |
| 6696 | + "MD", | |
| 6697 | + "VA" | |
| 6698 | + ], | |
| 6699 | + "cities": [ | |
| 6700 | + "Washington" | |
| 6701 | + ], | |
| 6702 | + "estimated_agents": 60, | |
| 6703 | + "estimated_listings": 160, | |
| 6704 | + "mls_affiliations": [ | |
| 6705 | + "BRIGHT" | |
| 6706 | + ] | |
| 6707 | + }, | |
| 6708 | + { | |
| 6709 | + "name": "Real Broker Northeast", | |
| 6710 | + "website": "www.onereal.com/northeast", | |
| 6711 | + "states": [ | |
| 6712 | + "NY", | |
| 6713 | + "NJ", | |
| 6714 | + "CT", | |
| 6715 | + "MA" | |
| 6716 | + ], | |
| 6717 | + "cities": [], | |
| 6718 | + "estimated_agents": 2000, | |
| 6719 | + "estimated_listings": 3200, | |
| 6720 | + "mls_affiliations": [] | |
| 6721 | + }, | |
| 6722 | + { | |
| 6723 | + "name": "Realty of America", | |
| 6724 | + "website": "www.realtyofamerica.com", | |
| 6725 | + "states": [ | |
| 6726 | + "TX", | |
| 6727 | + "FL", | |
| 6728 | + "GA", | |
| 6729 | + "NC", | |
| 6730 | + "SC", | |
| 6731 | + "TN", | |
| 6732 | + "OH", | |
| 6733 | + "AZ" | |
| 6734 | + ], | |
| 6735 | + "cities": [ | |
| 6736 | + "Houston" | |
| 6737 | + ], | |
| 6738 | + "estimated_agents": 2500, | |
| 6739 | + "estimated_listings": 3800, | |
| 6740 | + "mls_affiliations": [ | |
| 6741 | + "HAR" | |
| 6742 | + ] | |
| 6743 | + }, | |
| 6744 | + { | |
| 6745 | + "name": "Epique Realty", | |
| 6746 | + "website": "www.epiquerealty.com", | |
| 6747 | + "states": [ | |
| 6748 | + "TX", | |
| 6749 | + "FL", | |
| 6750 | + "CA", | |
| 6751 | + "GA", | |
| 6752 | + "NC", | |
| 6753 | + "CO", | |
| 6754 | + "AZ", | |
| 6755 | + "MI", | |
| 6756 | + "OH", | |
| 6757 | + "TN" | |
| 6758 | + ], | |
| 6759 | + "cities": [ | |
| 6760 | + "Houston" | |
| 6761 | + ], | |
| 6762 | + "estimated_agents": 4000, | |
| 6763 | + "estimated_listings": 6000, | |
| 6764 | + "mls_affiliations": [] | |
| 6765 | + }, | |
| 6766 | + { | |
| 6767 | + "name": "Worth Clark Realty", | |
| 6768 | + "website": "www.worthclark.com", | |
| 6769 | + "states": [ | |
| 6770 | + "MO", | |
| 6771 | + "IL", | |
| 6772 | + "TX", | |
| 6773 | + "CO", | |
| 6774 | + "KS", | |
| 6775 | + "IN", | |
| 6776 | + "OH", | |
| 6777 | + "VA", | |
| 6778 | + "AZ", | |
| 6779 | + "NM", | |
| 6780 | + "FL" | |
| 6781 | + ], | |
| 6782 | + "cities": [ | |
| 6783 | + "Saint Louis" | |
| 6784 | + ], | |
| 6785 | + "estimated_agents": 900, | |
| 6786 | + "estimated_listings": 1500, | |
| 6787 | + "mls_affiliations": [ | |
| 6788 | + "MARIS" | |
| 6789 | + ] | |
| 6790 | + }, | |
| 6791 | + { | |
| 6792 | + "name": "SimpleShowing", | |
| 6793 | + "website": "www.simpleshowing.com", | |
| 6794 | + "states": [ | |
| 6795 | + "FL", | |
| 6796 | + "GA", | |
| 6797 | + "TX" | |
| 6798 | + ], | |
| 6799 | + "cities": [ | |
| 6800 | + "Atlanta" | |
| 6801 | + ], | |
| 6802 | + "estimated_agents": 40, | |
| 6803 | + "estimated_listings": 160, | |
| 6804 | + "mls_affiliations": [ | |
| 6805 | + "FMLS" | |
| 6806 | + ] | |
| 6807 | + }, | |
| 6808 | + { | |
| 6809 | + "name": "1 Percent Lists", | |
| 6810 | + "website": "www.1percentlists.com", | |
| 6811 | + "states": [ | |
| 6812 | + "LA", | |
| 6813 | + "TX", | |
| 6814 | + "FL", | |
| 6815 | + "GA", | |
| 6816 | + "AL", | |
| 6817 | + "MS", | |
| 6818 | + "TN", | |
| 6819 | + "NC", | |
| 6820 | + "SC", | |
| 6821 | + "OH", | |
| 6822 | + "KY", | |
| 6823 | + "MO", | |
| 6824 | + "CO" | |
| 6825 | + ], | |
| 6826 | + "cities": [ | |
| 6827 | + "Covington" | |
| 6828 | + ], | |
| 6829 | + "estimated_agents": 300, | |
| 6830 | + "estimated_listings": 900, | |
| 6831 | + "mls_affiliations": [ | |
| 6832 | + "GSREIN" | |
| 6833 | + ] | |
| 6834 | + }, | |
| 6835 | + { | |
| 6836 | + "name": "Assist-2-Sell (network)", | |
| 6837 | + "website": "www.assist2sell.com", | |
| 6838 | + "states": [], | |
| 6839 | + "cities": [ | |
| 6840 | + "Reno" | |
| 6841 | + ], | |
| 6842 | + "estimated_agents": 600, | |
| 6843 | + "estimated_listings": 1500, | |
| 6844 | + "mls_affiliations": [] | |
| 6845 | + }, | |
| 6846 | + { | |
| 6847 | + "name": "Help-U-Sell Real Estate", | |
| 6848 | + "website": "www.helpusell.com", | |
| 6849 | + "states": [], | |
| 6850 | + "cities": [], | |
| 6851 | + "estimated_agents": 400, | |
| 6852 | + "estimated_listings": 1000, | |
| 6853 | + "mls_affiliations": [] | |
| 6854 | + }, | |
| 6855 | + { | |
| 6856 | + "name": "ByOwner.com", | |
| 6857 | + "website": "www.byowner.com", | |
| 6858 | + "states": [], | |
| 6859 | + "cities": [], | |
| 6860 | + "estimated_agents": 20, | |
| 6861 | + "estimated_listings": 1500, | |
| 6862 | + "mls_affiliations": [] | |
| 6863 | + }, | |
| 6864 | + { | |
| 6865 | + "name": "Unreal Estate", | |
| 6866 | + "website": "www.unrealestate.com", | |
| 6867 | + "states": [], | |
| 6868 | + "cities": [], | |
| 6869 | + "estimated_agents": 50, | |
| 6870 | + "estimated_listings": 1200, | |
| 6871 | + "mls_affiliations": [] | |
| 6872 | + }, | |
| 6873 | + { | |
| 6874 | + "name": "Ideal Agent (referral+listings)", | |
| 6875 | + "website": "www.idealagent.com", | |
| 6876 | + "states": [], | |
| 6877 | + "cities": [ | |
| 6878 | + "Tampa" | |
| 6879 | + ], | |
| 6880 | + "estimated_agents": 100, | |
| 6881 | + "estimated_listings": 500, | |
| 6882 | + "mls_affiliations": [] | |
| 6883 | + }, | |
| 6884 | + { | |
| 6885 | + "name": "Coldwell Banker Kappel Gateway Realty", | |
| 6886 | + "website": "www.kappelgateway.com", | |
| 6887 | + "states": [ | |
| 6888 | + "CA" | |
| 6889 | + ], | |
| 6890 | + "cities": [ | |
| 6891 | + "Fairfield", | |
| 6892 | + "Vacaville" | |
| 6893 | + ], | |
| 6894 | + "estimated_agents": 300, | |
| 6895 | + "estimated_listings": 650, | |
| 6896 | + "mls_affiliations": [ | |
| 6897 | + "BAREIS" | |
| 6898 | + ] | |
| 6899 | + }, | |
| 6900 | + { | |
| 6901 | + "name": "RE/MAX Gold", | |
| 6902 | + "website": "www.remaxgold.com", | |
| 6903 | + "states": [ | |
| 6904 | + "CA" | |
| 6905 | + ], | |
| 6906 | + "cities": [ | |
| 6907 | + "Sacramento", | |
| 6908 | + "Roseville" | |
| 6909 | + ], | |
| 6910 | + "estimated_agents": 1500, | |
| 6911 | + "estimated_listings": 2800, | |
| 6912 | + "mls_affiliations": [ | |
| 6913 | + "Metrolist" | |
| 6914 | + ] | |
| 6915 | + }, | |
| 6916 | + { | |
| 6917 | + "name": "Century 21 Real Estate Alliance", | |
| 6918 | + "website": "www.c21realestatealliance.com", | |
| 6919 | + "states": [ | |
| 6920 | + "CA" | |
| 6921 | + ], | |
| 6922 | + "cities": [ | |
| 6923 | + "San Francisco", | |
| 6924 | + "San Mateo" | |
| 6925 | + ], | |
| 6926 | + "estimated_agents": 800, | |
| 6927 | + "estimated_listings": 1300, | |
| 6928 | + "mls_affiliations": [ | |
| 6929 | + "SFAR" | |
| 6930 | + ] | |
| 6931 | + }, | |
| 6932 | + { | |
| 6933 | + "name": "Redfin California", | |
| 6934 | + "website": "www.redfin.com/state/california", | |
| 6935 | + "states": [ | |
| 6936 | + "CA" | |
| 6937 | + ], | |
| 6938 | + "cities": [], | |
| 6939 | + "estimated_agents": 500, | |
| 6940 | + "estimated_listings": 2500, | |
| 6941 | + "mls_affiliations": [ | |
| 6942 | + "CRMLS" | |
| 6943 | + ] | |
| 6944 | + }, | |
| 6945 | + { | |
| 6946 | + "name": "Douglas Elliman California", | |
| 6947 | + "website": "www.elliman.com/california", | |
| 6948 | + "states": [ | |
| 6949 | + "CA" | |
| 6950 | + ], | |
| 6951 | + "cities": [ | |
| 6952 | + "Beverly Hills", | |
| 6953 | + "Newport Beach" | |
| 6954 | + ], | |
| 6955 | + "estimated_agents": 500, | |
| 6956 | + "estimated_listings": 1100, | |
| 6957 | + "mls_affiliations": [ | |
| 6958 | + "CRMLS" | |
| 6959 | + ] | |
| 6960 | + }, | |
| 6961 | + { | |
| 6962 | + "name": "Christie's International Real Estate Sereno Tahoe", | |
| 6963 | + "website": "www.serenotahoe.com", | |
| 6964 | + "states": [ | |
| 6965 | + "CA", | |
| 6966 | + "NV" | |
| 6967 | + ], | |
| 6968 | + "cities": [ | |
| 6969 | + "Truckee" | |
| 6970 | + ], | |
| 6971 | + "estimated_agents": 100, | |
| 6972 | + "estimated_listings": 280, | |
| 6973 | + "mls_affiliations": [ | |
| 6974 | + "TahoeSierraMLS" | |
| 6975 | + ] | |
| 6976 | + }, | |
| 6977 | + { | |
| 6978 | + "name": "Coldwell Banker Realty SoCal", | |
| 6979 | + "website": "www.coldwellbankerhomes.com/ca", | |
| 6980 | + "states": [ | |
| 6981 | + "CA" | |
| 6982 | + ], | |
| 6983 | + "cities": [ | |
| 6984 | + "Los Angeles", | |
| 6985 | + "San Diego" | |
| 6986 | + ], | |
| 6987 | + "estimated_agents": 5000, | |
| 6988 | + "estimated_listings": 9000, | |
| 6989 | + "mls_affiliations": [ | |
| 6990 | + "CRMLS" | |
| 6991 | + ] | |
| 6992 | + }, | |
| 6993 | + { | |
| 6994 | + "name": "Keller Williams Beverly Hills", | |
| 6995 | + "website": "www.kwbh.com", | |
| 6996 | + "states": [ | |
| 6997 | + "CA" | |
| 6998 | + ], | |
| 6999 | + "cities": [ | |
| 7000 | + "Beverly Hills" | |
| 7001 | + ], | |
| 7002 | + "estimated_agents": 700, | |
| 7003 | + "estimated_listings": 1100, | |
| 7004 | + "mls_affiliations": [ | |
| 7005 | + "CRMLS" | |
| 7006 | + ] | |
| 7007 | + }, | |
| 7008 | + { | |
| 7009 | + "name": "Keller Williams Silicon Valley", | |
| 7010 | + "website": "www.kwsvr.com", | |
| 7011 | + "states": [ | |
| 7012 | + "CA" | |
| 7013 | + ], | |
| 7014 | + "cities": [ | |
| 7015 | + "San Jose", | |
| 7016 | + "Cupertino" | |
| 7017 | + ], | |
| 7018 | + "estimated_agents": 600, | |
| 7019 | + "estimated_listings": 950, | |
| 7020 | + "mls_affiliations": [ | |
| 7021 | + "MLSListings" | |
| 7022 | + ] | |
| 7023 | + }, | |
| 7024 | + { | |
| 7025 | + "name": "Redfin Corporation HQ brokerage", | |
| 7026 | + "website": "www.redfin.com/about", | |
| 7027 | + "states": [ | |
| 7028 | + "WA" | |
| 7029 | + ], | |
| 7030 | + "cities": [ | |
| 7031 | + "Seattle" | |
| 7032 | + ], | |
| 7033 | + "estimated_agents": 100, | |
| 7034 | + "estimated_listings": 300, | |
| 7035 | + "mls_affiliations": [ | |
| 7036 | + "NWMLS" | |
| 7037 | + ] | |
| 7038 | + }, | |
| 7039 | + { | |
| 7040 | + "name": "Excellence Real Estate (SoCal)", | |
| 7041 | + "website": "www.excellencere.com", | |
| 7042 | + "states": [ | |
| 7043 | + "CA" | |
| 7044 | + ], | |
| 7045 | + "cities": [ | |
| 7046 | + "Los Angeles", | |
| 7047 | + "Riverside" | |
| 7048 | + ], | |
| 7049 | + "estimated_agents": 900, | |
| 7050 | + "estimated_listings": 1400, | |
| 7051 | + "mls_affiliations": [ | |
| 7052 | + "CRMLS" | |
| 7053 | + ] | |
| 7054 | + }, | |
| 7055 | + { | |
| 7056 | + "name": "Realty ONE Group West", | |
| 7057 | + "website": "www.realtyonegroupwest.com", | |
| 7058 | + "states": [ | |
| 7059 | + "CA" | |
| 7060 | + ], | |
| 7061 | + "cities": [ | |
| 7062 | + "Irvine" | |
| 7063 | + ], | |
| 7064 | + "estimated_agents": 1500, | |
| 7065 | + "estimated_listings": 2300, | |
| 7066 | + "mls_affiliations": [ | |
| 7067 | + "CRMLS" | |
| 7068 | + ] | |
| 7069 | + }, | |
| 7070 | + { | |
| 7071 | + "name": "Pinnacle Estate Properties", | |
| 7072 | + "website": "www.pinnacleestate.com", | |
| 7073 | + "states": [ | |
| 7074 | + "CA" | |
| 7075 | + ], | |
| 7076 | + "cities": [ | |
| 7077 | + "Northridge", | |
| 7078 | + "Ventura" | |
| 7079 | + ], | |
| 7080 | + "estimated_agents": 600, | |
| 7081 | + "estimated_listings": 1100, | |
| 7082 | + "mls_affiliations": [ | |
| 7083 | + "CRMLS" | |
| 7084 | + ] | |
| 7085 | + }, | |
| 7086 | + { | |
| 7087 | + "name": "Realty Executives Santa Clarita", | |
| 7088 | + "website": "www.realtyexecutivesscv.com", | |
| 7089 | + "states": [ | |
| 7090 | + "CA" | |
| 7091 | + ], | |
| 7092 | + "cities": [ | |
| 7093 | + "Santa Clarita" | |
| 7094 | + ], | |
| 7095 | + "estimated_agents": 200, | |
| 7096 | + "estimated_listings": 420, | |
| 7097 | + "mls_affiliations": [ | |
| 7098 | + "CRMLS" | |
| 7099 | + ] | |
| 7100 | + }, | |
| 7101 | + { | |
| 7102 | + "name": "Compass San Diego", | |
| 7103 | + "website": "www.compass.com/sandiego", | |
| 7104 | + "states": [ | |
| 7105 | + "CA" | |
| 7106 | + ], | |
| 7107 | + "cities": [ | |
| 7108 | + "San Diego" | |
| 7109 | + ], | |
| 7110 | + "estimated_agents": 900, | |
| 7111 | + "estimated_listings": 1600, | |
| 7112 | + "mls_affiliations": [ | |
| 7113 | + "SDMLS" | |
| 7114 | + ] | |
| 7115 | + }, | |
| 7116 | + { | |
| 7117 | + "name": "Better Homes and Gardens RE HomeCity", | |
| 7118 | + "website": "www.homecity.com", | |
| 7119 | + "states": [ | |
| 7120 | + "TX" | |
| 7121 | + ], | |
| 7122 | + "cities": [ | |
| 7123 | + "Austin", | |
| 7124 | + "San Antonio" | |
| 7125 | + ], | |
| 7126 | + "estimated_agents": 300, | |
| 7127 | + "estimated_listings": 700, | |
| 7128 | + "mls_affiliations": [ | |
| 7129 | + "ACTRIS" | |
| 7130 | + ] | |
| 7131 | + }, | |
| 7132 | + { | |
| 7133 | + "name": "Coldwell Banker D'Ann Harper Realtors", | |
| 7134 | + "website": "www.cbharper.com", | |
| 7135 | + "states": [ | |
| 7136 | + "TX" | |
| 7137 | + ], | |
| 7138 | + "cities": [ | |
| 7139 | + "San Antonio" | |
| 7140 | + ], | |
| 7141 | + "estimated_agents": 400, | |
| 7142 | + "estimated_listings": 900, | |
| 7143 | + "mls_affiliations": [ | |
| 7144 | + "SABOR" | |
| 7145 | + ] | |
| 7146 | + }, | |
| 7147 | + { | |
| 7148 | + "name": "RE/MAX North San Antonio", | |
| 7149 | + "website": "www.remax-northsa.com", | |
| 7150 | + "states": [ | |
| 7151 | + "TX" | |
| 7152 | + ], | |
| 7153 | + "cities": [ | |
| 7154 | + "San Antonio" | |
| 7155 | + ], | |
| 7156 | + "estimated_agents": 130, | |
| 7157 | + "estimated_listings": 320, | |
| 7158 | + "mls_affiliations": [ | |
| 7159 | + "SABOR" | |
| 7160 | + ] | |
| 7161 | + }, | |
| 7162 | + { | |
| 7163 | + "name": "Redfin Dallas", | |
| 7164 | + "website": "www.redfin.com/city/30794/tx/dallas", | |
| 7165 | + "states": [ | |
| 7166 | + "TX" | |
| 7167 | + ], | |
| 7168 | + "cities": [ | |
| 7169 | + "Dallas" | |
| 7170 | + ], | |
| 7171 | + "estimated_agents": 60, | |
| 7172 | + "estimated_listings": 350, | |
| 7173 | + "mls_affiliations": [ | |
| 7174 | + "NTREIS" | |
| 7175 | + ] | |
| 7176 | + }, | |
| 7177 | + { | |
| 7178 | + "name": "Keller Williams Dallas Preston Road", | |
| 7179 | + "website": "www.kwdallas.com", | |
| 7180 | + "states": [ | |
| 7181 | + "TX" | |
| 7182 | + ], | |
| 7183 | + "cities": [ | |
| 7184 | + "Dallas" | |
| 7185 | + ], | |
| 7186 | + "estimated_agents": 700, | |
| 7187 | + "estimated_listings": 1100, | |
| 7188 | + "mls_affiliations": [ | |
| 7189 | + "NTREIS" | |
| 7190 | + ] | |
| 7191 | + }, | |
| 7192 | + { | |
| 7193 | + "name": "CENTURY 21 Judge Fite Company", | |
| 7194 | + "website": "www.c21judgefite.com", | |
| 7195 | + "states": [ | |
| 7196 | + "TX" | |
| 7197 | + ], | |
| 7198 | + "cities": [ | |
| 7199 | + "Dallas", | |
| 7200 | + "Fort Worth" | |
| 7201 | + ], | |
| 7202 | + "estimated_agents": 900, | |
| 7203 | + "estimated_listings": 1800, | |
| 7204 | + "mls_affiliations": [ | |
| 7205 | + "NTREIS" | |
| 7206 | + ] | |
| 7207 | + }, | |
| 7208 | + { | |
| 7209 | + "name": "RE/MAX Trinity Fort Worth", | |
| 7210 | + "website": "www.remaxtrinitytx.com", | |
| 7211 | + "states": [ | |
| 7212 | + "TX" | |
| 7213 | + ], | |
| 7214 | + "cities": [ | |
| 7215 | + "Fort Worth" | |
| 7216 | + ], | |
| 7217 | + "estimated_agents": 130, | |
| 7218 | + "estimated_listings": 320, | |
| 7219 | + "mls_affiliations": [ | |
| 7220 | + "NTREIS" | |
| 7221 | + ] | |
| 7222 | + }, | |
| 7223 | + { | |
| 7224 | + "name": "Keller Williams Houston Memorial", | |
| 7225 | + "website": "www.kwmemorial.com", | |
| 7226 | + "states": [ | |
| 7227 | + "TX" | |
| 7228 | + ], | |
| 7229 | + "cities": [ | |
| 7230 | + "Houston" | |
| 7231 | + ], | |
| 7232 | + "estimated_agents": 800, | |
| 7233 | + "estimated_listings": 1300, | |
| 7234 | + "mls_affiliations": [ | |
| 7235 | + "HAR" | |
| 7236 | + ] | |
| 7237 | + }, | |
| 7238 | + { | |
| 7239 | + "name": "RE/MAX The Woodlands & Spring", | |
| 7240 | + "website": "www.thewoodlandstx.com", | |
| 7241 | + "states": [ | |
| 7242 | + "TX" | |
| 7243 | + ], | |
| 7244 | + "cities": [ | |
| 7245 | + "The Woodlands" | |
| 7246 | + ], | |
| 7247 | + "estimated_agents": 200, | |
| 7248 | + "estimated_listings": 480, | |
| 7249 | + "mls_affiliations": [ | |
| 7250 | + "HAR" | |
| 7251 | + ] | |
| 7252 | + }, | |
| 7253 | + { | |
| 7254 | + "name": "Perry Homes Realty", | |
| 7255 | + "website": "www.perryhomes.com", | |
| 7256 | + "states": [ | |
| 7257 | + "TX" | |
| 7258 | + ], | |
| 7259 | + "cities": [ | |
| 7260 | + "Houston", | |
| 7261 | + "San Antonio" | |
| 7262 | + ], | |
| 7263 | + "estimated_agents": 100, | |
| 7264 | + "estimated_listings": 900, | |
| 7265 | + "mls_affiliations": [ | |
| 7266 | + "HAR" | |
| 7267 | + ] | |
| 7268 | + }, | |
| 7269 | + { | |
| 7270 | + "name": "Coldwell Banker United Realtors Texas", | |
| 7271 | + "website": "www.cbunited.com", | |
| 7272 | + "states": [ | |
| 7273 | + "TX" | |
| 7274 | + ], | |
| 7275 | + "cities": [ | |
| 7276 | + "Houston" | |
| 7277 | + ], | |
| 7278 | + "estimated_agents": 1000, | |
| 7279 | + "estimated_listings": 2000, | |
| 7280 | + "mls_affiliations": [ | |
| 7281 | + "HAR" | |
| 7282 | + ] | |
| 7283 | + }, | |
| 7284 | + { | |
| 7285 | + "name": "Side + Legacy: Nan and Company Properties", | |
| 7286 | + "website": "www.nanproperties.com", | |
| 7287 | + "states": [ | |
| 7288 | + "TX" | |
| 7289 | + ], | |
| 7290 | + "cities": [ | |
| 7291 | + "Houston" | |
| 7292 | + ], | |
| 7293 | + "estimated_agents": 200, | |
| 7294 | + "estimated_listings": 500, | |
| 7295 | + "mls_affiliations": [ | |
| 7296 | + "HAR" | |
| 7297 | + ] | |
| 7298 | + }, | |
| 7299 | + { | |
| 7300 | + "name": "Magnolia Realty (Waco)", | |
| 7301 | + "website": "www.magnoliarealty.com", | |
| 7302 | + "states": [ | |
| 7303 | + "TX" | |
| 7304 | + ], | |
| 7305 | + "cities": [ | |
| 7306 | + "Waco", | |
| 7307 | + "Dallas" | |
| 7308 | + ], | |
| 7309 | + "estimated_agents": 300, | |
| 7310 | + "estimated_listings": 650, | |
| 7311 | + "mls_affiliations": [ | |
| 7312 | + "WacoMLS" | |
| 7313 | + ] | |
| 7314 | + }, | |
| 7315 | + { | |
| 7316 | + "name": "Keller Williams El Paso", | |
| 7317 | + "website": "www.kwelpaso.com", | |
| 7318 | + "states": [ | |
| 7319 | + "TX" | |
| 7320 | + ], | |
| 7321 | + "cities": [ | |
| 7322 | + "El Paso" | |
| 7323 | + ], | |
| 7324 | + "estimated_agents": 300, | |
| 7325 | + "estimated_listings": 600, | |
| 7326 | + "mls_affiliations": [ | |
| 7327 | + "EPMLS" | |
| 7328 | + ] | |
| 7329 | + }, | |
| 7330 | + { | |
| 7331 | + "name": "ERA Sellers & Buyers Real Estate", | |
| 7332 | + "website": "www.erasellersandbuyers.com", | |
| 7333 | + "states": [ | |
| 7334 | + "TX", | |
| 7335 | + "NM" | |
| 7336 | + ], | |
| 7337 | + "cities": [ | |
| 7338 | + "El Paso" | |
| 7339 | + ], | |
| 7340 | + "estimated_agents": 200, | |
| 7341 | + "estimated_listings": 480, | |
| 7342 | + "mls_affiliations": [ | |
| 7343 | + "EPMLS" | |
| 7344 | + ] | |
| 7345 | + }, | |
| 7346 | + { | |
| 7347 | + "name": "Coldwell Banker Apex, Realtors RGV", | |
| 7348 | + "website": "www.cbapex.com/rgv", | |
| 7349 | + "states": [ | |
| 7350 | + "TX" | |
| 7351 | + ], | |
| 7352 | + "cities": [ | |
| 7353 | + "McAllen" | |
| 7354 | + ], | |
| 7355 | + "estimated_agents": 130, | |
| 7356 | + "estimated_listings": 320, | |
| 7357 | + "mls_affiliations": [ | |
| 7358 | + "GreaterMcAllenMLS" | |
| 7359 | + ] | |
| 7360 | + }, | |
| 7361 | + { | |
| 7362 | + "name": "RE/MAX Realtec Group", | |
| 7363 | + "website": "www.realtec.com", | |
| 7364 | + "states": [ | |
| 7365 | + "FL" | |
| 7366 | + ], | |
| 7367 | + "cities": [ | |
| 7368 | + "Palm Harbor" | |
| 7369 | + ], | |
| 7370 | + "estimated_agents": 130, | |
| 7371 | + "estimated_listings": 320, | |
| 7372 | + "mls_affiliations": [ | |
| 7373 | + "Stellar" | |
| 7374 | + ] | |
| 7375 | + }, | |
| 7376 | + { | |
| 7377 | + "name": "Keller Williams Tampa Properties", | |
| 7378 | + "website": "www.kwtampa.com", | |
| 7379 | + "states": [ | |
| 7380 | + "FL" | |
| 7381 | + ], | |
| 7382 | + "cities": [ | |
| 7383 | + "Tampa" | |
| 7384 | + ], | |
| 7385 | + "estimated_agents": 500, | |
| 7386 | + "estimated_listings": 900, | |
| 7387 | + "mls_affiliations": [ | |
| 7388 | + "Stellar" | |
| 7389 | + ] | |
| 7390 | + }, | |
| 7391 | + { | |
| 7392 | + "name": "Compass Florida", | |
| 7393 | + "website": "www.compass.com/fl", | |
| 7394 | + "states": [ | |
| 7395 | + "FL" | |
| 7396 | + ], | |
| 7397 | + "cities": [ | |
| 7398 | + "Miami", | |
| 7399 | + "Fort Lauderdale", | |
| 7400 | + "Tampa" | |
| 7401 | + ], | |
| 7402 | + "estimated_agents": 2200, | |
| 7403 | + "estimated_listings": 4200, | |
| 7404 | + "mls_affiliations": [ | |
| 7405 | + "MiamiMLS", | |
| 7406 | + "Stellar" | |
| 7407 | + ] | |
| 7408 | + }, | |
| 7409 | + { | |
| 7410 | + "name": "Redfin Florida", | |
| 7411 | + "website": "www.redfin.com/state/florida", | |
| 7412 | + "states": [ | |
| 7413 | + "FL" | |
| 7414 | + ], | |
| 7415 | + "cities": [], | |
| 7416 | + "estimated_agents": 200, | |
| 7417 | + "estimated_listings": 1200, | |
| 7418 | + "mls_affiliations": [ | |
| 7419 | + "Stellar", | |
| 7420 | + "MiamiMLS" | |
| 7421 | + ] | |
| 7422 | + }, | |
| 7423 | + { | |
| 7424 | + "name": "EXP Realty Florida", | |
| 7425 | + "website": "www.exprealty.com/fl", | |
| 7426 | + "states": [ | |
| 7427 | + "FL" | |
| 7428 | + ], | |
| 7429 | + "cities": [], | |
| 7430 | + "estimated_agents": 8000, | |
| 7431 | + "estimated_listings": 12000, | |
| 7432 | + "mls_affiliations": [ | |
| 7433 | + "Stellar" | |
| 7434 | + ] | |
| 7435 | + }, | |
| 7436 | + { | |
| 7437 | + "name": "RE/MAX Alliance Group", | |
| 7438 | + "website": "www.remax-alliancegroup.com", | |
| 7439 | + "states": [ | |
| 7440 | + "FL" | |
| 7441 | + ], | |
| 7442 | + "cities": [ | |
| 7443 | + "Sarasota", | |
| 7444 | + "Bradenton" | |
| 7445 | + ], | |
| 7446 | + "estimated_agents": 350, | |
| 7447 | + "estimated_listings": 800, | |
| 7448 | + "mls_affiliations": [ | |
| 7449 | + "Stellar" | |
| 7450 | + ] | |
| 7451 | + }, | |
| 7452 | + { | |
| 7453 | + "name": "RE/MAX Crown Realty", | |
| 7454 | + "website": "www.remaxcrownrealty.com", | |
| 7455 | + "states": [ | |
| 7456 | + "FL" | |
| 7457 | + ], | |
| 7458 | + "cities": [ | |
| 7459 | + "Fort Myers" | |
| 7460 | + ], | |
| 7461 | + "estimated_agents": 90, | |
| 7462 | + "estimated_listings": 240, | |
| 7463 | + "mls_affiliations": [ | |
| 7464 | + "NABOR" | |
| 7465 | + ] | |
| 7466 | + }, | |
| 7467 | + { | |
| 7468 | + "name": "MVP Realty Associates", | |
| 7469 | + "website": "www.mvprealty.com", | |
| 7470 | + "states": [ | |
| 7471 | + "FL" | |
| 7472 | + ], | |
| 7473 | + "cities": [ | |
| 7474 | + "Naples", | |
| 7475 | + "Fort Myers" | |
| 7476 | + ], | |
| 7477 | + "estimated_agents": 1800, | |
| 7478 | + "estimated_listings": 2800, | |
| 7479 | + "mls_affiliations": [ | |
| 7480 | + "NABOR" | |
| 7481 | + ] | |
| 7482 | + }, | |
| 7483 | + { | |
| 7484 | + "name": "Downing-Frye Realty", | |
| 7485 | + "website": "www.downingfrye.com", | |
| 7486 | + "states": [ | |
| 7487 | + "FL" | |
| 7488 | + ], | |
| 7489 | + "cities": [ | |
| 7490 | + "Naples" | |
| 7491 | + ], | |
| 7492 | + "estimated_agents": 600, | |
| 7493 | + "estimated_listings": 1300, | |
| 7494 | + "mls_affiliations": [ | |
| 7495 | + "NABOR" | |
| 7496 | + ] | |
| 7497 | + }, | |
| 7498 | + { | |
| 7499 | + "name": "Keller Williams Jacksonville Realty", | |
| 7500 | + "website": "www.kwjacksonville.com", | |
| 7501 | + "states": [ | |
| 7502 | + "FL" | |
| 7503 | + ], | |
| 7504 | + "cities": [ | |
| 7505 | + "Jacksonville" | |
| 7506 | + ], | |
| 7507 | + "estimated_agents": 400, | |
| 7508 | + "estimated_listings": 800, | |
| 7509 | + "mls_affiliations": [ | |
| 7510 | + "realMLS" | |
| 7511 | + ] | |
| 7512 | + }, | |
| 7513 | + { | |
| 7514 | + "name": "Engel & Völkers First Coast", | |
| 7515 | + "website": "www.firstcoast.evrealestate.com", | |
| 7516 | + "states": [ | |
| 7517 | + "FL" | |
| 7518 | + ], | |
| 7519 | + "cities": [ | |
| 7520 | + "Jacksonville Beach" | |
| 7521 | + ], | |
| 7522 | + "estimated_agents": 90, | |
| 7523 | + "estimated_listings": 240, | |
| 7524 | + "mls_affiliations": [ | |
| 7525 | + "realMLS" | |
| 7526 | + ] | |
| 7527 | + }, | |
| 7528 | + { | |
| 7529 | + "name": "Century 21 St Augustine Properties", | |
| 7530 | + "website": "www.c21saprop.com", | |
| 7531 | + "states": [ | |
| 7532 | + "FL" | |
| 7533 | + ], | |
| 7534 | + "cities": [ | |
| 7535 | + "Saint Augustine" | |
| 7536 | + ], | |
| 7537 | + "estimated_agents": 60, | |
| 7538 | + "estimated_listings": 160, | |
| 7539 | + "mls_affiliations": [ | |
| 7540 | + "SAMLS" | |
| 7541 | + ] | |
| 7542 | + }, | |
| 7543 | + { | |
| 7544 | + "name": "ERA Grizzard Real Estate", | |
| 7545 | + "website": "www.eragrizzard.com", | |
| 7546 | + "states": [ | |
| 7547 | + "FL" | |
| 7548 | + ], | |
| 7549 | + "cities": [ | |
| 7550 | + "Leesburg", | |
| 7551 | + "Orlando" | |
| 7552 | + ], | |
| 7553 | + "estimated_agents": 300, | |
| 7554 | + "estimated_listings": 700, | |
| 7555 | + "mls_affiliations": [ | |
| 7556 | + "Stellar" | |
| 7557 | + ] | |
| 7558 | + }, | |
| 7559 | + { | |
| 7560 | + "name": "Coldwell Banker Paradise", | |
| 7561 | + "website": "www.coldwellbankerparadise.com", | |
| 7562 | + "states": [ | |
| 7563 | + "FL" | |
| 7564 | + ], | |
| 7565 | + "cities": [ | |
| 7566 | + "Vero Beach" | |
| 7567 | + ], | |
| 7568 | + "estimated_agents": 200, | |
| 7569 | + "estimated_listings": 500, | |
| 7570 | + "mls_affiliations": [ | |
| 7571 | + "IRMLS" | |
| 7572 | + ] | |
| 7573 | + }, | |
| 7574 | + { | |
| 7575 | + "name": "The Corcoran Connection", | |
| 7576 | + "website": "www.thecorcoranconnection.com", | |
| 7577 | + "states": [ | |
| 7578 | + "FL" | |
| 7579 | + ], | |
| 7580 | + "cities": [ | |
| 7581 | + "Saint Cloud", | |
| 7582 | + "Kissimmee" | |
| 7583 | + ], | |
| 7584 | + "estimated_agents": 90, | |
| 7585 | + "estimated_listings": 240, | |
| 7586 | + "mls_affiliations": [ | |
| 7587 | + "Stellar" | |
| 7588 | + ] | |
| 7589 | + }, | |
| 7590 | + { | |
| 7591 | + "name": "Howard Hanna Michigan", | |
| 7592 | + "website": "www.howardhanna.com/mi", | |
| 7593 | + "states": [ | |
| 7594 | + "MI" | |
| 7595 | + ], | |
| 7596 | + "cities": [ | |
| 7597 | + "Detroit suburbs" | |
| 7598 | + ], | |
| 7599 | + "estimated_agents": 400, | |
| 7600 | + "estimated_listings": 900, | |
| 7601 | + "mls_affiliations": [ | |
| 7602 | + "Realcomp" | |
| 7603 | + ] | |
| 7604 | + }, | |
| 7605 | + { | |
| 7606 | + "name": "KW Domain Birmingham MI", | |
| 7607 | + "website": "www.kwdomain.com", | |
| 7608 | + "states": [ | |
| 7609 | + "MI" | |
| 7610 | + ], | |
| 7611 | + "cities": [ | |
| 7612 | + "Birmingham" | |
| 7613 | + ], | |
| 7614 | + "estimated_agents": 200, | |
| 7615 | + "estimated_listings": 420, | |
| 7616 | + "mls_affiliations": [ | |
| 7617 | + "Realcomp" | |
| 7618 | + ] | |
| 7619 | + }, | |
| 7620 | + { | |
| 7621 | + "name": "National Realty Centers", | |
| 7622 | + "website": "www.nationalrealtycenters.com", | |
| 7623 | + "states": [ | |
| 7624 | + "MI" | |
| 7625 | + ], | |
| 7626 | + "cities": [ | |
| 7627 | + "Northville" | |
| 7628 | + ], | |
| 7629 | + "estimated_agents": 700, | |
| 7630 | + "estimated_listings": 1100, | |
| 7631 | + "mls_affiliations": [ | |
| 7632 | + "Realcomp" | |
| 7633 | + ] | |
| 7634 | + }, | |
| 7635 | + { | |
| 7636 | + "name": "Five Star Real Estate", | |
| 7637 | + "website": "www.fivestarmichigan.com", | |
| 7638 | + "states": [ | |
| 7639 | + "MI" | |
| 7640 | + ], | |
| 7641 | + "cities": [ | |
| 7642 | + "Grand Rapids" | |
| 7643 | + ], | |
| 7644 | + "estimated_agents": 900, | |
| 7645 | + "estimated_listings": 1500, | |
| 7646 | + "mls_affiliations": [ | |
| 7647 | + "MichRIC" | |
| 7648 | + ] | |
| 7649 | + }, | |
| 7650 | + { | |
| 7651 | + "name": "Greenridge Realty", | |
| 7652 | + "website": "www.greenridge.com", | |
| 7653 | + "states": [ | |
| 7654 | + "MI" | |
| 7655 | + ], | |
| 7656 | + "cities": [ | |
| 7657 | + "Grand Rapids" | |
| 7658 | + ], | |
| 7659 | + "estimated_agents": 300, | |
| 7660 | + "estimated_listings": 700, | |
| 7661 | + "mls_affiliations": [ | |
| 7662 | + "MichRIC" | |
| 7663 | + ] | |
| 7664 | + }, | |
| 7665 | + { | |
| 7666 | + "name": "CENTURY 21 Affiliated", | |
| 7667 | + "website": "www.c21affiliated.com", | |
| 7668 | + "states": [ | |
| 7669 | + "WI", | |
| 7670 | + "IL", | |
| 7671 | + "MI", | |
| 7672 | + "IN", | |
| 7673 | + "FL" | |
| 7674 | + ], | |
| 7675 | + "cities": [ | |
| 7676 | + "Madison", | |
| 7677 | + "Chicago" | |
| 7678 | + ], | |
| 7679 | + "estimated_agents": 1400, | |
| 7680 | + "estimated_listings": 2600, | |
| 7681 | + "mls_affiliations": [ | |
| 7682 | + "SCWMLS", | |
| 7683 | + "MRED" | |
| 7684 | + ] | |
| 7685 | + }, | |
| 7686 | + { | |
| 7687 | + "name": "Coldwell Banker Real Estate Group", | |
| 7688 | + "website": "www.coldwellhomes.com", | |
| 7689 | + "states": [ | |
| 7690 | + "WI", | |
| 7691 | + "IL", | |
| 7692 | + "IN", | |
| 7693 | + "MI", | |
| 7694 | + "OH" | |
| 7695 | + ], | |
| 7696 | + "cities": [ | |
| 7697 | + "Green Bay", | |
| 7698 | + "Fort Wayne" | |
| 7699 | + ], | |
| 7700 | + "estimated_agents": 900, | |
| 7701 | + "estimated_listings": 1900, | |
| 7702 | + "mls_affiliations": [ | |
| 7703 | + "RANW" | |
| 7704 | + ] | |
| 7705 | + }, | |
| 7706 | + { | |
| 7707 | + "name": "RE/MAX 24/7 Real Estate", | |
| 7708 | + "website": "www.densmorerealty.com", | |
| 7709 | + "states": [ | |
| 7710 | + "WI" | |
| 7711 | + ], | |
| 7712 | + "cities": [ | |
| 7713 | + "Green Bay" | |
| 7714 | + ], | |
| 7715 | + "estimated_agents": 40, | |
| 7716 | + "estimated_listings": 110, | |
| 7717 | + "mls_affiliations": [ | |
| 7718 | + "RANW" | |
| 7719 | + ] | |
| 7720 | + }, | |
| 7721 | + { | |
| 7722 | + "name": "Smythe Realtors (Cressy & Everett)", | |
| 7723 | + "website": "www.cressyeverett.com", | |
| 7724 | + "states": [ | |
| 7725 | + "IN", | |
| 7726 | + "MI" | |
| 7727 | + ], | |
| 7728 | + "cities": [ | |
| 7729 | + "South Bend" | |
| 7730 | + ], | |
| 7731 | + "estimated_agents": 200, | |
| 7732 | + "estimated_listings": 480, | |
| 7733 | + "mls_affiliations": [ | |
| 7734 | + "IRMLS-IN" | |
| 7735 | + ] | |
| 7736 | + }, | |
| 7737 | + { | |
| 7738 | + "name": "Berkshire Hathaway HomeServices Michigan Real Estate", | |
| 7739 | + "website": "www.bhhsmi.com", | |
| 7740 | + "states": [ | |
| 7741 | + "MI", | |
| 7742 | + "IN" | |
| 7743 | + ], | |
| 7744 | + "cities": [ | |
| 7745 | + "Grand Rapids", | |
| 7746 | + "Kalamazoo" | |
| 7747 | + ], | |
| 7748 | + "estimated_agents": 500, | |
| 7749 | + "estimated_listings": 1100, | |
| 7750 | + "mls_affiliations": [ | |
| 7751 | + "MichRIC" | |
| 7752 | + ] | |
| 7753 | + }, | |
| 7754 | + { | |
| 7755 | + "name": "RE/MAX of Grand Rapids", | |
| 7756 | + "website": "www.grar.com", | |
| 7757 | + "states": [ | |
| 7758 | + "MI" | |
| 7759 | + ], | |
| 7760 | + "cities": [ | |
| 7761 | + "Grand Rapids" | |
| 7762 | + ], | |
| 7763 | + "estimated_agents": 200, | |
| 7764 | + "estimated_listings": 480, | |
| 7765 | + "mls_affiliations": [ | |
| 7766 | + "MichRIC" | |
| 7767 | + ] | |
| 7768 | + }, | |
| 7769 | + { | |
| 7770 | + "name": "Key Realty Ohio", | |
| 7771 | + "website": "www.keyrealtyohio.com", | |
| 7772 | + "states": [ | |
| 7773 | + "OH", | |
| 7774 | + "MI" | |
| 7775 | + ], | |
| 7776 | + "cities": [ | |
| 7777 | + "Toledo", | |
| 7778 | + "Columbus" | |
| 7779 | + ], | |
| 7780 | + "estimated_agents": 1200, | |
| 7781 | + "estimated_listings": 1900, | |
| 7782 | + "mls_affiliations": [ | |
| 7783 | + "NORIS" | |
| 7784 | + ] | |
| 7785 | + }, | |
| 7786 | + { | |
| 7787 | + "name": "The Danberry Company", | |
| 7788 | + "website": "www.danberry.com", | |
| 7789 | + "states": [ | |
| 7790 | + "OH", | |
| 7791 | + "MI" | |
| 7792 | + ], | |
| 7793 | + "cities": [ | |
| 7794 | + "Toledo" | |
| 7795 | + ], | |
| 7796 | + "estimated_agents": 300, | |
| 7797 | + "estimated_listings": 650, | |
| 7798 | + "mls_affiliations": [ | |
| 7799 | + "NORIS" | |
| 7800 | + ] | |
| 7801 | + }, | |
| 7802 | + { | |
| 7803 | + "name": "Howard Hanna Ohio", | |
| 7804 | + "website": "www.howardhanna.com/oh", | |
| 7805 | + "states": [ | |
| 7806 | + "OH" | |
| 7807 | + ], | |
| 7808 | + "cities": [ | |
| 7809 | + "Cleveland", | |
| 7810 | + "Columbus" | |
| 7811 | + ], | |
| 7812 | + "estimated_agents": 2000, | |
| 7813 | + "estimated_listings": 4200, | |
| 7814 | + "mls_affiliations": [ | |
| 7815 | + "YesMLS" | |
| 7816 | + ] | |
| 7817 | + }, | |
| 7818 | + { | |
| 7819 | + "name": "Keller Williams Capital Partners Columbus", | |
| 7820 | + "website": "www.kwcolumbus.com", | |
| 7821 | + "states": [ | |
| 7822 | + "OH" | |
| 7823 | + ], | |
| 7824 | + "cities": [ | |
| 7825 | + "Columbus" | |
| 7826 | + ], | |
| 7827 | + "estimated_agents": 500, | |
| 7828 | + "estimated_listings": 950, | |
| 7829 | + "mls_affiliations": [ | |
| 7830 | + "ColumbusMLS" | |
| 7831 | + ] | |
| 7832 | + }, | |
| 7833 | + { | |
| 7834 | + "name": "RE/MAX Alliance Ohio", | |
| 7835 | + "website": "www.remaxallianceohio.com", | |
| 7836 | + "states": [ | |
| 7837 | + "OH" | |
| 7838 | + ], | |
| 7839 | + "cities": [ | |
| 7840 | + "Columbus" | |
| 7841 | + ], | |
| 7842 | + "estimated_agents": 130, | |
| 7843 | + "estimated_listings": 320, | |
| 7844 | + "mls_affiliations": [ | |
| 7845 | + "ColumbusMLS" | |
| 7846 | + ] | |
| 7847 | + }, | |
| 7848 | + { | |
| 7849 | + "name": "Plum Tree Realty", | |
| 7850 | + "website": "www.plumtreerealty.com", | |
| 7851 | + "states": [ | |
| 7852 | + "OH", | |
| 7853 | + "KY", | |
| 7854 | + "IN" | |
| 7855 | + ], | |
| 7856 | + "cities": [ | |
| 7857 | + "Cincinnati" | |
| 7858 | + ], | |
| 7859 | + "estimated_agents": 700, | |
| 7860 | + "estimated_listings": 1100, | |
| 7861 | + "mls_affiliations": [ | |
| 7862 | + "CincyMLS" | |
| 7863 | + ] | |
| 7864 | + }, | |
| 7865 | + { | |
| 7866 | + "name": "Star One Realtors", | |
| 7867 | + "website": "www.starone.com", | |
| 7868 | + "states": [ | |
| 7869 | + "OH" | |
| 7870 | + ], | |
| 7871 | + "cities": [ | |
| 7872 | + "Cincinnati", | |
| 7873 | + "Dayton" | |
| 7874 | + ], | |
| 7875 | + "estimated_agents": 400, | |
| 7876 | + "estimated_listings": 850, | |
| 7877 | + "mls_affiliations": [ | |
| 7878 | + "CincyMLS", | |
| 7879 | + "DaytonMLS" | |
| 7880 | + ] | |
| 7881 | + }, | |
| 7882 | + { | |
| 7883 | + "name": "Irongate Realtors", | |
| 7884 | + "website": "www.irongaterealtors.com", | |
| 7885 | + "states": [ | |
| 7886 | + "OH" | |
| 7887 | + ], | |
| 7888 | + "cities": [ | |
| 7889 | + "Dayton" | |
| 7890 | + ], | |
| 7891 | + "estimated_agents": 300, | |
| 7892 | + "estimated_listings": 650, | |
| 7893 | + "mls_affiliations": [ | |
| 7894 | + "DaytonMLS" | |
| 7895 | + ] | |
| 7896 | + }, | |
| 7897 | + { | |
| 7898 | + "name": "Berkshire Hathaway HomeServices Stouffer Realty", | |
| 7899 | + "website": "www.stoufferrealty.com", | |
| 7900 | + "states": [ | |
| 7901 | + "OH" | |
| 7902 | + ], | |
| 7903 | + "cities": [ | |
| 7904 | + "Akron" | |
| 7905 | + ], | |
| 7906 | + "estimated_agents": 300, | |
| 7907 | + "estimated_listings": 650, | |
| 7908 | + "mls_affiliations": [ | |
| 7909 | + "YesMLS" | |
| 7910 | + ] | |
| 7911 | + }, | |
| 7912 | + { | |
| 7913 | + "name": "J Barrett & Company", | |
| 7914 | + "website": "www.jbarrettrealty.com", | |
| 7915 | + "states": [ | |
| 7916 | + "MA" | |
| 7917 | + ], | |
| 7918 | + "cities": [ | |
| 7919 | + "Beverly", | |
| 7920 | + "Marblehead" | |
| 7921 | + ], | |
| 7922 | + "estimated_agents": 130, | |
| 7923 | + "estimated_listings": 320, | |
| 7924 | + "mls_affiliations": [ | |
| 7925 | + "MLSPIN" | |
| 7926 | + ] | |
| 7927 | + }, | |
| 7928 | + { | |
| 7929 | + "name": "Sagan Harborside Sotheby's International Realty", | |
| 7930 | + "website": "www.saganharborside.com", | |
| 7931 | + "states": [ | |
| 7932 | + "MA" | |
| 7933 | + ], | |
| 7934 | + "cities": [ | |
| 7935 | + "Marblehead" | |
| 7936 | + ], | |
| 7937 | + "estimated_agents": 60, | |
| 7938 | + "estimated_listings": 160, | |
| 7939 | + "mls_affiliations": [ | |
| 7940 | + "MLSPIN" | |
| 7941 | + ] | |
| 7942 | + }, | |
| 7943 | + { | |
| 7944 | + "name": "Kinlin Grover Compass", | |
| 7945 | + "website": "www.kinlingrover.com", | |
| 7946 | + "states": [ | |
| 7947 | + "MA" | |
| 7948 | + ], | |
| 7949 | + "cities": [ | |
| 7950 | + "Cape Cod", | |
| 7951 | + "Osterville" | |
| 7952 | + ], | |
| 7953 | + "estimated_agents": 300, | |
| 7954 | + "estimated_listings": 750, | |
| 7955 | + "mls_affiliations": [ | |
| 7956 | + "CCIMLS" | |
| 7957 | + ] | |
| 7958 | + }, | |
| 7959 | + { | |
| 7960 | + "name": "Robert Paul Properties (Compass)", | |
| 7961 | + "website": "www.robertpaul.com", | |
| 7962 | + "states": [ | |
| 7963 | + "MA" | |
| 7964 | + ], | |
| 7965 | + "cities": [ | |
| 7966 | + "Osterville", | |
| 7967 | + "Boston" | |
| 7968 | + ], | |
| 7969 | + "estimated_agents": 90, | |
| 7970 | + "estimated_listings": 240, | |
| 7971 | + "mls_affiliations": [ | |
| 7972 | + "CCIMLS" | |
| 7973 | + ] | |
| 7974 | + }, | |
| 7975 | + { | |
| 7976 | + "name": "LandVest", | |
| 7977 | + "website": "www.landvest.com", | |
| 7978 | + "states": [ | |
| 7979 | + "MA", | |
| 7980 | + "ME", | |
| 7981 | + "NH", | |
| 7982 | + "VT", | |
| 7983 | + "NY" | |
| 7984 | + ], | |
| 7985 | + "cities": [ | |
| 7986 | + "Boston" | |
| 7987 | + ], | |
| 7988 | + "estimated_agents": 60, | |
| 7989 | + "estimated_listings": 200, | |
| 7990 | + "mls_affiliations": [ | |
| 7991 | + "MLSPIN" | |
| 7992 | + ] | |
| 7993 | + }, | |
| 7994 | + { | |
| 7995 | + "name": "The Stockman Associates (Berkshires)", | |
| 7996 | + "website": "www.stonehousepropertiesma.com", | |
| 7997 | + "states": [ | |
| 7998 | + "MA" | |
| 7999 | + ], | |
| 8000 | + "cities": [ | |
| 8001 | + "West Stockbridge" | |
| 8002 | + ], | |
| 8003 | + "estimated_agents": 20, | |
| 8004 | + "estimated_listings": 70, | |
| 8005 | + "mls_affiliations": [ | |
| 8006 | + "BerkshireMLS" | |
| 8007 | + ] | |
| 8008 | + }, | |
| 8009 | + { | |
| 8010 | + "name": "William Pitt SIR Berkshires", | |
| 8011 | + "website": "www.williampitt.com/berkshires", | |
| 8012 | + "states": [ | |
| 8013 | + "MA" | |
| 8014 | + ], | |
| 8015 | + "cities": [ | |
| 8016 | + "Great Barrington" | |
| 8017 | + ], | |
| 8018 | + "estimated_agents": 40, | |
| 8019 | + "estimated_listings": 130, | |
| 8020 | + "mls_affiliations": [ | |
| 8021 | + "BerkshireMLS" | |
| 8022 | + ] | |
| 8023 | + }, | |
| 8024 | + { | |
| 8025 | + "name": "Brown Harris Stevens Connecticut", | |
| 8026 | + "website": "www.bhsusa.com/connecticut", | |
| 8027 | + "states": [ | |
| 8028 | + "CT" | |
| 8029 | + ], | |
| 8030 | + "cities": [ | |
| 8031 | + "Greenwich" | |
| 8032 | + ], | |
| 8033 | + "estimated_agents": 130, | |
| 8034 | + "estimated_listings": 320, | |
| 8035 | + "mls_affiliations": [ | |
| 8036 | + "GreenwichMLS" | |
| 8037 | + ] | |
| 8038 | + }, | |
| 8039 | + { | |
| 8040 | + "name": "Coldwell Banker Realty Greenwich", | |
| 8041 | + "website": "www.coldwellbankerhomes.com/ct/greenwich", | |
| 8042 | + "states": [ | |
| 8043 | + "CT" | |
| 8044 | + ], | |
| 8045 | + "cities": [ | |
| 8046 | + "Greenwich" | |
| 8047 | + ], | |
| 8048 | + "estimated_agents": 130, | |
| 8049 | + "estimated_listings": 320, | |
| 8050 | + "mls_affiliations": [ | |
| 8051 | + "GreenwichMLS" | |
| 8052 | + ] | |
| 8053 | + }, | |
| 8054 | + { | |
| 8055 | + "name": "Carbutti & Co Realtors", | |
| 8056 | + "website": "www.carbutti.com", | |
| 8057 | + "states": [ | |
| 8058 | + "CT" | |
| 8059 | + ], | |
| 8060 | + "cities": [ | |
| 8061 | + "Wallingford" | |
| 8062 | + ], | |
| 8063 | + "estimated_agents": 40, | |
| 8064 | + "estimated_listings": 110, | |
| 8065 | + "mls_affiliations": [ | |
| 8066 | + "SmartMLS" | |
| 8067 | + ] | |
| 8068 | + }, | |
| 8069 | + { | |
| 8070 | + "name": "Pearce Real Estate", | |
| 8071 | + "website": "www.hpearce.com", | |
| 8072 | + "states": [ | |
| 8073 | + "CT" | |
| 8074 | + ], | |
| 8075 | + "cities": [ | |
| 8076 | + "New Haven" | |
| 8077 | + ], | |
| 8078 | + "estimated_agents": 90, | |
| 8079 | + "estimated_listings": 240, | |
| 8080 | + "mls_affiliations": [ | |
| 8081 | + "SmartMLS" | |
| 8082 | + ] | |
| 8083 | + }, | |
| 8084 | + { | |
| 8085 | + "name": "Seaport Real Estate Services", | |
| 8086 | + "website": "www.seaportre.com", | |
| 8087 | + "states": [ | |
| 8088 | + "CT" | |
| 8089 | + ], | |
| 8090 | + "cities": [ | |
| 8091 | + "Mystic" | |
| 8092 | + ], | |
| 8093 | + "estimated_agents": 30, | |
| 8094 | + "estimated_listings": 90, | |
| 8095 | + "mls_affiliations": [ | |
| 8096 | + "SmartMLS" | |
| 8097 | + ] | |
| 8098 | + }, | |
| 8099 | + { | |
| 8100 | + "name": "Keller Williams NYC", | |
| 8101 | + "website": "www.kwnyc.com", | |
| 8102 | + "states": [ | |
| 8103 | + "NY" | |
| 8104 | + ], | |
| 8105 | + "cities": [ | |
| 8106 | + "New York" | |
| 8107 | + ], | |
| 8108 | + "estimated_agents": 900, | |
| 8109 | + "estimated_listings": 1400, | |
| 8110 | + "mls_affiliations": [ | |
| 8111 | + "REBNY" | |
| 8112 | + ] | |
| 8113 | + }, | |
| 8114 | + { | |
| 8115 | + "name": "Elegran Real Estate", | |
| 8116 | + "website": "www.elegran.com", | |
| 8117 | + "states": [ | |
| 8118 | + "NY" | |
| 8119 | + ], | |
| 8120 | + "cities": [ | |
| 8121 | + "New York" | |
| 8122 | + ], | |
| 8123 | + "estimated_agents": 200, | |
| 8124 | + "estimated_listings": 420, | |
| 8125 | + "mls_affiliations": [ | |
| 8126 | + "REBNY" | |
| 8127 | + ] | |
| 8128 | + }, | |
| 8129 | + { | |
| 8130 | + "name": "Casa Blanca Real Estate", | |
| 8131 | + "website": "www.casa-blanca.com", | |
| 8132 | + "states": [ | |
| 8133 | + "NY" | |
| 8134 | + ], | |
| 8135 | + "cities": [ | |
| 8136 | + "New York" | |
| 8137 | + ], | |
| 8138 | + "estimated_agents": 60, | |
| 8139 | + "estimated_listings": 160, | |
| 8140 | + "mls_affiliations": [ | |
| 8141 | + "REBNY" | |
| 8142 | + ] | |
| 8143 | + }, | |
| 8144 | + { | |
| 8145 | + "name": "R New York", | |
| 8146 | + "website": "www.rnewyork.com", | |
| 8147 | + "states": [ | |
| 8148 | + "NY" | |
| 8149 | + ], | |
| 8150 | + "cities": [ | |
| 8151 | + "New York" | |
| 8152 | + ], | |
| 8153 | + "estimated_agents": 700, | |
| 8154 | + "estimated_listings": 1100, | |
| 8155 | + "mls_affiliations": [ | |
| 8156 | + "REBNY" | |
| 8157 | + ] | |
| 8158 | + }, | |
| 8159 | + { | |
| 8160 | + "name": "Spire Group NY", | |
| 8161 | + "website": "www.spiregroupny.com", | |
| 8162 | + "states": [ | |
| 8163 | + "NY" | |
| 8164 | + ], | |
| 8165 | + "cities": [ | |
| 8166 | + "New York" | |
| 8167 | + ], | |
| 8168 | + "estimated_agents": 300, | |
| 8169 | + "estimated_listings": 550, | |
| 8170 | + "mls_affiliations": [ | |
| 8171 | + "REBNY" | |
| 8172 | + ] | |
| 8173 | + }, | |
| 8174 | + { | |
| 8175 | + "name": "Exit Realty Premium", | |
| 8176 | + "website": "www.exitrealtypremium.com", | |
| 8177 | + "states": [ | |
| 8178 | + "NY" | |
| 8179 | + ], | |
| 8180 | + "cities": [ | |
| 8181 | + "Bronx", | |
| 8182 | + "Yonkers" | |
| 8183 | + ], | |
| 8184 | + "estimated_agents": 130, | |
| 8185 | + "estimated_listings": 300, | |
| 8186 | + "mls_affiliations": [ | |
| 8187 | + "OneKey" | |
| 8188 | + ] | |
| 8189 | + }, | |
| 8190 | + { | |
| 8191 | + "name": "Charles Rutenberg Realty NY", | |
| 8192 | + "website": "www.crrny.com", | |
| 8193 | + "states": [ | |
| 8194 | + "NY" | |
| 8195 | + ], | |
| 8196 | + "cities": [ | |
| 8197 | + "Plainview", | |
| 8198 | + "Long Island" | |
| 8199 | + ], | |
| 8200 | + "estimated_agents": 1200, | |
| 8201 | + "estimated_listings": 1900, | |
| 8202 | + "mls_affiliations": [ | |
| 8203 | + "OneKey" | |
| 8204 | + ] | |
| 8205 | + }, | |
| 8206 | + { | |
| 8207 | + "name": "Exit Family Realty Brooklyn", | |
| 8208 | + "website": "www.exitfamilyrealty.com", | |
| 8209 | + "states": [ | |
| 8210 | + "NY" | |
| 8211 | + ], | |
| 8212 | + "cities": [ | |
| 8213 | + "Brooklyn" | |
| 8214 | + ], | |
| 8215 | + "estimated_agents": 90, | |
| 8216 | + "estimated_listings": 220, | |
| 8217 | + "mls_affiliations": [ | |
| 8218 | + "OneKey" | |
| 8219 | + ] | |
| 8220 | + }, | |
| 8221 | + { | |
| 8222 | + "name": "Berkshire Hathaway HomeServices PenFed Realty", | |
| 8223 | + "website": "www.penfedrealty.com", | |
| 8224 | + "states": [ | |
| 8225 | + "MD", | |
| 8226 | + "VA", | |
| 8227 | + "DC", | |
| 8228 | + "PA", | |
| 8229 | + "WV", | |
| 8230 | + "KS", | |
| 8231 | + "TX", | |
| 8232 | + "FL" | |
| 8233 | + ], | |
| 8234 | + "cities": [ | |
| 8235 | + "Reston" | |
| 8236 | + ], | |
| 8237 | + "estimated_agents": 1500, | |
| 8238 | + "estimated_listings": 2900, | |
| 8239 | + "mls_affiliations": [ | |
| 8240 | + "BRIGHT" | |
| 8241 | + ] | |
| 8242 | + }, | |
| 8243 | + { | |
| 8244 | + "name": "Compass DMV", | |
| 8245 | + "website": "www.compass.com/dc", | |
| 8246 | + "states": [ | |
| 8247 | + "DC", | |
| 8248 | + "MD", | |
| 8249 | + "VA" | |
| 8250 | + ], | |
| 8251 | + "cities": [ | |
| 8252 | + "Washington" | |
| 8253 | + ], | |
| 8254 | + "estimated_agents": 1400, | |
| 8255 | + "estimated_listings": 2600, | |
| 8256 | + "mls_affiliations": [ | |
| 8257 | + "BRIGHT" | |
| 8258 | + ] | |
| 8259 | + }, | |
| 8260 | + { | |
| 8261 | + "name": "RLAH @properties (DMV)", | |
| 8262 | + "website": "www.rlahre.com", | |
| 8263 | + "states": [ | |
| 8264 | + "DC", | |
| 8265 | + "MD", | |
| 8266 | + "VA" | |
| 8267 | + ], | |
| 8268 | + "cities": [ | |
| 8269 | + "Washington" | |
| 8270 | + ], | |
| 8271 | + "estimated_agents": 700, | |
| 8272 | + "estimated_listings": 1300, | |
| 8273 | + "mls_affiliations": [ | |
| 8274 | + "BRIGHT" | |
| 8275 | + ] | |
| 8276 | + }, | |
| 8277 | + { | |
| 8278 | + "name": "Keller Williams Capital Properties DC", | |
| 8279 | + "website": "www.kwcapitalproperties.com", | |
| 8280 | + "states": [ | |
| 8281 | + "DC", | |
| 8282 | + "MD", | |
| 8283 | + "VA" | |
| 8284 | + ], | |
| 8285 | + "cities": [ | |
| 8286 | + "Washington" | |
| 8287 | + ], | |
| 8288 | + "estimated_agents": 900, | |
| 8289 | + "estimated_listings": 1500, | |
| 8290 | + "mls_affiliations": [ | |
| 8291 | + "BRIGHT" | |
| 8292 | + ] | |
| 8293 | + }, | |
| 8294 | + { | |
| 8295 | + "name": "Redfin Mid-Atlantic", | |
| 8296 | + "website": "www.redfin.com/city/12839/dc/washington-dc", | |
| 8297 | + "states": [ | |
| 8298 | + "DC", | |
| 8299 | + "MD", | |
| 8300 | + "VA" | |
| 8301 | + ], | |
| 8302 | + "cities": [ | |
| 8303 | + "Washington" | |
| 8304 | + ], | |
| 8305 | + "estimated_agents": 90, | |
| 8306 | + "estimated_listings": 480, | |
| 8307 | + "mls_affiliations": [ | |
| 8308 | + "BRIGHT" | |
| 8309 | + ] | |
| 8310 | + }, | |
| 8311 | + { | |
| 8312 | + "name": "Berkshire Hathaway HomeServices Hodrick Realty", | |
| 8313 | + "website": "www.hodrickrealty.com", | |
| 8314 | + "states": [ | |
| 8315 | + "PA" | |
| 8316 | + ], | |
| 8317 | + "cities": [ | |
| 8318 | + "Williamsport" | |
| 8319 | + ], | |
| 8320 | + "estimated_agents": 90, | |
| 8321 | + "estimated_listings": 240, | |
| 8322 | + "mls_affiliations": [ | |
| 8323 | + "WestBranchMLS" | |
| 8324 | + ] | |
| 8325 | + }, | |
| 8326 | + { | |
| 8327 | + "name": "Jack Gaughen Network Services (ERA)", | |
| 8328 | + "website": "www.jgr.com", | |
| 8329 | + "states": [ | |
| 8330 | + "PA" | |
| 8331 | + ], | |
| 8332 | + "cities": [ | |
| 8333 | + "Harrisburg" | |
| 8334 | + ], | |
| 8335 | + "estimated_agents": 300, | |
| 8336 | + "estimated_listings": 650, | |
| 8337 | + "mls_affiliations": [ | |
| 8338 | + "BRIGHT" | |
| 8339 | + ] | |
| 8340 | + }, | |
| 8341 | + { | |
| 8342 | + "name": "RE/MAX Realty Associates Harrisburg", | |
| 8343 | + "website": "www.forsalebyremax.com", | |
| 8344 | + "states": [ | |
| 8345 | + "PA" | |
| 8346 | + ], | |
| 8347 | + "cities": [ | |
| 8348 | + "Harrisburg" | |
| 8349 | + ], | |
| 8350 | + "estimated_agents": 130, | |
| 8351 | + "estimated_listings": 320, | |
| 8352 | + "mls_affiliations": [ | |
| 8353 | + "BRIGHT" | |
| 8354 | + ] | |
| 8355 | + }, | |
| 8356 | + { | |
| 8357 | + "name": "RSR Realtors", | |
| 8358 | + "website": "www.rsrrealtors.com", | |
| 8359 | + "states": [ | |
| 8360 | + "PA" | |
| 8361 | + ], | |
| 8362 | + "cities": [ | |
| 8363 | + "Lemoyne", | |
| 8364 | + "Harrisburg" | |
| 8365 | + ], | |
| 8366 | + "estimated_agents": 90, | |
| 8367 | + "estimated_listings": 240, | |
| 8368 | + "mls_affiliations": [ | |
| 8369 | + "BRIGHT" | |
| 8370 | + ] | |
| 8371 | + }, | |
| 8372 | + { | |
| 8373 | + "name": "Lusk & Associates Sotheby's International Realty", | |
| 8374 | + "website": "www.luskandassociates.com", | |
| 8375 | + "states": [ | |
| 8376 | + "PA" | |
| 8377 | + ], | |
| 8378 | + "cities": [ | |
| 8379 | + "Lancaster" | |
| 8380 | + ], | |
| 8381 | + "estimated_agents": 60, | |
| 8382 | + "estimated_listings": 160, | |
| 8383 | + "mls_affiliations": [ | |
| 8384 | + "BRIGHT" | |
| 8385 | + ] | |
| 8386 | + }, | |
| 8387 | + { | |
| 8388 | + "name": "The Simpson Company (Gainesville GA)", | |
| 8389 | + "website": "www.thesimpsoncompany.com", | |
| 8390 | + "states": [ | |
| 8391 | + "GA" | |
| 8392 | + ], | |
| 8393 | + "cities": [ | |
| 8394 | + "Gainesville" | |
| 8395 | + ], | |
| 8396 | + "estimated_agents": 60, | |
| 8397 | + "estimated_listings": 160, | |
| 8398 | + "mls_affiliations": [ | |
| 8399 | + "GAMLS" | |
| 8400 | + ] | |
| 8401 | + }, | |
| 8402 | + { | |
| 8403 | + "name": "Berkshire Hathaway HomeServices Georgia Properties", | |
| 8404 | + "website": "www.bhhsgeorgia.com", | |
| 8405 | + "states": [ | |
| 8406 | + "GA" | |
| 8407 | + ], | |
| 8408 | + "cities": [ | |
| 8409 | + "Atlanta", | |
| 8410 | + "Roswell" | |
| 8411 | + ], | |
| 8412 | + "estimated_agents": 1400, | |
| 8413 | + "estimated_listings": 2800, | |
| 8414 | + "mls_affiliations": [ | |
| 8415 | + "FMLS", | |
| 8416 | + "GAMLS" | |
| 8417 | + ] | |
| 8418 | + }, | |
| 8419 | + { | |
| 8420 | + "name": "Keller Williams Realty Atlanta Partners", | |
| 8421 | + "website": "www.kwatlantapartners.com", | |
| 8422 | + "states": [ | |
| 8423 | + "GA" | |
| 8424 | + ], | |
| 8425 | + "cities": [ | |
| 8426 | + "Atlanta" | |
| 8427 | + ], | |
| 8428 | + "estimated_agents": 1200, | |
| 8429 | + "estimated_listings": 2000, | |
| 8430 | + "mls_affiliations": [ | |
| 8431 | + "FMLS" | |
| 8432 | + ] | |
| 8433 | + }, | |
| 8434 | + { | |
| 8435 | + "name": "Coldwell Banker Realty Atlanta", | |
| 8436 | + "website": "www.coldwellbankerhomes.com/ga", | |
| 8437 | + "states": [ | |
| 8438 | + "GA" | |
| 8439 | + ], | |
| 8440 | + "cities": [ | |
| 8441 | + "Atlanta" | |
| 8442 | + ], | |
| 8443 | + "estimated_agents": 1300, | |
| 8444 | + "estimated_listings": 2500, | |
| 8445 | + "mls_affiliations": [ | |
| 8446 | + "FMLS" | |
| 8447 | + ] | |
| 8448 | + }, | |
| 8449 | + { | |
| 8450 | + "name": "RE/MAX Around Atlanta", | |
| 8451 | + "website": "www.remaxaroundatlanta.com", | |
| 8452 | + "states": [ | |
| 8453 | + "GA" | |
| 8454 | + ], | |
| 8455 | + "cities": [ | |
| 8456 | + "Atlanta" | |
| 8457 | + ], | |
| 8458 | + "estimated_agents": 300, | |
| 8459 | + "estimated_listings": 650, | |
| 8460 | + "mls_affiliations": [ | |
| 8461 | + "FMLS" | |
| 8462 | + ] | |
| 8463 | + }, | |
| 8464 | + { | |
| 8465 | + "name": "Century 21 Results", | |
| 8466 | + "website": "www.c21results.com", | |
| 8467 | + "states": [ | |
| 8468 | + "GA" | |
| 8469 | + ], | |
| 8470 | + "cities": [ | |
| 8471 | + "Roswell" | |
| 8472 | + ], | |
| 8473 | + "estimated_agents": 300, | |
| 8474 | + "estimated_listings": 600, | |
| 8475 | + "mls_affiliations": [ | |
| 8476 | + "FMLS" | |
| 8477 | + ] | |
| 8478 | + }, | |
| 8479 | + { | |
| 8480 | + "name": "Cora Bett Thomas Realty", | |
| 8481 | + "website": "www.corabettthomas.com", | |
| 8482 | + "states": [ | |
| 8483 | + "GA", | |
| 8484 | + "SC" | |
| 8485 | + ], | |
| 8486 | + "cities": [ | |
| 8487 | + "Savannah" | |
| 8488 | + ], | |
| 8489 | + "estimated_agents": 60, | |
| 8490 | + "estimated_listings": 160, | |
| 8491 | + "mls_affiliations": [ | |
| 8492 | + "SavannahMLS" | |
| 8493 | + ] | |
| 8494 | + }, | |
| 8495 | + { | |
| 8496 | + "name": "ERA King Real Estate", | |
| 8497 | + "website": "www.eraking.com", | |
| 8498 | + "states": [ | |
| 8499 | + "AL", | |
| 8500 | + "GA" | |
| 8501 | + ], | |
| 8502 | + "cities": [ | |
| 8503 | + "Anniston", | |
| 8504 | + "Birmingham" | |
| 8505 | + ], | |
| 8506 | + "estimated_agents": 300, | |
| 8507 | + "estimated_listings": 700, | |
| 8508 | + "mls_affiliations": [ | |
| 8509 | + "GALMLS" | |
| 8510 | + ] | |
| 8511 | + }, | |
| 8512 | + { | |
| 8513 | + "name": "Keller Williams Realty Augusta Partners", | |
| 8514 | + "website": "www.kwaugusta.com", | |
| 8515 | + "states": [ | |
| 8516 | + "GA", | |
| 8517 | + "SC" | |
| 8518 | + ], | |
| 8519 | + "cities": [ | |
| 8520 | + "Augusta" | |
| 8521 | + ], | |
| 8522 | + "estimated_agents": 200, | |
| 8523 | + "estimated_listings": 450, | |
| 8524 | + "mls_affiliations": [ | |
| 8525 | + "AugustaMLS" | |
| 8526 | + ] | |
| 8527 | + }, | |
| 8528 | + { | |
| 8529 | + "name": "Meybohm Real Estate", | |
| 8530 | + "website": "www.meybohm.com", | |
| 8531 | + "states": [ | |
| 8532 | + "GA", | |
| 8533 | + "SC" | |
| 8534 | + ], | |
| 8535 | + "cities": [ | |
| 8536 | + "Augusta" | |
| 8537 | + ], | |
| 8538 | + "estimated_agents": 300, | |
| 8539 | + "estimated_listings": 700, | |
| 8540 | + "mls_affiliations": [ | |
| 8541 | + "AugustaMLS" | |
| 8542 | + ] | |
| 8543 | + }, | |
| 8544 | + { | |
| 8545 | + "name": "Berkshire Hathaway HomeServices Beazley Realtors", | |
| 8546 | + "website": "www.beazleyrealtors.com", | |
| 8547 | + "states": [ | |
| 8548 | + "GA", | |
| 8549 | + "SC" | |
| 8550 | + ], | |
| 8551 | + "cities": [ | |
| 8552 | + "Augusta" | |
| 8553 | + ], | |
| 8554 | + "estimated_agents": 200, | |
| 8555 | + "estimated_listings": 480, | |
| 8556 | + "mls_affiliations": [ | |
| 8557 | + "AugustaMLS" | |
| 8558 | + ] | |
| 8559 | + }, | |
| 8560 | + { | |
| 8561 | + "name": "Weichert Realtors - The Griffin Company", | |
| 8562 | + "website": "www.thegriffincompany.com", | |
| 8563 | + "states": [ | |
| 8564 | + "AR" | |
| 8565 | + ], | |
| 8566 | + "cities": [ | |
| 8567 | + "Fayetteville", | |
| 8568 | + "Fort Smith" | |
| 8569 | + ], | |
| 8570 | + "estimated_agents": 130, | |
| 8571 | + "estimated_listings": 320, | |
| 8572 | + "mls_affiliations": [ | |
| 8573 | + "NWAMLS" | |
| 8574 | + ] | |
| 8575 | + }, | |
| 8576 | + { | |
| 8577 | + "name": "Chapman Hall Realtors", | |
| 8578 | + "website": "www.chapmanhallrealtors.com", | |
| 8579 | + "states": [ | |
| 8580 | + "GA" | |
| 8581 | + ], | |
| 8582 | + "cities": [ | |
| 8583 | + "Atlanta" | |
| 8584 | + ], | |
| 8585 | + "estimated_agents": 700, | |
| 8586 | + "estimated_listings": 1100, | |
| 8587 | + "mls_affiliations": [ | |
| 8588 | + "FMLS" | |
| 8589 | + ] | |
| 8590 | + }, | |
| 8591 | + { | |
| 8592 | + "name": "Southern Classic Realtors", | |
| 8593 | + "website": "www.southernclassicrealtors.com", | |
| 8594 | + "states": [ | |
| 8595 | + "GA" | |
| 8596 | + ], | |
| 8597 | + "cities": [ | |
| 8598 | + "McDonough" | |
| 8599 | + ], | |
| 8600 | + "estimated_agents": 900, | |
| 8601 | + "estimated_listings": 1400, | |
| 8602 | + "mls_affiliations": [ | |
| 8603 | + "GAMLS" | |
| 8604 | + ] | |
| 8605 | + }, | |
| 8606 | + { | |
| 8607 | + "name": "Duckworth Properties", | |
| 8608 | + "website": "www.duckworthproperties.com", | |
| 8609 | + "states": [ | |
| 8610 | + "GA" | |
| 8611 | + ], | |
| 8612 | + "cities": [ | |
| 8613 | + "Brunswick", | |
| 8614 | + "Saint Simons" | |
| 8615 | + ], | |
| 8616 | + "estimated_agents": 40, | |
| 8617 | + "estimated_listings": 120, | |
| 8618 | + "mls_affiliations": [ | |
| 8619 | + "GoldenIslesMLS" | |
| 8620 | + ] | |
| 8621 | + }, | |
| 8622 | + { | |
| 8623 | + "name": "DeLoach Sotheby's International Realty", | |
| 8624 | + "website": "www.deloachsir.com", | |
| 8625 | + "states": [ | |
| 8626 | + "GA" | |
| 8627 | + ], | |
| 8628 | + "cities": [ | |
| 8629 | + "Saint Simons Island" | |
| 8630 | + ], | |
| 8631 | + "estimated_agents": 40, | |
| 8632 | + "estimated_listings": 130, | |
| 8633 | + "mls_affiliations": [ | |
| 8634 | + "GoldenIslesMLS" | |
| 8635 | + ] | |
| 8636 | + }, | |
| 8637 | + { | |
| 8638 | + "name": "Coldwell Banker Sea Coast Advantage", | |
| 8639 | + "website": "www.seacoastrealty.com", | |
| 8640 | + "states": [ | |
| 8641 | + "NC", | |
| 8642 | + "SC" | |
| 8643 | + ], | |
| 8644 | + "cities": [ | |
| 8645 | + "Wilmington", | |
| 8646 | + "Myrtle Beach" | |
| 8647 | + ], | |
| 8648 | + "estimated_agents": 900, | |
| 8649 | + "estimated_listings": 2000, | |
| 8650 | + "mls_affiliations": [ | |
| 8651 | + "CapeFearMLS", | |
| 8652 | + "CCARMLS" | |
| 8653 | + ] | |
| 8654 | + }, | |
| 8655 | + { | |
| 8656 | + "name": "Berkshire Hathaway HomeServices Carolinas Realty", | |
| 8657 | + "website": "www.bhhscarolinas.com", | |
| 8658 | + "states": [ | |
| 8659 | + "NC", | |
| 8660 | + "SC" | |
| 8661 | + ], | |
| 8662 | + "cities": [ | |
| 8663 | + "Winston-Salem", | |
| 8664 | + "Charlotte" | |
| 8665 | + ], | |
| 8666 | + "estimated_agents": 500, | |
| 8667 | + "estimated_listings": 1100, | |
| 8668 | + "mls_affiliations": [ | |
| 8669 | + "TriadMLS", | |
| 8670 | + "Canopy" | |
| 8671 | + ] | |
| 8672 | + }, | |
| 8673 | + { | |
| 8674 | + "name": "Leonard Ryden Burr Real Estate", | |
| 8675 | + "website": "www.lrbrealestate.com", | |
| 8676 | + "states": [ | |
| 8677 | + "NC" | |
| 8678 | + ], | |
| 8679 | + "cities": [ | |
| 8680 | + "Winston-Salem" | |
| 8681 | + ], | |
| 8682 | + "estimated_agents": 90, | |
| 8683 | + "estimated_listings": 240, | |
| 8684 | + "mls_affiliations": [ | |
| 8685 | + "TriadMLS" | |
| 8686 | + ] | |
| 8687 | + }, | |
| 8688 | + { | |
| 8689 | + "name": "Berkshire Hathaway HomeServices Yost & Little Realty", | |
| 8690 | + "website": "www.yostandlittle.com", | |
| 8691 | + "states": [ | |
| 8692 | + "NC" | |
| 8693 | + ], | |
| 8694 | + "cities": [ | |
| 8695 | + "Greensboro" | |
| 8696 | + ], | |
| 8697 | + "estimated_agents": 130, | |
| 8698 | + "estimated_listings": 320, | |
| 8699 | + "mls_affiliations": [ | |
| 8700 | + "TriadMLS" | |
| 8701 | + ] | |
| 8702 | + }, | |
| 8703 | + { | |
| 8704 | + "name": "Coldwell Banker Advantage NC", | |
| 8705 | + "website": "www.cbadvantage.com", | |
| 8706 | + "states": [ | |
| 8707 | + "NC" | |
| 8708 | + ], | |
| 8709 | + "cities": [ | |
| 8710 | + "Raleigh", | |
| 8711 | + "Fayetteville" | |
| 8712 | + ], | |
| 8713 | + "estimated_agents": 700, | |
| 8714 | + "estimated_listings": 1400, | |
| 8715 | + "mls_affiliations": [ | |
| 8716 | + "TriangleMLS", | |
| 8717 | + "LongleafPineMLS" | |
| 8718 | + ] | |
| 8719 | + }, | |
| 8720 | + { | |
| 8721 | + "name": "Keller Williams Preferred Realty (Raleigh)", | |
| 8722 | + "website": "www.kwpreferred.com", | |
| 8723 | + "states": [ | |
| 8724 | + "NC" | |
| 8725 | + ], | |
| 8726 | + "cities": [ | |
| 8727 | + "Raleigh" | |
| 8728 | + ], | |
| 8729 | + "estimated_agents": 500, | |
| 8730 | + "estimated_listings": 900, | |
| 8731 | + "mls_affiliations": [ | |
| 8732 | + "TriangleMLS" | |
| 8733 | + ] | |
| 8734 | + }, | |
| 8735 | + { | |
| 8736 | + "name": "Compass Charlotte", | |
| 8737 | + "website": "www.compass.com/charlotte", | |
| 8738 | + "states": [ | |
| 8739 | + "NC", | |
| 8740 | + "SC" | |
| 8741 | + ], | |
| 8742 | + "cities": [ | |
| 8743 | + "Charlotte" | |
| 8744 | + ], | |
| 8745 | + "estimated_agents": 400, | |
| 8746 | + "estimated_listings": 800, | |
| 8747 | + "mls_affiliations": [ | |
| 8748 | + "Canopy" | |
| 8749 | + ] | |
| 8750 | + }, | |
| 8751 | + { | |
| 8752 | + "name": "Ivester Jackson Christie's", | |
| 8753 | + "website": "www.ivesterjackson.com", | |
| 8754 | + "states": [ | |
| 8755 | + "NC" | |
| 8756 | + ], | |
| 8757 | + "cities": [ | |
| 8758 | + "Charlotte", | |
| 8759 | + "Lake Norman" | |
| 8760 | + ], | |
| 8761 | + "estimated_agents": 130, | |
| 8762 | + "estimated_listings": 350, | |
| 8763 | + "mls_affiliations": [ | |
| 8764 | + "Canopy" | |
| 8765 | + ] | |
| 8766 | + }, | |
| 8767 | + { | |
| 8768 | + "name": "Lake Norman Realty", | |
| 8769 | + "website": "www.lakenormanrealty.com", | |
| 8770 | + "states": [ | |
| 8771 | + "NC" | |
| 8772 | + ], | |
| 8773 | + "cities": [ | |
| 8774 | + "Mooresville" | |
| 8775 | + ], | |
| 8776 | + "estimated_agents": 90, | |
| 8777 | + "estimated_listings": 260, | |
| 8778 | + "mls_affiliations": [ | |
| 8779 | + "Canopy" | |
| 8780 | + ] | |
| 8781 | + }, | |
| 8782 | + { | |
| 8783 | + "name": "The Litchfield Company", | |
| 8784 | + "website": "www.thelitchfieldcompany.com", | |
| 8785 | + "states": [ | |
| 8786 | + "SC" | |
| 8787 | + ], | |
| 8788 | + "cities": [ | |
| 8789 | + "Pawleys Island", | |
| 8790 | + "Myrtle Beach" | |
| 8791 | + ], | |
| 8792 | + "estimated_agents": 200, | |
| 8793 | + "estimated_listings": 550, | |
| 8794 | + "mls_affiliations": [ | |
| 8795 | + "CCARMLS" | |
| 8796 | + ] | |
| 8797 | + }, | |
| 8798 | + { | |
| 8799 | + "name": "Garden City Realty", | |
| 8800 | + "website": "www.gardencityrealty.com", | |
| 8801 | + "states": [ | |
| 8802 | + "SC" | |
| 8803 | + ], | |
| 8804 | + "cities": [ | |
| 8805 | + "Garden City Beach" | |
| 8806 | + ], | |
| 8807 | + "estimated_agents": 40, | |
| 8808 | + "estimated_listings": 130, | |
| 8809 | + "mls_affiliations": [ | |
| 8810 | + "CCARMLS" | |
| 8811 | + ] | |
| 8812 | + }, | |
| 8813 | + { | |
| 8814 | + "name": "Charleston Trident: AgentOwned Realty", | |
| 8815 | + "website": "www.agentownedrealty.com", | |
| 8816 | + "states": [ | |
| 8817 | + "SC" | |
| 8818 | + ], | |
| 8819 | + "cities": [ | |
| 8820 | + "Charleston" | |
| 8821 | + ], | |
| 8822 | + "estimated_agents": 400, | |
| 8823 | + "estimated_listings": 900, | |
| 8824 | + "mls_affiliations": [ | |
| 8825 | + "CharlestonMLS" | |
| 8826 | + ] | |
| 8827 | + }, | |
| 8828 | + { | |
| 8829 | + "name": "William Means Real Estate", | |
| 8830 | + "website": "www.williammeans.com", | |
| 8831 | + "states": [ | |
| 8832 | + "SC" | |
| 8833 | + ], | |
| 8834 | + "cities": [ | |
| 8835 | + "Charleston" | |
| 8836 | + ], | |
| 8837 | + "estimated_agents": 60, | |
| 8838 | + "estimated_listings": 160, | |
| 8839 | + "mls_affiliations": [ | |
| 8840 | + "CharlestonMLS" | |
| 8841 | + ] | |
| 8842 | + }, | |
| 8843 | + { | |
| 8844 | + "name": "Long & Foster Virginia Beach", | |
| 8845 | + "website": "www.longandfoster.com/va", | |
| 8846 | + "states": [ | |
| 8847 | + "VA" | |
| 8848 | + ], | |
| 8849 | + "cities": [ | |
| 8850 | + "Virginia Beach" | |
| 8851 | + ], | |
| 8852 | + "estimated_agents": 300, | |
| 8853 | + "estimated_listings": 700, | |
| 8854 | + "mls_affiliations": [ | |
| 8855 | + "REIN" | |
| 8856 | + ] | |
| 8857 | + }, | |
| 8858 | + { | |
| 8859 | + "name": "Berkshire Hathaway HomeServices Towne Realty Peninsula", | |
| 8860 | + "website": "www.bhhstownerealty.com", | |
| 8861 | + "states": [ | |
| 8862 | + "VA" | |
| 8863 | + ], | |
| 8864 | + "cities": [ | |
| 8865 | + "Newport News" | |
| 8866 | + ], | |
| 8867 | + "estimated_agents": 200, | |
| 8868 | + "estimated_listings": 480, | |
| 8869 | + "mls_affiliations": [ | |
| 8870 | + "REIN" | |
| 8871 | + ] | |
| 8872 | + }, | |
| 8873 | + { | |
| 8874 | + "name": "Nest Realty", | |
| 8875 | + "website": "www.nestrealty.com", | |
| 8876 | + "states": [ | |
| 8877 | + "VA", | |
| 8878 | + "NC", | |
| 8879 | + "TN", | |
| 8880 | + "SC", | |
| 8881 | + "GA" | |
| 8882 | + ], | |
| 8883 | + "cities": [ | |
| 8884 | + "Charlottesville", | |
| 8885 | + "Richmond" | |
| 8886 | + ], | |
| 8887 | + "estimated_agents": 400, | |
| 8888 | + "estimated_listings": 900, | |
| 8889 | + "mls_affiliations": [ | |
| 8890 | + "CAARMLS" | |
| 8891 | + ] | |
| 8892 | + }, | |
| 8893 | + { | |
| 8894 | + "name": "Frank Hardy Sotheby's International Realty", | |
| 8895 | + "website": "www.frankhardy.com", | |
| 8896 | + "states": [ | |
| 8897 | + "VA" | |
| 8898 | + ], | |
| 8899 | + "cities": [ | |
| 8900 | + "Charlottesville" | |
| 8901 | + ], | |
| 8902 | + "estimated_agents": 60, | |
| 8903 | + "estimated_listings": 170, | |
| 8904 | + "mls_affiliations": [ | |
| 8905 | + "CAARMLS" | |
| 8906 | + ] | |
| 8907 | + }, | |
| 8908 | + { | |
| 8909 | + "name": "Wiley Real Estate", | |
| 8910 | + "website": "www.wileyproperty.com", | |
| 8911 | + "states": [ | |
| 8912 | + "VA" | |
| 8913 | + ], | |
| 8914 | + "cities": [ | |
| 8915 | + "Orange", | |
| 8916 | + "Charlottesville" | |
| 8917 | + ], | |
| 8918 | + "estimated_agents": 30, | |
| 8919 | + "estimated_listings": 90, | |
| 8920 | + "mls_affiliations": [ | |
| 8921 | + "CAARMLS" | |
| 8922 | + ] | |
| 8923 | + }, | |
| 8924 | + { | |
| 8925 | + "name": "MKB Realtors", | |
| 8926 | + "website": "www.mkbrealtors.com", | |
| 8927 | + "states": [ | |
| 8928 | + "VA" | |
| 8929 | + ], | |
| 8930 | + "cities": [ | |
| 8931 | + "Roanoke" | |
| 8932 | + ], | |
| 8933 | + "estimated_agents": 130, | |
| 8934 | + "estimated_listings": 320, | |
| 8935 | + "mls_affiliations": [ | |
| 8936 | + "RVARMLS" | |
| 8937 | + ] | |
| 8938 | + }, | |
| 8939 | + { | |
| 8940 | + "name": "Long & Foster Roanoke", | |
| 8941 | + "website": "www.longandfoster.com/roanoke", | |
| 8942 | + "states": [ | |
| 8943 | + "VA" | |
| 8944 | + ], | |
| 8945 | + "cities": [ | |
| 8946 | + "Roanoke" | |
| 8947 | + ], | |
| 8948 | + "estimated_agents": 90, | |
| 8949 | + "estimated_listings": 240, | |
| 8950 | + "mls_affiliations": [ | |
| 8951 | + "RVARMLS" | |
| 8952 | + ] | |
| 8953 | + }, | |
| 8954 | + { | |
| 8955 | + "name": "8z Real Estate", | |
| 8956 | + "website": "www.8z.com", | |
| 8957 | + "states": [ | |
| 8958 | + "CO", | |
| 8959 | + "CA" | |
| 8960 | + ], | |
| 8961 | + "cities": [ | |
| 8962 | + "Boulder", | |
| 8963 | + "Denver" | |
| 8964 | + ], | |
| 8965 | + "estimated_agents": 200, | |
| 8966 | + "estimated_listings": 500, | |
| 8967 | + "mls_affiliations": [ | |
| 8968 | + "IRES", | |
| 8969 | + "REcolorado" | |
| 8970 | + ] | |
| 8971 | + }, | |
| 8972 | + { | |
| 8973 | + "name": "WK Real Estate", | |
| 8974 | + "website": "www.wkre.com", | |
| 8975 | + "states": [ | |
| 8976 | + "CO" | |
| 8977 | + ], | |
| 8978 | + "cities": [ | |
| 8979 | + "Boulder" | |
| 8980 | + ], | |
| 8981 | + "estimated_agents": 90, | |
| 8982 | + "estimated_listings": 240, | |
| 8983 | + "mls_affiliations": [ | |
| 8984 | + "IRES" | |
| 8985 | + ] | |
| 8986 | + }, | |
| 8987 | + { | |
| 8988 | + "name": "Compass Colorado", | |
| 8989 | + "website": "www.compass.com/co", | |
| 8990 | + "states": [ | |
| 8991 | + "CO" | |
| 8992 | + ], | |
| 8993 | + "cities": [ | |
| 8994 | + "Denver", | |
| 8995 | + "Boulder" | |
| 8996 | + ], | |
| 8997 | + "estimated_agents": 900, | |
| 8998 | + "estimated_listings": 1700, | |
| 8999 | + "mls_affiliations": [ | |
| 9000 | + "REcolorado" | |
| 9001 | + ] | |
| 9002 | + }, | |
| 9003 | + { | |
| 9004 | + "name": "Coldwell Banker Mason Morse", | |
| 9005 | + "website": "www.masonmorse.com", | |
| 9006 | + "states": [ | |
| 9007 | + "CO" | |
| 9008 | + ], | |
| 9009 | + "cities": [ | |
| 9010 | + "Aspen", | |
| 9011 | + "Carbondale" | |
| 9012 | + ], | |
| 9013 | + "estimated_agents": 90, | |
| 9014 | + "estimated_listings": 240, | |
| 9015 | + "mls_affiliations": [ | |
| 9016 | + "AspenGlenwoodMLS" | |
| 9017 | + ] | |
| 9018 | + }, | |
| 9019 | + { | |
| 9020 | + "name": "Telluride Properties", | |
| 9021 | + "website": "www.tellurideproperties.com", | |
| 9022 | + "states": [ | |
| 9023 | + "CO" | |
| 9024 | + ], | |
| 9025 | + "cities": [ | |
| 9026 | + "Telluride" | |
| 9027 | + ], | |
| 9028 | + "estimated_agents": 40, | |
| 9029 | + "estimated_listings": 140, | |
| 9030 | + "mls_affiliations": [ | |
| 9031 | + "TellurideMLS" | |
| 9032 | + ] | |
| 9033 | + }, | |
| 9034 | + { | |
| 9035 | + "name": "Steamboat Sotheby's International Realty", | |
| 9036 | + "website": "www.steamboatsir.com", | |
| 9037 | + "states": [ | |
| 9038 | + "CO" | |
| 9039 | + ], | |
| 9040 | + "cities": [ | |
| 9041 | + "Steamboat Springs" | |
| 9042 | + ], | |
| 9043 | + "estimated_agents": 90, | |
| 9044 | + "estimated_listings": 260, | |
| 9045 | + "mls_affiliations": [ | |
| 9046 | + "SteamboatMLS" | |
| 9047 | + ] | |
| 9048 | + }, | |
| 9049 | + { | |
| 9050 | + "name": "Breckenridge Associates Real Estate", | |
| 9051 | + "website": "www.breckenridgeassociates.com", | |
| 9052 | + "states": [ | |
| 9053 | + "CO" | |
| 9054 | + ], | |
| 9055 | + "cities": [ | |
| 9056 | + "Breckenridge" | |
| 9057 | + ], | |
| 9058 | + "estimated_agents": 40, | |
| 9059 | + "estimated_listings": 140, | |
| 9060 | + "mls_affiliations": [ | |
| 9061 | + "SummitMLS" | |
| 9062 | + ] | |
| 9063 | + }, | |
| 9064 | + { | |
| 9065 | + "name": "RE/MAX Properties of the Summit", | |
| 9066 | + "website": "www.summitproperties.com", | |
| 9067 | + "states": [ | |
| 9068 | + "CO" | |
| 9069 | + ], | |
| 9070 | + "cities": [ | |
| 9071 | + "Breckenridge" | |
| 9072 | + ], | |
| 9073 | + "estimated_agents": 40, | |
| 9074 | + "estimated_listings": 130, | |
| 9075 | + "mls_affiliations": [ | |
| 9076 | + "SummitMLS" | |
| 9077 | + ] | |
| 9078 | + }, | |
| 9079 | + { | |
| 9080 | + "name": "NAV Real Estate (fka Keller Williams Park City)", | |
| 9081 | + "website": "www.navrealestate.com", | |
| 9082 | + "states": [ | |
| 9083 | + "UT" | |
| 9084 | + ], | |
| 9085 | + "cities": [ | |
| 9086 | + "Park City" | |
| 9087 | + ], | |
| 9088 | + "estimated_agents": 130, | |
| 9089 | + "estimated_listings": 320, | |
| 9090 | + "mls_affiliations": [ | |
| 9091 | + "ParkCityMLS" | |
| 9092 | + ] | |
| 9093 | + }, | |
| 9094 | + { | |
| 9095 | + "name": "Berkshire Hathaway HomeServices Utah Properties", | |
| 9096 | + "website": "www.bhhsutah.com", | |
| 9097 | + "states": [ | |
| 9098 | + "UT" | |
| 9099 | + ], | |
| 9100 | + "cities": [ | |
| 9101 | + "Salt Lake City", | |
| 9102 | + "Park City" | |
| 9103 | + ], | |
| 9104 | + "estimated_agents": 500, | |
| 9105 | + "estimated_listings": 1100, | |
| 9106 | + "mls_affiliations": [ | |
| 9107 | + "UtahRealEstate", | |
| 9108 | + "ParkCityMLS" | |
| 9109 | + ] | |
| 9110 | + }, | |
| 9111 | + { | |
| 9112 | + "name": "Century 21 Everest", | |
| 9113 | + "website": "www.c21everest.com", | |
| 9114 | + "states": [ | |
| 9115 | + "UT", | |
| 9116 | + "CA", | |
| 9117 | + "MO" | |
| 9118 | + ], | |
| 9119 | + "cities": [ | |
| 9120 | + "Salt Lake City" | |
| 9121 | + ], | |
| 9122 | + "estimated_agents": 700, | |
| 9123 | + "estimated_listings": 1200, | |
| 9124 | + "mls_affiliations": [ | |
| 9125 | + "UtahRealEstate" | |
| 9126 | + ] | |
| 9127 | + }, | |
| 9128 | + { | |
| 9129 | + "name": "Realtypath", | |
| 9130 | + "website": "www.realtypath.com", | |
| 9131 | + "states": [ | |
| 9132 | + "UT" | |
| 9133 | + ], | |
| 9134 | + "cities": [ | |
| 9135 | + "Sandy" | |
| 9136 | + ], | |
| 9137 | + "estimated_agents": 1300, | |
| 9138 | + "estimated_listings": 1900, | |
| 9139 | + "mls_affiliations": [ | |
| 9140 | + "UtahRealEstate" | |
| 9141 | + ] | |
| 9142 | + }, | |
| 9143 | + { | |
| 9144 | + "name": "KW South Valley Keller Williams", | |
| 9145 | + "website": "www.kwsouthvalley.com", | |
| 9146 | + "states": [ | |
| 9147 | + "UT" | |
| 9148 | + ], | |
| 9149 | + "cities": [ | |
| 9150 | + "South Jordan" | |
| 9151 | + ], | |
| 9152 | + "estimated_agents": 300, | |
| 9153 | + "estimated_listings": 550, | |
| 9154 | + "mls_affiliations": [ | |
| 9155 | + "UtahRealEstate" | |
| 9156 | + ] | |
| 9157 | + }, | |
| 9158 | + { | |
| 9159 | + "name": "Red Rock Real Estate", | |
| 9160 | + "website": "www.redrockutah.com", | |
| 9161 | + "states": [ | |
| 9162 | + "UT" | |
| 9163 | + ], | |
| 9164 | + "cities": [ | |
| 9165 | + "Saint George" | |
| 9166 | + ], | |
| 9167 | + "estimated_agents": 300, | |
| 9168 | + "estimated_listings": 600, | |
| 9169 | + "mls_affiliations": [ | |
| 9170 | + "WCBRMLS" | |
| 9171 | + ] | |
| 9172 | + }, | |
| 9173 | + { | |
| 9174 | + "name": "RE/MAX Associates Utah", | |
| 9175 | + "website": "www.utahhomes.com", | |
| 9176 | + "states": [ | |
| 9177 | + "UT" | |
| 9178 | + ], | |
| 9179 | + "cities": [ | |
| 9180 | + "Salt Lake City", | |
| 9181 | + "Saint George" | |
| 9182 | + ], | |
| 9183 | + "estimated_agents": 400, | |
| 9184 | + "estimated_listings": 850, | |
| 9185 | + "mls_affiliations": [ | |
| 9186 | + "UtahRealEstate" | |
| 9187 | + ] | |
| 9188 | + }, | |
| 9189 | + { | |
| 9190 | + "name": "HomeSmart Advantage Group Las Vegas", | |
| 9191 | + "website": "www.homesmartlv.com", | |
| 9192 | + "states": [ | |
| 9193 | + "NV" | |
| 9194 | + ], | |
| 9195 | + "cities": [ | |
| 9196 | + "Las Vegas" | |
| 9197 | + ], | |
| 9198 | + "estimated_agents": 1200, | |
| 9199 | + "estimated_listings": 1800, | |
| 9200 | + "mls_affiliations": [ | |
| 9201 | + "LVR" | |
| 9202 | + ] | |
| 9203 | + }, | |
| 9204 | + { | |
| 9205 | + "name": "Coldwell Banker Premier Realty LV", | |
| 9206 | + "website": "www.lvrealestate.com", | |
| 9207 | + "states": [ | |
| 9208 | + "NV" | |
| 9209 | + ], | |
| 9210 | + "cities": [ | |
| 9211 | + "Las Vegas" | |
| 9212 | + ], | |
| 9213 | + "estimated_agents": 300, | |
| 9214 | + "estimated_listings": 650, | |
| 9215 | + "mls_affiliations": [ | |
| 9216 | + "LVR" | |
| 9217 | + ] | |
| 9218 | + }, | |
| 9219 | + { | |
| 9220 | + "name": "Realty ONE Group Nevada", | |
| 9221 | + "website": "www.realtyonegroup.com/nv", | |
| 9222 | + "states": [ | |
| 9223 | + "NV" | |
| 9224 | + ], | |
| 9225 | + "cities": [ | |
| 9226 | + "Las Vegas" | |
| 9227 | + ], | |
| 9228 | + "estimated_agents": 2600, | |
| 9229 | + "estimated_listings": 3800, | |
| 9230 | + "mls_affiliations": [ | |
| 9231 | + "LVR" | |
| 9232 | + ] | |
| 9233 | + }, | |
| 9234 | + { | |
| 9235 | + "name": "Sierra Sotheby's International Realty", | |
| 9236 | + "website": "www.sierrasothebysrealty.com", | |
| 9237 | + "states": [ | |
| 9238 | + "NV", | |
| 9239 | + "CA" | |
| 9240 | + ], | |
| 9241 | + "cities": [ | |
| 9242 | + "Incline Village", | |
| 9243 | + "Truckee" | |
| 9244 | + ], | |
| 9245 | + "estimated_agents": 200, | |
| 9246 | + "estimated_listings": 500, | |
| 9247 | + "mls_affiliations": [ | |
| 9248 | + "TahoeSierraMLS" | |
| 9249 | + ] | |
| 9250 | + }, | |
| 9251 | + { | |
| 9252 | + "name": "Windermere Real Estate Spokane", | |
| 9253 | + "website": "www.windermerespokane.com", | |
| 9254 | + "states": [ | |
| 9255 | + "WA" | |
| 9256 | + ], | |
| 9257 | + "cities": [ | |
| 9258 | + "Spokane" | |
| 9259 | + ], | |
| 9260 | + "estimated_agents": 200, | |
| 9261 | + "estimated_listings": 480, | |
| 9262 | + "mls_affiliations": [ | |
| 9263 | + "SpokaneMLS" | |
| 9264 | + ] | |
| 9265 | + }, | |
| 9266 | + { | |
| 9267 | + "name": "John L Scott Spokane", | |
| 9268 | + "website": "www.johnlscottspokane.com", | |
| 9269 | + "states": [ | |
| 9270 | + "WA" | |
| 9271 | + ], | |
| 9272 | + "cities": [ | |
| 9273 | + "Spokane" | |
| 9274 | + ], | |
| 9275 | + "estimated_agents": 130, | |
| 9276 | + "estimated_listings": 320, | |
| 9277 | + "mls_affiliations": [ | |
| 9278 | + "SpokaneMLS" | |
| 9279 | + ] | |
| 9280 | + }, | |
| 9281 | + { | |
| 9282 | + "name": "Professional Realty Services International", | |
| 9283 | + "website": "www.thepros.com", | |
| 9284 | + "states": [ | |
| 9285 | + "WA", | |
| 9286 | + "ID", | |
| 9287 | + "OR", | |
| 9288 | + "AZ", | |
| 9289 | + "CA", | |
| 9290 | + "NV" | |
| 9291 | + ], | |
| 9292 | + "cities": [ | |
| 9293 | + "Spokane" | |
| 9294 | + ], | |
| 9295 | + "estimated_agents": 900, | |
| 9296 | + "estimated_listings": 1500, | |
| 9297 | + "mls_affiliations": [ | |
| 9298 | + "SpokaneMLS" | |
| 9299 | + ] | |
| 9300 | + }, | |
| 9301 | + { | |
| 9302 | + "name": "Windermere Real Estate Tacoma", | |
| 9303 | + "website": "www.windermereprofessionalpartners.com", | |
| 9304 | + "states": [ | |
| 9305 | + "WA" | |
| 9306 | + ], | |
| 9307 | + "cities": [ | |
| 9308 | + "Tacoma" | |
| 9309 | + ], | |
| 9310 | + "estimated_agents": 300, | |
| 9311 | + "estimated_listings": 650, | |
| 9312 | + "mls_affiliations": [ | |
| 9313 | + "NWMLS" | |
| 9314 | + ] | |
| 9315 | + }, | |
| 9316 | + { | |
| 9317 | + "name": "Morrison House Sotheby's International Realty", | |
| 9318 | + "website": "www.morrisonhousesir.com", | |
| 9319 | + "states": [ | |
| 9320 | + "WA" | |
| 9321 | + ], | |
| 9322 | + "cities": [ | |
| 9323 | + "Kirkland" | |
| 9324 | + ], | |
| 9325 | + "estimated_agents": 60, | |
| 9326 | + "estimated_listings": 160, | |
| 9327 | + "mls_affiliations": [ | |
| 9328 | + "NWMLS" | |
| 9329 | + ] | |
| 9330 | + }, | |
| 9331 | + { | |
| 9332 | + "name": "Redfin Oregon", | |
| 9333 | + "website": "www.redfin.com/state/oregon", | |
| 9334 | + "states": [ | |
| 9335 | + "OR" | |
| 9336 | + ], | |
| 9337 | + "cities": [ | |
| 9338 | + "Portland" | |
| 9339 | + ], | |
| 9340 | + "estimated_agents": 60, | |
| 9341 | + "estimated_listings": 320, | |
| 9342 | + "mls_affiliations": [ | |
| 9343 | + "RMLS" | |
| 9344 | + ] | |
| 9345 | + }, | |
| 9346 | + { | |
| 9347 | + "name": "Soldera Properties", | |
| 9348 | + "website": "www.soldera.com", | |
| 9349 | + "states": [ | |
| 9350 | + "OR", | |
| 9351 | + "WA" | |
| 9352 | + ], | |
| 9353 | + "cities": [ | |
| 9354 | + "Portland" | |
| 9355 | + ], | |
| 9356 | + "estimated_agents": 90, | |
| 9357 | + "estimated_listings": 220, | |
| 9358 | + "mls_affiliations": [ | |
| 9359 | + "RMLS" | |
| 9360 | + ] | |
| 9361 | + }, | |
| 9362 | + { | |
| 9363 | + "name": "MORE Realty", | |
| 9364 | + "website": "www.morerealty.com", | |
| 9365 | + "states": [ | |
| 9366 | + "OR", | |
| 9367 | + "WA" | |
| 9368 | + ], | |
| 9369 | + "cities": [ | |
| 9370 | + "Tigard", | |
| 9371 | + "Portland" | |
| 9372 | + ], | |
| 9373 | + "estimated_agents": 1100, | |
| 9374 | + "estimated_listings": 1700, | |
| 9375 | + "mls_affiliations": [ | |
| 9376 | + "RMLS" | |
| 9377 | + ] | |
| 9378 | + }, | |
| 9379 | + { | |
| 9380 | + "name": "eXp Realty Oregon", | |
| 9381 | + "website": "www.exprealty.com/or", | |
| 9382 | + "states": [ | |
| 9383 | + "OR" | |
| 9384 | + ], | |
| 9385 | + "cities": [], | |
| 9386 | + "estimated_agents": 1300, | |
| 9387 | + "estimated_listings": 1900, | |
| 9388 | + "mls_affiliations": [ | |
| 9389 | + "RMLS" | |
| 9390 | + ] | |
| 9391 | + }, | |
| 9392 | + { | |
| 9393 | + "name": "Berkshire Hathaway HomeServices Real Estate Professionals", | |
| 9394 | + "website": "www.bhhsrep.com", | |
| 9395 | + "states": [ | |
| 9396 | + "OR" | |
| 9397 | + ], | |
| 9398 | + "cities": [ | |
| 9399 | + "Salem" | |
| 9400 | + ], | |
| 9401 | + "estimated_agents": 200, | |
| 9402 | + "estimated_listings": 480, | |
| 9403 | + "mls_affiliations": [ | |
| 9404 | + "WVMLS" | |
| 9405 | + ] | |
| 9406 | + }, | |
| 9407 | + { | |
| 9408 | + "name": "Windermere Real Estate Southern Oregon", | |
| 9409 | + "website": "www.windermeresouthernoregon.com", | |
| 9410 | + "states": [ | |
| 9411 | + "OR" | |
| 9412 | + ], | |
| 9413 | + "cities": [ | |
| 9414 | + "Medford", | |
| 9415 | + "Ashland" | |
| 9416 | + ], | |
| 9417 | + "estimated_agents": 90, | |
| 9418 | + "estimated_listings": 260, | |
| 9419 | + "mls_affiliations": [ | |
| 9420 | + "SOMLS" | |
| 9421 | + ] | |
| 9422 | + }, | |
| 9423 | + { | |
| 9424 | + "name": "Keller Williams Realty Diamond Partners", | |
| 9425 | + "website": "www.kwdiamond.com", | |
| 9426 | + "states": [ | |
| 9427 | + "KS" | |
| 9428 | + ], | |
| 9429 | + "cities": [ | |
| 9430 | + "Overland Park" | |
| 9431 | + ], | |
| 9432 | + "estimated_agents": 300, | |
| 9433 | + "estimated_listings": 600, | |
| 9434 | + "mls_affiliations": [ | |
| 9435 | + "Heartland" | |
| 9436 | + ] | |
| 9437 | + }, | |
| 9438 | + { | |
| 9439 | + "name": "Weigand Realtors (J.P. Weigand & Sons)", | |
| 9440 | + "website": "www.weigand.com", | |
| 9441 | + "states": [ | |
| 9442 | + "KS" | |
| 9443 | + ], | |
| 9444 | + "cities": [ | |
| 9445 | + "Wichita" | |
| 9446 | + ], | |
| 9447 | + "estimated_agents": 300, | |
| 9448 | + "estimated_listings": 700, | |
| 9449 | + "mls_affiliations": [ | |
| 9450 | + "SCKMLS" | |
| 9451 | + ] | |
| 9452 | + }, | |
| 9453 | + { | |
| 9454 | + "name": "Berkshire Hathaway HomeServices PenFed Realty Wichita", | |
| 9455 | + "website": "www.penfedrealty.com/wichita", | |
| 9456 | + "states": [ | |
| 9457 | + "KS" | |
| 9458 | + ], | |
| 9459 | + "cities": [ | |
| 9460 | + "Wichita" | |
| 9461 | + ], | |
| 9462 | + "estimated_agents": 130, | |
| 9463 | + "estimated_listings": 320, | |
| 9464 | + "mls_affiliations": [ | |
| 9465 | + "SCKMLS" | |
| 9466 | + ] | |
| 9467 | + }, | |
| 9468 | + { | |
| 9469 | + "name": "McCurdy Real Estate & Auction", | |
| 9470 | + "website": "www.mccurdy.com", | |
| 9471 | + "states": [ | |
| 9472 | + "KS" | |
| 9473 | + ], | |
| 9474 | + "cities": [ | |
| 9475 | + "Wichita" | |
| 9476 | + ], | |
| 9477 | + "estimated_agents": 40, | |
| 9478 | + "estimated_listings": 200, | |
| 9479 | + "mls_affiliations": [ | |
| 9480 | + "SCKMLS" | |
| 9481 | + ] | |
| 9482 | + }, | |
| 9483 | + { | |
| 9484 | + "name": "Coldwell Banker Plaza Real Estate", | |
| 9485 | + "website": "www.cbplaza.com", | |
| 9486 | + "states": [ | |
| 9487 | + "KS" | |
| 9488 | + ], | |
| 9489 | + "cities": [ | |
| 9490 | + "Wichita" | |
| 9491 | + ], | |
| 9492 | + "estimated_agents": 130, | |
| 9493 | + "estimated_listings": 320, | |
| 9494 | + "mls_affiliations": [ | |
| 9495 | + "SCKMLS" | |
| 9496 | + ] | |
| 9497 | + }, | |
| 9498 | + { | |
| 9499 | + "name": "ERA Great American Realty", | |
| 9500 | + "website": "www.eragreatamerican.com", | |
| 9501 | + "states": [ | |
| 9502 | + "KS" | |
| 9503 | + ], | |
| 9504 | + "cities": [ | |
| 9505 | + "Wichita" | |
| 9506 | + ], | |
| 9507 | + "estimated_agents": 60, | |
| 9508 | + "estimated_listings": 160, | |
| 9509 | + "mls_affiliations": [ | |
| 9510 | + "SCKMLS" | |
| 9511 | + ] | |
| 9512 | + }, | |
| 9513 | + { | |
| 9514 | + "name": "Gastineau Realty", | |
| 9515 | + "website": "www.gastineaurealty.com", | |
| 9516 | + "states": [ | |
| 9517 | + "MO" | |
| 9518 | + ], | |
| 9519 | + "cities": [ | |
| 9520 | + "Kansas City" | |
| 9521 | + ], | |
| 9522 | + "estimated_agents": 30, | |
| 9523 | + "estimated_listings": 90, | |
| 9524 | + "mls_affiliations": [ | |
| 9525 | + "Heartland" | |
| 9526 | + ] | |
| 9527 | + }, | |
| 9528 | + { | |
| 9529 | + "name": "EXP Realty Missouri", | |
| 9530 | + "website": "www.exprealty.com/mo", | |
| 9531 | + "states": [ | |
| 9532 | + "MO" | |
| 9533 | + ], | |
| 9534 | + "cities": [], | |
| 9535 | + "estimated_agents": 1300, | |
| 9536 | + "estimated_listings": 2000, | |
| 9537 | + "mls_affiliations": [ | |
| 9538 | + "MARIS", | |
| 9539 | + "Heartland" | |
| 9540 | + ] | |
| 9541 | + }, | |
| 9542 | + { | |
| 9543 | + "name": "Trelora Realty", | |
| 9544 | + "website": "www.trelora.com", | |
| 9545 | + "states": [ | |
| 9546 | + "CO", | |
| 9547 | + "WA", | |
| 9548 | + "AZ", | |
| 9549 | + "MO", | |
| 9550 | + "NC", | |
| 9551 | + "SC", | |
| 9552 | + "GA" | |
| 9553 | + ], | |
| 9554 | + "cities": [ | |
| 9555 | + "Denver" | |
| 9556 | + ], | |
| 9557 | + "estimated_agents": 100, | |
| 9558 | + "estimated_listings": 350, | |
| 9559 | + "mls_affiliations": [ | |
| 9560 | + "REcolorado" | |
| 9561 | + ] | |
| 9562 | + }, | |
| 9563 | + { | |
| 9564 | + "name": "House to Home Realty (Springfield MO)", | |
| 9565 | + "website": "www.housetohome.com", | |
| 9566 | + "states": [ | |
| 9567 | + "MO" | |
| 9568 | + ], | |
| 9569 | + "cities": [ | |
| 9570 | + "Springfield" | |
| 9571 | + ], | |
| 9572 | + "estimated_agents": 40, | |
| 9573 | + "estimated_listings": 110, | |
| 9574 | + "mls_affiliations": [ | |
| 9575 | + "SOMOMLS" | |
| 9576 | + ] | |
| 9577 | + }, | |
| 9578 | + { | |
| 9579 | + "name": "Keller Williams Greater Springfield", | |
| 9580 | + "website": "www.kwspringfieldmo.com", | |
| 9581 | + "states": [ | |
| 9582 | + "MO" | |
| 9583 | + ], | |
| 9584 | + "cities": [ | |
| 9585 | + "Springfield" | |
| 9586 | + ], | |
| 9587 | + "estimated_agents": 200, | |
| 9588 | + "estimated_listings": 420, | |
| 9589 | + "mls_affiliations": [ | |
| 9590 | + "SOMOMLS" | |
| 9591 | + ] | |
| 9592 | + }, | |
| 9593 | + { | |
| 9594 | + "name": "ReeceNichols South Central Kansas", | |
| 9595 | + "website": "www.reecenichols.com/wichita", | |
| 9596 | + "states": [ | |
| 9597 | + "KS" | |
| 9598 | + ], | |
| 9599 | + "cities": [ | |
| 9600 | + "Wichita" | |
| 9601 | + ], | |
| 9602 | + "estimated_agents": 90, | |
| 9603 | + "estimated_listings": 240, | |
| 9604 | + "mls_affiliations": [ | |
| 9605 | + "SCKMLS" | |
| 9606 | + ] | |
| 9607 | + }, | |
| 9608 | + { | |
| 9609 | + "name": "BHHS Stein & Summers", | |
| 9610 | + "website": "www.steinsummers.com", | |
| 9611 | + "states": [ | |
| 9612 | + "MO", | |
| 9613 | + "KS" | |
| 9614 | + ], | |
| 9615 | + "cities": [ | |
| 9616 | + "Saint Joseph" | |
| 9617 | + ], | |
| 9618 | + "estimated_agents": 40, | |
| 9619 | + "estimated_listings": 120, | |
| 9620 | + "mls_affiliations": [ | |
| 9621 | + "Heartland" | |
| 9622 | + ] | |
| 9623 | + }, | |
| 9624 | + { | |
| 9625 | + "name": "Coldwell Banker Select (Tulsa)", | |
| 9626 | + "website": "www.cbtulsa.com", | |
| 9627 | + "states": [ | |
| 9628 | + "OK" | |
| 9629 | + ], | |
| 9630 | + "cities": [ | |
| 9631 | + "Tulsa" | |
| 9632 | + ], | |
| 9633 | + "estimated_agents": 500, | |
| 9634 | + "estimated_listings": 1100, | |
| 9635 | + "mls_affiliations": [ | |
| 9636 | + "NORES" | |
| 9637 | + ] | |
| 9638 | + }, | |
| 9639 | + { | |
| 9640 | + "name": "Walter & Associates Realtors", | |
| 9641 | + "website": "www.walterandassociates.com", | |
| 9642 | + "states": [ | |
| 9643 | + "OK" | |
| 9644 | + ], | |
| 9645 | + "cities": [ | |
| 9646 | + "Tulsa" | |
| 9647 | + ], | |
| 9648 | + "estimated_agents": 60, | |
| 9649 | + "estimated_listings": 160, | |
| 9650 | + "mls_affiliations": [ | |
| 9651 | + "NORES" | |
| 9652 | + ] | |
| 9653 | + }, | |
| 9654 | + { | |
| 9655 | + "name": "Metro First Realty", | |
| 9656 | + "website": "www.metrofirstrealty.com", | |
| 9657 | + "states": [ | |
| 9658 | + "OK" | |
| 9659 | + ], | |
| 9660 | + "cities": [ | |
| 9661 | + "Oklahoma City" | |
| 9662 | + ], | |
| 9663 | + "estimated_agents": 500, | |
| 9664 | + "estimated_listings": 850, | |
| 9665 | + "mls_affiliations": [ | |
| 9666 | + "MLSOK" | |
| 9667 | + ] | |
| 9668 | + }, | |
| 9669 | + { | |
| 9670 | + "name": "Whittington Realty", | |
| 9671 | + "website": "www.whittingtonrealty.com", | |
| 9672 | + "states": [ | |
| 9673 | + "OK" | |
| 9674 | + ], | |
| 9675 | + "cities": [ | |
| 9676 | + "Oklahoma City" | |
| 9677 | + ], | |
| 9678 | + "estimated_agents": 40, | |
| 9679 | + "estimated_listings": 110, | |
| 9680 | + "mls_affiliations": [ | |
| 9681 | + "MLSOK" | |
| 9682 | + ] | |
| 9683 | + }, | |
| 9684 | + { | |
| 9685 | + "name": "Vermont Real Estate Company", | |
| 9686 | + "website": "www.vermontrealestatecompany.com", | |
| 9687 | + "states": [ | |
| 9688 | + "VT" | |
| 9689 | + ], | |
| 9690 | + "cities": [ | |
| 9691 | + "Burlington" | |
| 9692 | + ], | |
| 9693 | + "estimated_agents": 40, | |
| 9694 | + "estimated_listings": 120, | |
| 9695 | + "mls_affiliations": [ | |
| 9696 | + "NEREN" | |
| 9697 | + ] | |
| 9698 | + }, | |
| 9699 | + { | |
| 9700 | + "name": "Coldwell Banker Classic Properties VT", | |
| 9701 | + "website": "www.vermontproperty.net", | |
| 9702 | + "states": [ | |
| 9703 | + "VT" | |
| 9704 | + ], | |
| 9705 | + "cities": [ | |
| 9706 | + "Ludlow" | |
| 9707 | + ], | |
| 9708 | + "estimated_agents": 20, | |
| 9709 | + "estimated_listings": 70, | |
| 9710 | + "mls_affiliations": [ | |
| 9711 | + "NEREN" | |
| 9712 | + ] | |
| 9713 | + }, | |
| 9714 | + { | |
| 9715 | + "name": "Snyder Group Real Estate (KW Vermont)", | |
| 9716 | + "website": "www.kwvermont.com", | |
| 9717 | + "states": [ | |
| 9718 | + "VT" | |
| 9719 | + ], | |
| 9720 | + "cities": [ | |
| 9721 | + "South Burlington" | |
| 9722 | + ], | |
| 9723 | + "estimated_agents": 130, | |
| 9724 | + "estimated_listings": 300, | |
| 9725 | + "mls_affiliations": [ | |
| 9726 | + "NEREN" | |
| 9727 | + ] | |
| 9728 | + }, | |
| 9729 | + { | |
| 9730 | + "name": "Maine Real Estate Network (BH&G The Masiello Group)", | |
| 9731 | + "website": "www.masiello.com", | |
| 9732 | + "states": [ | |
| 9733 | + "ME", | |
| 9734 | + "NH", | |
| 9735 | + "VT" | |
| 9736 | + ], | |
| 9737 | + "cities": [ | |
| 9738 | + "Portland", | |
| 9739 | + "Keene" | |
| 9740 | + ], | |
| 9741 | + "estimated_agents": 500, | |
| 9742 | + "estimated_listings": 1200, | |
| 9743 | + "mls_affiliations": [ | |
| 9744 | + "MREIS", | |
| 9745 | + "PrimeMLS" | |
| 9746 | + ] | |
| 9747 | + }, | |
| 9748 | + { | |
| 9749 | + "name": "Duston Leddy Real Estate", | |
| 9750 | + "website": "www.dustonleddy.com", | |
| 9751 | + "states": [ | |
| 9752 | + "NH", | |
| 9753 | + "ME" | |
| 9754 | + ], | |
| 9755 | + "cities": [ | |
| 9756 | + "Portsmouth" | |
| 9757 | + ], | |
| 9758 | + "estimated_agents": 90, | |
| 9759 | + "estimated_listings": 240, | |
| 9760 | + "mls_affiliations": [ | |
| 9761 | + "PrimeMLS" | |
| 9762 | + ] | |
| 9763 | + }, | |
| 9764 | + { | |
| 9765 | + "name": "BHHS Verani Realty", | |
| 9766 | + "website": "www.verani.com", | |
| 9767 | + "states": [ | |
| 9768 | + "NH", | |
| 9769 | + "MA", | |
| 9770 | + "ME" | |
| 9771 | + ], | |
| 9772 | + "cities": [ | |
| 9773 | + "Londonderry" | |
| 9774 | + ], | |
| 9775 | + "estimated_agents": 500, | |
| 9776 | + "estimated_listings": 1100, | |
| 9777 | + "mls_affiliations": [ | |
| 9778 | + "PrimeMLS" | |
| 9779 | + ] | |
| 9780 | + }, | |
| 9781 | + { | |
| 9782 | + "name": "Roche Realty Group", | |
| 9783 | + "website": "www.rocherealty.com", | |
| 9784 | + "states": [ | |
| 9785 | + "NH" | |
| 9786 | + ], | |
| 9787 | + "cities": [ | |
| 9788 | + "Meredith", | |
| 9789 | + "Laconia" | |
| 9790 | + ], | |
| 9791 | + "estimated_agents": 40, | |
| 9792 | + "estimated_listings": 130, | |
| 9793 | + "mls_affiliations": [ | |
| 9794 | + "PrimeMLS" | |
| 9795 | + ] | |
| 9796 | + }, | |
| 9797 | + { | |
| 9798 | + "name": "Berkshire Hathaway HomeServices Preferred Real Estate (Auburn)", | |
| 9799 | + "website": "www.bhhspreferred.com", | |
| 9800 | + "states": [ | |
| 9801 | + "AL" | |
| 9802 | + ], | |
| 9803 | + "cities": [ | |
| 9804 | + "Auburn", | |
| 9805 | + "Opelika" | |
| 9806 | + ], | |
| 9807 | + "estimated_agents": 90, | |
| 9808 | + "estimated_listings": 240, | |
| 9809 | + "mls_affiliations": [ | |
| 9810 | + "LeeCountyMLS" | |
| 9811 | + ] | |
| 9812 | + }, | |
| 9813 | + { | |
| 9814 | + "name": "Hamner Real Estate", | |
| 9815 | + "website": "www.hamnerrealestate.com", | |
| 9816 | + "states": [ | |
| 9817 | + "AL" | |
| 9818 | + ], | |
| 9819 | + "cities": [ | |
| 9820 | + "Tuscaloosa" | |
| 9821 | + ], | |
| 9822 | + "estimated_agents": 40, | |
| 9823 | + "estimated_listings": 130, | |
| 9824 | + "mls_affiliations": [ | |
| 9825 | + "TuscaloosaMLS" | |
| 9826 | + ] | |
| 9827 | + }, | |
| 9828 | + { | |
| 9829 | + "name": "Keller Williams Realty Huntsville", | |
| 9830 | + "website": "www.kwhuntsville.com", | |
| 9831 | + "states": [ | |
| 9832 | + "AL" | |
| 9833 | + ], | |
| 9834 | + "cities": [ | |
| 9835 | + "Huntsville" | |
| 9836 | + ], | |
| 9837 | + "estimated_agents": 300, | |
| 9838 | + "estimated_listings": 600, | |
| 9839 | + "mls_affiliations": [ | |
| 9840 | + "ValleyMLS" | |
| 9841 | + ] | |
| 9842 | + }, | |
| 9843 | + { | |
| 9844 | + "name": "Matt Curtis Real Estate", | |
| 9845 | + "website": "www.mattcurtisrealestate.com", | |
| 9846 | + "states": [ | |
| 9847 | + "AL", | |
| 9848 | + "TN" | |
| 9849 | + ], | |
| 9850 | + "cities": [ | |
| 9851 | + "Madison", | |
| 9852 | + "Huntsville" | |
| 9853 | + ], | |
| 9854 | + "estimated_agents": 130, | |
| 9855 | + "estimated_listings": 350, | |
| 9856 | + "mls_affiliations": [ | |
| 9857 | + "ValleyMLS" | |
| 9858 | + ] | |
| 9859 | + }, | |
| 9860 | + { | |
| 9861 | + "name": "Legend Realty", | |
| 9862 | + "website": "www.legendrealty.com", | |
| 9863 | + "states": [ | |
| 9864 | + "AL" | |
| 9865 | + ], | |
| 9866 | + "cities": [ | |
| 9867 | + "Huntsville" | |
| 9868 | + ], | |
| 9869 | + "estimated_agents": 200, | |
| 9870 | + "estimated_listings": 450, | |
| 9871 | + "mls_affiliations": [ | |
| 9872 | + "ValleyMLS" | |
| 9873 | + ] | |
| 9874 | + }, | |
| 9875 | + { | |
| 9876 | + "name": "RE/MAX by the Bay (AL Gulf Coast)", | |
| 9877 | + "website": "www.remaxbythebayal.com", | |
| 9878 | + "states": [ | |
| 9879 | + "AL" | |
| 9880 | + ], | |
| 9881 | + "cities": [ | |
| 9882 | + "Fairhope", | |
| 9883 | + "Daphne" | |
| 9884 | + ], | |
| 9885 | + "estimated_agents": 60, | |
| 9886 | + "estimated_listings": 170, | |
| 9887 | + "mls_affiliations": [ | |
| 9888 | + "BaldwinMLS" | |
| 9889 | + ] | |
| 9890 | + }, | |
| 9891 | + { | |
| 9892 | + "name": "Berkshire Hathaway HomeServices Cooper & Co Realtors", | |
| 9893 | + "website": "www.bhhscooper.com", | |
| 9894 | + "states": [ | |
| 9895 | + "AL" | |
| 9896 | + ], | |
| 9897 | + "cities": [ | |
| 9898 | + "Mobile" | |
| 9899 | + ], | |
| 9900 | + "estimated_agents": 130, | |
| 9901 | + "estimated_listings": 320, | |
| 9902 | + "mls_affiliations": [ | |
| 9903 | + "MobileMLS" | |
| 9904 | + ] | |
| 9905 | + }, | |
| 9906 | + { | |
| 9907 | + "name": "Roberts Brothers Realtors", | |
| 9908 | + "website": "www.robertsbrothers.com", | |
| 9909 | + "states": [ | |
| 9910 | + "AL" | |
| 9911 | + ], | |
| 9912 | + "cities": [ | |
| 9913 | + "Mobile" | |
| 9914 | + ], | |
| 9915 | + "estimated_agents": 200, | |
| 9916 | + "estimated_listings": 480, | |
| 9917 | + "mls_affiliations": [ | |
| 9918 | + "MobileMLS" | |
| 9919 | + ] | |
| 9920 | + }, | |
| 9921 | + { | |
| 9922 | + "name": "Keller Williams Realty Jackson MS", | |
| 9923 | + "website": "www.kwjacksonms.com", | |
| 9924 | + "states": [ | |
| 9925 | + "MS" | |
| 9926 | + ], | |
| 9927 | + "cities": [ | |
| 9928 | + "Ridgeland", | |
| 9929 | + "Jackson" | |
| 9930 | + ], | |
| 9931 | + "estimated_agents": 200, | |
| 9932 | + "estimated_listings": 400, | |
| 9933 | + "mls_affiliations": [ | |
| 9934 | + "MGCMLS" | |
| 9935 | + ] | |
| 9936 | + }, | |
| 9937 | + { | |
| 9938 | + "name": "Crye-Leike Oxford MS", | |
| 9939 | + "website": "www.crye-leike.com/oxford", | |
| 9940 | + "states": [ | |
| 9941 | + "MS" | |
| 9942 | + ], | |
| 9943 | + "cities": [ | |
| 9944 | + "Oxford" | |
| 9945 | + ], | |
| 9946 | + "estimated_agents": 40, | |
| 9947 | + "estimated_listings": 120, | |
| 9948 | + "mls_affiliations": [ | |
| 9949 | + "NEMSMLS" | |
| 9950 | + ] | |
| 9951 | + }, | |
| 9952 | + { | |
| 9953 | + "name": "Tommy Morgan Realtors", | |
| 9954 | + "website": "www.tmhomes.com", | |
| 9955 | + "states": [ | |
| 9956 | + "MS" | |
| 9957 | + ], | |
| 9958 | + "cities": [ | |
| 9959 | + "Tupelo" | |
| 9960 | + ], | |
| 9961 | + "estimated_agents": 60, | |
| 9962 | + "estimated_listings": 170, | |
| 9963 | + "mls_affiliations": [ | |
| 9964 | + "NEMSMLS" | |
| 9965 | + ] | |
| 9966 | + }, | |
| 9967 | + { | |
| 9968 | + "name": "Latter & Blum Mississippi Gulf Coast", | |
| 9969 | + "website": "www.latter-blum.com/ms", | |
| 9970 | + "states": [ | |
| 9971 | + "MS" | |
| 9972 | + ], | |
| 9973 | + "cities": [ | |
| 9974 | + "Gulfport" | |
| 9975 | + ], | |
| 9976 | + "estimated_agents": 90, | |
| 9977 | + "estimated_listings": 240, | |
| 9978 | + "mls_affiliations": [ | |
| 9979 | + "MGCMLS" | |
| 9980 | + ] | |
| 9981 | + }, | |
| 9982 | + { | |
| 9983 | + "name": "Keller Williams Realty Services (Northshore LA)", | |
| 9984 | + "website": "www.kwnorthshorela.com", | |
| 9985 | + "states": [ | |
| 9986 | + "LA" | |
| 9987 | + ], | |
| 9988 | + "cities": [ | |
| 9989 | + "Mandeville", | |
| 9990 | + "Covington" | |
| 9991 | + ], | |
| 9992 | + "estimated_agents": 300, | |
| 9993 | + "estimated_listings": 600, | |
| 9994 | + "mls_affiliations": [ | |
| 9995 | + "GSREIN" | |
| 9996 | + ] | |
| 9997 | + }, | |
| 9998 | + { | |
| 9999 | + "name": "Berkshire Hathaway HomeServices United Properties (BR)", | |
| 10000 | + "website": "www.bhhsunited.com", | |
| 10001 | + "states": [ | |
| 10002 | + "LA" | |
| 10003 | + ], | |
| 10004 | + "cities": [ | |
| 10005 | + "Baton Rouge" | |
| 10006 | + ], | |
| 10007 | + "estimated_agents": 200, | |
| 10008 | + "estimated_listings": 480, | |
| 10009 | + "mls_affiliations": [ | |
| 10010 | + "GBRMLS" | |
| 10011 | + ] | |
| 10012 | + }, | |
| 10013 | + { | |
| 10014 | + "name": "Burns & Co. Inc. Realtors (Baton Rouge)", | |
| 10015 | + "website": "www.burnsandco-br.com", | |
| 10016 | + "states": [ | |
| 10017 | + "LA" | |
| 10018 | + ], | |
| 10019 | + "cities": [ | |
| 10020 | + "Baton Rouge" | |
| 10021 | + ], | |
| 10022 | + "estimated_agents": 90, | |
| 10023 | + "estimated_listings": 240, | |
| 10024 | + "mls_affiliations": [ | |
| 10025 | + "GBRMLS" | |
| 10026 | + ] | |
| 10027 | + }, | |
| 10028 | + { | |
| 10029 | + "name": "RE/MAX Professional (Lafayette LA)", | |
| 10030 | + "website": "www.remaxacadiana.com", | |
| 10031 | + "states": [ | |
| 10032 | + "LA" | |
| 10033 | + ], | |
| 10034 | + "cities": [ | |
| 10035 | + "Lafayette" | |
| 10036 | + ], | |
| 10037 | + "estimated_agents": 90, | |
| 10038 | + "estimated_listings": 240, | |
| 10039 | + "mls_affiliations": [ | |
| 10040 | + "RAAMLS" | |
| 10041 | + ] | |
| 10042 | + }, | |
| 10043 | + { | |
| 10044 | + "name": "Keaty Real Estate", | |
| 10045 | + "website": "www.keatyrealestate.com", | |
| 10046 | + "states": [ | |
| 10047 | + "LA" | |
| 10048 | + ], | |
| 10049 | + "cities": [ | |
| 10050 | + "Lafayette" | |
| 10051 | + ], | |
| 10052 | + "estimated_agents": 130, | |
| 10053 | + "estimated_listings": 320, | |
| 10054 | + "mls_affiliations": [ | |
| 10055 | + "RAAMLS" | |
| 10056 | + ] | |
| 10057 | + } | |
| 10058 | + ] | |
| 10059 | +} | |
| \ No newline at end of file | ||
added
frontend/index.html
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<!-- --------------------------------------------------------------------------- | |
| 3 | + Home-Ka — US real-estate aggregator (Groupe KA) | |
| 4 | + Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 5 | +---------------------------------------------------------------------------- --> | |
| 6 | +<html lang="en"> | |
| 7 | + <head> | |
| 8 | + <meta charset="UTF-8" /> | |
| 9 | + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> | |
| 10 | + <title>Home-Ka — Every home for sale in America</title> | |
| 11 | + <meta name="description" content="Home-Ka, a Groupe KA service, aggregates homes for sale across the United States from brokerages and listing platforms — every home for sale in America, in one place, always current." /> | |
| 12 | + <meta name="theme-color" content="#141814" /> | |
| 13 | + <meta name="mobile-web-app-capable" content="yes" /> | |
| 14 | + <meta name="apple-mobile-web-app-capable" content="yes" /> | |
| 15 | + <meta name="apple-mobile-web-app-title" content="Home-Ka" /> | |
| 16 | + <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| 17 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| 18 | + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" /> | |
| 19 | + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> | |
| 20 | + <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> | |
| 21 | + <meta property="og:title" content="Home-Ka — Every home for sale in America" /> | |
| 22 | + <meta property="og:description" content="US real-estate aggregator — every home for sale in America, aggregated in one place, always current." /> | |
| 23 | + <meta property="og:image" content="https://www.home-ka.com/og.png" /> | |
| 24 | + <meta property="og:image:width" content="1200" /> | |
| 25 | + <meta property="og:image:height" content="630" /> | |
| 26 | + <meta name="twitter:card" content="summary_large_image" /> | |
| 27 | + <meta name="twitter:image" content="https://www.home-ka.com/og.png" /> | |
| 28 | + </head> | |
| 29 | + <body> | |
| 30 | + <div id="root"></div> | |
| 31 | + <script type="module" src="/src/main.tsx"></script> | |
| 32 | + </body> | |
| 33 | +</html> | |
added
frontend/package-lock.json
+1874 −0
@@ -0,0 +1,1874 @@ | ||
| 1 | +{ | |
| 2 | + "name": "home-ka-frontend", | |
| 3 | + "version": "1.0.0", | |
| 4 | + "lockfileVersion": 3, | |
| 5 | + "requires": true, | |
| 6 | + "packages": { | |
| 7 | + "": { | |
| 8 | + "name": "home-ka-frontend", | |
| 9 | + "version": "1.0.0", | |
| 10 | + "dependencies": { | |
| 11 | + "@groupe-ka/ka-maps": "file:../vendor/ka-maps", | |
| 12 | + "mapbox-gl": "^3.28.1", | |
| 13 | + "react": "^18.3.1", | |
| 14 | + "react-dom": "^18.3.1", | |
| 15 | + "react-router-dom": "^6.26.0" | |
| 16 | + }, | |
| 17 | + "devDependencies": { | |
| 18 | + "@types/react": "^18.3.3", | |
| 19 | + "@types/react-dom": "^18.3.0", | |
| 20 | + "@vitejs/plugin-react": "^4.3.1", | |
| 21 | + "typescript": "^5.5.4", | |
| 22 | + "vite": "^5.4.0" | |
| 23 | + } | |
| 24 | + }, | |
| 25 | + "../vendor/ka-maps": { | |
| 26 | + "name": "@groupe-ka/ka-maps", | |
| 27 | + "version": "0.2.0", | |
| 28 | + "license": "UNLICENSED", | |
| 29 | + "dependencies": { | |
| 30 | + "@types/geojson": "^7946.0.16", | |
| 31 | + "mapbox-gl": "^3.28.1" | |
| 32 | + }, | |
| 33 | + "devDependencies": { | |
| 34 | + "@types/react": "^18.3.3", | |
| 35 | + "typescript": "^5.8.0", | |
| 36 | + "vitest": "^3.0.0" | |
| 37 | + }, | |
| 38 | + "peerDependencies": { | |
| 39 | + "react": ">=18" | |
| 40 | + }, | |
| 41 | + "peerDependenciesMeta": { | |
| 42 | + "react": { | |
| 43 | + "optional": true | |
| 44 | + } | |
| 45 | + } | |
| 46 | + }, | |
| 47 | + "node_modules/@babel/code-frame": { | |
| 48 | + "version": "7.29.7", | |
| 49 | + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", | |
| 50 | + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", | |
| 51 | + "dev": true, | |
| 52 | + "license": "MIT", | |
| 53 | + "dependencies": { | |
| 54 | + "@babel/helper-validator-identifier": "^7.29.7", | |
| 55 | + "js-tokens": "^4.0.0", | |
| 56 | + "picocolors": "^1.1.1" | |
| 57 | + }, | |
| 58 | + "engines": { | |
| 59 | + "node": ">=6.9.0" | |
| 60 | + } | |
| 61 | + }, | |
| 62 | + "node_modules/@babel/compat-data": { | |
| 63 | + "version": "7.29.7", | |
| 64 | + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", | |
| 65 | + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", | |
| 66 | + "dev": true, | |
| 67 | + "license": "MIT", | |
| 68 | + "engines": { | |
| 69 | + "node": ">=6.9.0" | |
| 70 | + } | |
| 71 | + }, | |
| 72 | + "node_modules/@babel/core": { | |
| 73 | + "version": "7.29.7", | |
| 74 | + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", | |
| 75 | + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", | |
| 76 | + "dev": true, | |
| 77 | + "license": "MIT", | |
| 78 | + "dependencies": { | |
| 79 | + "@babel/code-frame": "^7.29.7", | |
| 80 | + "@babel/generator": "^7.29.7", | |
| 81 | + "@babel/helper-compilation-targets": "^7.29.7", | |
| 82 | + "@babel/helper-module-transforms": "^7.29.7", | |
| 83 | + "@babel/helpers": "^7.29.7", | |
| 84 | + "@babel/parser": "^7.29.7", | |
| 85 | + "@babel/template": "^7.29.7", | |
| 86 | + "@babel/traverse": "^7.29.7", | |
| 87 | + "@babel/types": "^7.29.7", | |
| 88 | + "@jridgewell/remapping": "^2.3.5", | |
| 89 | + "convert-source-map": "^2.0.0", | |
| 90 | + "debug": "^4.1.0", | |
| 91 | + "gensync": "^1.0.0-beta.2", | |
| 92 | + "json5": "^2.2.3", | |
| 93 | + "semver": "^6.3.1" | |
| 94 | + }, | |
| 95 | + "engines": { | |
| 96 | + "node": ">=6.9.0" | |
| 97 | + }, | |
| 98 | + "funding": { | |
| 99 | + "type": "opencollective", | |
| 100 | + "url": "https://opencollective.com/babel" | |
| 101 | + } | |
| 102 | + }, | |
| 103 | + "node_modules/@babel/generator": { | |
| 104 | + "version": "7.29.8", | |
| 105 | + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", | |
| 106 | + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", | |
| 107 | + "dev": true, | |
| 108 | + "license": "MIT", | |
| 109 | + "dependencies": { | |
| 110 | + "@babel/parser": "^7.29.8", | |
| 111 | + "@babel/types": "^7.29.8", | |
| 112 | + "@jridgewell/gen-mapping": "^0.3.12", | |
| 113 | + "@jridgewell/trace-mapping": "^0.3.28", | |
| 114 | + "jsesc": "^3.0.2" | |
| 115 | + }, | |
| 116 | + "engines": { | |
| 117 | + "node": ">=6.9.0" | |
| 118 | + } | |
| 119 | + }, | |
| 120 | + "node_modules/@babel/helper-compilation-targets": { | |
| 121 | + "version": "7.29.7", | |
| 122 | + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", | |
| 123 | + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", | |
| 124 | + "dev": true, | |
| 125 | + "license": "MIT", | |
| 126 | + "dependencies": { | |
| 127 | + "@babel/compat-data": "^7.29.7", | |
| 128 | + "@babel/helper-validator-option": "^7.29.7", | |
| 129 | + "browserslist": "^4.24.0", | |
| 130 | + "lru-cache": "^5.1.1", | |
| 131 | + "semver": "^6.3.1" | |
| 132 | + }, | |
| 133 | + "engines": { | |
| 134 | + "node": ">=6.9.0" | |
| 135 | + } | |
| 136 | + }, | |
| 137 | + "node_modules/@babel/helper-globals": { | |
| 138 | + "version": "7.29.7", | |
| 139 | + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", | |
| 140 | + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", | |
| 141 | + "dev": true, | |
| 142 | + "license": "MIT", | |
| 143 | + "engines": { | |
| 144 | + "node": ">=6.9.0" | |
| 145 | + } | |
| 146 | + }, | |
| 147 | + "node_modules/@babel/helper-module-imports": { | |
| 148 | + "version": "7.29.7", | |
| 149 | + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", | |
| 150 | + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", | |
| 151 | + "dev": true, | |
| 152 | + "license": "MIT", | |
| 153 | + "dependencies": { | |
| 154 | + "@babel/traverse": "^7.29.7", | |
| 155 | + "@babel/types": "^7.29.7" | |
| 156 | + }, | |
| 157 | + "engines": { | |
| 158 | + "node": ">=6.9.0" | |
| 159 | + } | |
| 160 | + }, | |
| 161 | + "node_modules/@babel/helper-module-transforms": { | |
| 162 | + "version": "7.29.7", | |
| 163 | + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", | |
| 164 | + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", | |
| 165 | + "dev": true, | |
| 166 | + "license": "MIT", | |
| 167 | + "dependencies": { | |
| 168 | + "@babel/helper-module-imports": "^7.29.7", | |
| 169 | + "@babel/helper-validator-identifier": "^7.29.7", | |
| 170 | + "@babel/traverse": "^7.29.7" | |
| 171 | + }, | |
| 172 | + "engines": { | |
| 173 | + "node": ">=6.9.0" | |
| 174 | + }, | |
| 175 | + "peerDependencies": { | |
| 176 | + "@babel/core": "^7.0.0" | |
| 177 | + } | |
| 178 | + }, | |
| 179 | + "node_modules/@babel/helper-plugin-utils": { | |
| 180 | + "version": "7.29.7", | |
| 181 | + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", | |
| 182 | + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", | |
| 183 | + "dev": true, | |
| 184 | + "license": "MIT", | |
| 185 | + "engines": { | |
| 186 | + "node": ">=6.9.0" | |
| 187 | + } | |
| 188 | + }, | |
| 189 | + "node_modules/@babel/helper-string-parser": { | |
| 190 | + "version": "7.29.7", | |
| 191 | + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", | |
| 192 | + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", | |
| 193 | + "dev": true, | |
| 194 | + "license": "MIT", | |
| 195 | + "engines": { | |
| 196 | + "node": ">=6.9.0" | |
| 197 | + } | |
| 198 | + }, | |
| 199 | + "node_modules/@babel/helper-validator-identifier": { | |
| 200 | + "version": "7.29.7", | |
| 201 | + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", | |
| 202 | + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", | |
| 203 | + "dev": true, | |
| 204 | + "license": "MIT", | |
| 205 | + "engines": { | |
| 206 | + "node": ">=6.9.0" | |
| 207 | + } | |
| 208 | + }, | |
| 209 | + "node_modules/@babel/helper-validator-option": { | |
| 210 | + "version": "7.29.7", | |
| 211 | + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", | |
| 212 | + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", | |
| 213 | + "dev": true, | |
| 214 | + "license": "MIT", | |
| 215 | + "engines": { | |
| 216 | + "node": ">=6.9.0" | |
| 217 | + } | |
| 218 | + }, | |
| 219 | + "node_modules/@babel/helpers": { | |
| 220 | + "version": "7.29.7", | |
| 221 | + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", | |
| 222 | + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", | |
| 223 | + "dev": true, | |
| 224 | + "license": "MIT", | |
| 225 | + "dependencies": { | |
| 226 | + "@babel/template": "^7.29.7", | |
| 227 | + "@babel/types": "^7.29.7" | |
| 228 | + }, | |
| 229 | + "engines": { | |
| 230 | + "node": ">=6.9.0" | |
| 231 | + } | |
| 232 | + }, | |
| 233 | + "node_modules/@babel/parser": { | |
| 234 | + "version": "7.29.8", | |
| 235 | + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", | |
| 236 | + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", | |
| 237 | + "dev": true, | |
| 238 | + "license": "MIT", | |
| 239 | + "dependencies": { | |
| 240 | + "@babel/types": "^7.29.8" | |
| 241 | + }, | |
| 242 | + "bin": { | |
| 243 | + "parser": "bin/babel-parser.js" | |
| 244 | + }, | |
| 245 | + "engines": { | |
| 246 | + "node": ">=6.0.0" | |
| 247 | + } | |
| 248 | + }, | |
| 249 | + "node_modules/@babel/plugin-transform-react-jsx-self": { | |
| 250 | + "version": "7.29.7", | |
| 251 | + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", | |
| 252 | + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", | |
| 253 | + "dev": true, | |
| 254 | + "license": "MIT", | |
| 255 | + "dependencies": { | |
| 256 | + "@babel/helper-plugin-utils": "^7.29.7" | |
| 257 | + }, | |
| 258 | + "engines": { | |
| 259 | + "node": ">=6.9.0" | |
| 260 | + }, | |
| 261 | + "peerDependencies": { | |
| 262 | + "@babel/core": "^7.0.0-0" | |
| 263 | + } | |
| 264 | + }, | |
| 265 | + "node_modules/@babel/plugin-transform-react-jsx-source": { | |
| 266 | + "version": "7.29.7", | |
| 267 | + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", | |
| 268 | + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", | |
| 269 | + "dev": true, | |
| 270 | + "license": "MIT", | |
| 271 | + "dependencies": { | |
| 272 | + "@babel/helper-plugin-utils": "^7.29.7" | |
| 273 | + }, | |
| 274 | + "engines": { | |
| 275 | + "node": ">=6.9.0" | |
| 276 | + }, | |
| 277 | + "peerDependencies": { | |
| 278 | + "@babel/core": "^7.0.0-0" | |
| 279 | + } | |
| 280 | + }, | |
| 281 | + "node_modules/@babel/template": { | |
| 282 | + "version": "7.29.7", | |
| 283 | + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", | |
| 284 | + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", | |
| 285 | + "dev": true, | |
| 286 | + "license": "MIT", | |
| 287 | + "dependencies": { | |
| 288 | + "@babel/code-frame": "^7.29.7", | |
| 289 | + "@babel/parser": "^7.29.7", | |
| 290 | + "@babel/types": "^7.29.7" | |
| 291 | + }, | |
| 292 | + "engines": { | |
| 293 | + "node": ">=6.9.0" | |
| 294 | + } | |
| 295 | + }, | |
| 296 | + "node_modules/@babel/traverse": { | |
| 297 | + "version": "7.29.8", | |
| 298 | + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", | |
| 299 | + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", | |
| 300 | + "dev": true, | |
| 301 | + "license": "MIT", | |
| 302 | + "dependencies": { | |
| 303 | + "@babel/code-frame": "^7.29.7", | |
| 304 | + "@babel/generator": "^7.29.8", | |
| 305 | + "@babel/helper-globals": "^7.29.7", | |
| 306 | + "@babel/parser": "^7.29.8", | |
| 307 | + "@babel/template": "^7.29.7", | |
| 308 | + "@babel/types": "^7.29.8", | |
| 309 | + "debug": "^4.3.1" | |
| 310 | + }, | |
| 311 | + "engines": { | |
| 312 | + "node": ">=6.9.0" | |
| 313 | + } | |
| 314 | + }, | |
| 315 | + "node_modules/@babel/types": { | |
| 316 | + "version": "7.29.8", | |
| 317 | + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", | |
| 318 | + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", | |
| 319 | + "dev": true, | |
| 320 | + "license": "MIT", | |
| 321 | + "dependencies": { | |
| 322 | + "@babel/helper-string-parser": "^7.29.7", | |
| 323 | + "@babel/helper-validator-identifier": "^7.29.7" | |
| 324 | + }, | |
| 325 | + "engines": { | |
| 326 | + "node": ">=6.9.0" | |
| 327 | + } | |
| 328 | + }, | |
| 329 | + "node_modules/@esbuild/aix-ppc64": { | |
| 330 | + "version": "0.21.5", | |
| 331 | + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", | |
| 332 | + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", | |
| 333 | + "cpu": [ | |
| 334 | + "ppc64" | |
| 335 | + ], | |
| 336 | + "dev": true, | |
| 337 | + "license": "MIT", | |
| 338 | + "optional": true, | |
| 339 | + "os": [ | |
| 340 | + "aix" | |
| 341 | + ], | |
| 342 | + "engines": { | |
| 343 | + "node": ">=12" | |
| 344 | + } | |
| 345 | + }, | |
| 346 | + "node_modules/@esbuild/android-arm": { | |
| 347 | + "version": "0.21.5", | |
| 348 | + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", | |
| 349 | + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", | |
| 350 | + "cpu": [ | |
| 351 | + "arm" | |
| 352 | + ], | |
| 353 | + "dev": true, | |
| 354 | + "license": "MIT", | |
| 355 | + "optional": true, | |
| 356 | + "os": [ | |
| 357 | + "android" | |
| 358 | + ], | |
| 359 | + "engines": { | |
| 360 | + "node": ">=12" | |
| 361 | + } | |
| 362 | + }, | |
| 363 | + "node_modules/@esbuild/android-arm64": { | |
| 364 | + "version": "0.21.5", | |
| 365 | + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", | |
| 366 | + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", | |
| 367 | + "cpu": [ | |
| 368 | + "arm64" | |
| 369 | + ], | |
| 370 | + "dev": true, | |
| 371 | + "license": "MIT", | |
| 372 | + "optional": true, | |
| 373 | + "os": [ | |
| 374 | + "android" | |
| 375 | + ], | |
| 376 | + "engines": { | |
| 377 | + "node": ">=12" | |
| 378 | + } | |
| 379 | + }, | |
| 380 | + "node_modules/@esbuild/android-x64": { | |
| 381 | + "version": "0.21.5", | |
| 382 | + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", | |
| 383 | + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", | |
| 384 | + "cpu": [ | |
| 385 | + "x64" | |
| 386 | + ], | |
| 387 | + "dev": true, | |
| 388 | + "license": "MIT", | |
| 389 | + "optional": true, | |
| 390 | + "os": [ | |
| 391 | + "android" | |
| 392 | + ], | |
| 393 | + "engines": { | |
| 394 | + "node": ">=12" | |
| 395 | + } | |
| 396 | + }, | |
| 397 | + "node_modules/@esbuild/darwin-arm64": { | |
| 398 | + "version": "0.21.5", | |
| 399 | + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", | |
| 400 | + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", | |
| 401 | + "cpu": [ | |
| 402 | + "arm64" | |
| 403 | + ], | |
| 404 | + "dev": true, | |
| 405 | + "license": "MIT", | |
| 406 | + "optional": true, | |
| 407 | + "os": [ | |
| 408 | + "darwin" | |
| 409 | + ], | |
| 410 | + "engines": { | |
| 411 | + "node": ">=12" | |
| 412 | + } | |
| 413 | + }, | |
| 414 | + "node_modules/@esbuild/darwin-x64": { | |
| 415 | + "version": "0.21.5", | |
| 416 | + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", | |
| 417 | + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", | |
| 418 | + "cpu": [ | |
| 419 | + "x64" | |
| 420 | + ], | |
| 421 | + "dev": true, | |
| 422 | + "license": "MIT", | |
| 423 | + "optional": true, | |
| 424 | + "os": [ | |
| 425 | + "darwin" | |
| 426 | + ], | |
| 427 | + "engines": { | |
| 428 | + "node": ">=12" | |
| 429 | + } | |
| 430 | + }, | |
| 431 | + "node_modules/@esbuild/freebsd-arm64": { | |
| 432 | + "version": "0.21.5", | |
| 433 | + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", | |
| 434 | + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", | |
| 435 | + "cpu": [ | |
| 436 | + "arm64" | |
| 437 | + ], | |
| 438 | + "dev": true, | |
| 439 | + "license": "MIT", | |
| 440 | + "optional": true, | |
| 441 | + "os": [ | |
| 442 | + "freebsd" | |
| 443 | + ], | |
| 444 | + "engines": { | |
| 445 | + "node": ">=12" | |
| 446 | + } | |
| 447 | + }, | |
| 448 | + "node_modules/@esbuild/freebsd-x64": { | |
| 449 | + "version": "0.21.5", | |
| 450 | + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", | |
| 451 | + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", | |
| 452 | + "cpu": [ | |
| 453 | + "x64" | |
| 454 | + ], | |
| 455 | + "dev": true, | |
| 456 | + "license": "MIT", | |
| 457 | + "optional": true, | |
| 458 | + "os": [ | |
| 459 | + "freebsd" | |
| 460 | + ], | |
| 461 | + "engines": { | |
| 462 | + "node": ">=12" | |
| 463 | + } | |
| 464 | + }, | |
| 465 | + "node_modules/@esbuild/linux-arm": { | |
| 466 | + "version": "0.21.5", | |
| 467 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", | |
| 468 | + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", | |
| 469 | + "cpu": [ | |
| 470 | + "arm" | |
| 471 | + ], | |
| 472 | + "dev": true, | |
| 473 | + "license": "MIT", | |
| 474 | + "optional": true, | |
| 475 | + "os": [ | |
| 476 | + "linux" | |
| 477 | + ], | |
| 478 | + "engines": { | |
| 479 | + "node": ">=12" | |
| 480 | + } | |
| 481 | + }, | |
| 482 | + "node_modules/@esbuild/linux-arm64": { | |
| 483 | + "version": "0.21.5", | |
| 484 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", | |
| 485 | + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", | |
| 486 | + "cpu": [ | |
| 487 | + "arm64" | |
| 488 | + ], | |
| 489 | + "dev": true, | |
| 490 | + "license": "MIT", | |
| 491 | + "optional": true, | |
| 492 | + "os": [ | |
| 493 | + "linux" | |
| 494 | + ], | |
| 495 | + "engines": { | |
| 496 | + "node": ">=12" | |
| 497 | + } | |
| 498 | + }, | |
| 499 | + "node_modules/@esbuild/linux-ia32": { | |
| 500 | + "version": "0.21.5", | |
| 501 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", | |
| 502 | + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", | |
| 503 | + "cpu": [ | |
| 504 | + "ia32" | |
| 505 | + ], | |
| 506 | + "dev": true, | |
| 507 | + "license": "MIT", | |
| 508 | + "optional": true, | |
| 509 | + "os": [ | |
| 510 | + "linux" | |
| 511 | + ], | |
| 512 | + "engines": { | |
| 513 | + "node": ">=12" | |
| 514 | + } | |
| 515 | + }, | |
| 516 | + "node_modules/@esbuild/linux-loong64": { | |
| 517 | + "version": "0.21.5", | |
| 518 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", | |
| 519 | + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", | |
| 520 | + "cpu": [ | |
| 521 | + "loong64" | |
| 522 | + ], | |
| 523 | + "dev": true, | |
| 524 | + "license": "MIT", | |
| 525 | + "optional": true, | |
| 526 | + "os": [ | |
| 527 | + "linux" | |
| 528 | + ], | |
| 529 | + "engines": { | |
| 530 | + "node": ">=12" | |
| 531 | + } | |
| 532 | + }, | |
| 533 | + "node_modules/@esbuild/linux-mips64el": { | |
| 534 | + "version": "0.21.5", | |
| 535 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", | |
| 536 | + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", | |
| 537 | + "cpu": [ | |
| 538 | + "mips64el" | |
| 539 | + ], | |
| 540 | + "dev": true, | |
| 541 | + "license": "MIT", | |
| 542 | + "optional": true, | |
| 543 | + "os": [ | |
| 544 | + "linux" | |
| 545 | + ], | |
| 546 | + "engines": { | |
| 547 | + "node": ">=12" | |
| 548 | + } | |
| 549 | + }, | |
| 550 | + "node_modules/@esbuild/linux-ppc64": { | |
| 551 | + "version": "0.21.5", | |
| 552 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", | |
| 553 | + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", | |
| 554 | + "cpu": [ | |
| 555 | + "ppc64" | |
| 556 | + ], | |
| 557 | + "dev": true, | |
| 558 | + "license": "MIT", | |
| 559 | + "optional": true, | |
| 560 | + "os": [ | |
| 561 | + "linux" | |
| 562 | + ], | |
| 563 | + "engines": { | |
| 564 | + "node": ">=12" | |
| 565 | + } | |
| 566 | + }, | |
| 567 | + "node_modules/@esbuild/linux-riscv64": { | |
| 568 | + "version": "0.21.5", | |
| 569 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", | |
| 570 | + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", | |
| 571 | + "cpu": [ | |
| 572 | + "riscv64" | |
| 573 | + ], | |
| 574 | + "dev": true, | |
| 575 | + "license": "MIT", | |
| 576 | + "optional": true, | |
| 577 | + "os": [ | |
| 578 | + "linux" | |
| 579 | + ], | |
| 580 | + "engines": { | |
| 581 | + "node": ">=12" | |
| 582 | + } | |
| 583 | + }, | |
| 584 | + "node_modules/@esbuild/linux-s390x": { | |
| 585 | + "version": "0.21.5", | |
| 586 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", | |
| 587 | + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", | |
| 588 | + "cpu": [ | |
| 589 | + "s390x" | |
| 590 | + ], | |
| 591 | + "dev": true, | |
| 592 | + "license": "MIT", | |
| 593 | + "optional": true, | |
| 594 | + "os": [ | |
| 595 | + "linux" | |
| 596 | + ], | |
| 597 | + "engines": { | |
| 598 | + "node": ">=12" | |
| 599 | + } | |
| 600 | + }, | |
| 601 | + "node_modules/@esbuild/linux-x64": { | |
| 602 | + "version": "0.21.5", | |
| 603 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", | |
| 604 | + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", | |
| 605 | + "cpu": [ | |
| 606 | + "x64" | |
| 607 | + ], | |
| 608 | + "dev": true, | |
| 609 | + "license": "MIT", | |
| 610 | + "optional": true, | |
| 611 | + "os": [ | |
| 612 | + "linux" | |
| 613 | + ], | |
| 614 | + "engines": { | |
| 615 | + "node": ">=12" | |
| 616 | + } | |
| 617 | + }, | |
| 618 | + "node_modules/@esbuild/netbsd-x64": { | |
| 619 | + "version": "0.21.5", | |
| 620 | + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", | |
| 621 | + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", | |
| 622 | + "cpu": [ | |
| 623 | + "x64" | |
| 624 | + ], | |
| 625 | + "dev": true, | |
| 626 | + "license": "MIT", | |
| 627 | + "optional": true, | |
| 628 | + "os": [ | |
| 629 | + "netbsd" | |
| 630 | + ], | |
| 631 | + "engines": { | |
| 632 | + "node": ">=12" | |
| 633 | + } | |
| 634 | + }, | |
| 635 | + "node_modules/@esbuild/openbsd-x64": { | |
| 636 | + "version": "0.21.5", | |
| 637 | + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", | |
| 638 | + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", | |
| 639 | + "cpu": [ | |
| 640 | + "x64" | |
| 641 | + ], | |
| 642 | + "dev": true, | |
| 643 | + "license": "MIT", | |
| 644 | + "optional": true, | |
| 645 | + "os": [ | |
| 646 | + "openbsd" | |
| 647 | + ], | |
| 648 | + "engines": { | |
| 649 | + "node": ">=12" | |
| 650 | + } | |
| 651 | + }, | |
| 652 | + "node_modules/@esbuild/sunos-x64": { | |
| 653 | + "version": "0.21.5", | |
| 654 | + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", | |
| 655 | + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", | |
| 656 | + "cpu": [ | |
| 657 | + "x64" | |
| 658 | + ], | |
| 659 | + "dev": true, | |
| 660 | + "license": "MIT", | |
| 661 | + "optional": true, | |
| 662 | + "os": [ | |
| 663 | + "sunos" | |
| 664 | + ], | |
| 665 | + "engines": { | |
| 666 | + "node": ">=12" | |
| 667 | + } | |
| 668 | + }, | |
| 669 | + "node_modules/@esbuild/win32-arm64": { | |
| 670 | + "version": "0.21.5", | |
| 671 | + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", | |
| 672 | + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", | |
| 673 | + "cpu": [ | |
| 674 | + "arm64" | |
| 675 | + ], | |
| 676 | + "dev": true, | |
| 677 | + "license": "MIT", | |
| 678 | + "optional": true, | |
| 679 | + "os": [ | |
| 680 | + "win32" | |
| 681 | + ], | |
| 682 | + "engines": { | |
| 683 | + "node": ">=12" | |
| 684 | + } | |
| 685 | + }, | |
| 686 | + "node_modules/@esbuild/win32-ia32": { | |
| 687 | + "version": "0.21.5", | |
| 688 | + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", | |
| 689 | + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", | |
| 690 | + "cpu": [ | |
| 691 | + "ia32" | |
| 692 | + ], | |
| 693 | + "dev": true, | |
| 694 | + "license": "MIT", | |
| 695 | + "optional": true, | |
| 696 | + "os": [ | |
| 697 | + "win32" | |
| 698 | + ], | |
| 699 | + "engines": { | |
| 700 | + "node": ">=12" | |
| 701 | + } | |
| 702 | + }, | |
| 703 | + "node_modules/@esbuild/win32-x64": { | |
| 704 | + "version": "0.21.5", | |
| 705 | + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", | |
| 706 | + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", | |
| 707 | + "cpu": [ | |
| 708 | + "x64" | |
| 709 | + ], | |
| 710 | + "dev": true, | |
| 711 | + "license": "MIT", | |
| 712 | + "optional": true, | |
| 713 | + "os": [ | |
| 714 | + "win32" | |
| 715 | + ], | |
| 716 | + "engines": { | |
| 717 | + "node": ">=12" | |
| 718 | + } | |
| 719 | + }, | |
| 720 | + "node_modules/@groupe-ka/ka-maps": { | |
| 721 | + "resolved": "../vendor/ka-maps", | |
| 722 | + "link": true | |
| 723 | + }, | |
| 724 | + "node_modules/@jridgewell/gen-mapping": { | |
| 725 | + "version": "0.3.13", | |
| 726 | + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", | |
| 727 | + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", | |
| 728 | + "dev": true, | |
| 729 | + "license": "MIT", | |
| 730 | + "dependencies": { | |
| 731 | + "@jridgewell/sourcemap-codec": "^1.5.0", | |
| 732 | + "@jridgewell/trace-mapping": "^0.3.24" | |
| 733 | + } | |
| 734 | + }, | |
| 735 | + "node_modules/@jridgewell/remapping": { | |
| 736 | + "version": "2.3.5", | |
| 737 | + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", | |
| 738 | + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", | |
| 739 | + "dev": true, | |
| 740 | + "license": "MIT", | |
| 741 | + "dependencies": { | |
| 742 | + "@jridgewell/gen-mapping": "^0.3.5", | |
| 743 | + "@jridgewell/trace-mapping": "^0.3.24" | |
| 744 | + } | |
| 745 | + }, | |
| 746 | + "node_modules/@jridgewell/resolve-uri": { | |
| 747 | + "version": "3.1.2", | |
| 748 | + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", | |
| 749 | + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", | |
| 750 | + "dev": true, | |
| 751 | + "license": "MIT", | |
| 752 | + "engines": { | |
| 753 | + "node": ">=6.0.0" | |
| 754 | + } | |
| 755 | + }, | |
| 756 | + "node_modules/@jridgewell/sourcemap-codec": { | |
| 757 | + "version": "1.6.0", | |
| 758 | + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", | |
| 759 | + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", | |
| 760 | + "dev": true, | |
| 761 | + "license": "MIT" | |
| 762 | + }, | |
| 763 | + "node_modules/@jridgewell/trace-mapping": { | |
| 764 | + "version": "0.3.31", | |
| 765 | + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", | |
| 766 | + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", | |
| 767 | + "dev": true, | |
| 768 | + "license": "MIT", | |
| 769 | + "dependencies": { | |
| 770 | + "@jridgewell/resolve-uri": "^3.1.0", | |
| 771 | + "@jridgewell/sourcemap-codec": "^1.4.14" | |
| 772 | + } | |
| 773 | + }, | |
| 774 | + "node_modules/@napi-rs/lzma-linux-x64-gnu": { | |
| 775 | + "version": "1.5.1", | |
| 776 | + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", | |
| 777 | + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", | |
| 778 | + "cpu": [ | |
| 779 | + "x64" | |
| 780 | + ], | |
| 781 | + "dev": true, | |
| 782 | + "libc": [ | |
| 783 | + "glibc" | |
| 784 | + ], | |
| 785 | + "license": "MIT", | |
| 786 | + "optional": true, | |
| 787 | + "os": [ | |
| 788 | + "linux" | |
| 789 | + ], | |
| 790 | + "engines": { | |
| 791 | + "node": "^22.20 || ^24.12 || >=25" | |
| 792 | + } | |
| 793 | + }, | |
| 794 | + "node_modules/@remix-run/router": { | |
| 795 | + "version": "1.23.4", | |
| 796 | + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", | |
| 797 | + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", | |
| 798 | + "license": "MIT", | |
| 799 | + "engines": { | |
| 800 | + "node": ">=14.0.0" | |
| 801 | + } | |
| 802 | + }, | |
| 803 | + "node_modules/@rolldown/pluginutils": { | |
| 804 | + "version": "1.0.0-beta.27", | |
| 805 | + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", | |
| 806 | + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", | |
| 807 | + "dev": true, | |
| 808 | + "license": "MIT" | |
| 809 | + }, | |
| 810 | + "node_modules/@rollup/rollup-android-arm-eabi": { | |
| 811 | + "version": "4.63.1", | |
| 812 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", | |
| 813 | + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", | |
| 814 | + "cpu": [ | |
| 815 | + "arm" | |
| 816 | + ], | |
| 817 | + "dev": true, | |
| 818 | + "license": "MIT", | |
| 819 | + "optional": true, | |
| 820 | + "os": [ | |
| 821 | + "android" | |
| 822 | + ] | |
| 823 | + }, | |
| 824 | + "node_modules/@rollup/rollup-android-arm64": { | |
| 825 | + "version": "4.63.1", | |
| 826 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", | |
| 827 | + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", | |
| 828 | + "cpu": [ | |
| 829 | + "arm64" | |
| 830 | + ], | |
| 831 | + "dev": true, | |
| 832 | + "license": "MIT", | |
| 833 | + "optional": true, | |
| 834 | + "os": [ | |
| 835 | + "android" | |
| 836 | + ] | |
| 837 | + }, | |
| 838 | + "node_modules/@rollup/rollup-darwin-arm64": { | |
| 839 | + "version": "4.63.1", | |
| 840 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", | |
| 841 | + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", | |
| 842 | + "cpu": [ | |
| 843 | + "arm64" | |
| 844 | + ], | |
| 845 | + "dev": true, | |
| 846 | + "license": "MIT", | |
| 847 | + "optional": true, | |
| 848 | + "os": [ | |
| 849 | + "darwin" | |
| 850 | + ] | |
| 851 | + }, | |
| 852 | + "node_modules/@rollup/rollup-darwin-x64": { | |
| 853 | + "version": "4.63.1", | |
| 854 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", | |
| 855 | + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", | |
| 856 | + "cpu": [ | |
| 857 | + "x64" | |
| 858 | + ], | |
| 859 | + "dev": true, | |
| 860 | + "license": "MIT", | |
| 861 | + "optional": true, | |
| 862 | + "os": [ | |
| 863 | + "darwin" | |
| 864 | + ] | |
| 865 | + }, | |
| 866 | + "node_modules/@rollup/rollup-freebsd-arm64": { | |
| 867 | + "version": "4.63.1", | |
| 868 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", | |
| 869 | + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", | |
| 870 | + "cpu": [ | |
| 871 | + "arm64" | |
| 872 | + ], | |
| 873 | + "dev": true, | |
| 874 | + "license": "MIT", | |
| 875 | + "optional": true, | |
| 876 | + "os": [ | |
| 877 | + "freebsd" | |
| 878 | + ] | |
| 879 | + }, | |
| 880 | + "node_modules/@rollup/rollup-freebsd-x64": { | |
| 881 | + "version": "4.63.1", | |
| 882 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", | |
| 883 | + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", | |
| 884 | + "cpu": [ | |
| 885 | + "x64" | |
| 886 | + ], | |
| 887 | + "dev": true, | |
| 888 | + "license": "MIT", | |
| 889 | + "optional": true, | |
| 890 | + "os": [ | |
| 891 | + "freebsd" | |
| 892 | + ] | |
| 893 | + }, | |
| 894 | + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { | |
| 895 | + "version": "4.63.1", | |
| 896 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", | |
| 897 | + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", | |
| 898 | + "cpu": [ | |
| 899 | + "arm" | |
| 900 | + ], | |
| 901 | + "dev": true, | |
| 902 | + "libc": [ | |
| 903 | + "glibc" | |
| 904 | + ], | |
| 905 | + "license": "MIT", | |
| 906 | + "optional": true, | |
| 907 | + "os": [ | |
| 908 | + "linux" | |
| 909 | + ] | |
| 910 | + }, | |
| 911 | + "node_modules/@rollup/rollup-linux-arm-musleabihf": { | |
| 912 | + "version": "4.63.1", | |
| 913 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", | |
| 914 | + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", | |
| 915 | + "cpu": [ | |
| 916 | + "arm" | |
| 917 | + ], | |
| 918 | + "dev": true, | |
| 919 | + "libc": [ | |
| 920 | + "musl" | |
| 921 | + ], | |
| 922 | + "license": "MIT", | |
| 923 | + "optional": true, | |
| 924 | + "os": [ | |
| 925 | + "linux" | |
| 926 | + ] | |
| 927 | + }, | |
| 928 | + "node_modules/@rollup/rollup-linux-arm64-gnu": { | |
| 929 | + "version": "4.63.1", | |
| 930 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", | |
| 931 | + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", | |
| 932 | + "cpu": [ | |
| 933 | + "arm64" | |
| 934 | + ], | |
| 935 | + "dev": true, | |
| 936 | + "libc": [ | |
| 937 | + "glibc" | |
| 938 | + ], | |
| 939 | + "license": "MIT", | |
| 940 | + "optional": true, | |
| 941 | + "os": [ | |
| 942 | + "linux" | |
| 943 | + ] | |
| 944 | + }, | |
| 945 | + "node_modules/@rollup/rollup-linux-arm64-musl": { | |
| 946 | + "version": "4.63.1", | |
| 947 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", | |
| 948 | + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", | |
| 949 | + "cpu": [ | |
| 950 | + "arm64" | |
| 951 | + ], | |
| 952 | + "dev": true, | |
| 953 | + "libc": [ | |
| 954 | + "musl" | |
| 955 | + ], | |
| 956 | + "license": "MIT", | |
| 957 | + "optional": true, | |
| 958 | + "os": [ | |
| 959 | + "linux" | |
| 960 | + ] | |
| 961 | + }, | |
| 962 | + "node_modules/@rollup/rollup-linux-loong64-gnu": { | |
| 963 | + "version": "4.63.1", | |
| 964 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", | |
| 965 | + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", | |
| 966 | + "cpu": [ | |
| 967 | + "loong64" | |
| 968 | + ], | |
| 969 | + "dev": true, | |
| 970 | + "libc": [ | |
| 971 | + "glibc" | |
| 972 | + ], | |
| 973 | + "license": "MIT", | |
| 974 | + "optional": true, | |
| 975 | + "os": [ | |
| 976 | + "linux" | |
| 977 | + ] | |
| 978 | + }, | |
| 979 | + "node_modules/@rollup/rollup-linux-loong64-musl": { | |
| 980 | + "version": "4.63.1", | |
| 981 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", | |
| 982 | + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", | |
| 983 | + "cpu": [ | |
| 984 | + "loong64" | |
| 985 | + ], | |
| 986 | + "dev": true, | |
| 987 | + "libc": [ | |
| 988 | + "musl" | |
| 989 | + ], | |
| 990 | + "license": "MIT", | |
| 991 | + "optional": true, | |
| 992 | + "os": [ | |
| 993 | + "linux" | |
| 994 | + ] | |
| 995 | + }, | |
| 996 | + "node_modules/@rollup/rollup-linux-ppc64-gnu": { | |
| 997 | + "version": "4.63.1", | |
| 998 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", | |
| 999 | + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", | |
| 1000 | + "cpu": [ | |
| 1001 | + "ppc64" | |
| 1002 | + ], | |
| 1003 | + "dev": true, | |
| 1004 | + "libc": [ | |
| 1005 | + "glibc" | |
| 1006 | + ], | |
| 1007 | + "license": "MIT", | |
| 1008 | + "optional": true, | |
| 1009 | + "os": [ | |
| 1010 | + "linux" | |
| 1011 | + ] | |
| 1012 | + }, | |
| 1013 | + "node_modules/@rollup/rollup-linux-ppc64-musl": { | |
| 1014 | + "version": "4.63.1", | |
| 1015 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", | |
| 1016 | + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", | |
| 1017 | + "cpu": [ | |
| 1018 | + "ppc64" | |
| 1019 | + ], | |
| 1020 | + "dev": true, | |
| 1021 | + "libc": [ | |
| 1022 | + "musl" | |
| 1023 | + ], | |
| 1024 | + "license": "MIT", | |
| 1025 | + "optional": true, | |
| 1026 | + "os": [ | |
| 1027 | + "linux" | |
| 1028 | + ] | |
| 1029 | + }, | |
| 1030 | + "node_modules/@rollup/rollup-linux-riscv64-gnu": { | |
| 1031 | + "version": "4.63.1", | |
| 1032 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", | |
| 1033 | + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", | |
| 1034 | + "cpu": [ | |
| 1035 | + "riscv64" | |
| 1036 | + ], | |
| 1037 | + "dev": true, | |
| 1038 | + "libc": [ | |
| 1039 | + "glibc" | |
| 1040 | + ], | |
| 1041 | + "license": "MIT", | |
| 1042 | + "optional": true, | |
| 1043 | + "os": [ | |
| 1044 | + "linux" | |
| 1045 | + ] | |
| 1046 | + }, | |
| 1047 | + "node_modules/@rollup/rollup-linux-riscv64-musl": { | |
| 1048 | + "version": "4.63.1", | |
| 1049 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", | |
| 1050 | + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", | |
| 1051 | + "cpu": [ | |
| 1052 | + "riscv64" | |
| 1053 | + ], | |
| 1054 | + "dev": true, | |
| 1055 | + "libc": [ | |
| 1056 | + "musl" | |
| 1057 | + ], | |
| 1058 | + "license": "MIT", | |
| 1059 | + "optional": true, | |
| 1060 | + "os": [ | |
| 1061 | + "linux" | |
| 1062 | + ] | |
| 1063 | + }, | |
| 1064 | + "node_modules/@rollup/rollup-linux-s390x-gnu": { | |
| 1065 | + "version": "4.63.1", | |
| 1066 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", | |
| 1067 | + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", | |
| 1068 | + "cpu": [ | |
| 1069 | + "s390x" | |
| 1070 | + ], | |
| 1071 | + "dev": true, | |
| 1072 | + "libc": [ | |
| 1073 | + "glibc" | |
| 1074 | + ], | |
| 1075 | + "license": "MIT", | |
| 1076 | + "optional": true, | |
| 1077 | + "os": [ | |
| 1078 | + "linux" | |
| 1079 | + ] | |
| 1080 | + }, | |
| 1081 | + "node_modules/@rollup/rollup-linux-x64-gnu": { | |
| 1082 | + "version": "4.63.1", | |
| 1083 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", | |
| 1084 | + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", | |
| 1085 | + "cpu": [ | |
| 1086 | + "x64" | |
| 1087 | + ], | |
| 1088 | + "dev": true, | |
| 1089 | + "libc": [ | |
| 1090 | + "glibc" | |
| 1091 | + ], | |
| 1092 | + "license": "MIT", | |
| 1093 | + "optional": true, | |
| 1094 | + "os": [ | |
| 1095 | + "linux" | |
| 1096 | + ] | |
| 1097 | + }, | |
| 1098 | + "node_modules/@rollup/rollup-linux-x64-musl": { | |
| 1099 | + "version": "4.63.1", | |
| 1100 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", | |
| 1101 | + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", | |
| 1102 | + "cpu": [ | |
| 1103 | + "x64" | |
| 1104 | + ], | |
| 1105 | + "dev": true, | |
| 1106 | + "libc": [ | |
| 1107 | + "musl" | |
| 1108 | + ], | |
| 1109 | + "license": "MIT", | |
| 1110 | + "optional": true, | |
| 1111 | + "os": [ | |
| 1112 | + "linux" | |
| 1113 | + ] | |
| 1114 | + }, | |
| 1115 | + "node_modules/@rollup/rollup-openbsd-x64": { | |
| 1116 | + "version": "4.63.1", | |
| 1117 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", | |
| 1118 | + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", | |
| 1119 | + "cpu": [ | |
| 1120 | + "x64" | |
| 1121 | + ], | |
| 1122 | + "dev": true, | |
| 1123 | + "license": "MIT", | |
| 1124 | + "optional": true, | |
| 1125 | + "os": [ | |
| 1126 | + "openbsd" | |
| 1127 | + ] | |
| 1128 | + }, | |
| 1129 | + "node_modules/@rollup/rollup-openharmony-arm64": { | |
| 1130 | + "version": "4.63.1", | |
| 1131 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", | |
| 1132 | + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", | |
| 1133 | + "cpu": [ | |
| 1134 | + "arm64" | |
| 1135 | + ], | |
| 1136 | + "dev": true, | |
| 1137 | + "license": "MIT", | |
| 1138 | + "optional": true, | |
| 1139 | + "os": [ | |
| 1140 | + "openharmony" | |
| 1141 | + ] | |
| 1142 | + }, | |
| 1143 | + "node_modules/@rollup/rollup-win32-arm64-msvc": { | |
| 1144 | + "version": "4.63.1", | |
| 1145 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", | |
| 1146 | + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", | |
| 1147 | + "cpu": [ | |
| 1148 | + "arm64" | |
| 1149 | + ], | |
| 1150 | + "dev": true, | |
| 1151 | + "license": "MIT", | |
| 1152 | + "optional": true, | |
| 1153 | + "os": [ | |
| 1154 | + "win32" | |
| 1155 | + ] | |
| 1156 | + }, | |
| 1157 | + "node_modules/@rollup/rollup-win32-ia32-msvc": { | |
| 1158 | + "version": "4.63.1", | |
| 1159 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", | |
| 1160 | + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", | |
| 1161 | + "cpu": [ | |
| 1162 | + "ia32" | |
| 1163 | + ], | |
| 1164 | + "dev": true, | |
| 1165 | + "license": "MIT", | |
| 1166 | + "optional": true, | |
| 1167 | + "os": [ | |
| 1168 | + "win32" | |
| 1169 | + ] | |
| 1170 | + }, | |
| 1171 | + "node_modules/@rollup/rollup-win32-x64-gnu": { | |
| 1172 | + "version": "4.63.1", | |
| 1173 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", | |
| 1174 | + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", | |
| 1175 | + "cpu": [ | |
| 1176 | + "x64" | |
| 1177 | + ], | |
| 1178 | + "dev": true, | |
| 1179 | + "license": "MIT", | |
| 1180 | + "optional": true, | |
| 1181 | + "os": [ | |
| 1182 | + "win32" | |
| 1183 | + ] | |
| 1184 | + }, | |
| 1185 | + "node_modules/@rollup/rollup-win32-x64-msvc": { | |
| 1186 | + "version": "4.63.1", | |
| 1187 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", | |
| 1188 | + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", | |
| 1189 | + "cpu": [ | |
| 1190 | + "x64" | |
| 1191 | + ], | |
| 1192 | + "dev": true, | |
| 1193 | + "license": "MIT", | |
| 1194 | + "optional": true, | |
| 1195 | + "os": [ | |
| 1196 | + "win32" | |
| 1197 | + ] | |
| 1198 | + }, | |
| 1199 | + "node_modules/@types/babel__core": { | |
| 1200 | + "version": "7.20.5", | |
| 1201 | + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", | |
| 1202 | + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", | |
| 1203 | + "dev": true, | |
| 1204 | + "license": "MIT", | |
| 1205 | + "dependencies": { | |
| 1206 | + "@babel/parser": "^7.20.7", | |
| 1207 | + "@babel/types": "^7.20.7", | |
| 1208 | + "@types/babel__generator": "*", | |
| 1209 | + "@types/babel__template": "*", | |
| 1210 | + "@types/babel__traverse": "*" | |
| 1211 | + } | |
| 1212 | + }, | |
| 1213 | + "node_modules/@types/babel__generator": { | |
| 1214 | + "version": "7.27.0", | |
| 1215 | + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", | |
| 1216 | + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", | |
| 1217 | + "dev": true, | |
| 1218 | + "license": "MIT", | |
| 1219 | + "dependencies": { | |
| 1220 | + "@babel/types": "^7.0.0" | |
| 1221 | + } | |
| 1222 | + }, | |
| 1223 | + "node_modules/@types/babel__template": { | |
| 1224 | + "version": "7.4.4", | |
| 1225 | + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", | |
| 1226 | + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", | |
| 1227 | + "dev": true, | |
| 1228 | + "license": "MIT", | |
| 1229 | + "dependencies": { | |
| 1230 | + "@babel/parser": "^7.1.0", | |
| 1231 | + "@babel/types": "^7.0.0" | |
| 1232 | + } | |
| 1233 | + }, | |
| 1234 | + "node_modules/@types/babel__traverse": { | |
| 1235 | + "version": "7.28.0", | |
| 1236 | + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", | |
| 1237 | + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", | |
| 1238 | + "dev": true, | |
| 1239 | + "license": "MIT", | |
| 1240 | + "dependencies": { | |
| 1241 | + "@babel/types": "^7.28.2" | |
| 1242 | + } | |
| 1243 | + }, | |
| 1244 | + "node_modules/@types/estree": { | |
| 1245 | + "version": "1.0.9", | |
| 1246 | + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", | |
| 1247 | + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", | |
| 1248 | + "dev": true, | |
| 1249 | + "license": "MIT" | |
| 1250 | + }, | |
| 1251 | + "node_modules/@types/prop-types": { | |
| 1252 | + "version": "15.7.15", | |
| 1253 | + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", | |
| 1254 | + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", | |
| 1255 | + "dev": true, | |
| 1256 | + "license": "MIT" | |
| 1257 | + }, | |
| 1258 | + "node_modules/@types/react": { | |
| 1259 | + "version": "18.3.31", | |
| 1260 | + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", | |
| 1261 | + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", | |
| 1262 | + "dev": true, | |
| 1263 | + "license": "MIT", | |
| 1264 | + "dependencies": { | |
| 1265 | + "@types/prop-types": "*", | |
| 1266 | + "csstype": "^3.2.2" | |
| 1267 | + } | |
| 1268 | + }, | |
| 1269 | + "node_modules/@types/react-dom": { | |
| 1270 | + "version": "18.3.7", | |
| 1271 | + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", | |
| 1272 | + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", | |
| 1273 | + "dev": true, | |
| 1274 | + "license": "MIT", | |
| 1275 | + "peerDependencies": { | |
| 1276 | + "@types/react": "^18.0.0" | |
| 1277 | + } | |
| 1278 | + }, | |
| 1279 | + "node_modules/@vitejs/plugin-react": { | |
| 1280 | + "version": "4.7.0", | |
| 1281 | + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", | |
| 1282 | + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", | |
| 1283 | + "dev": true, | |
| 1284 | + "license": "MIT", | |
| 1285 | + "dependencies": { | |
| 1286 | + "@babel/core": "^7.28.0", | |
| 1287 | + "@babel/plugin-transform-react-jsx-self": "^7.27.1", | |
| 1288 | + "@babel/plugin-transform-react-jsx-source": "^7.27.1", | |
| 1289 | + "@rolldown/pluginutils": "1.0.0-beta.27", | |
| 1290 | + "@types/babel__core": "^7.20.5", | |
| 1291 | + "react-refresh": "^0.17.0" | |
| 1292 | + }, | |
| 1293 | + "engines": { | |
| 1294 | + "node": "^14.18.0 || >=16.0.0" | |
| 1295 | + }, | |
| 1296 | + "peerDependencies": { | |
| 1297 | + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" | |
| 1298 | + } | |
| 1299 | + }, | |
| 1300 | + "node_modules/baseline-browser-mapping": { | |
| 1301 | + "version": "2.11.20", | |
| 1302 | + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", | |
| 1303 | + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", | |
| 1304 | + "dev": true, | |
| 1305 | + "license": "Apache-2.0", | |
| 1306 | + "bin": { | |
| 1307 | + "baseline-browser-mapping": "dist/cli.cjs" | |
| 1308 | + }, | |
| 1309 | + "engines": { | |
| 1310 | + "node": ">=6.0.0" | |
| 1311 | + } | |
| 1312 | + }, | |
| 1313 | + "node_modules/browserslist": { | |
| 1314 | + "version": "4.28.8", | |
| 1315 | + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", | |
| 1316 | + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", | |
| 1317 | + "dev": true, | |
| 1318 | + "funding": [ | |
| 1319 | + { | |
| 1320 | + "type": "opencollective", | |
| 1321 | + "url": "https://opencollective.com/browserslist" | |
| 1322 | + }, | |
| 1323 | + { | |
| 1324 | + "type": "tidelift", | |
| 1325 | + "url": "https://tidelift.com/funding/github/npm/browserslist" | |
| 1326 | + }, | |
| 1327 | + { | |
| 1328 | + "type": "github", | |
| 1329 | + "url": "https://github.com/sponsors/ai" | |
| 1330 | + } | |
| 1331 | + ], | |
| 1332 | + "license": "MIT", | |
| 1333 | + "dependencies": { | |
| 1334 | + "baseline-browser-mapping": "^2.11.12", | |
| 1335 | + "caniuse-lite": "^1.0.30001809", | |
| 1336 | + "electron-to-chromium": "^1.5.402", | |
| 1337 | + "node-releases": "^2.0.53", | |
| 1338 | + "update-browserslist-db": "^1.3.0" | |
| 1339 | + }, | |
| 1340 | + "bin": { | |
| 1341 | + "browserslist": "cli.js" | |
| 1342 | + }, | |
| 1343 | + "engines": { | |
| 1344 | + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" | |
| 1345 | + } | |
| 1346 | + }, | |
| 1347 | + "node_modules/caniuse-lite": { | |
| 1348 | + "version": "1.0.30001810", | |
| 1349 | + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", | |
| 1350 | + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", | |
| 1351 | + "dev": true, | |
| 1352 | + "funding": [ | |
| 1353 | + { | |
| 1354 | + "type": "opencollective", | |
| 1355 | + "url": "https://opencollective.com/browserslist" | |
| 1356 | + }, | |
| 1357 | + { | |
| 1358 | + "type": "tidelift", | |
| 1359 | + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" | |
| 1360 | + }, | |
| 1361 | + { | |
| 1362 | + "type": "github", | |
| 1363 | + "url": "https://github.com/sponsors/ai" | |
| 1364 | + } | |
| 1365 | + ], | |
| 1366 | + "license": "CC-BY-4.0" | |
| 1367 | + }, | |
| 1368 | + "node_modules/convert-source-map": { | |
| 1369 | + "version": "2.0.0", | |
| 1370 | + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", | |
| 1371 | + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", | |
| 1372 | + "dev": true, | |
| 1373 | + "license": "MIT" | |
| 1374 | + }, | |
| 1375 | + "node_modules/csstype": { | |
| 1376 | + "version": "3.2.3", | |
| 1377 | + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", | |
| 1378 | + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", | |
| 1379 | + "dev": true, | |
| 1380 | + "license": "MIT" | |
| 1381 | + }, | |
| 1382 | + "node_modules/debug": { | |
| 1383 | + "version": "4.4.3", | |
| 1384 | + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", | |
| 1385 | + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", | |
| 1386 | + "dev": true, | |
| 1387 | + "license": "MIT", | |
| 1388 | + "dependencies": { | |
| 1389 | + "ms": "^2.1.3" | |
| 1390 | + }, | |
| 1391 | + "engines": { | |
| 1392 | + "node": ">=6.0" | |
| 1393 | + }, | |
| 1394 | + "peerDependenciesMeta": { | |
| 1395 | + "supports-color": { | |
| 1396 | + "optional": true | |
| 1397 | + } | |
| 1398 | + } | |
| 1399 | + }, | |
| 1400 | + "node_modules/electron-to-chromium": { | |
| 1401 | + "version": "1.5.416", | |
| 1402 | + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz", | |
| 1403 | + "integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==", | |
| 1404 | + "dev": true, | |
| 1405 | + "license": "ISC" | |
| 1406 | + }, | |
| 1407 | + "node_modules/esbuild": { | |
| 1408 | + "version": "0.21.5", | |
| 1409 | + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", | |
| 1410 | + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", | |
| 1411 | + "dev": true, | |
| 1412 | + "hasInstallScript": true, | |
| 1413 | + "license": "MIT", | |
| 1414 | + "bin": { | |
| 1415 | + "esbuild": "bin/esbuild" | |
| 1416 | + }, | |
| 1417 | + "engines": { | |
| 1418 | + "node": ">=12" | |
| 1419 | + }, | |
| 1420 | + "optionalDependencies": { | |
| 1421 | + "@esbuild/aix-ppc64": "0.21.5", | |
| 1422 | + "@esbuild/android-arm": "0.21.5", | |
| 1423 | + "@esbuild/android-arm64": "0.21.5", | |
| 1424 | + "@esbuild/android-x64": "0.21.5", | |
| 1425 | + "@esbuild/darwin-arm64": "0.21.5", | |
| 1426 | + "@esbuild/darwin-x64": "0.21.5", | |
| 1427 | + "@esbuild/freebsd-arm64": "0.21.5", | |
| 1428 | + "@esbuild/freebsd-x64": "0.21.5", | |
| 1429 | + "@esbuild/linux-arm": "0.21.5", | |
| 1430 | + "@esbuild/linux-arm64": "0.21.5", | |
| 1431 | + "@esbuild/linux-ia32": "0.21.5", | |
| 1432 | + "@esbuild/linux-loong64": "0.21.5", | |
| 1433 | + "@esbuild/linux-mips64el": "0.21.5", | |
| 1434 | + "@esbuild/linux-ppc64": "0.21.5", | |
| 1435 | + "@esbuild/linux-riscv64": "0.21.5", | |
| 1436 | + "@esbuild/linux-s390x": "0.21.5", | |
| 1437 | + "@esbuild/linux-x64": "0.21.5", | |
| 1438 | + "@esbuild/netbsd-x64": "0.21.5", | |
| 1439 | + "@esbuild/openbsd-x64": "0.21.5", | |
| 1440 | + "@esbuild/sunos-x64": "0.21.5", | |
| 1441 | + "@esbuild/win32-arm64": "0.21.5", | |
| 1442 | + "@esbuild/win32-ia32": "0.21.5", | |
| 1443 | + "@esbuild/win32-x64": "0.21.5" | |
| 1444 | + } | |
| 1445 | + }, | |
| 1446 | + "node_modules/escalade": { | |
| 1447 | + "version": "3.2.0", | |
| 1448 | + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", | |
| 1449 | + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", | |
| 1450 | + "dev": true, | |
| 1451 | + "license": "MIT", | |
| 1452 | + "engines": { | |
| 1453 | + "node": ">=6" | |
| 1454 | + } | |
| 1455 | + }, | |
| 1456 | + "node_modules/fsevents": { | |
| 1457 | + "version": "2.3.3", | |
| 1458 | + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", | |
| 1459 | + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", | |
| 1460 | + "dev": true, | |
| 1461 | + "hasInstallScript": true, | |
| 1462 | + "license": "MIT", | |
| 1463 | + "optional": true, | |
| 1464 | + "os": [ | |
| 1465 | + "darwin" | |
| 1466 | + ], | |
| 1467 | + "engines": { | |
| 1468 | + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" | |
| 1469 | + } | |
| 1470 | + }, | |
| 1471 | + "node_modules/gensync": { | |
| 1472 | + "version": "1.0.0-beta.2", | |
| 1473 | + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", | |
| 1474 | + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", | |
| 1475 | + "dev": true, | |
| 1476 | + "license": "MIT", | |
| 1477 | + "engines": { | |
| 1478 | + "node": ">=6.9.0" | |
| 1479 | + } | |
| 1480 | + }, | |
| 1481 | + "node_modules/js-tokens": { | |
| 1482 | + "version": "4.0.0", | |
| 1483 | + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", | |
| 1484 | + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", | |
| 1485 | + "license": "MIT" | |
| 1486 | + }, | |
| 1487 | + "node_modules/jsesc": { | |
| 1488 | + "version": "3.1.0", | |
| 1489 | + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", | |
| 1490 | + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", | |
| 1491 | + "dev": true, | |
| 1492 | + "license": "MIT", | |
| 1493 | + "bin": { | |
| 1494 | + "jsesc": "bin/jsesc" | |
| 1495 | + }, | |
| 1496 | + "engines": { | |
| 1497 | + "node": ">=6" | |
| 1498 | + } | |
| 1499 | + }, | |
| 1500 | + "node_modules/json5": { | |
| 1501 | + "version": "2.2.3", | |
| 1502 | + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", | |
| 1503 | + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", | |
| 1504 | + "dev": true, | |
| 1505 | + "license": "MIT", | |
| 1506 | + "bin": { | |
| 1507 | + "json5": "lib/cli.js" | |
| 1508 | + }, | |
| 1509 | + "engines": { | |
| 1510 | + "node": ">=6" | |
| 1511 | + } | |
| 1512 | + }, | |
| 1513 | + "node_modules/loose-envify": { | |
| 1514 | + "version": "1.4.0", | |
| 1515 | + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", | |
| 1516 | + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", | |
| 1517 | + "license": "MIT", | |
| 1518 | + "dependencies": { | |
| 1519 | + "js-tokens": "^3.0.0 || ^4.0.0" | |
| 1520 | + }, | |
| 1521 | + "bin": { | |
| 1522 | + "loose-envify": "cli.js" | |
| 1523 | + } | |
| 1524 | + }, | |
| 1525 | + "node_modules/lru-cache": { | |
| 1526 | + "version": "5.1.1", | |
| 1527 | + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", | |
| 1528 | + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", | |
| 1529 | + "dev": true, | |
| 1530 | + "license": "ISC", | |
| 1531 | + "dependencies": { | |
| 1532 | + "yallist": "^3.0.2" | |
| 1533 | + } | |
| 1534 | + }, | |
| 1535 | + "node_modules/mapbox-gl": { | |
| 1536 | + "version": "3.29.0", | |
| 1537 | + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.29.0.tgz", | |
| 1538 | + "integrity": "sha512-Fnh1WLsZMfihwRZY5scp456iQuZo9G97tTpb26bf/Ejsi/L7O+4dE9+I03VoOor2ul3DEOp6F2P3273omyVsNw==", | |
| 1539 | + "license": "SEE LICENSE IN LICENSE.txt", | |
| 1540 | + "workspaces": [ | |
| 1541 | + "src/style-spec", | |
| 1542 | + "plugins/mapbox-gl-pmtiles-provider", | |
| 1543 | + "test/bundlers/*", | |
| 1544 | + "test/build/typings" | |
| 1545 | + ] | |
| 1546 | + }, | |
| 1547 | + "node_modules/ms": { | |
| 1548 | + "version": "2.1.3", | |
| 1549 | + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", | |
| 1550 | + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", | |
| 1551 | + "dev": true, | |
| 1552 | + "license": "MIT" | |
| 1553 | + }, | |
| 1554 | + "node_modules/nanoid": { | |
| 1555 | + "version": "3.3.18", | |
| 1556 | + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", | |
| 1557 | + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", | |
| 1558 | + "dev": true, | |
| 1559 | + "funding": [ | |
| 1560 | + { | |
| 1561 | + "type": "github", | |
| 1562 | + "url": "https://github.com/sponsors/ai" | |
| 1563 | + } | |
| 1564 | + ], | |
| 1565 | + "license": "MIT", | |
| 1566 | + "bin": { | |
| 1567 | + "nanoid": "bin/nanoid.cjs" | |
| 1568 | + }, | |
| 1569 | + "engines": { | |
| 1570 | + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" | |
| 1571 | + } | |
| 1572 | + }, | |
| 1573 | + "node_modules/node-releases": { | |
| 1574 | + "version": "2.0.54", | |
| 1575 | + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", | |
| 1576 | + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", | |
| 1577 | + "dev": true, | |
| 1578 | + "license": "MIT", | |
| 1579 | + "engines": { | |
| 1580 | + "node": ">=18" | |
| 1581 | + } | |
| 1582 | + }, | |
| 1583 | + "node_modules/picocolors": { | |
| 1584 | + "version": "1.1.1", | |
| 1585 | + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | |
| 1586 | + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", | |
| 1587 | + "dev": true, | |
| 1588 | + "license": "ISC" | |
| 1589 | + }, | |
| 1590 | + "node_modules/postcss": { | |
| 1591 | + "version": "8.5.26", | |
| 1592 | + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", | |
| 1593 | + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", | |
| 1594 | + "dev": true, | |
| 1595 | + "funding": [ | |
| 1596 | + { | |
| 1597 | + "type": "opencollective", | |
| 1598 | + "url": "https://opencollective.com/postcss/" | |
| 1599 | + }, | |
| 1600 | + { | |
| 1601 | + "type": "tidelift", | |
| 1602 | + "url": "https://tidelift.com/funding/github/npm/postcss" | |
| 1603 | + }, | |
| 1604 | + { | |
| 1605 | + "type": "github", | |
| 1606 | + "url": "https://github.com/sponsors/ai" | |
| 1607 | + } | |
| 1608 | + ], | |
| 1609 | + "license": "MIT", | |
| 1610 | + "dependencies": { | |
| 1611 | + "nanoid": "^3.3.17", | |
| 1612 | + "picocolors": "^1.1.1", | |
| 1613 | + "source-map-js": "^1.2.1" | |
| 1614 | + }, | |
| 1615 | + "engines": { | |
| 1616 | + "node": "^10 || ^12 || >=14" | |
| 1617 | + } | |
| 1618 | + }, | |
| 1619 | + "node_modules/react": { | |
| 1620 | + "version": "18.3.1", | |
| 1621 | + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", | |
| 1622 | + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", | |
| 1623 | + "license": "MIT", | |
| 1624 | + "dependencies": { | |
| 1625 | + "loose-envify": "^1.1.0" | |
| 1626 | + }, | |
| 1627 | + "engines": { | |
| 1628 | + "node": ">=0.10.0" | |
| 1629 | + } | |
| 1630 | + }, | |
| 1631 | + "node_modules/react-dom": { | |
| 1632 | + "version": "18.3.1", | |
| 1633 | + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", | |
| 1634 | + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", | |
| 1635 | + "license": "MIT", | |
| 1636 | + "dependencies": { | |
| 1637 | + "loose-envify": "^1.1.0", | |
| 1638 | + "scheduler": "^0.23.2" | |
| 1639 | + }, | |
| 1640 | + "peerDependencies": { | |
| 1641 | + "react": "^18.3.1" | |
| 1642 | + } | |
| 1643 | + }, | |
| 1644 | + "node_modules/react-refresh": { | |
| 1645 | + "version": "0.17.0", | |
| 1646 | + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", | |
| 1647 | + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", | |
| 1648 | + "dev": true, | |
| 1649 | + "license": "MIT", | |
| 1650 | + "engines": { | |
| 1651 | + "node": ">=0.10.0" | |
| 1652 | + } | |
| 1653 | + }, | |
| 1654 | + "node_modules/react-router": { | |
| 1655 | + "version": "6.30.6", | |
| 1656 | + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", | |
| 1657 | + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", | |
| 1658 | + "license": "MIT", | |
| 1659 | + "dependencies": { | |
| 1660 | + "@remix-run/router": "1.23.4" | |
| 1661 | + }, | |
| 1662 | + "engines": { | |
| 1663 | + "node": ">=14.0.0" | |
| 1664 | + }, | |
| 1665 | + "peerDependencies": { | |
| 1666 | + "react": ">=16.8" | |
| 1667 | + } | |
| 1668 | + }, | |
| 1669 | + "node_modules/react-router-dom": { | |
| 1670 | + "version": "6.30.6", | |
| 1671 | + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", | |
| 1672 | + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", | |
| 1673 | + "license": "MIT", | |
| 1674 | + "dependencies": { | |
| 1675 | + "@remix-run/router": "1.23.4", | |
| 1676 | + "react-router": "6.30.6" | |
| 1677 | + }, | |
| 1678 | + "engines": { | |
| 1679 | + "node": ">=14.0.0" | |
| 1680 | + }, | |
| 1681 | + "peerDependencies": { | |
| 1682 | + "react": ">=16.8", | |
| 1683 | + "react-dom": ">=16.8" | |
| 1684 | + } | |
| 1685 | + }, | |
| 1686 | + "node_modules/rollup": { | |
| 1687 | + "version": "4.63.1", | |
| 1688 | + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", | |
| 1689 | + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", | |
| 1690 | + "dev": true, | |
| 1691 | + "license": "MIT", | |
| 1692 | + "dependencies": { | |
| 1693 | + "@types/estree": "1.0.9" | |
| 1694 | + }, | |
| 1695 | + "bin": { | |
| 1696 | + "rollup": "dist/bin/rollup" | |
| 1697 | + }, | |
| 1698 | + "engines": { | |
| 1699 | + "node": ">=18.0.0", | |
| 1700 | + "npm": ">=8.0.0" | |
| 1701 | + }, | |
| 1702 | + "optionalDependencies": { | |
| 1703 | + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", | |
| 1704 | + "@rollup/rollup-android-arm-eabi": "4.63.1", | |
| 1705 | + "@rollup/rollup-android-arm64": "4.63.1", | |
| 1706 | + "@rollup/rollup-darwin-arm64": "4.63.1", | |
| 1707 | + "@rollup/rollup-darwin-x64": "4.63.1", | |
| 1708 | + "@rollup/rollup-freebsd-arm64": "4.63.1", | |
| 1709 | + "@rollup/rollup-freebsd-x64": "4.63.1", | |
| 1710 | + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", | |
| 1711 | + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", | |
| 1712 | + "@rollup/rollup-linux-arm64-gnu": "4.63.1", | |
| 1713 | + "@rollup/rollup-linux-arm64-musl": "4.63.1", | |
| 1714 | + "@rollup/rollup-linux-loong64-gnu": "4.63.1", | |
| 1715 | + "@rollup/rollup-linux-loong64-musl": "4.63.1", | |
| 1716 | + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", | |
| 1717 | + "@rollup/rollup-linux-ppc64-musl": "4.63.1", | |
| 1718 | + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", | |
| 1719 | + "@rollup/rollup-linux-riscv64-musl": "4.63.1", | |
| 1720 | + "@rollup/rollup-linux-s390x-gnu": "4.63.1", | |
| 1721 | + "@rollup/rollup-linux-x64-gnu": "4.63.1", | |
| 1722 | + "@rollup/rollup-linux-x64-musl": "4.63.1", | |
| 1723 | + "@rollup/rollup-openbsd-x64": "4.63.1", | |
| 1724 | + "@rollup/rollup-openharmony-arm64": "4.63.1", | |
| 1725 | + "@rollup/rollup-win32-arm64-msvc": "4.63.1", | |
| 1726 | + "@rollup/rollup-win32-ia32-msvc": "4.63.1", | |
| 1727 | + "@rollup/rollup-win32-x64-gnu": "4.63.1", | |
| 1728 | + "@rollup/rollup-win32-x64-msvc": "4.63.1", | |
| 1729 | + "fsevents": "~2.3.2" | |
| 1730 | + } | |
| 1731 | + }, | |
| 1732 | + "node_modules/scheduler": { | |
| 1733 | + "version": "0.23.2", | |
| 1734 | + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", | |
| 1735 | + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", | |
| 1736 | + "license": "MIT", | |
| 1737 | + "dependencies": { | |
| 1738 | + "loose-envify": "^1.1.0" | |
| 1739 | + } | |
| 1740 | + }, | |
| 1741 | + "node_modules/semver": { | |
| 1742 | + "version": "6.3.1", | |
| 1743 | + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", | |
| 1744 | + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", | |
| 1745 | + "dev": true, | |
| 1746 | + "license": "ISC", | |
| 1747 | + "bin": { | |
| 1748 | + "semver": "bin/semver.js" | |
| 1749 | + } | |
| 1750 | + }, | |
| 1751 | + "node_modules/source-map-js": { | |
| 1752 | + "version": "1.2.1", | |
| 1753 | + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", | |
| 1754 | + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", | |
| 1755 | + "dev": true, | |
| 1756 | + "license": "BSD-3-Clause", | |
| 1757 | + "engines": { | |
| 1758 | + "node": ">=0.10.0" | |
| 1759 | + } | |
| 1760 | + }, | |
| 1761 | + "node_modules/typescript": { | |
| 1762 | + "version": "5.9.3", | |
| 1763 | + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", | |
| 1764 | + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", | |
| 1765 | + "dev": true, | |
| 1766 | + "license": "Apache-2.0", | |
| 1767 | + "bin": { | |
| 1768 | + "tsc": "bin/tsc", | |
| 1769 | + "tsserver": "bin/tsserver" | |
| 1770 | + }, | |
| 1771 | + "engines": { | |
| 1772 | + "node": ">=14.17" | |
| 1773 | + } | |
| 1774 | + }, | |
| 1775 | + "node_modules/update-browserslist-db": { | |
| 1776 | + "version": "1.3.2", | |
| 1777 | + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", | |
| 1778 | + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", | |
| 1779 | + "dev": true, | |
| 1780 | + "funding": [ | |
| 1781 | + { | |
| 1782 | + "type": "opencollective", | |
| 1783 | + "url": "https://opencollective.com/browserslist" | |
| 1784 | + }, | |
| 1785 | + { | |
| 1786 | + "type": "tidelift", | |
| 1787 | + "url": "https://tidelift.com/funding/github/npm/browserslist" | |
| 1788 | + }, | |
| 1789 | + { | |
| 1790 | + "type": "github", | |
| 1791 | + "url": "https://github.com/sponsors/ai" | |
| 1792 | + } | |
| 1793 | + ], | |
| 1794 | + "license": "MIT", | |
| 1795 | + "dependencies": { | |
| 1796 | + "escalade": "^3.2.0", | |
| 1797 | + "picocolors": "^1.1.1" | |
| 1798 | + }, | |
| 1799 | + "bin": { | |
| 1800 | + "update-browserslist-db": "cli.js" | |
| 1801 | + }, | |
| 1802 | + "peerDependencies": { | |
| 1803 | + "browserslist": ">= 4.21.0" | |
| 1804 | + } | |
| 1805 | + }, | |
| 1806 | + "node_modules/vite": { | |
| 1807 | + "version": "5.4.21", | |
| 1808 | + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", | |
| 1809 | + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", | |
| 1810 | + "dev": true, | |
| 1811 | + "license": "MIT", | |
| 1812 | + "dependencies": { | |
| 1813 | + "esbuild": "^0.21.3", | |
| 1814 | + "postcss": "^8.4.43", | |
| 1815 | + "rollup": "^4.20.0" | |
| 1816 | + }, | |
| 1817 | + "bin": { | |
| 1818 | + "vite": "bin/vite.js" | |
| 1819 | + }, | |
| 1820 | + "engines": { | |
| 1821 | + "node": "^18.0.0 || >=20.0.0" | |
| 1822 | + }, | |
| 1823 | + "funding": { | |
| 1824 | + "url": "https://github.com/vitejs/vite?sponsor=1" | |
| 1825 | + }, | |
| 1826 | + "optionalDependencies": { | |
| 1827 | + "fsevents": "~2.3.3" | |
| 1828 | + }, | |
| 1829 | + "peerDependencies": { | |
| 1830 | + "@types/node": "^18.0.0 || >=20.0.0", | |
| 1831 | + "less": "*", | |
| 1832 | + "lightningcss": "^1.21.0", | |
| 1833 | + "sass": "*", | |
| 1834 | + "sass-embedded": "*", | |
| 1835 | + "stylus": "*", | |
| 1836 | + "sugarss": "*", | |
| 1837 | + "terser": "^5.4.0" | |
| 1838 | + }, | |
| 1839 | + "peerDependenciesMeta": { | |
| 1840 | + "@types/node": { | |
| 1841 | + "optional": true | |
| 1842 | + }, | |
| 1843 | + "less": { | |
| 1844 | + "optional": true | |
| 1845 | + }, | |
| 1846 | + "lightningcss": { | |
| 1847 | + "optional": true | |
| 1848 | + }, | |
| 1849 | + "sass": { | |
| 1850 | + "optional": true | |
| 1851 | + }, | |
| 1852 | + "sass-embedded": { | |
| 1853 | + "optional": true | |
| 1854 | + }, | |
| 1855 | + "stylus": { | |
| 1856 | + "optional": true | |
| 1857 | + }, | |
| 1858 | + "sugarss": { | |
| 1859 | + "optional": true | |
| 1860 | + }, | |
| 1861 | + "terser": { | |
| 1862 | + "optional": true | |
| 1863 | + } | |
| 1864 | + } | |
| 1865 | + }, | |
| 1866 | + "node_modules/yallist": { | |
| 1867 | + "version": "3.1.1", | |
| 1868 | + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", | |
| 1869 | + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", | |
| 1870 | + "dev": true, | |
| 1871 | + "license": "ISC" | |
| 1872 | + } | |
| 1873 | + } | |
| 1874 | +} | |
added
frontend/package.json
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +{ | |
| 2 | + "name": "home-ka-frontend", | |
| 3 | + "author": "Simon-Pierre Boucher <contact@spboucher.ai>", | |
| 4 | + "private": true, | |
| 5 | + "version": "1.0.0", | |
| 6 | + "type": "module", | |
| 7 | + "scripts": { | |
| 8 | + "dev": "vite", | |
| 9 | + "build": "tsc -b && vite build", | |
| 10 | + "preview": "vite preview" | |
| 11 | + }, | |
| 12 | + "dependencies": { | |
| 13 | + "@groupe-ka/ka-maps": "file:../vendor/ka-maps", | |
| 14 | + "mapbox-gl": "^3.28.1", | |
| 15 | + "react": "^18.3.1", | |
| 16 | + "react-dom": "^18.3.1", | |
| 17 | + "react-router-dom": "^6.26.0" | |
| 18 | + }, | |
| 19 | + "devDependencies": { | |
| 20 | + "@types/react": "^18.3.3", | |
| 21 | + "@types/react-dom": "^18.3.0", | |
| 22 | + "@vitejs/plugin-react": "^4.3.1", | |
| 23 | + "typescript": "^5.5.4", | |
| 24 | + "vite": "^5.4.0" | |
| 25 | + } | |
| 26 | +} | |
added
frontend/public/apple-touch-icon.png
+0 −0
Binary file not shown.
added
frontend/public/favicon.svg
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"> | |
| 2 | +<rect width="64" height="64" rx="14" fill="#141814"/> | |
| 3 | +<rect x="4.5" y="4.5" width="55" height="55" rx="11" fill="none" stroke="#e23744" stroke-opacity="0.35" stroke-width="2"/> | |
| 4 | +<text x="32" y="43" text-anchor="middle" font-family="'Space Grotesk','Arial Black',sans-serif" font-size="30" font-weight="700" letter-spacing="-1" fill="#e23744" transform="rotate(-4 32 32)">Ka</text> | |
| 5 | +</svg> | |
| \ No newline at end of file | ||
added
frontend/public/og.png
+0 −0
Binary file not shown.
added
frontend/scripts/check-order.mjs
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +// Validation ordre des sections — fiche Immo-Ka (ordre DOM = ordre visuel) | |
| 2 | +import { chromium, devices } from "playwright"; | |
| 3 | + | |
| 4 | +const BASE = process.env.BASE || "http://localhost:18096"; | |
| 5 | +const UID = process.argv[2]; | |
| 6 | +const URL = `${BASE}/property/${encodeURIComponent(UID)}`; | |
| 7 | +const SEL = [".f-galerie", ".f-hero", ".f-desc", "#caracteristiques", "#pieces", "#inclusions", "#carte", ".quartier"]; | |
| 8 | + | |
| 9 | +async function check(name, ctxOpts) { | |
| 10 | + const browser = await chromium.launch(); | |
| 11 | + const ctx = await browser.newContext(ctxOpts); | |
| 12 | + const page = await ctx.newPage(); | |
| 13 | + await page.goto(URL, { waitUntil: "networkidle" }); | |
| 14 | + await page.waitForSelector(".f-galerie", { timeout: 15000 }); | |
| 15 | + await page.waitForTimeout(1200); | |
| 16 | + const data = await page.evaluate((sel) => { | |
| 17 | + const out = []; | |
| 18 | + for (const s of sel) { | |
| 19 | + const el = document.querySelector(s); | |
| 20 | + if (!el) { out.push({ s, missing: true }); continue; } | |
| 21 | + const r = el.getBoundingClientRect(); | |
| 22 | + out.push({ s, hidden: r.height === 0 && r.width === 0, top: Math.round(r.top + window.scrollY), left: Math.round(r.left), order: getComputedStyle(el).order }); | |
| 23 | + } | |
| 24 | + return { out, scrollY: window.scrollY }; | |
| 25 | + }, SEL); | |
| 26 | + console.log(`\n=== ${name} === scrollY: ${data.scrollY}`); | |
| 27 | + for (const b of data.out) | |
| 28 | + console.log(b.missing ? `${b.s.padEnd(18)} (non rendue)` : b.hidden ? `${b.s.padEnd(18)} (vide/masquée)` : | |
| 29 | + `${b.s.padEnd(18)} top=${String(b.top).padStart(6)} left=${String(b.left).padStart(4)} order=${b.order}`); | |
| 30 | + await browser.close(); | |
| 31 | + return data; | |
| 32 | +} | |
| 33 | + | |
| 34 | +const mob = await check("iPhone 14 (mobile)", { ...devices["iPhone 14"] }); | |
| 35 | +await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } }); | |
| 36 | +const vis = mob.out.filter(b => !b.missing && !b.hidden); | |
| 37 | +const sorted = vis.every((b, i) => i === 0 || b.top >= vis[i - 1].top); | |
| 38 | +const ok = sorted && mob.scrollY === 0 && vis[0].s === ".f-galerie" && vis.every(b => b.order === "0"); | |
| 39 | +console.log(`\nMOBILE: ordre ${sorted ? "CROISSANT ✓" : "DÉSORDONNÉ ✗"} · scrollY=${mob.scrollY} · 1re=${vis[0].s} · sans order=${vis.every(b => b.order === "0")}`); | |
| 40 | +console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC"); | |
| 41 | +process.exit(ok ? 0 : 1); | |
added
frontend/src/App.tsx
+199 −0
@@ -0,0 +1,199 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// App.tsx : global layout (header + live ticker + Groupe KA footer) + routing. | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | +import { Ico } from "./components/Icons"; | |
| 7 | +import { useEffect, useState } from "react"; | |
| 8 | +import { NavLink, Route, Routes, useLocation } from "react-router-dom"; | |
| 9 | +import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api"; | |
| 10 | +import GroupeKaBadge from "./ka/GroupeKaBadge"; | |
| 11 | +import KaFooter from "./ka/KaFooter"; | |
| 12 | +import ContactPage from "./pages/Contact"; | |
| 13 | +import Home from "./pages/Home"; | |
| 14 | +import { PrivacyPage, TermsPage } from "./pages/Legal"; | |
| 15 | +import ListingPage from "./pages/Listing"; | |
| 16 | +import SourcesPage from "./pages/Sources"; | |
| 17 | +import StatsPage from "./pages/Stats"; | |
| 18 | +import AdminPage from "./pages/Admin"; | |
| 19 | + | |
| 20 | +function Ticker() { | |
| 21 | + const [items, setItems] = useState<string[]>([]); | |
| 22 | + | |
| 23 | + useEffect(() => { | |
| 24 | + Promise.all([fetchStats(), fetchFacets(), fetchSources()]) | |
| 25 | + .then(([stats, facets, src]) => { | |
| 26 | + registerSourceNames(src.sources); | |
| 27 | + const parts: string[] = [`${stats.total.toLocaleString("en-US")} homes for sale`]; | |
| 28 | + if (stats.states) parts.push(`${stats.states} states`); | |
| 29 | + if (stats.cities) parts.push(`${stats.cities.toLocaleString("en-US")} cities`); | |
| 30 | + if (stats.avg_price != null) | |
| 31 | + parts.push(`Average price $${Math.round(stats.avg_price).toLocaleString("en-US")}`); | |
| 32 | + for (const s of facets.sources.slice(0, 12)) | |
| 33 | + parts.push(`${sourceName(s.source)} · ${s.n}`); | |
| 34 | + parts.push("Updated automatically"); | |
| 35 | + setItems(parts); | |
| 36 | + }) | |
| 37 | + .catch(() => setItems(["Home-Ka — every home for sale in America"])); | |
| 38 | + }, []); | |
| 39 | + | |
| 40 | + if (items.length === 0) return null; | |
| 41 | + return ( | |
| 42 | + <div className="ticker" aria-hidden="true"> | |
| 43 | + <div className="ticker-track"> | |
| 44 | + {[...items, ...items].map((t, i) => ( | |
| 45 | + <span key={i}>{t}</span> | |
| 46 | + ))} | |
| 47 | + </div> | |
| 48 | + </div> | |
| 49 | + ); | |
| 50 | +} | |
| 51 | + | |
| 52 | +const NAV_LINKS = [ | |
| 53 | + { to: "/", label: "Homes", icon: "home", end: true }, | |
| 54 | + { to: "/?view=map", label: "Map", icon: "map", end: false, force: true }, | |
| 55 | + { to: "/stats", label: "Stats", icon: "chart", end: false }, | |
| 56 | + { to: "/sources", label: "Sources", icon: "building", end: false }, | |
| 57 | + { to: "/contact", label: "Contact", icon: "arrow", end: false }, | |
| 58 | +]; | |
| 59 | + | |
| 60 | +function Header() { | |
| 61 | + const [open, setOpen] = useState(false); | |
| 62 | + const location = useLocation(); | |
| 63 | + | |
| 64 | + useEffect(() => { setOpen(false); }, [location]); | |
| 65 | + useEffect(() => { | |
| 66 | + document.documentElement.classList.toggle("ka-scroll-lock", open); | |
| 67 | + return () => { document.documentElement.classList.remove("ka-scroll-lock"); }; | |
| 68 | + }, [open]); | |
| 69 | + | |
| 70 | + return ( | |
| 71 | + <> | |
| 72 | + <header className="header"> | |
| 73 | + <div className="container header-inner"> | |
| 74 | + <NavLink to="/" className="brand" aria-label="Home-Ka — home"> | |
| 75 | + Home<span className="ka">Ka</span> | |
| 76 | + </NavLink> | |
| 77 | + <GroupeKaBadge /> | |
| 78 | + <nav className="nav" aria-label="Main navigation"> | |
| 79 | + <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}> | |
| 80 | + Homes | |
| 81 | + </NavLink> | |
| 82 | + <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}> | |
| 83 | + Stats | |
| 84 | + </NavLink> | |
| 85 | + <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}> | |
| 86 | + Sources | |
| 87 | + </NavLink> | |
| 88 | + <NavLink to="/contact" className={({ isActive }) => (isActive ? "active" : "")}> | |
| 89 | + Contact | |
| 90 | + </NavLink> | |
| 91 | + </nav> | |
| 92 | + <button | |
| 93 | + className={`menu-btn ${open ? "open" : ""}`} | |
| 94 | + aria-expanded={open} | |
| 95 | + aria-label={open ? "Close menu" : "Open menu"} | |
| 96 | + onClick={() => setOpen(!open)} | |
| 97 | + > | |
| 98 | + <span /><span /><span /> | |
| 99 | + </button> | |
| 100 | + </div> | |
| 101 | + | |
| 102 | + <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Mobile menu"> | |
| 103 | + <button type="button" className="mm-close" aria-label="Close menu" | |
| 104 | + onClick={() => setOpen(false)}>✕</button> | |
| 105 | + {NAV_LINKS.map((l, i) => ( | |
| 106 | + <NavLink | |
| 107 | + key={l.to} | |
| 108 | + to={l.to} | |
| 109 | + end={l.end} | |
| 110 | + style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }} | |
| 111 | + className={({ isActive }) => `mm-link ${isActive && !l.force ? "active" : ""}`} | |
| 112 | + onClick={() => setOpen(false)} | |
| 113 | + > | |
| 114 | + <span className="mm-ico" aria-hidden="true"><Ico name={l.icon} size={19} /></span> | |
| 115 | + {l.label} | |
| 116 | + <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span> | |
| 117 | + </NavLink> | |
| 118 | + ))} | |
| 119 | + <div className="mm-foot"> | |
| 120 | + <GroupeKaBadge /> | |
| 121 | + <p> | |
| 122 | + Independent aggregator — updated automatically; every listing | |
| 123 | + links back to the original announcement at the source. | |
| 124 | + </p> | |
| 125 | + </div> | |
| 126 | + </div> | |
| 127 | + </header> | |
| 128 | + {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />} | |
| 129 | + <Ticker /> | |
| 130 | + </> | |
| 131 | + ); | |
| 132 | +} | |
| 133 | + | |
| 134 | +const LOCAL_LEGAL = [ | |
| 135 | + { label: "Terms of Use (Home-Ka)", href: "/terms" }, | |
| 136 | + { label: "Privacy Policy (Home-Ka)", href: "/privacy" }, | |
| 137 | + { label: "Contact", href: "/contact" }, | |
| 138 | +]; | |
| 139 | + | |
| 140 | +/** Bottom navigation bar (mobile) — floating pill, same language as Lou-Ka v2. */ | |
| 141 | +function MobileTabBar() { | |
| 142 | + const location = useLocation(); | |
| 143 | + const isMap = new URLSearchParams(location.search).get("view") === "map"; | |
| 144 | + const tabs = [ | |
| 145 | + { to: "/", label: "Discover", icon: <Ico name="search" size={20} stroke={1.6} />, on: location.pathname === "/" && !isMap }, | |
| 146 | + { to: "/?view=map", label: "Map", icon: <Ico name="map" size={20} stroke={1.6} />, on: location.pathname === "/" && isMap }, | |
| 147 | + { to: "/stats", label: "Stats", icon: <Ico name="chart" size={20} stroke={1.6} />, on: location.pathname.startsWith("/stats") }, | |
| 148 | + { to: "/sources", label: "Sources", icon: <Ico name="building" size={20} stroke={1.6} />, on: location.pathname.startsWith("/sources") }, | |
| 149 | + ]; | |
| 150 | + return ( | |
| 151 | + <nav className="tabbar" aria-label="Mobile navigation"> | |
| 152 | + {tabs.map((t) => ( | |
| 153 | + <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}> | |
| 154 | + {t.icon} | |
| 155 | + {t.label} | |
| 156 | + </NavLink> | |
| 157 | + ))} | |
| 158 | + </nav> | |
| 159 | + ); | |
| 160 | +} | |
| 161 | + | |
| 162 | +export default function App() { | |
| 163 | + return ( | |
| 164 | + <> | |
| 165 | + <Header /> | |
| 166 | + <main> | |
| 167 | + <Routes> | |
| 168 | + <Route path="/" element={<Home />} /> | |
| 169 | + <Route path="/property/:uid" element={<ListingPage />} /> | |
| 170 | + {/* SEO canonical URL: /property/{uid}/{slug} — without this route any | |
| 171 | + shared/direct link would land on the 404 page */} | |
| 172 | + <Route path="/property/:uid/:slug" element={<ListingPage />} /> | |
| 173 | + <Route path="/stats" element={<StatsPage />} /> | |
| 174 | + <Route path="/sources" element={<SourcesPage />} /> | |
| 175 | + <Route path="/contact" element={<ContactPage />} /> | |
| 176 | + <Route path="/terms" element={<TermsPage />} /> | |
| 177 | + <Route path="/privacy" element={<PrivacyPage />} /> | |
| 178 | + {/* Admin — reachable by URL only, not linked in the nav */} | |
| 179 | + <Route path="/admin" element={<AdminPage tab="sources" />} /> | |
| 180 | + <Route path="/admin/sources" element={<AdminPage tab="sources" />} /> | |
| 181 | + <Route path="/admin/connectors" element={<AdminPage tab="connectors" />} /> | |
| 182 | + <Route path="/admin/brokerages" element={<AdminPage tab="brokerages" />} /> | |
| 183 | + <Route | |
| 184 | + path="*" | |
| 185 | + element={ | |
| 186 | + <div className="notice container"> | |
| 187 | + <div className="big">🧭</div> | |
| 188 | + <h2>Page not found</h2> | |
| 189 | + <p>The requested link does not exist.</p> | |
| 190 | + </div> | |
| 191 | + } | |
| 192 | + /> | |
| 193 | + </Routes> | |
| 194 | + </main> | |
| 195 | + <MobileTabBar /> | |
| 196 | + <KaFooter siteId="home-ka" localLegal={LOCAL_LEGAL} /> | |
| 197 | + </> | |
| 198 | + ); | |
| 199 | +} | |
added
frontend/src/api.ts
+402 −0
@@ -0,0 +1,402 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// api.ts : types + robust API client (timeout, typed errors) for the Home-Ka | |
| 5 | +// FastAPI backend (homeka/web.py) — public search/map/detail + admin API. | |
| 6 | +// ----------------------------------------------------------------------------- | |
| 7 | + | |
| 8 | +/** details: free-form dictionary of source characteristics (label → value). */ | |
| 9 | +export interface ListingDetails { | |
| 10 | + price_from?: boolean; | |
| 11 | + cover_thumb?: string; | |
| 12 | + photo_captions?: string[]; | |
| 13 | + [key: string]: unknown; | |
| 14 | +} | |
| 15 | + | |
| 16 | +/** A LISTING = one publication of a property by one source. */ | |
| 17 | +export interface Listing { | |
| 18 | + uid: string; | |
| 19 | + source: string; | |
| 20 | + external_id: string; | |
| 21 | + property_id?: number | null; | |
| 22 | + url: string; | |
| 23 | + title: string; | |
| 24 | + street_address: string; | |
| 25 | + unit?: string | null; | |
| 26 | + city: string; | |
| 27 | + state: string; // 2-letter USPS | |
| 28 | + zip_code: string; | |
| 29 | + county: string; | |
| 30 | + property_type: string; | |
| 31 | + property_subtype?: string | null; | |
| 32 | + list_price: number | null; | |
| 33 | + price_label: string; | |
| 34 | + bedrooms: number | null; | |
| 35 | + bathrooms: number | null; // can be 2.5 | |
| 36 | + bathrooms_full?: number | null; | |
| 37 | + bathrooms_half?: number | null; | |
| 38 | + living_area_sqft: number | null; | |
| 39 | + lot_size_sqft: number | null; | |
| 40 | + year_built: number | null; | |
| 41 | + apn?: string | null; | |
| 42 | + mls_id: string; | |
| 43 | + mls_name?: string | null; | |
| 44 | + status: string; // active | pending | sold | withdrawn | coming-soon | |
| 45 | + listed_at?: string | null; | |
| 46 | + days_on_market?: number | null; | |
| 47 | + brokerage_name: string; | |
| 48 | + office_name?: string | null; | |
| 49 | + agent_name: string; | |
| 50 | + agent_phone: string; | |
| 51 | + agent_email?: string | null; | |
| 52 | + description: string; | |
| 53 | + features: string[]; | |
| 54 | + details: ListingDetails; | |
| 55 | + images: string[]; | |
| 56 | + lat: number | null; | |
| 57 | + lng: number | null; | |
| 58 | + price_history?: { ts: number; price: number | null }[]; | |
| 59 | + duplicates?: DuplicateListing[]; // same property published elsewhere | |
| 60 | + property?: Property | null; // the physical PROPERTY behind this listing | |
| 61 | + first_seen?: number; | |
| 62 | + last_seen?: number; | |
| 63 | + updated_at?: number; | |
| 64 | + active?: number; | |
| 65 | + published?: number; | |
| 66 | +} | |
| 67 | + | |
| 68 | +export interface DuplicateListing { | |
| 69 | + uid: string; | |
| 70 | + source: string; | |
| 71 | + url: string; | |
| 72 | + brokerage_name: string; | |
| 73 | + agent_name: string; | |
| 74 | + price_label: string; | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** One row of a property's listing history (properties → listings). */ | |
| 78 | +export interface PropertyListingRef { | |
| 79 | + uid: string; | |
| 80 | + source: string; | |
| 81 | + status: string; | |
| 82 | + list_price: number | null; | |
| 83 | + first_seen: number | null; | |
| 84 | + last_seen: number | null; | |
| 85 | + active: number; | |
| 86 | +} | |
| 87 | + | |
| 88 | +/** The physical PROPERTY — Home-Ka separates PROPERTY from LISTING. */ | |
| 89 | +export interface Property { | |
| 90 | + id: number; | |
| 91 | + apn?: string | null; | |
| 92 | + street_address?: string | null; | |
| 93 | + unit?: string | null; | |
| 94 | + city?: string | null; | |
| 95 | + state?: string | null; | |
| 96 | + zip_code?: string | null; | |
| 97 | + county?: string | null; | |
| 98 | + property_type?: string | null; | |
| 99 | + year_built?: number | null; | |
| 100 | + living_area_sqft?: number | null; | |
| 101 | + lot_size_sqft?: number | null; | |
| 102 | + lat?: number | null; | |
| 103 | + lng?: number | null; | |
| 104 | + details?: Record<string, unknown>; | |
| 105 | + first_seen?: number | null; | |
| 106 | + last_seen?: number | null; | |
| 107 | + listing_history?: PropertyListingRef[]; | |
| 108 | +} | |
| 109 | + | |
| 110 | +export interface Facets { | |
| 111 | + states: { state: string; n: number }[]; | |
| 112 | + cities: { city: string; n: number }[]; | |
| 113 | + property_types: string[]; | |
| 114 | + sources: { source: string; n: number }[]; | |
| 115 | +} | |
| 116 | + | |
| 117 | +export interface Source { | |
| 118 | + id: string; | |
| 119 | + name: string; | |
| 120 | + connector_type: string | null; | |
| 121 | + states: string[]; | |
| 122 | + enabled: number; | |
| 123 | + active_listings: number; | |
| 124 | + last_sync: number | null; | |
| 125 | +} | |
| 126 | + | |
| 127 | +export interface SyncLogRow { | |
| 128 | + source: string; | |
| 129 | + ts: number; | |
| 130 | + found: number; | |
| 131 | + added: number; | |
| 132 | + updated: number; | |
| 133 | + removed: number; | |
| 134 | + ok: number; | |
| 135 | + message: string; | |
| 136 | +} | |
| 137 | + | |
| 138 | +export interface Stats { | |
| 139 | + total: number; | |
| 140 | + sources: number; | |
| 141 | + cities: number; | |
| 142 | + states: number; | |
| 143 | + avg_price: number | null; | |
| 144 | + min_price: number | null; | |
| 145 | + max_price: number | null; | |
| 146 | + properties: number; | |
| 147 | + by_state: { state: string; n: number; avg_price: number | null }[]; | |
| 148 | + by_type: { property_type: string; n: number; avg_price: number | null }[]; | |
| 149 | + quality?: Record<string, unknown>; | |
| 150 | + recent_syncs?: SyncLogRow[]; | |
| 151 | +} | |
| 152 | + | |
| 153 | +// --- Source display names ----------------------------------------------------- | |
| 154 | +const SOURCE_NAMES: Record<string, string> = {}; | |
| 155 | +export function registerSourceNames(sources: Source[]) { | |
| 156 | + for (const s of sources) SOURCE_NAMES[s.id] = s.name; | |
| 157 | +} | |
| 158 | +export function sourceName(id: string): string { | |
| 159 | + if (SOURCE_NAMES[id]) return SOURCE_NAMES[id]; | |
| 160 | + return id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); | |
| 161 | +} | |
| 162 | + | |
| 163 | +async function get<T>(path: string, headers?: Record<string, string>): Promise<T> { | |
| 164 | + const ctrl = new AbortController(); | |
| 165 | + const timer = setTimeout(() => ctrl.abort(), 25000); | |
| 166 | + try { | |
| 167 | + const res = await fetch(path, { signal: ctrl.signal, headers }); | |
| 168 | + if (!res.ok) throw new Error(`API ${res.status} — ${path}`); | |
| 169 | + return (await res.json()) as T; | |
| 170 | + } finally { | |
| 171 | + clearTimeout(timer); | |
| 172 | + } | |
| 173 | +} | |
| 174 | + | |
| 175 | +async function post<T>( | |
| 176 | + path: string, body?: unknown, headers?: Record<string, string>, | |
| 177 | +): Promise<T> { | |
| 178 | + const ctrl = new AbortController(); | |
| 179 | + const timer = setTimeout(() => ctrl.abort(), 60000); | |
| 180 | + try { | |
| 181 | + const res = await fetch(path, { | |
| 182 | + method: "POST", | |
| 183 | + headers: { "Content-Type": "application/json", ...(headers ?? {}) }, | |
| 184 | + body: body === undefined ? undefined : JSON.stringify(body), | |
| 185 | + signal: ctrl.signal, | |
| 186 | + }); | |
| 187 | + if (!res.ok) throw new Error(`API ${res.status} — ${path}`); | |
| 188 | + return (await res.json()) as T; | |
| 189 | + } finally { | |
| 190 | + clearTimeout(timer); | |
| 191 | + } | |
| 192 | +} | |
| 193 | + | |
| 194 | +export interface ListingFilters { | |
| 195 | + q?: string; | |
| 196 | + city?: string; | |
| 197 | + state?: string; | |
| 198 | + zip_code?: string; | |
| 199 | + county?: string; | |
| 200 | + property_type?: string; | |
| 201 | + status?: string; | |
| 202 | + source?: string; | |
| 203 | + price_min?: string; | |
| 204 | + price_max?: string; | |
| 205 | + beds_min?: string; | |
| 206 | + baths_min?: string; | |
| 207 | + sqft_min?: string; | |
| 208 | + sort?: string; // recent | price_asc | price_desc | |
| 209 | +} | |
| 210 | + | |
| 211 | +export function listingParams(f: ListingFilters): URLSearchParams { | |
| 212 | + const params = new URLSearchParams(); | |
| 213 | + for (const [k, v] of Object.entries(f)) if (v) params.set(k, v); | |
| 214 | + return params; | |
| 215 | +} | |
| 216 | + | |
| 217 | +export function fetchListings(f: ListingFilters, limit = 60, offset = 0) { | |
| 218 | + const params = listingParams(f); | |
| 219 | + params.set("limit", String(limit)); | |
| 220 | + params.set("offset", String(offset)); | |
| 221 | + return get<{ total: number; count: number; listings: Listing[] }>( | |
| 222 | + `/api/listings?${params}`); | |
| 223 | +} | |
| 224 | + | |
| 225 | +export const fetchListing = (uid: string) => | |
| 226 | + get<Listing>(`/api/listings/${encodeURIComponent(uid)}`); | |
| 227 | +export const fetchFacets = (state?: string) => | |
| 228 | + get<Facets>(`/api/facets${state ? `?state=${encodeURIComponent(state)}` : ""}`); | |
| 229 | +export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); | |
| 230 | +export const fetchStats = () => get<Stats>("/api/stats"); | |
| 231 | + | |
| 232 | +// --- Formatting (US) ---------------------------------------------------------- | |
| 233 | +export const fmtPrice = (p: number | null | undefined, label?: string) => | |
| 234 | + p != null | |
| 235 | + ? "$" + Math.round(p).toLocaleString("en-US") | |
| 236 | + : label || "Price on request"; | |
| 237 | + | |
| 238 | +export const fmtArea = (a: number | null | undefined): string | null => | |
| 239 | + a != null ? `${Math.round(a).toLocaleString("en-US")} sq ft` : null; | |
| 240 | + | |
| 241 | +export const fmtDate = (ts: number): string => | |
| 242 | + new Date(ts * 1000).toLocaleDateString("en-US", { | |
| 243 | + month: "long", day: "numeric", year: "numeric", | |
| 244 | + }); | |
| 245 | + | |
| 246 | +/** 2.5 → "2.5", 2 → "2" — bathrooms can be half-counts. */ | |
| 247 | +export const fmtBaths = (b: number | null | undefined): string => | |
| 248 | + b == null ? "—" : (Number.isInteger(b) ? String(b) : b.toFixed(1)); | |
| 249 | + | |
| 250 | +/** "Austin, TX 78704" from the parts that exist. */ | |
| 251 | +export const fmtCityLine = ( | |
| 252 | + l: { city?: string | null; state?: string | null; zip_code?: string | null }, | |
| 253 | +): string => | |
| 254 | + [ | |
| 255 | + [l.city, l.state].filter(Boolean).join(", "), | |
| 256 | + l.zip_code ?? "", | |
| 257 | + ].filter(Boolean).join(" "); | |
| 258 | + | |
| 259 | +export const STATUS_LABELS: Record<string, string> = { | |
| 260 | + "active": "For sale", | |
| 261 | + "pending": "Pending", | |
| 262 | + "sold": "Sold", | |
| 263 | + "withdrawn": "Withdrawn", | |
| 264 | + "coming-soon": "Coming soon", | |
| 265 | +}; | |
| 266 | + | |
| 267 | +// ============================================================================= | |
| 268 | +// Admin API — /api/admin/* (optional X-Admin-Token header, stored locally) | |
| 269 | +// ============================================================================= | |
| 270 | + | |
| 271 | +export const ADMIN_TOKEN_KEY = "homeka_admin_token"; | |
| 272 | + | |
| 273 | +export function getAdminToken(): string { | |
| 274 | + try { return localStorage.getItem(ADMIN_TOKEN_KEY) ?? ""; } catch { return ""; } | |
| 275 | +} | |
| 276 | +export function setAdminToken(token: string) { | |
| 277 | + try { | |
| 278 | + if (token) localStorage.setItem(ADMIN_TOKEN_KEY, token); | |
| 279 | + else localStorage.removeItem(ADMIN_TOKEN_KEY); | |
| 280 | + } catch { /* storage unavailable */ } | |
| 281 | +} | |
| 282 | +function adminHeaders(): Record<string, string> { | |
| 283 | + const t = getAdminToken(); | |
| 284 | + return t ? { "X-Admin-Token": t } : {}; | |
| 285 | +} | |
| 286 | + | |
| 287 | +export interface AdminOverview { | |
| 288 | + active_listings: number; | |
| 289 | + properties: number; | |
| 290 | + enabled_sources: number; | |
| 291 | + errors_24h: number; | |
| 292 | + brokerages_by_status: { partnership_status: string; n: number }[]; | |
| 293 | + last_successful_sync: number | null; | |
| 294 | +} | |
| 295 | + | |
| 296 | +export interface AdminSource { | |
| 297 | + id: string; | |
| 298 | + name: string; | |
| 299 | + connector_type: string | null; | |
| 300 | + enabled: number; | |
| 301 | + authority: number | null; | |
| 302 | + states: string[]; | |
| 303 | + notes: string; | |
| 304 | + brokerage_id: number | null; | |
| 305 | + config: Record<string, unknown>; | |
| 306 | + active_listings: number; | |
| 307 | + published: number; | |
| 308 | + no_geo: number; | |
| 309 | + last_sync: number | null; | |
| 310 | + last_ok: number | null; | |
| 311 | + last_message: string | null; | |
| 312 | + last_found: number | null; | |
| 313 | + last_added: number | null; | |
| 314 | + last_removed: number | null; | |
| 315 | + errors_7d: number; | |
| 316 | + freshness_hours: number | null; | |
| 317 | +} | |
| 318 | + | |
| 319 | +export interface ConnectorFamily { | |
| 320 | + family: string; | |
| 321 | + class: string; | |
| 322 | + module: string; | |
| 323 | + doc: string; | |
| 324 | + instances: number; | |
| 325 | +} | |
| 326 | +export interface CustomConnector { | |
| 327 | + source_id: string; | |
| 328 | + class: string; | |
| 329 | + module: string; | |
| 330 | +} | |
| 331 | + | |
| 332 | +export interface Brokerage { | |
| 333 | + id: number; | |
| 334 | + name: string; | |
| 335 | + website: string | null; | |
| 336 | + states: string[]; | |
| 337 | + cities: string[]; | |
| 338 | + estimated_agents: number | null; | |
| 339 | + estimated_listings: number | null; | |
| 340 | + mls_affiliations: string[]; | |
| 341 | + idx_present: number | null; | |
| 342 | + idx_provider: string | null; | |
| 343 | + reso_detected: number | null; | |
| 344 | + possible_feed_type: string | null; | |
| 345 | + contact_page: string | null; | |
| 346 | + partnership_contact: string | null; | |
| 347 | + technical_contact: string | null; | |
| 348 | + feed_probability_score: number | null; | |
| 349 | + priority_score: number | null; | |
| 350 | + partnership_status: string; | |
| 351 | + source_id: string | null; | |
| 352 | + last_inspected: number | null; | |
| 353 | + inspect_error: string | null; | |
| 354 | + evidence: Record<string, unknown>; | |
| 355 | +} | |
| 356 | + | |
| 357 | +export const PARTNERSHIP_STATUSES = [ | |
| 358 | + "prospect", "to-contact", "contacted", "in-discussion", | |
| 359 | + "feed-received", "live", "declined", | |
| 360 | +] as const; | |
| 361 | + | |
| 362 | +export const fetchAdminOverview = () => | |
| 363 | + get<AdminOverview>("/api/admin/overview", adminHeaders()); | |
| 364 | + | |
| 365 | +export const fetchAdminSources = () => | |
| 366 | + get<{ sources: AdminSource[] }>("/api/admin/sources", adminHeaders()); | |
| 367 | + | |
| 368 | +export const fetchAdminConnectors = () => | |
| 369 | + get<{ families: ConnectorFamily[]; custom: CustomConnector[] }>( | |
| 370 | + "/api/admin/connectors", adminHeaders()); | |
| 371 | + | |
| 372 | +export interface BrokerageFilters { | |
| 373 | + state?: string; | |
| 374 | + status?: string; | |
| 375 | + feed_type?: string; | |
| 376 | + idx_provider?: string; | |
| 377 | + q?: string; | |
| 378 | + min_priority?: string; | |
| 379 | + inspected?: string; // "1" | "0" | "" | |
| 380 | + sort?: string; // priority | feed | name | inspected | |
| 381 | +} | |
| 382 | + | |
| 383 | +export function fetchAdminBrokerages(f: BrokerageFilters, limit = 50, offset = 0) { | |
| 384 | + const params = new URLSearchParams(); | |
| 385 | + for (const [k, v] of Object.entries(f)) if (v) params.set(k, v); | |
| 386 | + params.set("limit", String(limit)); | |
| 387 | + params.set("offset", String(offset)); | |
| 388 | + return get<{ total: number; count: number; brokerages: Brokerage[] }>( | |
| 389 | + `/api/admin/brokerages?${params}`, adminHeaders()); | |
| 390 | +} | |
| 391 | + | |
| 392 | +export const inspectBrokerage = (id: number) => | |
| 393 | + post<Record<string, unknown>>( | |
| 394 | + `/api/admin/brokerages/${id}/inspect`, undefined, adminHeaders()); | |
| 395 | + | |
| 396 | +export const setBrokerageStatus = (id: number, status: string) => | |
| 397 | + post<{ id: number; status: string }>( | |
| 398 | + `/api/admin/brokerages/${id}/status`, { status }, adminHeaders()); | |
| 399 | + | |
| 400 | +export const runDiscovery = (limit = 25) => | |
| 401 | + post<{ status: string; limit: number }>( | |
| 402 | + `/api/admin/discover?limit=${limit}`, undefined, adminHeaders()); | |
added
frontend/src/components/AmenityIco.tsx
+111 −0
@@ -0,0 +1,111 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Groupe KA — shared component (Home-Ka edition) | |
| 3 | +// components/AmenityIco.tsx : contextual icon for a feature/amenity label. | |
| 4 | +// Each label is matched by keyword (accents neutralized) to one of the ~30 | |
| 5 | +// house pictograms — 1.8 stroke, 24×24, currentColor. Fallback: ✓. | |
| 6 | +// ----------------------------------------------------------------------------- | |
| 7 | +import { ReactNode } from "react"; | |
| 8 | + | |
| 9 | +const ICONS: Record<string, ReactNode> = { | |
| 10 | + heat: (<><rect x="4" y="9" width="16" height="9" rx="2" /><path d="M8 9v9M12 9v9M16 9v9M7.6 3.5c0 1.6 1.2 1.6 1.2 3.2M11.4 3.5c0 1.6 1.2 1.6 1.2 3.2M15.2 3.5c0 1.6 1.2 1.6 1.2 3.2" /></>), | |
| 11 | + bolt: <path d="M13 2.5 4.5 13.5H11l-1.5 8 8.5-11H11.5l1.5-8z" />, | |
| 12 | + droplet: (<><path d="M12 3.5S6.2 9.8 6.2 13.6a5.8 5.8 0 0 0 11.6 0C17.8 9.8 12 3.5 12 3.5z" /><path d="M9.4 14a2.7 2.7 0 0 0 2.1 2.6" /></>), | |
| 13 | + wifi: <path d="M3.5 9.5a13 13 0 0 1 17 0M6.5 13a8.5 8.5 0 0 1 11 0M9.5 16.4a4.4 4.4 0 0 1 5 0M12 19.6h.01" />, | |
| 14 | + dishwasher: (<><rect x="4.5" y="3" width="15" height="18" rx="2" /><path d="M4.5 8.5h15M8 12.5V17M12 12.5V17M16 12.5V17M7.5 5.7h.01M10.5 5.7h.01" /></>), | |
| 15 | + washer: (<><rect x="4.5" y="3" width="15" height="18" rx="2" /><circle cx="12" cy="14" r="4.2" /><path d="M4.5 7.5h15M16.4 5.2h.01M9.2 14c1 .8 1.9.8 2.8 0s1.9-.8 2.8 0" /></>), | |
| 16 | + fridge: (<><rect x="6.5" y="2.5" width="11" height="19" rx="2" /><path d="M6.5 9.5h11M9.5 5.5v2M9.5 12.5V16" /></>), | |
| 17 | + snow: <path d="M12 2.5v19M3.8 7.25l16.4 9.5M20.2 7.25 3.8 16.75" />, | |
| 18 | + elevator: (<><rect x="4.5" y="3" width="15" height="18" rx="2" /><path d="M12 3v18M7 11l1.5-1.8L10 11M14 13l1.5 1.8L17 13" /></>), | |
| 19 | + balcony: <path d="M4 11.5h16M5 11.5V20M19 11.5V20M9.5 11.5V20M14.5 11.5V20M4 20h16M7 11.5V6.5a5 5 0 0 1 10 0v5" />, | |
| 20 | + pool: <path d="M9 4.5v9M13.5 4.5v9M9 7.5h4.5M3 16.5c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0M3 20c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0" />, | |
| 21 | + gym: <path d="M6.7 6.7v10.6M17.3 6.7v10.6M3.5 9.2v5.6M20.5 9.2v5.6M6.7 12h10.6" />, | |
| 22 | + laundry: <path d="M4 9.5h16l-1.8 10a1.8 1.8 0 0 1-1.8 1.5H7.6a1.8 1.8 0 0 1-1.8-1.5zM8 9.5 12 3l4 6.5M9.3 13.5v3.5M12 13.5v3.5M14.7 13.5v3.5" />, | |
| 23 | + box: <path d="M3.5 8 12 3.5 20.5 8v8L12 20.5 3.5 16zM3.5 8 12 12.5 20.5 8M12 12.5v8" />, | |
| 24 | + parking: (<><rect x="3.5" y="3.5" width="17" height="17" rx="3.5" /><path d="M9.5 16.5v-9H13a2.9 2.9 0 0 1 0 5.8H9.5" /></>), | |
| 25 | + garage: <path d="M3.5 20V9.5L12 4l8.5 5.5V20M7 20v-6.5h10V20M7 16.8h10" />, | |
| 26 | + sofa: <path d="M5.5 11V8.8A2.8 2.8 0 0 1 8.3 6h7.4a2.8 2.8 0 0 1 2.8 2.8V11M3.5 13.7a2.2 2.2 0 0 1 4.4 0v1h8.2v-1a2.2 2.2 0 0 1 4.4 0V17.5h-17zM5.5 17.5v1.8M18.5 17.5v1.8" />, | |
| 27 | + nosmoke: <path d="M4 4l16 16M14.5 13H4v3h6.5M17.5 13H20v3h-1" />, | |
| 28 | + paw: (<><circle cx="8.2" cy="7.8" r="1.7" /><circle cx="15.8" cy="7.8" r="1.7" /><circle cx="4.8" cy="12" r="1.6" /><circle cx="19.2" cy="12" r="1.6" /><path d="M12 11c2.8 0 5.2 2.3 5.2 4.8 0 1.8-1.4 3-3.2 3-.8 0-1.3-.3-2-.3s-1.2.3-2 .3c-1.8 0-3.2-1.2-3.2-3C6.8 13.3 9.2 11 12 11z" /></>), | |
| 29 | + fire: (<><path d="M4 4.5h16M5.5 4.5V19.5M18.5 4.5V19.5M4 19.5h16" /><path d="M12 8.5c-1.8 2-3 3.4-3 5.3a3 3 0 0 0 6 0c0-1.9-1.2-3.3-3-5.3z" /></>), | |
| 30 | + tree: <path d="M12 3 7.2 9.5h2.4L5.5 15.5h13L14.4 9.5h2.4zM12 15.5V21" />, | |
| 31 | + spa: <path d="M8 3.5c0 1.7-1.4 2-1.4 3.7S8 9.5 8 11M12.5 3.5c0 1.7-1.4 2-1.4 3.7s1.4 2.3 1.4 3.8M17 3.5c0 1.7-1.4 2-1.4 3.7S17 9.5 17 11M3.5 15.5c1.4 1.3 2.8 1.3 4.2 0s2.9-1.3 4.3 0 2.8 1.3 4.2 0 2.9-1.3 4.3 0M3.5 19.5c1.4 1.3 2.8 1.3 4.2 0s2.9-1.3 4.3 0 2.8 1.3 4.2 0 2.9-1.3 4.3 0" />, | |
| 32 | + shield: (<><path d="M12 3 5 6v5.5c0 4.4 3 7.6 7 9.5 4-1.9 7-5.1 7-9.5V6z" /><path d="m9 11.5 2.2 2.2L15.5 9" /></>), | |
| 33 | + eye: (<><path d="M2.5 12S6.3 5.8 12 5.8 21.5 12 21.5 12 17.7 18.2 12 18.2 2.5 12 2.5 12z" /><circle cx="12" cy="12" r="2.8" /></>), | |
| 34 | + waves: <path d="M3 9c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0M3 14c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0M3 19c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0" />, | |
| 35 | + calendar: (<><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8.5 3v4M15.5 3v4M8 14h.01M12 14h.01M16 14h.01" /></>), | |
| 36 | + ruler: <path d="M3.5 16.2 16.2 3.5l4.3 4.3L7.8 20.5zM7.8 15.9l1.8 1.8M10.6 13.1l1.8 1.8M13.4 10.3l1.8 1.8M16.2 7.5 18 9.3" />, | |
| 37 | + bed: <path d="M3.5 18V6.5M3.5 13.5h17V18M3.5 10h6.5v3.5M20.5 13.5v-1a3 3 0 0 0-3-3H10" />, | |
| 38 | + bath: <path d="M4 12.5h16v1.7a4.8 4.8 0 0 1-4.8 4.8H8.8A4.8 4.8 0 0 1 4 14.2zM6 12.5V6a2.5 2.5 0 0 1 4.6-1.3M7 19l-1 1.8M17 19l1 1.8" />, | |
| 39 | + people: (<><circle cx="9" cy="8" r="3.2" /><path d="M3.5 20.5c0-3.1 2.4-5.2 5.5-5.2s5.5 2.1 5.5 5.2M15.5 5.4a3.2 3.2 0 0 1 0 5.2M20.5 20.5c0-2.7-1.8-4.6-4.3-5.1" /></>), | |
| 40 | + stairs: <path d="M3.5 20.5h4.2v-4.2h4.2v-4.2h4.2V7.9h4.4" />, | |
| 41 | + door: (<><rect x="6" y="3" width="12" height="18" rx="1.5" /><path d="M14.8 12h.01M4 21h16" /></>), | |
| 42 | + home: <path d="m3.5 11 8.5-7 8.5 7M6 9.5V20h12V9.5" />, | |
| 43 | + sun: (<><circle cx="12" cy="12" r="4" /><path d="M12 2.5v2.5M12 19v2.5M2.5 12H5M19 12h2.5M5.3 5.3l1.8 1.8M16.9 16.9l1.8 1.8M18.7 5.3l-1.8 1.8M7.1 16.9l-1.8 1.8" /></>), | |
| 44 | + check: <path d="m4.5 12.5 5.3 5.3L19.5 6.5" />, | |
| 45 | + spark: <path d="M12 3.5 13.9 10l6.6 2-6.6 2L12 20.5 10.1 14l-6.6-2 6.6-2z" />, | |
| 46 | +}; | |
| 47 | + | |
| 48 | +/** [pattern (on lowercase accent-free label), icon key] — order = priority */ | |
| 49 | +const RULES: [RegExp, string][] = [ | |
| 50 | + [/dish ?washer/, "dishwasher"], | |
| 51 | + [/washer|dryer|laundry hookup/, "washer"], | |
| 52 | + [/laundry/, "laundry"], | |
| 53 | + [/appliance|fridge|refrigerator|range\b|oven|stove|microwave/, "fridge"], | |
| 54 | + [/air condition|central air|cooling|heat pump|\ba\/?c\b|hvac/, "snow"], | |
| 55 | + [/heat|furnace|radiant|baseboard/, "heat"], | |
| 56 | + [/water heater|hot water/, "droplet"], | |
| 57 | + [/electric|solar|generator/, "bolt"], | |
| 58 | + [/internet|wi-?fi|\bcable\b|fiber/, "wifi"], | |
| 59 | + [/elevator/, "elevator"], | |
| 60 | + [/balcon|terrace|patio|porch|deck|veranda/, "balcony"], | |
| 61 | + [/pool/, "pool"], | |
| 62 | + [/gym|fitness|exercise/, "gym"], | |
| 63 | + [/storage|closet|walk-?in|pantry|shed/, "box"], | |
| 64 | + [/garage/, "garage"], | |
| 65 | + [/parking|carport|driveway/, "parking"], | |
| 66 | + [/furnish/, "sofa"], | |
| 67 | + [/non-?smoking|smoke-?free/, "nosmoke"], | |
| 68 | + [/\bpet\b|\bdog\b|\bcat\b/, "paw"], | |
| 69 | + [/fireplace|wood stove|chimney/, "fire"], | |
| 70 | + [/spa\b|jacuzzi|sauna|hot tub|whirlpool/, "spa"], | |
| 71 | + [/security|surveillance|camera|alarm|intercom|gated|doorman/, "shield"], | |
| 72 | + [/\bview\b|panoram/, "eye"], | |
| 73 | + [/waterfront|lake|river|beach|ocean|dock|boat/, "waves"], | |
| 74 | + [/yard|garden|tree|wooded|lawn|landscap|fence/, "tree"], | |
| 75 | + [/sunn?y|natural light|bright|sunroom|skylight/, "sun"], | |
| 76 | + [/available|move-?in/, "calendar"], | |
| 77 | + [/sq ?ft|square (feet|foot)|acre/, "ruler"], | |
| 78 | + [/bed ?room|\bbed\b/, "bed"], | |
| 79 | + [/bath ?room|\bbath\b|shower/, "bath"], | |
| 80 | + [/occupant|tenant|resident/, "people"], | |
| 81 | + [/stor(y|ies)|floor|stair|level|basement|attic|loft/, "stairs"], | |
| 82 | + [/studio|\bunit\b/, "door"], | |
| 83 | + [/single ?family|ranch|bungalow|house/, "home"], | |
| 84 | + [/sewer|septic/, "waves"], | |
| 85 | + [/water supply|well water|city water/, "droplet"], | |
| 86 | + [/construction|new build|foundation|roof|siding|remodel|renovat|updated/, "home"], | |
| 87 | + [/commercial|zoning|use\b/, "box"], | |
| 88 | +]; | |
| 89 | + | |
| 90 | +/** lowercase + accents neutralized, for robust matching */ | |
| 91 | +const norm = (s: string) => | |
| 92 | + s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); | |
| 93 | + | |
| 94 | +export function amenityKey(label: string, fallback = "check"): string { | |
| 95 | + const n = norm(label); | |
| 96 | + for (const [re, key] of RULES) if (re.test(n)) return key; | |
| 97 | + return fallback; | |
| 98 | +} | |
| 99 | + | |
| 100 | +export default function AmenityIco({ label, size = 16, fallback = "check" }: | |
| 101 | + { label: string; size?: number; fallback?: string }) { | |
| 102 | + return ( | |
| 103 | + <svg | |
| 104 | + width={size} height={size} viewBox="0 0 24 24" aria-hidden="true" | |
| 105 | + fill="none" stroke="currentColor" strokeWidth={1.8} | |
| 106 | + strokeLinecap="round" strokeLinejoin="round" | |
| 107 | + > | |
| 108 | + {ICONS[amenityKey(label, fallback)] ?? ICONS.check} | |
| 109 | + </svg> | |
| 110 | + ); | |
| 111 | +} | |
added
frontend/src/components/Icons.tsx
+188 −0
@@ -0,0 +1,188 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// components/Icons.tsx : HOUSE iconography — 1.7 px strokes on a 24 grid | |
| 5 | +// (zero emoji, zero generic packs). Each icon is a clean stroke path that | |
| 6 | +// inherits the text color (currentColor). | |
| 7 | +// ----------------------------------------------------------------------------- | |
| 8 | +import { ReactNode } from "react"; | |
| 9 | + | |
| 10 | +const P: Record<string, ReactNode> = { | |
| 11 | + // --- navigation ----------------------------------------------------------- | |
| 12 | + home: (<> | |
| 13 | + <path d="M3.5 10.6 12 3.4l8.5 7.2" /> | |
| 14 | + <path d="M5.6 9.4V20h12.8V9.4" /> | |
| 15 | + <path d="M10 20v-5.6h4V20" /> | |
| 16 | + </>), | |
| 17 | + map: (<> | |
| 18 | + <path d="M3.2 6.4 9 4.2l6 2.2 5.8-2.2v13.4L15 19.8l-6-2.2-5.8 2.2z" /> | |
| 19 | + <path d="M9 4.2v13.4M15 6.4v13.4" /> | |
| 20 | + </>), | |
| 21 | + chart: (<> | |
| 22 | + <path d="M4 20h16" /> | |
| 23 | + <path d="M7.2 20v-5.6M12 20V8.8M16.8 20V12" /> | |
| 24 | + </>), | |
| 25 | + building: (<> | |
| 26 | + <path d="M5 20V5.6A1.6 1.6 0 0 1 6.6 4h6.2a1.6 1.6 0 0 1 1.6 1.6V20" /> | |
| 27 | + <path d="M14.4 9.4h3.4A1.6 1.6 0 0 1 19.4 11v9" /> | |
| 28 | + <path d="M3.2 20h17.6" /> | |
| 29 | + <path d="M8 8h2.4M8 11.6h2.4M8 15.2h2.4M16.4 13h.9M16.4 16.2h.9" /> | |
| 30 | + </>), | |
| 31 | + | |
| 32 | + // --- specs propriété -------------------------------------------------------- | |
| 33 | + bed: (<> | |
| 34 | + <path d="M3.4 6.8V18.6" /> | |
| 35 | + <path d="M3.4 15.4h17.2v3.2" /> | |
| 36 | + <path d="M3.4 12.2h6.2v3.2" /> | |
| 37 | + <circle cx="6.5" cy="9.9" r="1.35" /> | |
| 38 | + <path d="M11.4 12.2h5.8a3.4 3.4 0 0 1 3.4 3.2" /> | |
| 39 | + </>), | |
| 40 | + bath: (<> | |
| 41 | + <path d="M3.6 12.4h16.8v1.4a5.2 5.2 0 0 1-5.2 5.2H8.8a5.2 5.2 0 0 1-5.2-5.2z" /> | |
| 42 | + <path d="M5.8 12.4V5.9a2.1 2.1 0 0 1 4-1" /> | |
| 43 | + <path d="m7 19.4-1 2.1M17 19.4l1 2.1" /> | |
| 44 | + </>), | |
| 45 | + drop: (<> | |
| 46 | + <path d="M12 3.6s6 6.4 6 10.6a6 6 0 1 1-12 0C6 10 12 3.6 12 3.6z" /> | |
| 47 | + <path d="M9.4 14.2a2.7 2.7 0 0 0 2 2.6" /> | |
| 48 | + </>), | |
| 49 | + area: (<> | |
| 50 | + <path d="M4.4 19.6 19.6 4.4" /> | |
| 51 | + <path d="M4.4 14v5.6H10" /> | |
| 52 | + <path d="M19.6 10V4.4H14" /> | |
| 53 | + </>), | |
| 54 | + land: (<> | |
| 55 | + <path d="M4.4 9.6v9.8M9.5 9.6v9.8M14.5 9.6v9.8M19.6 9.6v9.8" /> | |
| 56 | + <path d="M3 12.6h18M3 16.4h18" /> | |
| 57 | + <path d="M4.4 9.6 12 5.2l7.6 4.4" /> | |
| 58 | + </>), | |
| 59 | + calendar: (<> | |
| 60 | + <rect x="4" y="5.6" width="16" height="14.8" rx="2" /> | |
| 61 | + <path d="M4 10.2h16M8.2 3.4v4M15.8 3.4v4" /> | |
| 62 | + </>), | |
| 63 | + tag: (<> | |
| 64 | + <path d="m12.9 3.6 7.5 7.5a1.8 1.8 0 0 1 0 2.5l-6.8 6.8a1.8 1.8 0 0 1-2.5 0L3.6 12.9V3.6z" /> | |
| 65 | + <circle cx="8" cy="8" r="1.5" /> | |
| 66 | + </>), | |
| 67 | + camera: (<> | |
| 68 | + <rect x="3.4" y="7" width="17.2" height="13" rx="2" /> | |
| 69 | + <path d="M8.6 7 10 4.4h4L15.4 7" /> | |
| 70 | + <circle cx="12" cy="13.2" r="3.4" /> | |
| 71 | + </>), | |
| 72 | + | |
| 73 | + // --- actions / états ---------------------------------------------------------- | |
| 74 | + search: (<> | |
| 75 | + <circle cx="10.6" cy="10.6" r="6.2" /> | |
| 76 | + <path d="m15.3 15.3 5.3 5.3" /> | |
| 77 | + </>), | |
| 78 | + arrow: (<> | |
| 79 | + <path d="M4 12h15.2" /> | |
| 80 | + <path d="m13.8 6.4 5.4 5.6-5.4 5.6" /> | |
| 81 | + </>), | |
| 82 | + check: (<path d="m5 12.8 4.3 4.4L19 7.4" />), | |
| 83 | + alert: (<> | |
| 84 | + <path d="M12 4.2 2.9 19.4h18.2z" /> | |
| 85 | + <path d="M12 10.2v4.4" /> | |
| 86 | + <path d="M12 17.4v.05" /> | |
| 87 | + </>), | |
| 88 | + phone: (<path d="M5.2 4h3.6L10.4 8.4 8.3 10.1a12.5 12.5 0 0 0 5.6 5.6l1.7-2.1 4.4 1.6v3.6a1.8 1.8 0 0 1-1.9 1.8A16.4 16.4 0 0 1 3.4 5.9 1.8 1.8 0 0 1 5.2 4z" />), | |
| 89 | + trendup: (<> | |
| 90 | + <path d="m3.6 17.4 6-6.4 4 3.6 6.8-7.8" /> | |
| 91 | + <path d="M15.6 6.8h4.8v4.8" /> | |
| 92 | + </>), | |
| 93 | + trenddown: (<> | |
| 94 | + <path d="m3.6 6.8 6 6.4 4-3.6 6.8 7.8" /> | |
| 95 | + <path d="M15.6 17.4h4.8v-4.8" /> | |
| 96 | + </>), | |
| 97 | + external: (<> | |
| 98 | + <path d="M14.4 4h5.6v5.6" /> | |
| 99 | + <path d="M20 4 11.4 12.6" /> | |
| 100 | + <path d="M18.6 13.6V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7.4a2 2 0 0 1 2-2h4.4" /> | |
| 101 | + </>), | |
| 102 | + pin: (<> | |
| 103 | + <path d="M12 21.2S5.4 15.7 5.4 10.8a6.6 6.6 0 0 1 13.2 0c0 4.9-6.6 10.4-6.6 10.4z" /> | |
| 104 | + <circle cx="12" cy="10.6" r="2.3" /> | |
| 105 | + </>), | |
| 106 | + download: (<> | |
| 107 | + <path d="M12 3.6v11.2" /> | |
| 108 | + <path d="m7.2 10.4 4.8 4.8 4.8-4.8" /> | |
| 109 | + <path d="M4.4 19.2h15.2" /> | |
| 110 | + </>), | |
| 111 | + | |
| 112 | + // --- quartier --------------------------------------------------------------------- | |
| 113 | + leaf: (<> | |
| 114 | + <path d="M5 19.2C5 9.6 12 5 20 4.2c0 9-4.2 15-13.2 15z" /> | |
| 115 | + <path d="M5 19.2C7 14 11 10 15 8" /> | |
| 116 | + </>), | |
| 117 | + thermo: (<> | |
| 118 | + <path d="M10.4 4.2a1.9 1.9 0 0 1 3.8 0v8.8a4.2 4.2 0 1 1-3.8 0z" /> | |
| 119 | + <path d="M12.3 8.4v7" /> | |
| 120 | + </>), | |
| 121 | + sun: (<> | |
| 122 | + <circle cx="12" cy="12" r="4.2" /> | |
| 123 | + <path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.8 5.8l1.4 1.4M16.8 16.8l1.4 1.4M18.2 5.8l-1.4 1.4M7.2 16.8l-1.4 1.4" /> | |
| 124 | + </>), | |
| 125 | + shield: (<> | |
| 126 | + <path d="M12 3.4 5.2 5.9v5.9c0 4.6 3 7.6 6.8 8.8 3.8-1.2 6.8-4.2 6.8-8.8V5.9z" /> | |
| 127 | + <path d="m9.2 12 2 2 3.8-4" /> | |
| 128 | + </>), | |
| 129 | + cart: (<> | |
| 130 | + <circle cx="9.6" cy="19.6" r="1.35" /> | |
| 131 | + <circle cx="17" cy="19.6" r="1.35" /> | |
| 132 | + <path d="M3.4 4.4h2.2l2.5 10.8h9.6l2.7-7.8H7" /> | |
| 133 | + </>), | |
| 134 | + bus: (<> | |
| 135 | + <rect x="4.6" y="3.8" width="14.8" height="13.4" rx="2.4" /> | |
| 136 | + <path d="M4.6 10.4h14.8" /> | |
| 137 | + <path d="M7.6 20.2v-3M16.4 20.2v-3" /> | |
| 138 | + <path d="M8.3 14h.05M15.7 14h.05" /> | |
| 139 | + </>), | |
| 140 | + tree: (<> | |
| 141 | + <path d="M12 3.4 6.8 11.4h2.8L5.4 17.8h13.2l-4.2-6.4h2.8z" /> | |
| 142 | + <path d="M12 17.8v3.4" /> | |
| 143 | + </>), | |
| 144 | + school: (<> | |
| 145 | + <path d="m12 4.2 9.8 4.4L12 13 2.2 8.6z" /> | |
| 146 | + <path d="M6.6 10.8v5c0 1.6 2.4 3 5.4 3s5.4-1.4 5.4-3v-5" /> | |
| 147 | + <path d="M21.8 8.6v5.4" /> | |
| 148 | + </>), | |
| 149 | + health: (<> | |
| 150 | + <circle cx="12" cy="12" r="8.4" /> | |
| 151 | + <path d="M12 8.4v7.2M8.4 12h7.2" /> | |
| 152 | + </>), | |
| 153 | + pill: (<> | |
| 154 | + <rect x="3.2" y="8.6" width="17.6" height="6.8" rx="3.4" transform="rotate(-33 12 12)" /> | |
| 155 | + <path d="M12 8.6v6.8" transform="rotate(-33 12 12)" /> | |
| 156 | + </>), | |
| 157 | + people: (<> | |
| 158 | + <circle cx="9" cy="8" r="3.2" /> | |
| 159 | + <path d="M3.6 20a5.4 5.4 0 0 1 10.8 0" /> | |
| 160 | + <path d="M15.4 5.4a3.2 3.2 0 0 1 0 5.9M17 14.8a5.4 5.4 0 0 1 3.4 5.2" /> | |
| 161 | + </>), | |
| 162 | +}; | |
| 163 | + | |
| 164 | +export type IconName = keyof typeof P; | |
| 165 | + | |
| 166 | +export function Ico({ name, size = 18, className = "", stroke = 1.7 }: | |
| 167 | + { name: IconName | string; size?: number; className?: string; stroke?: number }) { | |
| 168 | + const paths = P[name as IconName]; | |
| 169 | + if (!paths) return null; | |
| 170 | + return ( | |
| 171 | + <svg | |
| 172 | + className={`ico ${className}`} | |
| 173 | + width={size} height={size} viewBox="0 0 24 24" | |
| 174 | + fill="none" stroke="currentColor" strokeWidth={stroke} | |
| 175 | + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" | |
| 176 | + > | |
| 177 | + {paths} | |
| 178 | + </svg> | |
| 179 | + ); | |
| 180 | +} | |
| 181 | + | |
| 182 | +/** HTML-string version for map popups (outside React). */ | |
| 183 | +export function icoHTML(name: IconName, size = 30): string { | |
| 184 | + const d: Record<string, string> = { | |
| 185 | + home: '<path d="M3.5 10.6 12 3.4l8.5 7.2"/><path d="M5.6 9.4V20h12.8V9.4"/><path d="M10 20v-5.6h4V20"/>', | |
| 186 | + }; | |
| 187 | + return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${d[name] ?? d.home}</svg>`; | |
| 188 | +} | |
added
frontend/src/components/ListingCard.tsx
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// components/ListingCard.tsx : home card (results grid) | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | +import { Link } from "react-router-dom"; | |
| 7 | +import { Listing, STATUS_LABELS, fmtArea, fmtBaths, fmtCityLine, fmtPrice, sourceName } from "../api"; | |
| 8 | +import { Ico } from "./Icons"; | |
| 9 | +import PropertyImg from "./PropertyImg"; | |
| 10 | + | |
| 11 | +export default function ListingCard({ l }: { l: Listing }) { | |
| 12 | + // lightweight thumbnail if the connector provides one (mobile/cellular), | |
| 13 | + // otherwise the full-size cover photo | |
| 14 | + const img = (typeof l.details?.cover_thumb === "string" && l.details.cover_thumb) | |
| 15 | + || (l.images && l.images.length > 0 ? l.images[0] : null); | |
| 16 | + const meta: { ico: string; txt: string }[] = []; | |
| 17 | + if (l.bedrooms != null) meta.push({ ico: "bed", txt: `${l.bedrooms} bd` }); | |
| 18 | + if (l.bathrooms != null) meta.push({ ico: "bath", txt: `${fmtBaths(l.bathrooms)} ba` }); | |
| 19 | + const area = fmtArea(l.living_area_sqft); | |
| 20 | + if (area) meta.push({ ico: "area", txt: area }); | |
| 21 | + const statusBadge = l.status && l.status !== "active" | |
| 22 | + ? STATUS_LABELS[l.status] ?? l.status : null; | |
| 23 | + | |
| 24 | + return ( | |
| 25 | + <Link to={`/property/${encodeURIComponent(l.uid)}`} className="card"> | |
| 26 | + <div className="card-img"> | |
| 27 | + <PropertyImg src={img} alt={l.street_address || l.title} type={l.property_type} /> | |
| 28 | + {l.property_type && <span className="badge type">{l.property_type}</span>} | |
| 29 | + {statusBadge && <span className="badge status">{statusBadge}</span>} | |
| 30 | + {l.images.length > 1 && ( | |
| 31 | + <span className="badge right"><Ico name="camera" size={12} /> {l.images.length}</span> | |
| 32 | + )} | |
| 33 | + </div> | |
| 34 | + <div className="card-body"> | |
| 35 | + <div className="card-price"> | |
| 36 | + {fmtPrice(l.list_price, l.price_label)} | |
| 37 | + </div> | |
| 38 | + <div className="card-title">{l.street_address || l.title}</div> | |
| 39 | + <div className="card-meta"> | |
| 40 | + <span>{fmtCityLine(l) || "—"}</span> | |
| 41 | + </div> | |
| 42 | + {meta.length > 0 && ( | |
| 43 | + <div className="card-specs"> | |
| 44 | + {meta.map((m) => ( | |
| 45 | + <span key={m.ico}><Ico name={m.ico} size={13} /> {m.txt}</span> | |
| 46 | + ))} | |
| 47 | + </div> | |
| 48 | + )} | |
| 49 | + <div className="card-foot"> | |
| 50 | + <span className="source-tag">{sourceName(l.source)}</span> | |
| 51 | + {l.mls_id && <span className="avail">MLS {l.mls_id}</span>} | |
| 52 | + </div> | |
| 53 | + </div> | |
| 54 | + </Link> | |
| 55 | + ); | |
| 56 | +} | |
added
frontend/src/components/MapView.tsx
+323 −0
@@ -0,0 +1,323 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Author: Simon-Pierre Boucher | |
| 3 | +// Contact: contact@spboucher.ai | |
| 4 | +// Project: Groupe Ka / Ka Maps (Home-Ka integration) | |
| 5 | +// components/MapView.tsx : the Home-Ka MAP MODE (Ka Map System v2) — same | |
| 6 | +// architecture as Lou-Ka Maps, themed green/off-white/ink. | |
| 7 | +// · viewport takeover (KaMapShell): mobile edge-to-edge + 3-notch results | |
| 8 | +// bottom sheet, desktop resizable list|map split; | |
| 9 | +// · viewport-driven data: /api/listings.geojson?bbox=… (Ka Maps adapter — | |
| 10 | +// cancellable requests, never a storm); | |
| 11 | +// · unified toolbar (zoom, 3D, draw, locate), contextual "Search this | |
| 12 | +// area", drawn zone clipped CLIENT-SIDE (setClipPolygon) with a | |
| 13 | +// "N homes in this area" CTA; | |
| 14 | +// · contextual preview card v2: photo, price, swipe/chevrons between | |
| 15 | +// neighboring homes. | |
| 16 | +// ----------------------------------------------------------------------------- | |
| 17 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 18 | +import { useNavigate } from "react-router-dom"; | |
| 19 | +import "mapbox-gl/dist/mapbox-gl.css"; | |
| 20 | +import "@groupe-ka/ka-maps/styles.css"; | |
| 21 | +import type { KaMap, MapProperty } from "@groupe-ka/ka-maps"; | |
| 22 | +import { cameraFromParams, cameraToParams } from "@groupe-ka/ka-maps"; | |
| 23 | +import { | |
| 24 | + KaBrandBadge, | |
| 25 | + KaDrawAreaMode, | |
| 26 | + KaMapShell, | |
| 27 | + KaMapToolbar, | |
| 28 | + KaMapView, | |
| 29 | + KaPropertyPreview, | |
| 30 | + KaToolbar3D, | |
| 31 | + KaToolbarDraw, | |
| 32 | + KaToolbarGroup, | |
| 33 | + KaToolbarLocate, | |
| 34 | + KaToolbarZoom, | |
| 35 | + LoadingIndicator, | |
| 36 | + SearchAreaControl, | |
| 37 | + useKaMap, | |
| 38 | + useKaShell, | |
| 39 | +} from "@groupe-ka/ka-maps/react"; | |
| 40 | +import { Listing, ListingFilters, sourceName } from "../api"; | |
| 41 | +import ListingCard from "./ListingCard"; | |
| 42 | +import { homeKaMapTheme } from "../kamaps/theme"; | |
| 43 | +import { homeKaMapAdapter } from "../kamaps/adapter"; | |
| 44 | +import { MAPBOX_TOKEN } from "../kamaps/config"; | |
| 45 | + | |
| 46 | +// Continental US — initial camera | |
| 47 | +const US_CENTER = { lat: 39.81, lng: -98.55 }; | |
| 48 | +const US_ZOOM = 4; | |
| 49 | + | |
| 50 | +/** Preview card — Ka Map System ka-prevcard structure, Home-Ka content | |
| 51 | + * (the adapter already carries photo/price/traits: no extra fetch). */ | |
| 52 | +function PreviewCard({ p }: { p: MapProperty }) { | |
| 53 | + const navigate = useNavigate(); | |
| 54 | + const extra = (p.extra ?? {}) as { | |
| 55 | + title?: string | null; priceLabel?: string | null; | |
| 56 | + source?: string; state?: string | null; | |
| 57 | + }; | |
| 58 | + const price = p.price != null | |
| 59 | + ? `$${p.price.toLocaleString("en-US", { maximumFractionDigits: 0 })}` | |
| 60 | + : extra.priceLabel || "Price on request"; | |
| 61 | + const meta = [ | |
| 62 | + p.propertyType, | |
| 63 | + p.bedrooms != null ? `${p.bedrooms} bd` : "", | |
| 64 | + p.bathrooms != null ? `${p.bathrooms} ba` : "", | |
| 65 | + [p.city, extra.state].filter(Boolean).join(", "), | |
| 66 | + extra.source ? sourceName(extra.source) : "", | |
| 67 | + ].filter(Boolean).join(" · "); | |
| 68 | + const detail = p.originalUrl ?? "/"; | |
| 69 | + return ( | |
| 70 | + <> | |
| 71 | + <div className="ka-prevcard-media"> | |
| 72 | + {p.thumbnailUrl ? ( | |
| 73 | + <img | |
| 74 | + src={p.thumbnailUrl} alt="" loading="lazy" | |
| 75 | + onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} | |
| 76 | + /> | |
| 77 | + ) : ( | |
| 78 | + <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div> | |
| 79 | + )} | |
| 80 | + </div> | |
| 81 | + <div className="ka-prevcard-body"> | |
| 82 | + <div className="ka-prevcard-price">{price}</div> | |
| 83 | + <div className="ka-prevcard-addr">{p.address ?? extra.title ?? ""}</div> | |
| 84 | + <div className="ka-prevcard-meta">{meta}</div> | |
| 85 | + <div className="ka-prevcard-actions"> | |
| 86 | + <a | |
| 87 | + className="ka-prevcard-cta" | |
| 88 | + href={detail} | |
| 89 | + onClick={(e) => { e.preventDefault(); navigate(detail); }} | |
| 90 | + > | |
| 91 | + View details → | |
| 92 | + </a> | |
| 93 | + </div> | |
| 94 | + </div> | |
| 95 | + </> | |
| 96 | + ); | |
| 97 | +} | |
| 98 | + | |
| 99 | +/** Bridge: exposes the KaMap engine to the parent component (off-canvas). */ | |
| 100 | +function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) { | |
| 101 | + const map = useKaMap(); | |
| 102 | + useEffect(() => { | |
| 103 | + onEngine(map); | |
| 104 | + return () => onEngine(null); | |
| 105 | + }, [map, onEngine]); | |
| 106 | + return null; | |
| 107 | +} | |
| 108 | + | |
| 109 | +/** Mobile: selecting a marker collapses the sheet to mini. */ | |
| 110 | +function SheetAutoCollapse({ selectedUid }: { selectedUid: string | null }) { | |
| 111 | + const shell = useKaShell(); | |
| 112 | + const shellRef = useRef(shell); | |
| 113 | + shellRef.current = shell; | |
| 114 | + useEffect(() => { | |
| 115 | + const s = shellRef.current; | |
| 116 | + if (selectedUid && s?.isMobile) s.setSheet("mini"); | |
| 117 | + }, [selectedUid]); | |
| 118 | + return null; | |
| 119 | +} | |
| 120 | + | |
| 121 | +export interface MapViewProps { | |
| 122 | + filters: ListingFilters; | |
| 123 | + /** Current list page (12 listings) — results panel of the shell. */ | |
| 124 | + listings: Listing[] | null; | |
| 125 | + total: number; | |
| 126 | + page: number; | |
| 127 | + totalPages: number; | |
| 128 | + onPage: (p: number) => void; | |
| 129 | + sort: string; | |
| 130 | + onSort: (s: string) => void; | |
| 131 | + onExit?: () => void; | |
| 132 | + onOpenFilters?: () => void; | |
| 133 | + filtersCount?: number; | |
| 134 | +} | |
| 135 | + | |
| 136 | +export default function MapView({ | |
| 137 | + filters, listings, total, page, totalPages, onPage, sort, onSort, | |
| 138 | + onExit, onOpenFilters, filtersCount = 0, | |
| 139 | +}: MapViewProps) { | |
| 140 | + // initial camera: shared URL (?lat&lng&zoom), otherwise continental US | |
| 141 | + const initialCamera = useMemo(() => { | |
| 142 | + const cam = cameraFromParams(new URLSearchParams(window.location.search)); | |
| 143 | + return cam ?? { ...US_CENTER, zoom: US_ZOOM }; | |
| 144 | + }, []); | |
| 145 | + | |
| 146 | + const [selectedUid, setSelectedUid] = useState<string | null>(null); | |
| 147 | + const [mapCount, setMapCount] = useState<number | null>(null); | |
| 148 | + const [hasZone, setHasZone] = useState(false); | |
| 149 | + const [isMobile, setIsMobile] = useState( | |
| 150 | + () => window.matchMedia("(max-width: 780px)").matches); | |
| 151 | + const engineRef = useRef<KaMap | null>(null); | |
| 152 | + const listRef = useRef<HTMLDivElement | null>(null); | |
| 153 | + | |
| 154 | + useEffect(() => { | |
| 155 | + const mq = window.matchMedia("(max-width: 780px)"); | |
| 156 | + const update = () => setIsMobile(mq.matches); | |
| 157 | + mq.addEventListener("change", update); | |
| 158 | + return () => mq.removeEventListener("change", update); | |
| 159 | + }, []); | |
| 160 | + | |
| 161 | + // camera → URL (replaceState: no router re-render) | |
| 162 | + const onMoveEnd = useCallback((center: { lat: number; lng: number }, zoom: number) => { | |
| 163 | + const url = new URL(window.location.href); | |
| 164 | + url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString(); | |
| 165 | + window.history.replaceState(null, "", url); | |
| 166 | + }, []); | |
| 167 | + | |
| 168 | + const mapFilters = useMemo( | |
| 169 | + () => Object.fromEntries(Object.entries(filters).filter(([, v]) => v)), | |
| 170 | + [filters], | |
| 171 | + ); | |
| 172 | + | |
| 173 | + const handleEngine = useCallback((m: KaMap | null) => { | |
| 174 | + engineRef.current = m; | |
| 175 | + }, []); | |
| 176 | + | |
| 177 | + // map selection: preview card + list card scrolled into view | |
| 178 | + const onSelect = useCallback((p: MapProperty | null) => { | |
| 179 | + const uid = p?.id ?? null; | |
| 180 | + setSelectedUid(uid); | |
| 181 | + if (!uid) return; | |
| 182 | + const card = listRef.current?.querySelector<HTMLElement>( | |
| 183 | + `[data-uid="${CSS.escape(uid)}"]`); | |
| 184 | + card?.scrollIntoView({ behavior: "smooth", block: "nearest" }); | |
| 185 | + }, []); | |
| 186 | + | |
| 187 | + // drawn zone: CLIENT-SIDE clip of the displayed set (the Home-Ka API does | |
| 188 | + // not filter by polygon) — the CTA count comes from the data event. | |
| 189 | + const onDraw = useCallback((polygon: [number, number][] | null, drawing: boolean) => { | |
| 190 | + if (drawing) return; | |
| 191 | + setHasZone(polygon !== null); | |
| 192 | + engineRef.current?.setClipPolygon(polygon); | |
| 193 | + }, []); | |
| 194 | + | |
| 195 | + const listHeader = ( | |
| 196 | + <div className="ms2-head"> | |
| 197 | + <div className="ms2-count" role="status" aria-live="polite"> | |
| 198 | + <b>{total.toLocaleString("en-US")}</b> | |
| 199 | + {" "}home{total !== 1 ? "s" : ""} | |
| 200 | + {mapCount != null && hasZone && ( | |
| 201 | + <span className="ms2-zone">{mapCount.toLocaleString("en-US")} in this area</span> | |
| 202 | + )} | |
| 203 | + </div> | |
| 204 | + <label className="ms2-sort"> | |
| 205 | + <select | |
| 206 | + value={sort} | |
| 207 | + onChange={(e) => onSort(e.target.value)} | |
| 208 | + aria-label="Sort results" | |
| 209 | + > | |
| 210 | + <option value="recent">Newest</option> | |
| 211 | + <option value="price_asc">Price: low to high</option> | |
| 212 | + <option value="price_desc">Price: high to low</option> | |
| 213 | + </select> | |
| 214 | + </label> | |
| 215 | + </div> | |
| 216 | + ); | |
| 217 | + | |
| 218 | + const listPane = ( | |
| 219 | + <div className="ms2-list" aria-label="List results" ref={listRef}> | |
| 220 | + {(listings ?? []).map((l) => ( | |
| 221 | + <div | |
| 222 | + key={l.uid} | |
| 223 | + data-uid={l.uid} | |
| 224 | + className={`map-card${selectedUid === l.uid ? " map-card-sel" : ""}`} | |
| 225 | + onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")} | |
| 226 | + onMouseLeave={() => engineRef.current?.setHovered(null, "app")} | |
| 227 | + > | |
| 228 | + <ListingCard l={l} /> | |
| 229 | + </div> | |
| 230 | + ))} | |
| 231 | + {listings !== null && listings.length === 0 && ( | |
| 232 | + <div className="ms-empty" role="status"> | |
| 233 | + <h3>No homes match your criteria</h3> | |
| 234 | + <p>Try widening your search.</p> | |
| 235 | + </div> | |
| 236 | + )} | |
| 237 | + {totalPages > 1 && ( | |
| 238 | + <nav className="pager ms2-pager" aria-label="Pagination"> | |
| 239 | + <button className="pager-btn" onClick={() => { | |
| 240 | + onPage(page - 1); | |
| 241 | + listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" }); | |
| 242 | + }} disabled={page <= 1}>‹ Prev</button> | |
| 243 | + <span className="pager-info">Page {page} / {totalPages}</span> | |
| 244 | + <button className="pager-btn" onClick={() => { | |
| 245 | + onPage(page + 1); | |
| 246 | + listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" }); | |
| 247 | + }} disabled={page >= totalPages}>Next ›</button> | |
| 248 | + </nav> | |
| 249 | + )} | |
| 250 | + </div> | |
| 251 | + ); | |
| 252 | + | |
| 253 | + return ( | |
| 254 | + <KaMapShell | |
| 255 | + className="ms2" | |
| 256 | + brand={<span className="ms2-brand"><b>Home·Ka</b><span>Map</span></span>} | |
| 257 | + onExit={onExit} | |
| 258 | + exitLabel="List" | |
| 259 | + storageKey="homeka-map-split" | |
| 260 | + listHeader={listHeader} | |
| 261 | + list={listPane} | |
| 262 | + topExtras={ | |
| 263 | + onOpenFilters ? ( | |
| 264 | + <button className="ka-top-btn" onClick={onOpenFilters}> | |
| 265 | + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true"> | |
| 266 | + <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" /> | |
| 267 | + </svg> | |
| 268 | + Filters | |
| 269 | + {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>} | |
| 270 | + </button> | |
| 271 | + ) : null | |
| 272 | + } | |
| 273 | + > | |
| 274 | + <KaMapView | |
| 275 | + theme={homeKaMapTheme} | |
| 276 | + adapter={homeKaMapAdapter} | |
| 277 | + mapboxToken={MAPBOX_TOKEN} | |
| 278 | + filters={mapFilters} | |
| 279 | + center={initialCamera} | |
| 280 | + zoom={initialCamera.zoom} | |
| 281 | + pitch={50} | |
| 282 | + // Realistic rendering: full-color Standard + 3D landmarks — same | |
| 283 | + // settings as Lou-Ka Maps. | |
| 284 | + basemap={{ theme: "default", showLandmarks: true }} | |
| 285 | + // valueClamp: bounds each LIST PRICE's contribution to the cluster | |
| 286 | + // bubble (a $20M mansion does not skew the average) | |
| 287 | + cluster={{ maxZoom: 15, valueClamp: [100_000, 3_000_000], valueMinCount: 10 }} | |
| 288 | + searchMode="manual" | |
| 289 | + navControl={false} | |
| 290 | + onMoveEnd={onMoveEnd} | |
| 291 | + onSelect={onSelect} | |
| 292 | + onData={(count) => setMapCount(count)} | |
| 293 | + onDraw={onDraw} | |
| 294 | + > | |
| 295 | + <EngineBridge onEngine={handleEngine} /> | |
| 296 | + <SheetAutoCollapse selectedUid={selectedUid} /> | |
| 297 | + <KaBrandBadge /> | |
| 298 | + | |
| 299 | + {/* THE control cluster — zoom (desktop), 3D, draw, locate */} | |
| 300 | + <KaMapToolbar> | |
| 301 | + {!isMobile && ( | |
| 302 | + <KaToolbarGroup><KaToolbarZoom /></KaToolbarGroup> | |
| 303 | + )} | |
| 304 | + <KaToolbarGroup> | |
| 305 | + <KaToolbar3D /> | |
| 306 | + <KaToolbarDraw /> | |
| 307 | + <KaToolbarLocate /> | |
| 308 | + </KaToolbarGroup> | |
| 309 | + </KaMapToolbar> | |
| 310 | + | |
| 311 | + {/* draw mode: temporary banner + "N homes" CTA */} | |
| 312 | + <KaDrawAreaMode | |
| 313 | + formatCount={(n) => `${n.toLocaleString("en-US")} home${n !== 1 ? "s" : ""}`} | |
| 314 | + onClear={() => engineRef.current?.setClipPolygon(null)} | |
| 315 | + /> | |
| 316 | + | |
| 317 | + <SearchAreaControl /> | |
| 318 | + <LoadingIndicator label="Updating homes…" /> | |
| 319 | + <KaPropertyPreview render={(p) => <PreviewCard p={p} />} /> | |
| 320 | + </KaMapView> | |
| 321 | + </KaMapShell> | |
| 322 | + ); | |
| 323 | +} | |
added
frontend/src/components/PropertyImg.tsx
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// components/PropertyImg.tsx : robust property image | |
| 5 | +// · <TypeFallback/> : elegant PER-PROPERTY-TYPE fallback in Home-Ka colors — | |
| 6 | +// never a broken-image icon or a gray void. | |
| 7 | +// · <PropertyImg/> : <img> with automatic fallback if the URL fails to load. | |
| 8 | +// ----------------------------------------------------------------------------- | |
| 9 | +import { useState } from "react"; | |
| 10 | +import { Ico } from "./Icons"; | |
| 11 | + | |
| 12 | +const TYPE_ICONS: Record<string, string> = { | |
| 13 | + "Single Family": "home", "Single-Family": "home", "House": "home", | |
| 14 | + "Townhouse": "home", "Mobile Home": "home", "Manufactured": "home", | |
| 15 | + "Cabin": "tree", "Condo": "building", "Condominium": "building", | |
| 16 | + "Apartment": "building", "Multi-Family": "building", "Duplex": "building", | |
| 17 | + "Triplex": "building", "Land": "land", "Lot": "land", "Farm": "leaf", | |
| 18 | + "Ranch": "leaf", "Commercial": "cart", | |
| 19 | +}; | |
| 20 | + | |
| 21 | +/** Fallback image per property type (green gradient + icon + label). */ | |
| 22 | +export function TypeFallback({ type, label = true }: { type?: string; label?: boolean }) { | |
| 23 | + const ico = TYPE_ICONS[type ?? ""] ?? "home"; | |
| 24 | + return ( | |
| 25 | + <div className="type-fallback" aria-label={type || "Property"}> | |
| 26 | + <Ico name={ico} size={40} /> | |
| 27 | + {label && <span>{type || "Photos coming soon"}</span>} | |
| 28 | + </div> | |
| 29 | + ); | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** <img> that switches to the type fallback if loading fails. */ | |
| 33 | +export default function PropertyImg({ | |
| 34 | + src, alt, type, eager = false, | |
| 35 | +}: { src?: string | null; alt: string; type?: string; eager?: boolean }) { | |
| 36 | + const [broken, setBroken] = useState(false); | |
| 37 | + if (!src || broken) return <TypeFallback type={type} />; | |
| 38 | + return ( | |
| 39 | + <img | |
| 40 | + src={src} | |
| 41 | + alt={alt} | |
| 42 | + loading={eager ? "eager" : "lazy"} | |
| 43 | + decoding="async" | |
| 44 | + onError={() => setBroken(true)} | |
| 45 | + /> | |
| 46 | + ); | |
| 47 | +} | |
added
frontend/src/components/PropertyMap.tsx
+70 −0
@@ -0,0 +1,70 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Author: Simon-Pierre Boucher | |
| 3 | +// Contact: contact@spboucher.ai | |
| 4 | +// Project: Groupe Ka / Ka Maps (Home-Ka integration) | |
| 5 | +// components/PropertyMap.tsx : 3D mini-map of the detail page — same component | |
| 6 | +// as Lou-Ka (KaSpotlightMap): tight camera on the address, realistic Mapbox | |
| 7 | +// Standard basemap, the listing's building highlighted in Home-Ka GREEN. | |
| 8 | +// Lazily loaded from Listing.tsx. | |
| 9 | +// ----------------------------------------------------------------------------- | |
| 10 | +import { useMemo } from "react"; | |
| 11 | +import "mapbox-gl/dist/mapbox-gl.css"; | |
| 12 | +import "@groupe-ka/ka-maps/styles.css"; | |
| 13 | +import type { MapProperty } from "@groupe-ka/ka-maps"; | |
| 14 | +import { KaBrandBadge, KaSpotlightMap } from "@groupe-ka/ka-maps/react"; | |
| 15 | +import { homeKaMapTheme } from "../kamaps/theme"; | |
| 16 | +import { HOMEKA_APP_SOURCE } from "../kamaps/adapter"; | |
| 17 | +import { MAPBOX_TOKEN } from "../kamaps/config"; | |
| 18 | + | |
| 19 | +/** Home-Ka signal green — same language as the brand accent. */ | |
| 20 | +export const BUILDING_GREEN = "#0f7b4a"; | |
| 21 | + | |
| 22 | +export interface PropertyMapProps { | |
| 23 | + uid: string; | |
| 24 | + lat: number; | |
| 25 | + lng: number; | |
| 26 | + price?: number | null; | |
| 27 | + propertyType?: string; | |
| 28 | + address?: string; | |
| 29 | + city?: string; | |
| 30 | + image?: string | null; | |
| 31 | + deal?: boolean; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export default function PropertyMap({ | |
| 35 | + uid, lat, lng, price, propertyType, address, city, image, deal, | |
| 36 | +}: PropertyMapProps) { | |
| 37 | + const property = useMemo<MapProperty>(() => ({ | |
| 38 | + id: uid, | |
| 39 | + appSource: HOMEKA_APP_SOURCE, | |
| 40 | + latitude: lat, | |
| 41 | + longitude: lng, | |
| 42 | + kind: "listing", | |
| 43 | + listingType: "sale", | |
| 44 | + price: price ?? undefined, | |
| 45 | + propertyType: propertyType || undefined, | |
| 46 | + address: address || undefined, | |
| 47 | + city: city || undefined, | |
| 48 | + thumbnailUrl: image ?? undefined, | |
| 49 | + highlight: deal ?? false, | |
| 50 | + }), [uid, lat, lng, price, propertyType, address, city, image, deal]); | |
| 51 | + | |
| 52 | + return ( | |
| 53 | + <div | |
| 54 | + className="lmap3d" role="img" | |
| 55 | + aria-label={`3D map — ${address || "property"}, the listed building in green`} | |
| 56 | + > | |
| 57 | + <KaSpotlightMap | |
| 58 | + theme={homeKaMapTheme} | |
| 59 | + mapboxToken={MAPBOX_TOKEN} | |
| 60 | + property={property} | |
| 61 | + buildingColor={BUILDING_GREEN} | |
| 62 | + > | |
| 63 | + <KaBrandBadge /> | |
| 64 | + </KaSpotlightMap> | |
| 65 | + <span className="lmap3d-legende" aria-hidden="true"> | |
| 66 | + <i /> Listed building | |
| 67 | + </span> | |
| 68 | + </div> | |
| 69 | + ); | |
| 70 | +} | |
added
frontend/src/ka/GroupeKaBadge.tsx
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// ka-ui/react/GroupeKaBadge.tsx — badge « Un service Groupe KA », cliquable | |
| 3 | +// vers le hub. À placer dans le header de chaque site (à côté du logo de la | |
| 4 | +// marque). Variante dark pour les surfaces encre. Zone de protection : le | |
| 5 | +// padding interne du badge suffit ; ne rien coller à moins de 8 px du badge. | |
| 6 | +export default function GroupeKaBadge({ dark = false }: { dark?: boolean }) { | |
| 7 | + return ( | |
| 8 | + <a | |
| 9 | + className={`gk-badge${dark ? " gk-badge--dark" : ""}`} | |
| 10 | + href="https://www.groupe-ka.com" | |
| 11 | + target="_blank" | |
| 12 | + rel="noopener noreferrer" | |
| 13 | + aria-label="A Groupe KA service — visit the ecosystem portal" | |
| 14 | + > | |
| 15 | + A service by | |
| 16 | + <b> | |
| 17 | + Groupe<span className="ka">KA</span> | |
| 18 | + </b> | |
| 19 | + </a> | |
| 20 | + ); | |
| 21 | +} | |
added
frontend/src/ka/KaFooter.tsx
+102 −0
@@ -0,0 +1,102 @@ | ||
| 1 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// ka-ui/react/KaFooter.tsx — shared Groupe KA footer (ink background), | |
| 3 | +// vendored in each Vite/React app under frontend/src/ka/. Consumes | |
| 4 | +// ecosystem.json for the site list; copy localized in US English for Home-Ka. | |
| 5 | +import eco from "./ecosystem.json"; | |
| 6 | + | |
| 7 | +type LocalLink = { label: string; href: string }; | |
| 8 | + | |
| 9 | +const TAGLINE_EN = | |
| 10 | + "A holding of fully automated product and service aggregators."; | |
| 11 | +const DISCLAIMER_EN = | |
| 12 | + "Groupe KA is a content aggregator: we sell nothing, rent nothing and are party to no transaction."; | |
| 13 | + | |
| 14 | +// English labels for the hub legal pages (hrefs unchanged) | |
| 15 | +const LEGAL_LABELS_EN: Record<string, string> = { | |
| 16 | + "https://www.groupe-ka.com/conditions": "Terms of Use (Groupe KA)", | |
| 17 | + "https://www.groupe-ka.com/confidentialite": "Privacy Policy (Groupe KA)", | |
| 18 | + "https://www.groupe-ka.com/loi-25": "Personal information protection", | |
| 19 | + "https://www.groupe-ka.com/bots": "Crawler transparency", | |
| 20 | +}; | |
| 21 | + | |
| 22 | +export default function KaFooter({ | |
| 23 | + siteId, | |
| 24 | + localLegal = [], | |
| 25 | +}: { | |
| 26 | + /** id of the current site in ecosystem.json (e.g. "home-ka") */ | |
| 27 | + siteId: string; | |
| 28 | + /** legal pages OWNED by the site (e.g. /terms), shown before the hub's */ | |
| 29 | + localLegal?: LocalLink[]; | |
| 30 | +}) { | |
| 31 | + const year = new Date().getFullYear(); | |
| 32 | + // graceful fallback if the siteId is not (yet) in ecosystem.json | |
| 33 | + const site = eco.sites.find((s) => s.id === siteId) ?? null; | |
| 34 | + const others = eco.sites.filter((s) => s.id !== siteId); | |
| 35 | + return ( | |
| 36 | + <footer className="ka-footer" id="contact"> | |
| 37 | + <div className="container"> | |
| 38 | + <a | |
| 39 | + className="wordmark" | |
| 40 | + href={eco.hub.url} | |
| 41 | + target={siteId === "groupe-ka" ? undefined : "_blank"} | |
| 42 | + rel="noopener noreferrer" | |
| 43 | + aria-label="Groupe KA — the ecosystem portal" | |
| 44 | + > | |
| 45 | + Groupe <span className="ka">KA</span> | |
| 46 | + </a> | |
| 47 | + <p className="desc"> | |
| 48 | + {TAGLINE_EN} Hundreds of connectors read the sites at the source, | |
| 49 | + normalize the data and resynchronize on their own.{" "} | |
| 50 | + {site ? ( | |
| 51 | + <> | |
| 52 | + <b style={{ color: "var(--paper)" }}>{site.wordmark}</b> —{" "} | |
| 53 | + {site.tagline} — is a Groupe KA service. | |
| 54 | + </> | |
| 55 | + ) : null} | |
| 56 | + </p> | |
| 57 | + <p className="notice"> | |
| 58 | + <b>{DISCLAIMER_EN}</b> | |
| 59 | + </p> | |
| 60 | + <ul className="sites"> | |
| 61 | + {others.map((s) => ( | |
| 62 | + <li key={s.id}> | |
| 63 | + <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer"> | |
| 64 | + {s.wordmark} | |
| 65 | + </a> | |
| 66 | + </li> | |
| 67 | + ))} | |
| 68 | + {eco.extraFooterLinks.map((l) => ( | |
| 69 | + <li key={l.href}> | |
| 70 | + <a href={l.href} target="_blank" rel="noopener noreferrer"> | |
| 71 | + {l.label} | |
| 72 | + </a> | |
| 73 | + </li> | |
| 74 | + ))} | |
| 75 | + </ul> | |
| 76 | + <div className="contacts"> | |
| 77 | + <p style={{ margin: 0 }}> | |
| 78 | + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a> | |
| 79 | + <span>All Home-Ka inquiries — data, partnerships, legal & press</span> | |
| 80 | + </p> | |
| 81 | + </div> | |
| 82 | + <p className="legal"> | |
| 83 | + © {year} {eco.org.copyrightHolder} | |
| 84 | + {localLegal.map((l) => ( | |
| 85 | + <span key={l.href}> | |
| 86 | + {" · "} | |
| 87 | + <a href={l.href}>{l.label}</a> | |
| 88 | + </span> | |
| 89 | + ))} | |
| 90 | + {eco.legal.map((l) => ( | |
| 91 | + <span key={l.href}> | |
| 92 | + {" · "} | |
| 93 | + <a href={l.href} target="_blank" rel="noopener noreferrer"> | |
| 94 | + {LEGAL_LABELS_EN[l.href] ?? l.label} | |
| 95 | + </a> | |
| 96 | + </span> | |
| 97 | + ))} | |
| 98 | + </p> | |
| 99 | + </div> | |
| 100 | + </footer> | |
| 101 | + ); | |
| 102 | +} | |
added
frontend/src/ka/ecosystem.json
+216 −0
@@ -0,0 +1,216 @@ | ||
| 1 | +{ | |
| 2 | + "org": { | |
| 3 | + "name": "Groupe KA", | |
| 4 | + "legalName": "Groupe KA — Simon-Pierre Boucher", | |
| 5 | + "tagline": "Holding québécois d'agrégateurs de produits et services entièrement automatisés.", | |
| 6 | + "disclaimer": "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction.", | |
| 7 | + "copyrightHolder": "Groupe KA — Simon-Pierre Boucher" | |
| 8 | + }, | |
| 9 | + "hub": { | |
| 10 | + "url": "https://www.groupe-ka.com", | |
| 11 | + "loginPath": "/connexion", | |
| 12 | + "signupNote": "La création de compte KA ID se fait sur le hub groupe-ka.com ; chaque site délègue sa connexion via /api/auth/ka/login." | |
| 13 | + }, | |
| 14 | + "contacts": [ | |
| 15 | + { | |
| 16 | + "email": "contact@groupe-ka.com", | |
| 17 | + "role": "Projets, partenariats & données" | |
| 18 | + }, | |
| 19 | + { | |
| 20 | + "email": "info@groupe-ka.com", | |
| 21 | + "role": "Médias & questions générales" | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "email": "admin@groupe-ka.com", | |
| 25 | + "role": "Légal, vie privée & Loi 25" | |
| 26 | + } | |
| 27 | + ], | |
| 28 | + "legal": [ | |
| 29 | + { | |
| 30 | + "label": "Conditions d'utilisation", | |
| 31 | + "href": "https://www.groupe-ka.com/conditions" | |
| 32 | + }, | |
| 33 | + { | |
| 34 | + "label": "Politique de confidentialité", | |
| 35 | + "href": "https://www.groupe-ka.com/confidentialite" | |
| 36 | + }, | |
| 37 | + { | |
| 38 | + "label": "Protection des renseignements personnels (Loi 25)", | |
| 39 | + "href": "https://www.groupe-ka.com/loi-25" | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + "label": "Transparence des robots d'indexation", | |
| 43 | + "href": "https://www.groupe-ka.com/bots" | |
| 44 | + } | |
| 45 | + ], | |
| 46 | + "sites": [ | |
| 47 | + { | |
| 48 | + "id": "groupe-ka", | |
| 49 | + "wordmark": "Groupe KA", | |
| 50 | + "domain": "www.groupe-ka.com", | |
| 51 | + "accent": "#d9f26b", | |
| 52 | + "accentSoft": "#f0f9d2", | |
| 53 | + "accentDeep": "#123f2e", | |
| 54 | + "onAccent": "#141814", | |
| 55 | + "tagline": "Le portail de l'écosystème ·Ka" | |
| 56 | + }, | |
| 57 | + { | |
| 58 | + "id": "trouve-ka", | |
| 59 | + "wordmark": "Trouve·Ka", | |
| 60 | + "domain": "www.trouve-ka.com", | |
| 61 | + "accent": "#1c7ed6", | |
| 62 | + "accentSoft": "#e7f2fd", | |
| 63 | + "accentDeep": "#14508f", | |
| 64 | + "onAccent": "#ffffff", | |
| 65 | + "tagline": "Le moteur de recherche du web québécois" | |
| 66 | + }, | |
| 67 | + { | |
| 68 | + "id": "lou-ka", | |
| 69 | + "wordmark": "Lou·Ka", | |
| 70 | + "domain": "www.lou-ka.com", | |
| 71 | + "accent": "#ff6a00", | |
| 72 | + "accentSoft": "#fff1e6", | |
| 73 | + "accentDeep": "#cc5500", | |
| 74 | + "onAccent": "#ffffff", | |
| 75 | + "tagline": "Tous les logements à louer" | |
| 76 | + }, | |
| 77 | + { | |
| 78 | + "id": "immo-ka", | |
| 79 | + "wordmark": "Immo·Ka", | |
| 80 | + "domain": "www.immo-ka.com", | |
| 81 | + "accent": "#e23744", | |
| 82 | + "accentSoft": "#fbe0e2", | |
| 83 | + "accentDeep": "#a8232e", | |
| 84 | + "onAccent": "#ffffff", | |
| 85 | + "tagline": "Toutes les propriétés à vendre" | |
| 86 | + }, | |
| 87 | + { | |
| 88 | + "id": "home-ka", | |
| 89 | + "wordmark": "Home·Ka", | |
| 90 | + "domain": "www.home-ka.com", | |
| 91 | + "accent": "#0f7b4a", | |
| 92 | + "accentSoft": "#dcefe5", | |
| 93 | + "accentDeep": "#0b5e38", | |
| 94 | + "onAccent": "#ffffff", | |
| 95 | + "tagline": "Every home for sale in America" | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + "id": "vrai-prix", | |
| 99 | + "wordmark": "Vrai-Prix", | |
| 100 | + "domain": "www.vrai-prix.com", | |
| 101 | + "accent": "#ff5148", | |
| 102 | + "accentSoft": "#ffe3e0", | |
| 103 | + "accentDeep": "#9e2a25", | |
| 104 | + "onAccent": "#ffffff", | |
| 105 | + "tagline": "La valeur réelle de chaque propriété" | |
| 106 | + }, | |
| 107 | + { | |
| 108 | + "id": "auto-ka", | |
| 109 | + "wordmark": "Auto·Ka", | |
| 110 | + "domain": "www.auto-ka.com", | |
| 111 | + "accent": "#ff5a2a", | |
| 112 | + "accentSoft": "#ffe8de", | |
| 113 | + "accentDeep": "#cc3f16", | |
| 114 | + "onAccent": "#ffffff", | |
| 115 | + "tagline": "Les voitures usagées du Québec" | |
| 116 | + }, | |
| 117 | + { | |
| 118 | + "id": "fabri-ka", | |
| 119 | + "wordmark": "Fabri·Ka", | |
| 120 | + "domain": "www.fabri-ka.com", | |
| 121 | + "accent": "#c4532e", | |
| 122 | + "accentSoft": "#f7e3da", | |
| 123 | + "accentDeep": "#a94525", | |
| 124 | + "onAccent": "#ffffff", | |
| 125 | + "tagline": "Les produits fabriqués au Québec" | |
| 126 | + }, | |
| 127 | + { | |
| 128 | + "id": "food-ka", | |
| 129 | + "wordmark": "Food·Ka", | |
| 130 | + "domain": "www.food-ka.com", | |
| 131 | + "accent": "#1f9d55", | |
| 132 | + "accentSoft": "#e2f5ea", | |
| 133 | + "accentDeep": "#157a40", | |
| 134 | + "onAccent": "#ffffff", | |
| 135 | + "tagline": "Les prix d'épicerie, suivis à la source" | |
| 136 | + }, | |
| 137 | + { | |
| 138 | + "id": "resto-ka", | |
| 139 | + "wordmark": "Resto·Ka", | |
| 140 | + "domain": "www.resto-ka.com", | |
| 141 | + "accent": "#f08c00", | |
| 142 | + "accentSoft": "#fdeed7", | |
| 143 | + "accentDeep": "#b96a00", | |
| 144 | + "onAccent": "#141814", | |
| 145 | + "tagline": "Chaque resto, chaque plat, chaque prix" | |
| 146 | + }, | |
| 147 | + { | |
| 148 | + "id": "sorti-ka", | |
| 149 | + "wordmark": "Sorti·Ka", | |
| 150 | + "domain": "www.sorti-ka.com", | |
| 151 | + "accent": "#d6336c", | |
| 152 | + "accentSoft": "#fbe0eb", | |
| 153 | + "accentDeep": "#a12551", | |
| 154 | + "onAccent": "#ffffff", | |
| 155 | + "tagline": "Toutes les sorties, dans les 17 régions" | |
| 156 | + }, | |
| 157 | + { | |
| 158 | + "id": "crea-ka", | |
| 159 | + "wordmark": "Créa·Ka", | |
| 160 | + "domain": "www.crea-ka.com", | |
| 161 | + "accent": "#7048e8", | |
| 162 | + "accentSoft": "#ece5fc", | |
| 163 | + "accentDeep": "#5433b8", | |
| 164 | + "onAccent": "#ffffff", | |
| 165 | + "tagline": "Les créateurs d'ici, tous leurs liens" | |
| 166 | + }, | |
| 167 | + { | |
| 168 | + "id": "api-ka", | |
| 169 | + "wordmark": "API·Ka", | |
| 170 | + "domain": "www.api-ka.com", | |
| 171 | + "accent": "#3b5bdb", | |
| 172 | + "accentSoft": "#e4eafb", | |
| 173 | + "accentDeep": "#2b44a8", | |
| 174 | + "onAccent": "#ffffff", | |
| 175 | + "tagline": "La donnée de l'écosystème, par API" | |
| 176 | + }, | |
| 177 | + { | |
| 178 | + "id": "job-ka", | |
| 179 | + "wordmark": "Job·Ka", | |
| 180 | + "domain": "www.job-ka.com", | |
| 181 | + "accent": "#0c8599", | |
| 182 | + "accentSoft": "#def0f4", | |
| 183 | + "accentDeep": "#095c6b", | |
| 184 | + "onAccent": "#ffffff", | |
| 185 | + "tagline": "Tous les emplois des employeurs québécois" | |
| 186 | + }, | |
| 187 | + { | |
| 188 | + "id": "ka-stats", | |
| 189 | + "wordmark": "Ka·Stats", | |
| 190 | + "domain": "www.ka-stats.com", | |
| 191 | + "accent": "#095797", | |
| 192 | + "accentSoft": "#e2edf6", | |
| 193 | + "accentDeep": "#063a63", | |
| 194 | + "onAccent": "#ffffff", | |
| 195 | + "tagline": "L'explorateur de statistiques du Québec" | |
| 196 | + } | |
| 197 | + ], | |
| 198 | + "extraFooterLinks": [ | |
| 199 | + { | |
| 200 | + "label": "ValoPlex", | |
| 201 | + "href": "https://www.valoplex.com" | |
| 202 | + }, | |
| 203 | + { | |
| 204 | + "label": "Ka2", | |
| 205 | + "href": "https://www.ka2.bot" | |
| 206 | + }, | |
| 207 | + { | |
| 208 | + "label": "Ka4", | |
| 209 | + "href": "https://www.ka4.bot" | |
| 210 | + }, | |
| 211 | + { | |
| 212 | + "label": "Ka6", | |
| 213 | + "href": "https://www.ka6.bot" | |
| 214 | + } | |
| 215 | + ] | |
| 216 | +} | |
| \ No newline at end of file | ||
added
frontend/src/ka/tokens.css
+319 −0
@@ -0,0 +1,319 @@ | ||
| 1 | +/* ----------------------------------------------------------------------------- | |
| 2 | + Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | + Fichier : ka-ui/tokens.css — SOURCE CANONIQUE (repo ka-ui sur spbgit) | |
| 4 | + Desc. : Design system commun GROUPE KA « éditorial sharp » — tokens + socle | |
| 5 | + de composants partagés par les 12 plateformes. Chaque site importe ce | |
| 6 | + fichier PUIS surcharge uniquement son accent (voir accents.css). | |
| 7 | + | |
| 8 | + · Typo : display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono | |
| 9 | + · Palette: papier #f5f3ee · encre #141814 · vert profond #1c5c41 + ACCENT du site | |
| 10 | + · Signature : bordures encre 1,5–2 px + ombres décalées dures, grain de film, | |
| 11 | + surlignés d'accent inclinés (néo-brutalisme raffiné) | |
| 12 | + · Breakpoints communs : 360 / 768 / 1024 / 1440 px (mobile-first) | |
| 13 | + · Zones tactiles ≥ 44×44 px, safe-areas iOS, typographie fluide (clamp) | |
| 14 | +----------------------------------------------------------------------------- */ | |
| 15 | + | |
| 16 | +:root { | |
| 17 | + /* ---- Palette fixe Groupe KA (identique sur les 12 sites) ---- */ | |
| 18 | + --paper: #f5f3ee; | |
| 19 | + --surface: #ffffff; | |
| 20 | + --surface-2: #faf9f5; | |
| 21 | + --ink: #141814; | |
| 22 | + --ink-2: #4d5551; | |
| 23 | + --ink-3: #8b928c; | |
| 24 | + --line: rgba(20, 24, 20, 0.14); | |
| 25 | + --line-strong: rgba(20, 24, 20, 0.85); | |
| 26 | + --green: #1c5c41; | |
| 27 | + --green-deep: #123f2e; | |
| 28 | + --amber: #e8a33d; | |
| 29 | + --amber-soft: #fdf3e2; | |
| 30 | + --danger: #b3423a; | |
| 31 | + --danger-soft: #fbe9e7; | |
| 32 | + | |
| 33 | + /* ---- ACCENT du site — SEULES variables surchargées par marque ---- */ | |
| 34 | + --accent: #d9f26b; /* défaut : lime Groupe KA */ | |
| 35 | + --accent-soft: #f0f9d2; | |
| 36 | + --accent-deep: #123f2e; | |
| 37 | + --on-accent: var(--ink); /* couleur du texte posé SUR l'accent */ | |
| 38 | + | |
| 39 | + /* alias rétro-compatibles (les satellites historiques utilisent --lime) */ | |
| 40 | + --lime: var(--accent); | |
| 41 | + --lime-soft: var(--accent-soft); | |
| 42 | + | |
| 43 | + /* ---- Géométrie sharp ---- */ | |
| 44 | + --r-card: 10px; | |
| 45 | + --r-ctl: 6px; | |
| 46 | + --r-pill: 999px; | |
| 47 | + | |
| 48 | + /* ---- Ombres décalées — la signature du groupe ---- */ | |
| 49 | + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05); | |
| 50 | + --shadow-off: 6px 6px 0 var(--ink); | |
| 51 | + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08); | |
| 52 | + --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18); | |
| 53 | + | |
| 54 | + /* ---- Typographie ---- */ | |
| 55 | + --font-display: "Space Grotesk", system-ui, sans-serif; | |
| 56 | + --font-body: "Inter", system-ui, sans-serif; | |
| 57 | + --font-mono: "JetBrains Mono", ui-monospace, monospace; | |
| 58 | + | |
| 59 | + /* Échelle fluide (mobile-first, clamp) */ | |
| 60 | + --fs-h1: clamp(30px, 3.2vw + 18px, 54px); | |
| 61 | + --fs-h2: clamp(23px, 1.8vw + 14px, 34px); | |
| 62 | + --fs-h3: clamp(17px, 0.9vw + 12px, 22px); | |
| 63 | + --fs-body: 15px; | |
| 64 | + --fs-small: 13px; | |
| 65 | + | |
| 66 | + /* Espacements */ | |
| 67 | + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; | |
| 68 | + --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px; | |
| 69 | + | |
| 70 | + /* Cible tactile minimale */ | |
| 71 | + --touch: 44px; | |
| 72 | + | |
| 73 | + /* ---- Échelle z-index COMMUNE (obligatoire — aucune valeur arbitraire) ---- | |
| 74 | + grain de film body::before = 9999 (pointer-events:none, toujours au-dessus, | |
| 75 | + purement visuel). Tout composant interactif se place sous 9999 : */ | |
| 76 | + --z-content: 1; /* contenu positionné ordinaire */ | |
| 77 | + --z-sticky: 300; /* éléments sticky de contenu (sous-nav…) */ | |
| 78 | + --z-header: 500; /* header du site */ | |
| 79 | + --z-bottombar: 600; /* tab bar / barre d'action basse */ | |
| 80 | + --z-dropdown: 700; /* menus déroulants (au-dessus des barres) */ | |
| 81 | + --z-overlay: 800; /* voile sous modale/panneau */ | |
| 82 | + --z-modal: 900; /* modales, panneaux de filtres, galeries */ | |
| 83 | + --z-toast: 950; /* notifications */ | |
| 84 | + | |
| 85 | + /* ---- Hauteur de viewport DYNAMIQUE (barre du navigateur mobile qui se | |
| 86 | + replie au scroll) : toujours var(--vh100), JAMAIS 100vh en dur. ---- */ | |
| 87 | + --vh100: 100vh; | |
| 88 | +} | |
| 89 | +@supports (height: 100dvh) { :root { --vh100: 100dvh; } } | |
| 90 | + | |
| 91 | +/* ---- Socle ---- */ | |
| 92 | +* { box-sizing: border-box; } | |
| 93 | +/* overflow-x: clip sur html ET body — sur WebKit iOS, clip posé sur body seul | |
| 94 | + ne bloque PAS le défilement latéral du viewport (quirk de propagation) ; | |
| 95 | + clip (≠ hidden) ne casse pas position:sticky. */ | |
| 96 | +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; overflow-x: clip; } | |
| 97 | +body { | |
| 98 | + margin: 0; | |
| 99 | + background: var(--paper); | |
| 100 | + color: var(--ink); | |
| 101 | + font-family: var(--font-body); | |
| 102 | + font-size: var(--fs-body); | |
| 103 | + line-height: 1.55; | |
| 104 | + -webkit-font-smoothing: antialiased; | |
| 105 | + padding-bottom: env(safe-area-inset-bottom); | |
| 106 | + overflow-x: clip; /* jamais de débordement horizontal */ | |
| 107 | +} | |
| 108 | +img, svg, video { max-width: 100%; height: auto; display: block; } | |
| 109 | +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0 0 0.5em; } | |
| 110 | +h1 { font-size: var(--fs-h1); line-height: 1.06; } | |
| 111 | +h2 { font-size: var(--fs-h2); line-height: 1.15; } | |
| 112 | +h3 { font-size: var(--fs-h3); line-height: 1.25; } | |
| 113 | +a { color: inherit; } | |
| 114 | +::selection { background: var(--accent); color: var(--on-accent); } | |
| 115 | +:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; } | |
| 116 | + | |
| 117 | +/* Grain de film pleine page — ⚠️ jamais de z-index sur body > * (position: | |
| 118 | + relative seulement), sinon les utilitaires z-* sont écrasés. */ | |
| 119 | +body::before { | |
| 120 | + content: ""; | |
| 121 | + position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999; | |
| 122 | + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E"); | |
| 123 | +} | |
| 124 | +body > * { position: relative; } | |
| 125 | + | |
| 126 | +/* ---- Conteneur commun ---- */ | |
| 127 | +.container { max-width: 1152px; margin: 0 auto; padding: 0 var(--sp-4); } | |
| 128 | +@media (min-width: 768px) { .container { padding: 0 var(--sp-5); } } | |
| 129 | + | |
| 130 | +/* ---- Micro-typographie signature ---- */ | |
| 131 | +.kicker { | |
| 132 | + display: inline-flex; align-items: center; gap: 10px; | |
| 133 | + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500; | |
| 134 | + text-transform: uppercase; letter-spacing: 0.12em; color: var(--green); | |
| 135 | +} | |
| 136 | +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); } | |
| 137 | +.klabel { | |
| 138 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; | |
| 139 | + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); | |
| 140 | +} | |
| 141 | +.hl { | |
| 142 | + display: inline-block; background: var(--accent); color: var(--on-accent); | |
| 143 | + border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg); | |
| 144 | +} | |
| 145 | +.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); } | |
| 146 | + | |
| 147 | +/* ---- Boutons (cible ≥ 44 px) ---- */ | |
| 148 | +.btn { | |
| 149 | + display: inline-flex; align-items: center; justify-content: center; gap: 8px; | |
| 150 | + min-height: var(--touch); padding: 10px 18px; | |
| 151 | + font-family: var(--font-display); font-weight: 700; font-size: 14px; | |
| 152 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 153 | + background: var(--surface); color: var(--ink); | |
| 154 | + cursor: pointer; text-decoration: none; | |
| 155 | + transition: transform 0.15s, box-shadow 0.15s, background 0.15s, color 0.15s; | |
| 156 | +} | |
| 157 | +.btn:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); } | |
| 158 | +.btn:active { transform: translate(0, 0); box-shadow: none; } | |
| 159 | +.btn:disabled { opacity: 0.45; pointer-events: none; } | |
| 160 | +.btn-primary { background: var(--ink); color: var(--accent); } | |
| 161 | +.btn-primary:hover { background: var(--accent-deep); } | |
| 162 | +.btn-accent { background: var(--accent); color: var(--on-accent); } | |
| 163 | +.btn-ghost { background: transparent; border-color: var(--line); } | |
| 164 | +.btn-ghost:hover { border-color: var(--ink); } | |
| 165 | + | |
| 166 | +/* ---- Cartes ---- */ | |
| 167 | +.card { | |
| 168 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 169 | + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); | |
| 170 | + overflow: hidden; | |
| 171 | +} | |
| 172 | +.card-hover { transition: transform 0.15s, box-shadow 0.15s; } | |
| 173 | +.card-hover:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); } | |
| 174 | + | |
| 175 | +/* ---- Chips / badges ---- */ | |
| 176 | +.chip { | |
| 177 | + display: inline-flex; align-items: center; gap: 6px; | |
| 178 | + padding: 4px 11px; font-family: var(--font-mono); font-size: 11px; font-weight: 700; | |
| 179 | + text-transform: uppercase; letter-spacing: 0.06em; | |
| 180 | + border: 1.5px solid var(--ink); border-radius: var(--r-pill); | |
| 181 | + background: var(--surface); color: var(--ink); | |
| 182 | +} | |
| 183 | +.chip-accent { background: var(--accent); color: var(--on-accent); } | |
| 184 | +.chip-soft { background: var(--accent-soft); border-color: var(--line); } | |
| 185 | + | |
| 186 | +/* ---- Champs ---- */ | |
| 187 | +.input, .select, .textarea { | |
| 188 | + width: 100%; min-height: var(--touch); padding: 10px 14px; | |
| 189 | + font: inherit; color: var(--ink); background: var(--surface); | |
| 190 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 191 | +} | |
| 192 | +.input:focus, .select:focus, .textarea:focus { | |
| 193 | + outline: none; box-shadow: 3px 3px 0 var(--accent); border-color: var(--ink); | |
| 194 | +} | |
| 195 | +.input::placeholder { color: var(--ink-3); } | |
| 196 | + | |
| 197 | +/* ---- Badge « Un service Groupe KA » (header, cliquable → hub) ---- */ | |
| 198 | +.gk-badge { | |
| 199 | + display: inline-flex; align-items: center; gap: 7px; | |
| 200 | + min-height: 30px; padding: 3px 10px 4px; | |
| 201 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; | |
| 202 | + letter-spacing: 0.08em; text-transform: uppercase; text-decoration: none; | |
| 203 | + border: 1.5px solid var(--ink); border-radius: var(--r-pill); | |
| 204 | + background: var(--surface); color: var(--ink-2); white-space: nowrap; | |
| 205 | +} | |
| 206 | +.gk-badge b { | |
| 207 | + font-family: var(--font-display); font-size: 12px; letter-spacing: -0.02em; | |
| 208 | + text-transform: none; color: var(--ink); | |
| 209 | +} | |
| 210 | +.gk-badge b .ka { | |
| 211 | + display: inline-block; background: var(--ink); color: var(--accent); | |
| 212 | + border-radius: 5px; padding: 0 5px 1px; margin-left: 3px; transform: rotate(-2deg); | |
| 213 | +} | |
| 214 | +.gk-badge:hover .ka { transform: rotate(0); } | |
| 215 | +.gk-badge--dark { background: transparent; border-color: rgba(245,243,238,0.4); color: rgba(245,243,238,0.75); } | |
| 216 | +.gk-badge--dark b { color: var(--paper); } | |
| 217 | + | |
| 218 | +/* ---- Footer commun (fond encre — voir react/KaFooter.tsx) ---- */ | |
| 219 | +.ka-footer { margin-top: var(--sp-8); background: var(--ink); padding: 44px 0; font-size: 13px; color: rgba(245,243,238,0.75); } | |
| 220 | +.ka-footer a { text-decoration: none; } | |
| 221 | +.ka-footer .wordmark { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.04em; color: var(--paper); text-decoration: none; } | |
| 222 | +.ka-footer .wordmark .ka { color: var(--accent); } | |
| 223 | +.ka-footer .desc { max-width: 640px; margin-top: var(--sp-4); } | |
| 224 | +.ka-footer .notice { max-width: 640px; margin-top: var(--sp-4); border-left: 2px solid var(--accent); padding-left: var(--sp-4); } | |
| 225 | +.ka-footer .notice b { color: var(--paper); } | |
| 226 | +.ka-footer .sites { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 26px 0 0; padding: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } | |
| 227 | +.ka-footer .sites a { color: rgba(245,243,238,0.65); } | |
| 228 | +.ka-footer .sites a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; } | |
| 229 | +.ka-footer .contacts { display: grid; gap: 14px 32px; border-top: 1px solid rgba(245,243,238,0.15); margin-top: 30px; padding-top: 26px; } | |
| 230 | +@media (min-width: 768px) { .ka-footer .contacts { grid-template-columns: repeat(3, 1fr); } } | |
| 231 | +.ka-footer .contacts a { font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: rgba(245,243,238,0.85); } | |
| 232 | +.ka-footer .contacts a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; } | |
| 233 | +.ka-footer .contacts span { display: block; margin-top: 3px; font-size: 11px; color: rgba(245,243,238,0.5); } | |
| 234 | +.ka-footer .legal { margin-top: 30px; font-family: var(--font-mono); font-size: 11px; color: rgba(245,243,238,0.45); } | |
| 235 | +.ka-footer .legal a { color: inherit; } | |
| 236 | +.ka-footer .legal a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; } | |
| 237 | + | |
| 238 | +/* ---- Tableaux → cartes empilées sous 768 px ---- */ | |
| 239 | +.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } | |
| 240 | +@media (max-width: 767px) { | |
| 241 | + .tbl-stack thead { display: none; } | |
| 242 | + .tbl-stack tr { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-card); margin-bottom: var(--sp-3); background: var(--surface); } | |
| 243 | + .tbl-stack td { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 12px; border: 0; } | |
| 244 | + .tbl-stack td::before { content: attr(data-label); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); } | |
| 245 | +} | |
| 246 | + | |
| 247 | +/* ---- Utilitaires responsive ---- */ | |
| 248 | +.only-mobile { display: initial; } .only-desktop { display: none; } | |
| 249 | +@media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } } | |
| 250 | + | |
| 251 | +/* ============================================================================= | |
| 252 | + SOCLE MOBILE OBLIGATOIRE (2026-08-19) — règles communes aux 13 sites. | |
| 253 | + RÈGLES pour tout composant futur : | |
| 254 | + · TAP, jamais :hover, pour ouvrir un menu (le survol n'existe pas au doigt) ; | |
| 255 | + le :hover ne sert qu'aux effets décoratifs, sous @media (hover: hover). | |
| 256 | + · var(--vh100) (dvh) et JAMAIS 100vh pour tout élément calé sur le viewport. | |
| 257 | + · position:fixed exige qu'AUCUN ancêtre n'ait transform/filter/perspective/ | |
| 258 | + will-change/backdrop-filter — sinon monter l'élément à la racine (body). | |
| 259 | + · Barre basse fixe = .ka-bottombar + classe has-bottombar sur <body>. | |
| 260 | + · Menu/panneau ouvert = .ka-scroll-lock sur <html> (scroll arrière-plan gelé). | |
| 261 | + · z-index : uniquement l'échelle --z-* ci-dessus. | |
| 262 | + · Zones tactiles ≥ var(--touch) (44 px), champs ≥ 16 px (zoom iOS). | |
| 263 | + ========================================================================== */ | |
| 264 | + | |
| 265 | +/* Anti-zoom iOS : au doigt, aucun champ sous 16 px (le focus d'un champ <16 px | |
| 266 | + déclenche un zoom automatique de page sur Safari iOS). !important assumé : | |
| 267 | + c'est un filet d'accessibilité, un champ plus petit casse le zoom quoi | |
| 268 | + qu'il arrive — ne s'applique qu'aux écrans tactiles. */ | |
| 269 | +@media (hover: none) and (pointer: coarse) { | |
| 270 | + input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), | |
| 271 | + select, textarea { font-size: max(16px, 1em) !important; } | |
| 272 | +} | |
| 273 | + | |
| 274 | +/* Zone tactile du badge Groupe KA étendue à ≥44 px au doigt, sans changer | |
| 275 | + son rendu (débord de la zone cliquable via pseudo-élément). */ | |
| 276 | +@media (pointer: coarse) { | |
| 277 | + .gk-badge { position: relative; } | |
| 278 | + .gk-badge::after { content: ""; position: absolute; inset: -8px; } | |
| 279 | +} | |
| 280 | + | |
| 281 | +/* Hauteurs viewport dynamiques */ | |
| 282 | +.h-viewport { height: 100vh; height: 100dvh; } | |
| 283 | +.min-h-viewport { min-height: 100vh; min-height: 100dvh; } | |
| 284 | + | |
| 285 | +/* Barre fixe basse fiable : safe-area iPhone incluse, immunisée contre les | |
| 286 | + transforms accidentels, et compensation d'espace via body.has-bottombar. */ | |
| 287 | +.ka-bottombar { | |
| 288 | + position: fixed; left: 0; right: 0; bottom: 0; | |
| 289 | + z-index: var(--z-bottombar); | |
| 290 | + padding-bottom: env(safe-area-inset-bottom, 0px); | |
| 291 | + background: var(--surface); | |
| 292 | + border-top: 1.5px solid var(--ink); | |
| 293 | + transform: none !important; filter: none !important; | |
| 294 | +} | |
| 295 | +body.has-bottombar { padding-bottom: calc(var(--bottombar-h, 64px) + env(safe-area-inset-bottom, 0px)); } | |
| 296 | + | |
| 297 | +/* Verrou de scroll d'arrière-plan (menu mobile, modale, panneau de filtres | |
| 298 | + ouverts) : ajouter/retirer .ka-scroll-lock sur <html>. */ | |
| 299 | +html.ka-scroll-lock, html.ka-scroll-lock body { overflow: hidden !important; overscroll-behavior: none; } | |
| 300 | + | |
| 301 | +/* Menu déroulant de référence : au-dessus de tout contenu (cartes Mapbox | |
| 302 | + incluses), défilement interne avec élan, jamais plus haut que l'écran. */ | |
| 303 | +.ka-menu { | |
| 304 | + position: absolute; z-index: var(--z-dropdown); | |
| 305 | + min-width: 180px; | |
| 306 | + max-height: min(60vh, 420px); max-height: min(60dvh, 420px); | |
| 307 | + overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; | |
| 308 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 309 | + border-radius: var(--r-card); box-shadow: var(--shadow-off-mid); | |
| 310 | +} | |
| 311 | +.ka-menu a, .ka-menu button, .ka-menu label, .ka-menu [role="menuitem"], .ka-menu [role="option"] { | |
| 312 | + display: flex; align-items: center; min-height: var(--touch); | |
| 313 | + padding: 10px 14px; width: 100%; text-decoration: none; | |
| 314 | +} | |
| 315 | +/* Voile plein écran sous une modale / un panneau */ | |
| 316 | +.ka-overlay { | |
| 317 | + position: fixed; inset: 0; z-index: var(--z-overlay); | |
| 318 | + background: rgba(20, 24, 20, 0.45); | |
| 319 | +} | |
added
frontend/src/kamaps/adapter.ts
+101 −0
@@ -0,0 +1,101 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Author: Simon-Pierre Boucher | |
| 3 | +// Contact: contact@spboucher.ai | |
| 4 | +// Project: Groupe Ka / Ka Maps (Home-Ka integration) | |
| 5 | +// kamaps/adapter.ts : Home-Ka → MapProperty adapter. Viewport-driven data via | |
| 6 | +// /api/listings.geojson?bbox=… (cancellable requests, never a storm). | |
| 7 | +// ----------------------------------------------------------------------------- | |
| 8 | +import type { | |
| 9 | + BoundsQuery, | |
| 10 | + BoundsQueryResult, | |
| 11 | + KaAppSource, | |
| 12 | + KaDataAdapter, | |
| 13 | + MapProperty, | |
| 14 | +} from "@groupe-ka/ka-maps"; | |
| 15 | +import { bboxToString } from "@groupe-ka/ka-maps"; | |
| 16 | +import type { ListingFilters } from "../api"; | |
| 17 | + | |
| 18 | +/** ka-maps ships a closed KaAppSource union (pre-Home-Ka) — cast our id. */ | |
| 19 | +export const HOMEKA_APP_SOURCE = "home-ka" as KaAppSource; | |
| 20 | + | |
| 21 | +interface HomeKaFeatureProps { | |
| 22 | + uid: string; | |
| 23 | + title: string | null; | |
| 24 | + address: string | null; | |
| 25 | + price: number | null; | |
| 26 | + price_label: string | null; | |
| 27 | + property_type: string | null; | |
| 28 | + bedrooms: number | null; | |
| 29 | + bathrooms: number | null; | |
| 30 | + source: string; | |
| 31 | + city: string | null; | |
| 32 | + state: string | null; | |
| 33 | + image: string | null; | |
| 34 | +} | |
| 35 | + | |
| 36 | +interface HomeKaFC { | |
| 37 | + type: "FeatureCollection"; | |
| 38 | + features: { | |
| 39 | + geometry: { coordinates: [number, number] }; | |
| 40 | + properties: HomeKaFeatureProps; | |
| 41 | + }[]; | |
| 42 | + totalGeocoded?: number; | |
| 43 | + totalMatching?: number; | |
| 44 | +} | |
| 45 | + | |
| 46 | +function toMapProperty( | |
| 47 | + coords: [number, number], | |
| 48 | + p: HomeKaFeatureProps, | |
| 49 | +): MapProperty { | |
| 50 | + return { | |
| 51 | + id: p.uid, | |
| 52 | + appSource: HOMEKA_APP_SOURCE, | |
| 53 | + longitude: coords[0], | |
| 54 | + latitude: coords[1], | |
| 55 | + kind: "listing", | |
| 56 | + listingType: "sale", | |
| 57 | + price: p.price ?? undefined, | |
| 58 | + propertyType: p.property_type ?? undefined, | |
| 59 | + bedrooms: p.bedrooms ?? undefined, | |
| 60 | + bathrooms: p.bathrooms ?? undefined, | |
| 61 | + address: p.address ?? p.title ?? undefined, | |
| 62 | + city: p.city ?? undefined, | |
| 63 | + region: p.state ?? undefined, | |
| 64 | + thumbnailUrl: p.image ?? undefined, | |
| 65 | + originalUrl: `/property/${encodeURIComponent(p.uid)}`, | |
| 66 | + extra: { | |
| 67 | + title: p.title, | |
| 68 | + priceLabel: p.price_label, | |
| 69 | + source: p.source, | |
| 70 | + state: p.state, | |
| 71 | + }, | |
| 72 | + }; | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** Viewport→data adapter for the Home-Ka map. */ | |
| 76 | +export const homeKaMapAdapter: KaDataAdapter = { | |
| 77 | + id: "home-ka-listings", | |
| 78 | + appSource: HOMEKA_APP_SOURCE, | |
| 79 | + async fetchInBounds(query: BoundsQuery): Promise<BoundsQueryResult> { | |
| 80 | + const params = new URLSearchParams(); | |
| 81 | + const filters = (query.filters ?? {}) as Partial<ListingFilters>; | |
| 82 | + for (const [k, v] of Object.entries(filters)) { | |
| 83 | + if (v && k !== "sort") params.set(k, String(v)); | |
| 84 | + } | |
| 85 | + params.set("bbox", bboxToString(query.bbox)); | |
| 86 | + params.set("limit", "3000"); | |
| 87 | + | |
| 88 | + const res = await fetch(`/api/listings.geojson?${params}`, { | |
| 89 | + signal: query.signal, | |
| 90 | + }); | |
| 91 | + if (!res.ok) throw new Error(`Map: API ${res.status}`); | |
| 92 | + const data = (await res.json()) as HomeKaFC; | |
| 93 | + | |
| 94 | + return { | |
| 95 | + properties: data.features.map((f) => | |
| 96 | + toMapProperty(f.geometry.coordinates, f.properties), | |
| 97 | + ), | |
| 98 | + totalCount: data.totalMatching, | |
| 99 | + }; | |
| 100 | + }, | |
| 101 | +}; | |
added
frontend/src/kamaps/config.ts
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Author: Simon-Pierre Boucher | |
| 3 | +// Contact: contact@spboucher.ai | |
| 4 | +// Project: Groupe Ka / Ka Maps (Home-Ka integration) | |
| 5 | +// kamaps/config.ts : PUBLIC Mapbox token (pk.…), overridable at build time via | |
| 6 | +// VITE_MAPBOX_TOKEN; URL restrictions managed in the Mapbox dashboard. | |
| 7 | +// ----------------------------------------------------------------------------- | |
| 8 | + | |
| 9 | +export const MAPBOX_TOKEN: string = | |
| 10 | + (import.meta.env.VITE_MAPBOX_TOKEN as string | undefined) ?? | |
| 11 | + "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q"; | |
added
frontend/src/kamaps/theme.ts
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Author: Simon-Pierre Boucher | |
| 3 | +// Contact: contact@spboucher.ai | |
| 4 | +// Project: Groupe Ka / Ka Maps (Home-Ka integration) | |
| 5 | +// kamaps/theme.ts : Home-Ka Maps theme — forest-green / off-white / ink. | |
| 6 | +// Premium price pins: off-white with ink text; the GREEN #0f7b4a is reserved | |
| 7 | +// for brand moments (selection, controls, highlights). | |
| 8 | +// ----------------------------------------------------------------------------- | |
| 9 | +import type { KaMapTheme } from "@groupe-ka/ka-maps"; | |
| 10 | + | |
| 11 | +const INK = "#12181a"; | |
| 12 | +const GREEN = "#0f7b4a"; | |
| 13 | +const OFFWHITE = "#fffdf8"; | |
| 14 | +const WHITE = "#ffffff"; | |
| 15 | + | |
| 16 | +export const homeKaMapTheme: KaMapTheme = { | |
| 17 | + id: "home-ka", | |
| 18 | + productName: "Home-Ka Maps", | |
| 19 | + accent: GREEN, | |
| 20 | + onAccent: WHITE, | |
| 21 | + fontFamily: '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif', | |
| 22 | + markers: { | |
| 23 | + // List price: off-white pin, ink text — readable without crushing the | |
| 24 | + // basemap. Selection/hover: solid ink, the absolute anchor. | |
| 25 | + sale: { | |
| 26 | + background: OFFWHITE, | |
| 27 | + text: INK, | |
| 28 | + halo: "rgba(18, 24, 26, 0.30)", | |
| 29 | + selectedBackground: INK, | |
| 30 | + selectedText: WHITE, | |
| 31 | + }, | |
| 32 | + highlight: { | |
| 33 | + background: GREEN, | |
| 34 | + text: WHITE, | |
| 35 | + halo: "rgba(255, 255, 255, 0.55)", | |
| 36 | + selectedBackground: INK, | |
| 37 | + selectedText: WHITE, | |
| 38 | + }, | |
| 39 | + }, | |
| 40 | + cluster: { | |
| 41 | + background: OFFWHITE, | |
| 42 | + text: INK, | |
| 43 | + border: GREEN, | |
| 44 | + valueText: "#0b5e38", | |
| 45 | + valueHalo: "rgba(255, 253, 248, 0.92)", | |
| 46 | + }, | |
| 47 | + supportsDark: false, | |
| 48 | +}; | |
added
frontend/src/main.tsx
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// main.tsx : React entry point — Groupe KA tokens (ka/tokens.css) BEFORE the | |
| 5 | +// local stylesheet. | |
| 6 | +// ----------------------------------------------------------------------------- | |
| 7 | +import React from "react"; | |
| 8 | +import ReactDOM from "react-dom/client"; | |
| 9 | +import { BrowserRouter } from "react-router-dom"; | |
| 10 | +import App from "./App"; | |
| 11 | +import "./ka/tokens.css"; | |
| 12 | +import "./styles.css"; | |
| 13 | + | |
| 14 | +ReactDOM.createRoot(document.getElementById("root")!).render( | |
| 15 | + <React.StrictMode> | |
| 16 | + <BrowserRouter> | |
| 17 | + <App /> | |
| 18 | + </BrowserRouter> | |
| 19 | + </React.StrictMode> | |
| 20 | +); | |
added
frontend/src/pages/Admin.tsx
+478 −0
@@ -0,0 +1,478 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Admin.tsx : back-office (/admin/sources, /admin/connectors, | |
| 5 | +// /admin/brokerages) — reachable by URL only, not linked in the nav. | |
| 6 | +// Optional token (X-Admin-Token) stored in localStorage (homeka_admin_token). | |
| 7 | +// ----------------------------------------------------------------------------- | |
| 8 | +import { useCallback, useEffect, useState } from "react"; | |
| 9 | +import { Link } from "react-router-dom"; | |
| 10 | +import { | |
| 11 | + AdminOverview, AdminSource, Brokerage, BrokerageFilters, ConnectorFamily, | |
| 12 | + CustomConnector, PARTNERSHIP_STATUSES, | |
| 13 | + fetchAdminBrokerages, fetchAdminConnectors, fetchAdminOverview, | |
| 14 | + fetchAdminSources, getAdminToken, inspectBrokerage, runDiscovery, | |
| 15 | + setAdminToken, setBrokerageStatus, sourceName, | |
| 16 | +} from "../api"; | |
| 17 | +import { Ico } from "../components/Icons"; | |
| 18 | + | |
| 19 | +const BROK_PAGE = 50; | |
| 20 | + | |
| 21 | +const n = (x: number | null | undefined) => | |
| 22 | + x == null ? "—" : Math.round(x).toLocaleString("en-US"); | |
| 23 | + | |
| 24 | +function ago(ts: number | null | undefined): string { | |
| 25 | + if (ts == null) return "never"; | |
| 26 | + const s = Math.max(0, Math.floor(Date.now() / 1000 - (ts > 1e12 ? ts / 1000 : ts))); | |
| 27 | + if (s < 3600) return `${Math.round(s / 60)} min ago`; | |
| 28 | + if (s < 172800) return `${Math.round(s / 3600)} h ago`; | |
| 29 | + return `${Math.round(s / 86400)} d ago`; | |
| 30 | +} | |
| 31 | + | |
| 32 | +// --- Token bar ----------------------------------------------------------------- | |
| 33 | +function TokenBar({ onChange }: { onChange: () => void }) { | |
| 34 | + const [token, setToken] = useState(getAdminToken()); | |
| 35 | + const [saved, setSaved] = useState(false); | |
| 36 | + return ( | |
| 37 | + <div className="adm-token"> | |
| 38 | + <label htmlFor="adm-token-input">Admin token</label> | |
| 39 | + <input | |
| 40 | + id="adm-token-input" | |
| 41 | + type="password" | |
| 42 | + placeholder="X-Admin-Token (leave empty if the API is open)" | |
| 43 | + value={token} | |
| 44 | + onChange={(e) => { setToken(e.target.value); setSaved(false); }} | |
| 45 | + autoComplete="off" | |
| 46 | + /> | |
| 47 | + <button | |
| 48 | + className="btn btn-ghost" | |
| 49 | + onClick={() => { setAdminToken(token.trim()); setSaved(true); onChange(); }} | |
| 50 | + > | |
| 51 | + {saved ? "Saved ✓" : "Save"} | |
| 52 | + </button> | |
| 53 | + </div> | |
| 54 | + ); | |
| 55 | +} | |
| 56 | + | |
| 57 | +// --- Overview cards -------------------------------------------------------------- | |
| 58 | +function Overview({ ov, error }: { ov: AdminOverview | null; error: string | null }) { | |
| 59 | + if (error) return <div className="notice">Overview unavailable — {error}</div>; | |
| 60 | + if (!ov) return <div className="notice">Loading overview…</div>; | |
| 61 | + return ( | |
| 62 | + <section className="tiles adm-tiles" aria-label="Overview"> | |
| 63 | + <div className="tile"> | |
| 64 | + <div className="tile-v">{n(ov.active_listings)}</div> | |
| 65 | + <div className="tile-k">Active listings</div> | |
| 66 | + </div> | |
| 67 | + <div className="tile"> | |
| 68 | + <div className="tile-v">{n(ov.properties)}</div> | |
| 69 | + <div className="tile-k">Properties</div> | |
| 70 | + </div> | |
| 71 | + <div className="tile"> | |
| 72 | + <div className="tile-v">{n(ov.enabled_sources)}</div> | |
| 73 | + <div className="tile-k">Enabled sources</div> | |
| 74 | + </div> | |
| 75 | + <div className="tile"> | |
| 76 | + <div className="tile-v">{n(ov.errors_24h)}</div> | |
| 77 | + <div className="tile-k">Errors (24 h)</div> | |
| 78 | + </div> | |
| 79 | + <div className="tile"> | |
| 80 | + <div className="tile-v">{ago(ov.last_successful_sync)}</div> | |
| 81 | + <div className="tile-k">Last successful sync</div> | |
| 82 | + </div> | |
| 83 | + <div className="tile adm-tile-wide"> | |
| 84 | + <div className="tile-k" style={{ marginBottom: 6 }}>Brokerages by partnership status</div> | |
| 85 | + <div className="adm-status-row"> | |
| 86 | + {ov.brokerages_by_status.length === 0 && <span>—</span>} | |
| 87 | + {ov.brokerages_by_status.map((b) => ( | |
| 88 | + <span key={b.partnership_status} className="adm-badge"> | |
| 89 | + {b.partnership_status || "unknown"} <b>{n(b.n)}</b> | |
| 90 | + </span> | |
| 91 | + ))} | |
| 92 | + </div> | |
| 93 | + </div> | |
| 94 | + </section> | |
| 95 | + ); | |
| 96 | +} | |
| 97 | + | |
| 98 | +// --- Sources tab ------------------------------------------------------------------- | |
| 99 | +function SourcesTab({ reloadKey }: { reloadKey: number }) { | |
| 100 | + const [rows, setRows] = useState<AdminSource[] | null>(null); | |
| 101 | + const [error, setError] = useState<string | null>(null); | |
| 102 | + | |
| 103 | + useEffect(() => { | |
| 104 | + setRows(null); setError(null); | |
| 105 | + fetchAdminSources().then((r) => setRows(r.sources)).catch((e) => setError(String(e))); | |
| 106 | + }, [reloadKey]); | |
| 107 | + | |
| 108 | + if (error) return <div className="notice">Could not load sources — {error}</div>; | |
| 109 | + if (!rows) return <div className="notice">Loading sources…</div>; | |
| 110 | + return ( | |
| 111 | + <div className="q-table-wrap"> | |
| 112 | + <table className="q-table adm-table"> | |
| 113 | + <thead> | |
| 114 | + <tr> | |
| 115 | + <th>Source</th><th>Type</th><th>States</th> | |
| 116 | + <th>Active</th><th>Published</th><th>Last sync</th> | |
| 117 | + <th>Freshness</th><th>Errors 7d</th><th>Last message</th> | |
| 118 | + </tr> | |
| 119 | + </thead> | |
| 120 | + <tbody> | |
| 121 | + {rows.map((s) => ( | |
| 122 | + <tr key={s.id}> | |
| 123 | + <td> | |
| 124 | + <b>{s.name || s.id}</b> | |
| 125 | + {!s.enabled && <span className="adm-badge adm-off"> disabled</span>} | |
| 126 | + <div className="adm-sub">{s.id}</div> | |
| 127 | + </td> | |
| 128 | + <td>{s.connector_type ?? "—"}</td> | |
| 129 | + <td>{s.states?.length ? s.states.join(", ") : "—"}</td> | |
| 130 | + <td className="num">{n(s.active_listings)}</td> | |
| 131 | + <td className="num">{n(s.published)}</td> | |
| 132 | + <td> | |
| 133 | + {s.last_sync != null ? ago(s.last_sync) : "never"}{" "} | |
| 134 | + {s.last_sync != null && ( | |
| 135 | + s.last_ok | |
| 136 | + ? <span className="adm-badge adm-ok">OK</span> | |
| 137 | + : <span className="adm-badge adm-err">Error</span> | |
| 138 | + )} | |
| 139 | + </td> | |
| 140 | + <td className="num">{s.freshness_hours != null ? `${s.freshness_hours} h` : "—"}</td> | |
| 141 | + <td className="num">{n(s.errors_7d)}</td> | |
| 142 | + <td className="adm-msg" title={s.last_message ?? ""}>{s.last_message ?? "—"}</td> | |
| 143 | + </tr> | |
| 144 | + ))} | |
| 145 | + </tbody> | |
| 146 | + </table> | |
| 147 | + </div> | |
| 148 | + ); | |
| 149 | +} | |
| 150 | + | |
| 151 | +// --- Connectors tab ------------------------------------------------------------------ | |
| 152 | +function ConnectorsTab({ reloadKey }: { reloadKey: number }) { | |
| 153 | + const [families, setFamilies] = useState<ConnectorFamily[] | null>(null); | |
| 154 | + const [custom, setCustom] = useState<CustomConnector[]>([]); | |
| 155 | + const [error, setError] = useState<string | null>(null); | |
| 156 | + | |
| 157 | + useEffect(() => { | |
| 158 | + setFamilies(null); setError(null); | |
| 159 | + fetchAdminConnectors() | |
| 160 | + .then((r) => { setFamilies(r.families); setCustom(r.custom); }) | |
| 161 | + .catch((e) => setError(String(e))); | |
| 162 | + }, [reloadKey]); | |
| 163 | + | |
| 164 | + if (error) return <div className="notice">Could not load connectors — {error}</div>; | |
| 165 | + if (!families) return <div className="notice">Loading connectors…</div>; | |
| 166 | + return ( | |
| 167 | + <> | |
| 168 | + <div className="adm-cards"> | |
| 169 | + {families.map((f) => ( | |
| 170 | + <div className="adm-card" key={f.family}> | |
| 171 | + <div className="adm-card-head"> | |
| 172 | + <b>{f.family}</b> | |
| 173 | + <span className="adm-badge">{n(f.instances)} instance{f.instances !== 1 ? "s" : ""}</span> | |
| 174 | + </div> | |
| 175 | + <div className="adm-sub">{f.class} · {f.module}</div> | |
| 176 | + <p>{f.doc || "—"}</p> | |
| 177 | + </div> | |
| 178 | + ))} | |
| 179 | + </div> | |
| 180 | + {custom.length > 0 && ( | |
| 181 | + <> | |
| 182 | + <h2 style={{ marginTop: 22 }}>Custom connectors</h2> | |
| 183 | + <div className="q-table-wrap"> | |
| 184 | + <table className="q-table"> | |
| 185 | + <thead><tr><th>Source</th><th>Class</th><th>Module</th></tr></thead> | |
| 186 | + <tbody> | |
| 187 | + {custom.map((c) => ( | |
| 188 | + <tr key={c.source_id}> | |
| 189 | + <td>{sourceName(c.source_id)} <span className="adm-sub">{c.source_id}</span></td> | |
| 190 | + <td>{c.class}</td> | |
| 191 | + <td>{c.module}</td> | |
| 192 | + </tr> | |
| 193 | + ))} | |
| 194 | + </tbody> | |
| 195 | + </table> | |
| 196 | + </div> | |
| 197 | + </> | |
| 198 | + )} | |
| 199 | + </> | |
| 200 | + ); | |
| 201 | +} | |
| 202 | + | |
| 203 | +// --- Brokerages tab --------------------------------------------------------------------- | |
| 204 | +function BrokeragesTab({ reloadKey }: { reloadKey: number }) { | |
| 205 | + const [rows, setRows] = useState<Brokerage[] | null>(null); | |
| 206 | + const [total, setTotal] = useState(0); | |
| 207 | + const [page, setPage] = useState(1); | |
| 208 | + const [error, setError] = useState<string | null>(null); | |
| 209 | + const [busy, setBusy] = useState<Record<number, boolean>>({}); | |
| 210 | + const [discovering, setDiscovering] = useState(false); | |
| 211 | + | |
| 212 | + // filters | |
| 213 | + const [q, setQ] = useState(""); | |
| 214 | + const [state, setState] = useState(""); | |
| 215 | + const [status, setStatus] = useState(""); | |
| 216 | + const [feedType, setFeedType] = useState(""); | |
| 217 | + const [minPriority, setMinPriority] = useState(""); | |
| 218 | + const [inspected, setInspected] = useState(""); | |
| 219 | + const [sort, setSort] = useState("priority"); | |
| 220 | + | |
| 221 | + const filters: BrokerageFilters = { | |
| 222 | + q, state, status, feed_type: feedType, | |
| 223 | + min_priority: minPriority, inspected, sort, | |
| 224 | + }; | |
| 225 | + const filterKey = JSON.stringify(filters); | |
| 226 | + | |
| 227 | + const load = useCallback((p: number) => { | |
| 228 | + setRows(null); setError(null); | |
| 229 | + fetchAdminBrokerages(JSON.parse(filterKey) as BrokerageFilters, BROK_PAGE, (p - 1) * BROK_PAGE) | |
| 230 | + .then((r) => { setRows(r.brokerages); setTotal(r.total); }) | |
| 231 | + .catch((e) => setError(String(e))); | |
| 232 | + }, [filterKey]); | |
| 233 | + | |
| 234 | + useEffect(() => { setPage(1); }, [filterKey]); | |
| 235 | + useEffect(() => { load(page); }, [load, page, reloadKey]); | |
| 236 | + | |
| 237 | + const totalPages = Math.max(1, Math.ceil(total / BROK_PAGE)); | |
| 238 | + | |
| 239 | + const onStatus = async (b: Brokerage, next: string) => { | |
| 240 | + setBusy((x) => ({ ...x, [b.id]: true })); | |
| 241 | + try { | |
| 242 | + await setBrokerageStatus(b.id, next); | |
| 243 | + setRows((rs) => rs?.map((r) => r.id === b.id ? { ...r, partnership_status: next } : r) ?? null); | |
| 244 | + } catch (e) { | |
| 245 | + alert(`Status update failed: ${e}`); | |
| 246 | + } finally { | |
| 247 | + setBusy((x) => ({ ...x, [b.id]: false })); | |
| 248 | + } | |
| 249 | + }; | |
| 250 | + | |
| 251 | + const onInspect = async (b: Brokerage) => { | |
| 252 | + setBusy((x) => ({ ...x, [b.id]: true })); | |
| 253 | + try { | |
| 254 | + await inspectBrokerage(b.id); | |
| 255 | + // refresh the page to pick up the newly inspected row | |
| 256 | + load(page); | |
| 257 | + } catch (e) { | |
| 258 | + alert(`Inspection failed: ${e}`); | |
| 259 | + setBusy((x) => ({ ...x, [b.id]: false })); | |
| 260 | + } | |
| 261 | + }; | |
| 262 | + | |
| 263 | + const onDiscover = async () => { | |
| 264 | + setDiscovering(true); | |
| 265 | + try { | |
| 266 | + await runDiscovery(); | |
| 267 | + alert("Discovery batch started in the background."); | |
| 268 | + } catch (e) { | |
| 269 | + alert(`Discovery failed to start: ${e}`); | |
| 270 | + } finally { | |
| 271 | + setDiscovering(false); | |
| 272 | + } | |
| 273 | + }; | |
| 274 | + | |
| 275 | + const contacts = (b: Brokerage): { label: string; email: string }[] => { | |
| 276 | + const out: { label: string; email: string }[] = []; | |
| 277 | + if (b.partnership_contact && b.partnership_contact.includes("@")) | |
| 278 | + out.push({ label: "partnership", email: b.partnership_contact }); | |
| 279 | + if (b.technical_contact && b.technical_contact.includes("@")) | |
| 280 | + out.push({ label: "technical", email: b.technical_contact }); | |
| 281 | + return out; | |
| 282 | + }; | |
| 283 | + | |
| 284 | + return ( | |
| 285 | + <> | |
| 286 | + <div className="adm-controls"> | |
| 287 | + <input | |
| 288 | + placeholder="Search name or website…" | |
| 289 | + value={q} | |
| 290 | + onChange={(e) => setQ(e.target.value)} | |
| 291 | + aria-label="Search brokerages" | |
| 292 | + /> | |
| 293 | + <input | |
| 294 | + placeholder="State (e.g. TX)" | |
| 295 | + value={state} | |
| 296 | + maxLength={2} | |
| 297 | + style={{ width: 90 }} | |
| 298 | + onChange={(e) => setState(e.target.value.toUpperCase())} | |
| 299 | + aria-label="State" | |
| 300 | + /> | |
| 301 | + <select value={status} onChange={(e) => setStatus(e.target.value)} aria-label="Partnership status"> | |
| 302 | + <option value="">Any status</option> | |
| 303 | + {PARTNERSHIP_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)} | |
| 304 | + </select> | |
| 305 | + <select value={feedType} onChange={(e) => setFeedType(e.target.value)} aria-label="Feed type"> | |
| 306 | + <option value="">Any feed</option> | |
| 307 | + {["reso", "rets", "json-api", "xml", "csv", "idx-partner", "crawl"].map((f) => ( | |
| 308 | + <option key={f} value={f}>{f}</option> | |
| 309 | + ))} | |
| 310 | + </select> | |
| 311 | + <input | |
| 312 | + type="number" | |
| 313 | + placeholder="Min priority" | |
| 314 | + value={minPriority} | |
| 315 | + style={{ width: 110 }} | |
| 316 | + onChange={(e) => setMinPriority(e.target.value)} | |
| 317 | + aria-label="Minimum priority score" | |
| 318 | + /> | |
| 319 | + <select value={inspected} onChange={(e) => setInspected(e.target.value)} aria-label="Inspected"> | |
| 320 | + <option value="">Inspected or not</option> | |
| 321 | + <option value="1">Inspected</option> | |
| 322 | + <option value="0">Not inspected</option> | |
| 323 | + </select> | |
| 324 | + <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort"> | |
| 325 | + <option value="priority">Sort: priority</option> | |
| 326 | + <option value="feed">Sort: feed probability</option> | |
| 327 | + <option value="name">Sort: name</option> | |
| 328 | + <option value="inspected">Sort: last inspected</option> | |
| 329 | + </select> | |
| 330 | + <button className="btn btn-primary" onClick={onDiscover} disabled={discovering}> | |
| 331 | + {discovering ? "Starting…" : "Run discovery batch"} | |
| 332 | + </button> | |
| 333 | + </div> | |
| 334 | + | |
| 335 | + {error && <div className="notice">Could not load brokerages — {error}</div>} | |
| 336 | + {!rows && !error && <div className="notice">Loading brokerages…</div>} | |
| 337 | + | |
| 338 | + {rows && ( | |
| 339 | + <> | |
| 340 | + <p className="stats-foot" style={{ marginTop: 0 }}> | |
| 341 | + <b>{n(total)}</b> brokerage{total !== 1 ? "s" : ""} — page {page} / {totalPages} | |
| 342 | + </p> | |
| 343 | + <div className="q-table-wrap"> | |
| 344 | + <table className="q-table adm-table"> | |
| 345 | + <thead> | |
| 346 | + <tr> | |
| 347 | + <th>Brokerage</th><th>States</th><th>Agents</th><th>Listings</th> | |
| 348 | + <th>IDX provider</th><th>Feed</th><th>Feed %</th><th>Priority</th> | |
| 349 | + <th>Status</th><th>Contacts</th><th>Inspected</th><th></th> | |
| 350 | + </tr> | |
| 351 | + </thead> | |
| 352 | + <tbody> | |
| 353 | + {rows.map((b) => ( | |
| 354 | + <tr key={b.id}> | |
| 355 | + <td> | |
| 356 | + {b.website | |
| 357 | + ? <a href={b.website} target="_blank" rel="noopener noreferrer"><b>{b.name}</b> <Ico name="external" size={11} /></a> | |
| 358 | + : <b>{b.name}</b>} | |
| 359 | + {b.mls_affiliations?.length > 0 && ( | |
| 360 | + <div className="adm-sub">{b.mls_affiliations.join(", ")}</div> | |
| 361 | + )} | |
| 362 | + </td> | |
| 363 | + <td>{b.states?.length ? b.states.join(", ") : "—"}</td> | |
| 364 | + <td className="num">{n(b.estimated_agents)}</td> | |
| 365 | + <td className="num">{n(b.estimated_listings)}</td> | |
| 366 | + <td> | |
| 367 | + {b.idx_provider ?? "—"} | |
| 368 | + {b.reso_detected ? <span className="adm-badge adm-ok"> RESO</span> : null} | |
| 369 | + </td> | |
| 370 | + <td>{b.possible_feed_type ?? "—"}</td> | |
| 371 | + <td className="num">{b.feed_probability_score != null ? Math.round(b.feed_probability_score) : "—"}</td> | |
| 372 | + <td className="num">{b.priority_score != null ? Math.round(b.priority_score) : "—"}</td> | |
| 373 | + <td> | |
| 374 | + <select | |
| 375 | + className="adm-status-select" | |
| 376 | + value={b.partnership_status || "prospect"} | |
| 377 | + disabled={!!busy[b.id]} | |
| 378 | + onChange={(e) => onStatus(b, e.target.value)} | |
| 379 | + aria-label={`Partnership status — ${b.name}`} | |
| 380 | + > | |
| 381 | + {PARTNERSHIP_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)} | |
| 382 | + </select> | |
| 383 | + </td> | |
| 384 | + <td> | |
| 385 | + {contacts(b).length === 0 && "—"} | |
| 386 | + {contacts(b).map((c) => ( | |
| 387 | + <div key={c.email}> | |
| 388 | + <a href={`mailto:${c.email}`} title={c.label}>{c.email}</a> | |
| 389 | + </div> | |
| 390 | + ))} | |
| 391 | + </td> | |
| 392 | + <td> | |
| 393 | + {b.last_inspected != null ? ago(b.last_inspected) : "never"} | |
| 394 | + {b.inspect_error && ( | |
| 395 | + <span className="adm-badge adm-err" title={b.inspect_error}> err</span> | |
| 396 | + )} | |
| 397 | + </td> | |
| 398 | + <td> | |
| 399 | + <button | |
| 400 | + className="btn btn-ghost adm-inspect" | |
| 401 | + disabled={!!busy[b.id]} | |
| 402 | + onClick={() => onInspect(b)} | |
| 403 | + > | |
| 404 | + {busy[b.id] ? "…" : "Inspect"} | |
| 405 | + </button> | |
| 406 | + </td> | |
| 407 | + </tr> | |
| 408 | + ))} | |
| 409 | + {rows.length === 0 && ( | |
| 410 | + <tr><td colSpan={12}>No brokerage matches these filters.</td></tr> | |
| 411 | + )} | |
| 412 | + </tbody> | |
| 413 | + </table> | |
| 414 | + </div> | |
| 415 | + {totalPages > 1 && ( | |
| 416 | + <nav className="pager" aria-label="Brokerage pagination"> | |
| 417 | + <button className="pager-btn" onClick={() => setPage(page - 1)} disabled={page <= 1}>‹ Prev</button> | |
| 418 | + <span className="pager-info">Page {page} / {totalPages}</span> | |
| 419 | + <button className="pager-btn" onClick={() => setPage(page + 1)} disabled={page >= totalPages}>Next ›</button> | |
| 420 | + </nav> | |
| 421 | + )} | |
| 422 | + </> | |
| 423 | + )} | |
| 424 | + </> | |
| 425 | + ); | |
| 426 | +} | |
| 427 | + | |
| 428 | +// --- Page ------------------------------------------------------------------------ | |
| 429 | +export type AdminTab = "sources" | "connectors" | "brokerages"; | |
| 430 | + | |
| 431 | +export default function AdminPage({ tab }: { tab: AdminTab }) { | |
| 432 | + const [ov, setOv] = useState<AdminOverview | null>(null); | |
| 433 | + const [ovError, setOvError] = useState<string | null>(null); | |
| 434 | + const [reloadKey, setReloadKey] = useState(0); | |
| 435 | + | |
| 436 | + const loadOverview = useCallback(() => { | |
| 437 | + setOv(null); setOvError(null); | |
| 438 | + fetchAdminOverview().then(setOv).catch((e) => setOvError(String(e))); | |
| 439 | + }, []); | |
| 440 | + useEffect(() => { loadOverview(); }, [loadOverview, reloadKey]); | |
| 441 | + | |
| 442 | + const tabs: { id: AdminTab; label: string }[] = [ | |
| 443 | + { id: "sources", label: "Sources" }, | |
| 444 | + { id: "connectors", label: "Connectors" }, | |
| 445 | + { id: "brokerages", label: "Brokerages" }, | |
| 446 | + ]; | |
| 447 | + | |
| 448 | + return ( | |
| 449 | + <div className="container admin"> | |
| 450 | + <span className="kicker">Back office</span> | |
| 451 | + <h1>Home-Ka admin</h1> | |
| 452 | + <p className="sub"> | |
| 453 | + Operational view of the aggregation pipeline — sources, connector | |
| 454 | + families and the brokerage partnership funnel. | |
| 455 | + </p> | |
| 456 | + | |
| 457 | + <TokenBar onChange={() => setReloadKey((k) => k + 1)} /> | |
| 458 | + | |
| 459 | + <Overview ov={ov} error={ovError} /> | |
| 460 | + | |
| 461 | + <nav className="adm-tabs" aria-label="Admin sections"> | |
| 462 | + {tabs.map((t) => ( | |
| 463 | + <Link | |
| 464 | + key={t.id} | |
| 465 | + to={`/admin/${t.id}`} | |
| 466 | + className={tab === t.id ? "on" : ""} | |
| 467 | + > | |
| 468 | + {t.label} | |
| 469 | + </Link> | |
| 470 | + ))} | |
| 471 | + </nav> | |
| 472 | + | |
| 473 | + {tab === "sources" && <SourcesTab reloadKey={reloadKey} />} | |
| 474 | + {tab === "connectors" && <ConnectorsTab reloadKey={reloadKey} />} | |
| 475 | + {tab === "brokerages" && <BrokeragesTab reloadKey={reloadKey} />} | |
| 476 | + </div> | |
| 477 | + ); | |
| 478 | +} | |
added
frontend/src/pages/Contact.tsx
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Contact.tsx : contact page — operator Groupe KA, contact@spboucher.ai. | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | +import eco from "../ka/ecosystem.json"; | |
| 7 | + | |
| 8 | +export default function ContactPage() { | |
| 9 | + const sites = eco.sites.filter((s) => s.id !== "home-ka"); | |
| 10 | + return ( | |
| 11 | + <div className="container contact"> | |
| 12 | + <span className="kicker">Contact</span> | |
| 13 | + <h1> | |
| 14 | + Get in touch with <span className="hl">Groupe KA</span> | |
| 15 | + </h1> | |
| 16 | + <p className="lede"> | |
| 17 | + Home-Ka is a <b>Groupe KA</b> service — an independent, fully automated | |
| 18 | + US real-estate aggregator. For anything related to Home-Ka (data, | |
| 19 | + partnerships, brokerage feeds, listing removal, press), write to us. | |
| 20 | + </p> | |
| 21 | + | |
| 22 | + <div className="contact-cards"> | |
| 23 | + <a className="contact-card" href="mailto:contact@spboucher.ai"> | |
| 24 | + <span className="contact-role">All inquiries — data, partnerships, legal & press</span> | |
| 25 | + <span className="contact-mail">contact@spboucher.ai</span> | |
| 26 | + </a> | |
| 27 | + </div> | |
| 28 | + | |
| 29 | + <p className="contact-disclaimer"> | |
| 30 | + <b> | |
| 31 | + Groupe KA is a content aggregator: we sell nothing, rent nothing and | |
| 32 | + are party to no transaction. | |
| 33 | + </b>{" "} | |
| 34 | + Every listing links back to the original announcement at the source: | |
| 35 | + for a specific home, contact the listing agent or brokerage shown on | |
| 36 | + the page directly. | |
| 37 | + </p> | |
| 38 | + | |
| 39 | + <div className="contact-hub"> | |
| 40 | + <a | |
| 41 | + className="btn btn-primary" | |
| 42 | + href={eco.hub.url} | |
| 43 | + target="_blank" | |
| 44 | + rel="noopener noreferrer" | |
| 45 | + > | |
| 46 | + Visit the groupe-ka.com portal ↗ | |
| 47 | + </a> | |
| 48 | + </div> | |
| 49 | + | |
| 50 | + <section className="contact-sites"> | |
| 51 | + <h2>The ·Ka ecosystem</h2> | |
| 52 | + <ul> | |
| 53 | + {sites.map((s) => ( | |
| 54 | + <li key={s.id}> | |
| 55 | + <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer"> | |
| 56 | + <b>{s.wordmark}</b> | |
| 57 | + <span>{s.tagline}</span> | |
| 58 | + </a> | |
| 59 | + </li> | |
| 60 | + ))} | |
| 61 | + </ul> | |
| 62 | + </section> | |
| 63 | + </div> | |
| 64 | + ); | |
| 65 | +} | |
added
frontend/src/pages/Home.tsx
+519 −0
@@ -0,0 +1,519 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Home.tsx : home — hero, live stats, advanced filters, grid + map view | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | +import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react"; | |
| 7 | +import { useSearchParams } from "react-router-dom"; | |
| 8 | +import { | |
| 9 | + Facets, Listing, ListingFilters, Stats, | |
| 10 | + fetchFacets, fetchListings, fetchSources, fetchStats, | |
| 11 | + registerSourceNames, sourceName, | |
| 12 | +} from "../api"; | |
| 13 | +import ListingCard from "../components/ListingCard"; | |
| 14 | +import { Ico } from "../components/Icons"; | |
| 15 | + | |
| 16 | +const MapView = lazy(() => import("../components/MapView")); | |
| 17 | + | |
| 18 | +const PRICE_STEPS = [100000, 200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000, 3000000]; | |
| 19 | +const SQFT_STEPS = [800, 1000, 1500, 2000, 3000]; | |
| 20 | +const PAGE = 12; | |
| 21 | + | |
| 22 | +const fmtK = (n: number) => | |
| 23 | + n >= 1_000_000 ? `$${n / 1_000_000}M` : `$${Math.round(n / 1000)}K`; | |
| 24 | + | |
| 25 | +/** Animated hero counter (~0.9 s, cubic easing). Respects reduced-motion. */ | |
| 26 | +function useCountUp(target: number | null | undefined, ms = 900): string | null { | |
| 27 | + const [v, setV] = useState<number | null>(null); | |
| 28 | + useEffect(() => { | |
| 29 | + if (target == null) return; | |
| 30 | + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setV(target); return; } | |
| 31 | + let raf = 0; | |
| 32 | + const t0 = performance.now(); | |
| 33 | + const step = (t: number) => { | |
| 34 | + const p = Math.min(1, (t - t0) / ms); | |
| 35 | + setV(Math.round(target * (1 - Math.pow(1 - p, 3)))); | |
| 36 | + if (p < 1) raf = requestAnimationFrame(step); | |
| 37 | + }; | |
| 38 | + raf = requestAnimationFrame(step); | |
| 39 | + return () => cancelAnimationFrame(raf); | |
| 40 | + }, [target, ms]); | |
| 41 | + return v == null ? null : v.toLocaleString("en-US"); | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** Live "X ago" — re-rendered every second while the ts exists. */ | |
| 45 | +function useAgo(ts: number | null): string | null { | |
| 46 | + const [, tick] = useState(0); | |
| 47 | + useEffect(() => { | |
| 48 | + if (ts == null) return; | |
| 49 | + const id = setInterval(() => tick((x) => x + 1), 1000); | |
| 50 | + return () => clearInterval(id); | |
| 51 | + }, [ts]); | |
| 52 | + if (ts == null) return null; | |
| 53 | + const s = Math.max(0, Math.floor(Date.now() / 1000 - ts)); | |
| 54 | + if (s < 90) return `${s} s ago`; | |
| 55 | + if (s < 5400) return `${Math.round(s / 60)} min ago`; | |
| 56 | + return `${Math.round(s / 3600)} h ago`; | |
| 57 | +} | |
| 58 | + | |
| 59 | +// pagination window: 1 … (p-1) p (p+1) … N | |
| 60 | +function pageNumbers(p: number, n: number): (number | "…")[] { | |
| 61 | + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1); | |
| 62 | + const out: (number | "…")[] = [1]; | |
| 63 | + const lo = Math.max(2, p - 1), hi = Math.min(n - 1, p + 1); | |
| 64 | + if (lo > 2) out.push("…"); | |
| 65 | + for (let i = lo; i <= hi; i++) out.push(i); | |
| 66 | + if (hi < n - 1) out.push("…"); | |
| 67 | + out.push(n); | |
| 68 | + return out; | |
| 69 | +} | |
| 70 | + | |
| 71 | +export default function Home() { | |
| 72 | + const [listings, setListings] = useState<Listing[] | null>(null); | |
| 73 | + const [total, setTotal] = useState(0); | |
| 74 | + const [params, setParams] = useSearchParams(); | |
| 75 | + const [page, setPage] = useState(Number(params.get("page")) || 1); // 12/page, in the URL | |
| 76 | + const [facets, setFacets] = useState<Facets | null>(null); | |
| 77 | + const [cities, setCities] = useState<{ city: string; n: number }[]>([]); | |
| 78 | + const [stats, setStats] = useState<Stats | null>(null); | |
| 79 | + const [error, setError] = useState<string | null>(null); | |
| 80 | + const [q, setQ] = useState(params.get("q") ?? ""); | |
| 81 | + const [usState, setUsState] = useState(params.get("state") ?? ""); | |
| 82 | + const [city, setCity] = useState(params.get("city") ?? ""); | |
| 83 | + const [ptype, setPtype] = useState(params.get("property_type") ?? ""); | |
| 84 | + const [source, setSource] = useState(params.get("source") ?? ""); | |
| 85 | + const [priceMin, setPriceMin] = useState(params.get("price_min") ?? ""); | |
| 86 | + const [priceMax, setPriceMax] = useState(params.get("price_max") ?? ""); | |
| 87 | + const [bedsMin, setBedsMin] = useState(params.get("beds_min") ?? ""); | |
| 88 | + const [bathsMin, setBathsMin] = useState(params.get("baths_min") ?? ""); | |
| 89 | + const [sqftMin, setSqftMin] = useState(params.get("sqft_min") ?? ""); | |
| 90 | + const [sort, setSort] = useState(params.get("sort") ?? "recent"); | |
| 91 | + | |
| 92 | + const [sheetOpen, setSheetOpen] = useState(false); | |
| 93 | + const [advOpen, setAdvOpen] = useState(false); | |
| 94 | + | |
| 95 | + // Filters bottom-sheet: background scroll lock + Escape to close | |
| 96 | + useEffect(() => { | |
| 97 | + if (!sheetOpen) return; | |
| 98 | + document.body.style.overflow = "hidden"; | |
| 99 | + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); }; | |
| 100 | + window.addEventListener("keydown", onKey); | |
| 101 | + return () => { | |
| 102 | + document.body.style.overflow = ""; | |
| 103 | + window.removeEventListener("keydown", onKey); | |
| 104 | + }; | |
| 105 | + }, [sheetOpen]); | |
| 106 | + const activeFilters = [q, usState, city, ptype, source, priceMin, priceMax, bedsMin, bathsMin, sqftMin].filter(Boolean).length; | |
| 107 | + const advCount = [bedsMin, bathsMin, sqftMin, source].filter(Boolean).length; | |
| 108 | + | |
| 109 | + const [view, setView] = useState<"list" | "map">(params.get("view") === "map" ? "map" : "list"); | |
| 110 | + useEffect(() => { setView(params.get("view") === "map" ? "map" : "list"); }, [params]); | |
| 111 | + | |
| 112 | + // map mode (Ka Map System v2): body class — the page's filter sheet becomes | |
| 113 | + // a modal ABOVE the full-viewport shell. | |
| 114 | + useEffect(() => { | |
| 115 | + document.body.classList.toggle("ka-map-mode", view === "map"); | |
| 116 | + return () => document.body.classList.remove("ka-map-mode"); | |
| 117 | + }, [view]); | |
| 118 | + | |
| 119 | + const filters: ListingFilters = useMemo(() => ({ | |
| 120 | + q, city, state: usState, property_type: ptype, source, | |
| 121 | + price_min: priceMin, price_max: priceMax, | |
| 122 | + beds_min: bedsMin, baths_min: bathsMin, sqft_min: sqftMin, sort, | |
| 123 | + }), [q, city, usState, ptype, source, priceMin, priceMax, bedsMin, bathsMin, sqftMin, sort]); | |
| 124 | + | |
| 125 | + useEffect(() => { | |
| 126 | + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); | |
| 127 | + fetchFacets().then(setFacets).catch(() => {}); | |
| 128 | + fetchStats().then(setStats).catch(() => {}); | |
| 129 | + }, []); | |
| 130 | + | |
| 131 | + // cities narrowed by the selected state | |
| 132 | + useEffect(() => { | |
| 133 | + fetchFacets(usState || undefined).then((f) => setCities(f.cities)).catch(() => setCities([])); | |
| 134 | + }, [usState]); | |
| 135 | + | |
| 136 | + // back to page 1 when filters change | |
| 137 | + const firstRender = useRef(true); | |
| 138 | + useEffect(() => { | |
| 139 | + if (firstRender.current) { firstRender.current = false; return; } | |
| 140 | + setPage(1); | |
| 141 | + }, [filters]); | |
| 142 | + | |
| 143 | + // sync filters + page + sort + view into the URL → going back from a | |
| 144 | + // detail page returns to the SAME page/filters. | |
| 145 | + useEffect(() => { | |
| 146 | + const p = new URLSearchParams(); | |
| 147 | + const set = (k: string, v: string) => { if (v) p.set(k, v); }; | |
| 148 | + set("q", q); set("state", usState); set("city", city); | |
| 149 | + set("property_type", ptype); set("source", source); | |
| 150 | + set("price_min", priceMin); set("price_max", priceMax); | |
| 151 | + set("beds_min", bedsMin); set("baths_min", bathsMin); | |
| 152 | + set("sqft_min", sqftMin); | |
| 153 | + if (sort && sort !== "recent") p.set("sort", sort); | |
| 154 | + if (view === "map") p.set("view", "map"); | |
| 155 | + if (page > 1) p.set("page", String(page)); | |
| 156 | + setParams(p, { replace: true }); | |
| 157 | + }, [filters, page, view, q, usState, city, ptype, source, priceMin, priceMax, | |
| 158 | + bedsMin, bathsMin, sqftMin, sort, setParams]); | |
| 159 | + | |
| 160 | + // load the current page (12 listings) — replaces the grid | |
| 161 | + useEffect(() => { | |
| 162 | + let cancelled = false; | |
| 163 | + setListings(null); setError(null); | |
| 164 | + fetchListings(filters, PAGE, (page - 1) * PAGE) | |
| 165 | + .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } }) | |
| 166 | + .catch((e) => !cancelled && setError(String(e))); | |
| 167 | + return () => { cancelled = true; }; | |
| 168 | + }, [filters, page]); | |
| 169 | + | |
| 170 | + const totalPages = Math.max(1, Math.ceil(total / PAGE)); | |
| 171 | + const gotoPage = (p: number) => { | |
| 172 | + setPage(Math.min(Math.max(1, p), totalPages)); | |
| 173 | + if (view === "map") return; // the map-mode panel manages its own scroll | |
| 174 | + document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| 175 | + }; | |
| 176 | + | |
| 177 | + const resetAll = () => { | |
| 178 | + setQ(""); setUsState(""); setCity(""); setPtype(""); setSource(""); | |
| 179 | + setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin(""); setSqftMin(""); | |
| 180 | + }; | |
| 181 | + | |
| 182 | + // live hero data — real connector syncs (recent_syncs) | |
| 183 | + const totalLive = useCountUp(stats?.total); | |
| 184 | + const lastSync = useMemo(() => { | |
| 185 | + const ss = stats?.recent_syncs ?? []; | |
| 186 | + if (!ss.length) return null; | |
| 187 | + const ts = Math.max(...ss.map((s) => s.ts)); | |
| 188 | + return ts > 1e12 ? Math.round(ts / 1000) : ts; | |
| 189 | + }, [stats]); | |
| 190 | + const syncAgo = useAgo(lastSync); | |
| 191 | + const newToday = useMemo(() => { | |
| 192 | + const ss = stats?.recent_syncs ?? []; | |
| 193 | + const now = Date.now() / 1000; | |
| 194 | + return ss | |
| 195 | + .filter((s) => (s.ts > 1e12 ? s.ts / 1000 : s.ts) > now - 86400) | |
| 196 | + .reduce((n, s) => n + (s.added || 0), 0); | |
| 197 | + }, [stats]); | |
| 198 | + | |
| 199 | + const pills: { label: string; clear: () => void }[] = []; | |
| 200 | + if (q) pills.push({ label: `“${q}”`, clear: () => setQ("") }); | |
| 201 | + if (usState) pills.push({ label: usState, clear: () => { setUsState(""); setCity(""); } }); | |
| 202 | + if (city) pills.push({ label: city, clear: () => setCity("") }); | |
| 203 | + if (ptype) pills.push({ label: ptype, clear: () => setPtype("") }); | |
| 204 | + if (priceMin) pills.push({ label: `≥ ${fmtK(Number(priceMin))}`, clear: () => setPriceMin("") }); | |
| 205 | + if (priceMax) pills.push({ label: `≤ ${fmtK(Number(priceMax))}`, clear: () => setPriceMax("") }); | |
| 206 | + if (bedsMin) pills.push({ label: `${bedsMin}+ bd`, clear: () => setBedsMin("") }); | |
| 207 | + if (bathsMin) pills.push({ label: `${bathsMin}+ ba`, clear: () => setBathsMin("") }); | |
| 208 | + if (sqftMin) pills.push({ label: `≥ ${sqftMin} sq ft`, clear: () => setSqftMin("") }); | |
| 209 | + if (source) pills.push({ label: sourceName(source), clear: () => setSource("") }); | |
| 210 | + | |
| 211 | + const topTypes = (facets?.property_types ?? []).slice(0, 7); | |
| 212 | + | |
| 213 | + return ( | |
| 214 | + <div className="container"> | |
| 215 | + <section className="hero"> | |
| 216 | + <div className="hero-wrap"> | |
| 217 | + <div className="hero-main"> | |
| 218 | + <span className="kicker">Aggregator — every listing source in the U.S.</span> | |
| 219 | + <h1 className="hero-display" aria-label="Every home for sale in America. One place."> | |
| 220 | + <span className="hd-l1" aria-hidden="true">Every home</span> | |
| 221 | + <span className="hd-l2" aria-hidden="true">for sale</span> | |
| 222 | + <span className="hd-l3" aria-hidden="true">in America.</span> | |
| 223 | + <span className="hd-l4" aria-hidden="true">One <em className="signal">place</em>.</span> | |
| 224 | + </h1> | |
| 225 | + <p className="lede"> | |
| 226 | + Brokerages, MLS feeds and listing platforms across the United States — | |
| 227 | + aggregated in one place, always current, with full photos and details | |
| 228 | + and a direct link to the original listing. | |
| 229 | + </p> | |
| 230 | + <div className="live-line" aria-label="Live data"> | |
| 231 | + <span className="live-flag"><span className="live-dot" /> live</span> | |
| 232 | + {syncAgo && <span className="live-item">synced {syncAgo}</span>} | |
| 233 | + {newToday > 0 && ( | |
| 234 | + <span className="live-item"><b>+{newToday.toLocaleString("en-US")}</b> today</span> | |
| 235 | + )} | |
| 236 | + {stats && stats.sources > 0 && ( | |
| 237 | + <span className="live-item"><b>{stats.sources}</b> sources</span> | |
| 238 | + )} | |
| 239 | + </div> | |
| 240 | + </div> | |
| 241 | + <aside className="hero-data" aria-label="The market in numbers"> | |
| 242 | + <div className="hd-row"> | |
| 243 | + <b>{totalLive ?? "—"}</b><span>homes indexed</span> | |
| 244 | + </div> | |
| 245 | + {stats && stats.states != null && stats.states > 0 && ( | |
| 246 | + <div className="hd-row"> | |
| 247 | + <b>{stats.states.toLocaleString("en-US")}</b><span>states</span> | |
| 248 | + </div> | |
| 249 | + )} | |
| 250 | + {stats?.avg_price != null && ( | |
| 251 | + <div className="hd-row"> | |
| 252 | + <b>${Math.round(stats.avg_price).toLocaleString("en-US")}</b><span>average price</span> | |
| 253 | + </div> | |
| 254 | + )} | |
| 255 | + {stats?.max_price != null && ( | |
| 256 | + <div className="hd-row"> | |
| 257 | + <b>{fmtK(stats.max_price)}</b><span>highest price</span> | |
| 258 | + </div> | |
| 259 | + )} | |
| 260 | + </aside> | |
| 261 | + </div> | |
| 262 | + </section> | |
| 263 | + | |
| 264 | + {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />} | |
| 265 | + <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Search and filters"> | |
| 266 | + <div className="sheet-handle" aria-hidden="true" /> | |
| 267 | + <div className="sheet-head"> | |
| 268 | + <span>Refine your search</span> | |
| 269 | + <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Close filters">✕</button> | |
| 270 | + </div> | |
| 271 | + | |
| 272 | + {/* — search, the heart of the product: one large underlined field — */} | |
| 273 | + <div className="q-big"> | |
| 274 | + <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> | |
| 275 | + <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /> | |
| 276 | + </svg> | |
| 277 | + <input id="f-q" placeholder="Where do you want to live?" value={q} | |
| 278 | + onChange={(e) => setQ(e.target.value)} | |
| 279 | + aria-label="Search — address, city, ZIP or MLS number" /> | |
| 280 | + {q && <button className="f-clear" onClick={() => setQ("")} aria-label="Clear search">✕</button>} | |
| 281 | + </div> | |
| 282 | + | |
| 283 | + {/* — inline criteria, separated by rules (no boxes) — */} | |
| 284 | + <div className="crit-line"> | |
| 285 | + <label className="crit"> | |
| 286 | + <span>State</span> | |
| 287 | + <select value={usState} onChange={(e) => { setUsState(e.target.value); setCity(""); }}> | |
| 288 | + <option value="">All</option> | |
| 289 | + {(facets?.states ?? []).map((s) => ( | |
| 290 | + <option key={s.state} value={s.state}>{s.state} ({s.n.toLocaleString("en-US")})</option> | |
| 291 | + ))} | |
| 292 | + </select> | |
| 293 | + </label> | |
| 294 | + <label className="crit"> | |
| 295 | + <span>City</span> | |
| 296 | + <select value={city} onChange={(e) => setCity(e.target.value)}> | |
| 297 | + <option value="">All</option> | |
| 298 | + {cities.map((c) => <option key={c.city} value={c.city}>{c.city}</option>)} | |
| 299 | + </select> | |
| 300 | + </label> | |
| 301 | + <label className="crit"> | |
| 302 | + <span>Type</span> | |
| 303 | + <select value={ptype} onChange={(e) => setPtype(e.target.value)}> | |
| 304 | + <option value="">All</option> | |
| 305 | + {(facets?.property_types ?? []).map((t) => <option key={t} value={t}>{t}</option>)} | |
| 306 | + </select> | |
| 307 | + </label> | |
| 308 | + <div className="crit"> | |
| 309 | + <span>Price</span> | |
| 310 | + <div className="range-pair"> | |
| 311 | + <select aria-label="Minimum price" value={priceMin} onChange={(e) => setPriceMin(e.target.value)}> | |
| 312 | + <option value="">Min</option> | |
| 313 | + {PRICE_STEPS.map((p) => ( | |
| 314 | + <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>{fmtK(p)}</option> | |
| 315 | + ))} | |
| 316 | + </select> | |
| 317 | + <span className="range-sep">—</span> | |
| 318 | + <select aria-label="Maximum price" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}> | |
| 319 | + <option value="">Max</option> | |
| 320 | + {PRICE_STEPS.map((p) => ( | |
| 321 | + <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>{fmtK(p)}</option> | |
| 322 | + ))} | |
| 323 | + </select> | |
| 324 | + </div> | |
| 325 | + </div> | |
| 326 | + <button className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}> | |
| 327 | + All criteria | |
| 328 | + {advCount > 0 && <span className="crit-badge">{advCount}</span>} | |
| 329 | + <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" /> | |
| 330 | + </button> | |
| 331 | + </div> | |
| 332 | + | |
| 333 | + {(advOpen || sheetOpen) && ( | |
| 334 | + <div className="f-adv"> | |
| 335 | + <div className="f-group"> | |
| 336 | + <label>Bedrooms (min.)</label> | |
| 337 | + <div className="seg" role="group"> | |
| 338 | + <button className={bedsMin === "" ? "on" : ""} onClick={() => setBedsMin("")}>Any</button> | |
| 339 | + {["1", "2", "3", "4", "5"].map((n) => ( | |
| 340 | + <button key={n} className={bedsMin === n ? "on" : ""} onClick={() => setBedsMin(n)}>{n}+</button> | |
| 341 | + ))} | |
| 342 | + </div> | |
| 343 | + </div> | |
| 344 | + <div className="f-group"> | |
| 345 | + <label>Bathrooms (min.)</label> | |
| 346 | + <div className="seg" role="group"> | |
| 347 | + <button className={bathsMin === "" ? "on" : ""} onClick={() => setBathsMin("")}>Any</button> | |
| 348 | + {["1", "2", "3"].map((n) => ( | |
| 349 | + <button key={n} className={bathsMin === n ? "on" : ""} onClick={() => setBathsMin(n)}>{n}+</button> | |
| 350 | + ))} | |
| 351 | + </div> | |
| 352 | + </div> | |
| 353 | + <div className="f-group"> | |
| 354 | + <label>Minimum living area</label> | |
| 355 | + <div className="seg" role="group"> | |
| 356 | + <button className={sqftMin === "" ? "on" : ""} onClick={() => setSqftMin("")}>Any</button> | |
| 357 | + {SQFT_STEPS.map((a) => ( | |
| 358 | + <button key={a} className={sqftMin === String(a) ? "on" : ""} onClick={() => setSqftMin(String(a))}>{a}+</button> | |
| 359 | + ))} | |
| 360 | + </div> | |
| 361 | + </div> | |
| 362 | + <div className="f-group"> | |
| 363 | + <label>Source</label> | |
| 364 | + <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}> | |
| 365 | + <option value="">All</option> | |
| 366 | + {(facets?.sources ?? []).map((s) => ( | |
| 367 | + <option key={s.source} value={s.source}>{sourceName(s.source)} ({s.n})</option> | |
| 368 | + ))} | |
| 369 | + </select> | |
| 370 | + </div> | |
| 371 | + <div className="f-group f-group-end"> | |
| 372 | + <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}> | |
| 373 | + Reset all{activeFilters > 0 ? ` (${activeFilters})` : ""} | |
| 374 | + </button> | |
| 375 | + </div> | |
| 376 | + </div> | |
| 377 | + )} | |
| 378 | + | |
| 379 | + <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}> | |
| 380 | + Show {listings ? `${total.toLocaleString("en-US")} homes` : "results"} | |
| 381 | + </button> | |
| 382 | + </section> | |
| 383 | + | |
| 384 | + {/* mobile: criteria summary — opens the sheet (search included, no FAB) */} | |
| 385 | + <button className="crit-summary" onClick={() => setSheetOpen(true)}> | |
| 386 | + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true"> | |
| 387 | + <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" /> | |
| 388 | + </svg> | |
| 389 | + <span className="cs-txt"> | |
| 390 | + {pills.length > 0 | |
| 391 | + ? pills.slice(0, 3).map((p) => p.label).join(" · ") + (pills.length > 3 ? ` · +${pills.length - 3}` : "") | |
| 392 | + : "State, city, type, price, beds…"} | |
| 393 | + </span> | |
| 394 | + <span className="f-chev" aria-hidden="true" /> | |
| 395 | + </button> | |
| 396 | + | |
| 397 | + <div className="chips" role="group" aria-label="Quick filters"> | |
| 398 | + {topTypes.map((t) => ( | |
| 399 | + <button key={t} className={`chip ${ptype === t ? "on" : ""}`} onClick={() => setPtype(ptype === t ? "" : t)}> | |
| 400 | + {t} | |
| 401 | + </button> | |
| 402 | + ))} | |
| 403 | + </div> | |
| 404 | + | |
| 405 | + {pills.length > 0 && ( | |
| 406 | + <div className="pills" aria-label="Active filters"> | |
| 407 | + {pills.map((p) => ( | |
| 408 | + <button key={p.label} className="pill" onClick={p.clear} aria-label={`Remove filter ${p.label}`}> | |
| 409 | + {p.label} <span className="pill-x">✕</span> | |
| 410 | + </button> | |
| 411 | + ))} | |
| 412 | + <button className="pill pill-clear" onClick={resetAll}>Clear all</button> | |
| 413 | + </div> | |
| 414 | + )} | |
| 415 | + | |
| 416 | + <div className="results-bar" id="results-top"> | |
| 417 | + <h2 className="rb-count"> | |
| 418 | + {listings | |
| 419 | + ? <><b>{total.toLocaleString("en-US")}</b> home{total !== 1 ? "s" : ""}</> | |
| 420 | + : "Homes"} | |
| 421 | + </h2> | |
| 422 | + <div className="rb-tools"> | |
| 423 | + <label className="rb-sort"> | |
| 424 | + <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort"> | |
| 425 | + <option value="recent">Newest</option> | |
| 426 | + <option value="price_asc">Price: low to high</option> | |
| 427 | + <option value="price_desc">Price: high to low</option> | |
| 428 | + </select> | |
| 429 | + </label> | |
| 430 | + <div className="rb-tabs" role="tablist" aria-label="View mode"> | |
| 431 | + <button role="tab" aria-selected={view === "list"} className={`rb-tab ${view === "list" ? "on" : ""}`} onClick={() => setView("list")}> | |
| 432 | + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true"> | |
| 433 | + <path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01" /> | |
| 434 | + </svg> | |
| 435 | + List | |
| 436 | + </button> | |
| 437 | + <button role="tab" aria-selected={view === "map"} className={`rb-tab ${view === "map" ? "on" : ""}`} onClick={() => setView("map")}> | |
| 438 | + <Ico name="map" size={13} /> Map | |
| 439 | + </button> | |
| 440 | + </div> | |
| 441 | + <button className="rb-filters" onClick={() => setSheetOpen(true)}> | |
| 442 | + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true"> | |
| 443 | + <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" /> | |
| 444 | + </svg> | |
| 445 | + Filters | |
| 446 | + {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>} | |
| 447 | + </button> | |
| 448 | + </div> | |
| 449 | + </div> | |
| 450 | + | |
| 451 | + {error && ( | |
| 452 | + <div className="notice"> | |
| 453 | + <div className="big"><Ico name="alert" size={40} /></div> | |
| 454 | + <h2>Could not load listings</h2> | |
| 455 | + <p>{error}</p> | |
| 456 | + <button className="btn btn-primary" onClick={() => window.location.reload()}>Retry</button> | |
| 457 | + </div> | |
| 458 | + )} | |
| 459 | + | |
| 460 | + {!error && view === "list" && listings === null && ( | |
| 461 | + <div className="grid grid-edito" aria-busy="true"> | |
| 462 | + {Array.from({ length: 8 }).map((_, i) => ( | |
| 463 | + <div className="skel" key={i}><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div> | |
| 464 | + ))} | |
| 465 | + </div> | |
| 466 | + )} | |
| 467 | + | |
| 468 | + {!error && view === "list" && listings !== null && listings.length === 0 && ( | |
| 469 | + <div className="notice"> | |
| 470 | + <div className="big"><Ico name="search" size={40} /></div> | |
| 471 | + <h2>No homes match your criteria</h2> | |
| 472 | + <p>Try widening your search | |
| 473 | + {activeFilters > 0 && <> — or <button className="link-btn" onClick={resetAll}>remove the {activeFilters} active filters</button></>}.</p> | |
| 474 | + </div> | |
| 475 | + )} | |
| 476 | + | |
| 477 | + {!error && view === "map" && ( | |
| 478 | + <div className="view-pane" key="map"> | |
| 479 | + <Suspense fallback={<div className="ka-shell-fallback">Loading the map…</div>}> | |
| 480 | + <MapView | |
| 481 | + filters={filters} | |
| 482 | + listings={listings} | |
| 483 | + total={total} | |
| 484 | + page={page} | |
| 485 | + totalPages={totalPages} | |
| 486 | + onPage={gotoPage} | |
| 487 | + sort={sort} | |
| 488 | + onSort={setSort} | |
| 489 | + onExit={() => setView("list")} | |
| 490 | + onOpenFilters={() => setSheetOpen(true)} | |
| 491 | + filtersCount={activeFilters} | |
| 492 | + /> | |
| 493 | + </Suspense> | |
| 494 | + </div> | |
| 495 | + )} | |
| 496 | + | |
| 497 | + {!error && view === "list" && listings !== null && listings.length > 0 && ( | |
| 498 | + <div className="view-pane"> | |
| 499 | + <div className="grid grid-edito"> | |
| 500 | + {listings.map((l) => <ListingCard key={l.uid} l={l} />)} | |
| 501 | + </div> | |
| 502 | + {totalPages > 1 && ( | |
| 503 | + <nav className="pager" aria-label="Pagination"> | |
| 504 | + <button className="pager-btn" onClick={() => gotoPage(page - 1)} disabled={page <= 1}>‹ Prev</button> | |
| 505 | + {pageNumbers(page, totalPages).map((p, i) => | |
| 506 | + p === "…" | |
| 507 | + ? <span key={`e${i}`} className="pager-gap">…</span> | |
| 508 | + : <button key={p} className={`pager-btn ${p === page ? "on" : ""}`} | |
| 509 | + onClick={() => gotoPage(p as number)}>{p}</button> | |
| 510 | + )} | |
| 511 | + <button className="pager-btn" onClick={() => gotoPage(page + 1)} disabled={page >= totalPages}>Next ›</button> | |
| 512 | + <span className="pager-info">Page {page} / {totalPages}</span> | |
| 513 | + </nav> | |
| 514 | + )} | |
| 515 | + </div> | |
| 516 | + )} | |
| 517 | + </div> | |
| 518 | + ); | |
| 519 | +} | |
added
frontend/src/pages/Legal.tsx
+129 −0
@@ -0,0 +1,129 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// Legal.tsx : Terms of Use & Privacy Policy | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | +const UPDATED = "August 28, 2026"; | |
| 7 | + | |
| 8 | +function LegalShell({ title, children }: { title: string; children: React.ReactNode }) { | |
| 9 | + return ( | |
| 10 | + <div className="container legal"> | |
| 11 | + <h1>{title}</h1> | |
| 12 | + <p className="legal-meta"> | |
| 13 | + Home-Ka is a <b>Groupe KA</b> application. Last updated: {UPDATED}. | |
| 14 | + </p> | |
| 15 | + {children} | |
| 16 | + </div> | |
| 17 | + ); | |
| 18 | +} | |
| 19 | + | |
| 20 | +export function TermsPage() { | |
| 21 | + return ( | |
| 22 | + <LegalShell title="Terms of Use"> | |
| 23 | + <h2>1. The application</h2> | |
| 24 | + <p> | |
| 25 | + Home-Ka (<a href="https://www.home-ka.com">www.home-ka.com</a>) is an | |
| 26 | + independent aggregator of homes for sale in the United States, operated | |
| 27 | + by <b>Groupe KA</b>. Listings come from public brokerage websites, | |
| 28 | + MLS/RESO feeds and listing platforms; every listing links back to the | |
| 29 | + original announcement at the source. | |
| 30 | + </p> | |
| 31 | + | |
| 32 | + <h2>2. No brokerage role</h2> | |
| 33 | + <p> | |
| 34 | + Groupe KA is not a real-estate brokerage and does not act as a broker, | |
| 35 | + agent or intermediary under any US state real-estate license law. | |
| 36 | + Home-Ka takes part in no transaction: any purchase inquiry must be made | |
| 37 | + directly with the brokerage, agent or seller identified on the listing. | |
| 38 | + </p> | |
| 39 | + | |
| 40 | + <h2>3. Accuracy of information</h2> | |
| 41 | + <p> | |
| 42 | + Data (prices, characteristics, photos, statuses) is provided for | |
| 43 | + informational purposes only, with no warranty of accuracy, completeness | |
| 44 | + or timeliness. The official record remains the original listing at the | |
| 45 | + source. Nothing on Home-Ka constitutes a professional appraisal or | |
| 46 | + financial advice. | |
| 47 | + </p> | |
| 48 | + | |
| 49 | + <h2>4. Intellectual property</h2> | |
| 50 | + <p> | |
| 51 | + Photos and descriptions belong to their respective brokerages, agents | |
| 52 | + or sellers. Any rights holder may request removal of content by writing | |
| 53 | + to <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a> — | |
| 54 | + requests are handled promptly. | |
| 55 | + </p> | |
| 56 | + | |
| 57 | + <h2>5. Permitted use</h2> | |
| 58 | + <p> | |
| 59 | + The application is offered for personal, non-commercial use. Bulk data | |
| 60 | + extraction, resale of the data or interference with the service are | |
| 61 | + prohibited. | |
| 62 | + </p> | |
| 63 | + | |
| 64 | + <h2>6. Limitation of liability</h2> | |
| 65 | + <p> | |
| 66 | + Groupe KA cannot be held liable for decisions made on the basis of the | |
| 67 | + information displayed, nor for errors originating from aggregated | |
| 68 | + sources. The application is provided "as is", with no availability | |
| 69 | + warranty. | |
| 70 | + </p> | |
| 71 | + | |
| 72 | + <h2>7. Contact</h2> | |
| 73 | + <p> | |
| 74 | + Questions about these terms:{" "} | |
| 75 | + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>. | |
| 76 | + </p> | |
| 77 | + </LegalShell> | |
| 78 | + ); | |
| 79 | +} | |
| 80 | + | |
| 81 | +export function PrivacyPage() { | |
| 82 | + return ( | |
| 83 | + <LegalShell title="Privacy Policy"> | |
| 84 | + <h2>1. Information collected</h2> | |
| 85 | + <p> | |
| 86 | + Home-Ka works <b>without user accounts</b> and collects no personally | |
| 87 | + identifiable information while you browse. Technical server logs (IP | |
| 88 | + address, pages viewed, browser) are used solely to operate and secure | |
| 89 | + the service, and are purged periodically. | |
| 90 | + </p> | |
| 91 | + | |
| 92 | + <h2>2. Cookies</h2> | |
| 93 | + <p> | |
| 94 | + The application uses no advertising cookies and no third-party | |
| 95 | + trackers. Only local settings (display preferences, the optional admin | |
| 96 | + token) may be stored in your browser. | |
| 97 | + </p> | |
| 98 | + | |
| 99 | + <h2>3. Displayed data</h2> | |
| 100 | + <p> | |
| 101 | + Property pages come from listings already public on the source | |
| 102 | + brokerage websites and platforms. Agent names and contact details shown | |
| 103 | + are those the source itself publishes for professional solicitation. | |
| 104 | + </p> | |
| 105 | + | |
| 106 | + <h2>4. Sharing</h2> | |
| 107 | + <p> | |
| 108 | + Groupe KA does not sell, rent or share any visit data with third | |
| 109 | + parties. | |
| 110 | + </p> | |
| 111 | + | |
| 112 | + <h2>5. Your rights</h2> | |
| 113 | + <p> | |
| 114 | + You may request access to, correction of, or removal of personal | |
| 115 | + information concerning you — for instance if your property or your | |
| 116 | + contact details appear on a page and you want them removed. Applicable | |
| 117 | + state privacy laws (such as the California Consumer Privacy Act) are | |
| 118 | + honored. | |
| 119 | + </p> | |
| 120 | + | |
| 121 | + <h2>6. Contact</h2> | |
| 122 | + <p> | |
| 123 | + Privacy requests and general questions:{" "} | |
| 124 | + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>. Every | |
| 125 | + request receives a reply within 30 days. | |
| 126 | + </p> | |
| 127 | + </LegalShell> | |
| 128 | + ); | |
| 129 | +} | |
added
frontend/src/pages/Listing.tsx
+501 −0
@@ -0,0 +1,501 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Listing.tsx : full property detail page | |
| 5 | +// Groupe KA standard section order (DOM order = visual order, mobile AND | |
| 6 | +// desktop — no CSS `order:` / `column-reverse` reordering): | |
| 7 | +// 1. photo gallery (hero) → 2. price + status badge + address + key facts → | |
| 8 | +// 3. description → 4. features → 5. practical details → 6. price history → | |
| 9 | +// 7. map → 8. "Also listed on" + PROPERTY history → footer. | |
| 10 | +// ----------------------------------------------------------------------------- | |
| 11 | +import { Suspense, lazy, useEffect, useRef, useState } from "react"; | |
| 12 | +import { Link, useParams } from "react-router-dom"; | |
| 13 | +import { | |
| 14 | + Listing, PropertyListingRef, STATUS_LABELS, fetchListing, fetchSources, | |
| 15 | + fmtArea, fmtBaths, fmtCityLine, fmtDate, fmtPrice, | |
| 16 | + registerSourceNames, sourceName, | |
| 17 | +} from "../api"; | |
| 18 | + | |
| 19 | +const PropertyMap = lazy(() => import("../components/PropertyMap")); | |
| 20 | +import { Ico } from "../components/Icons"; | |
| 21 | +import AmenityIco from "../components/AmenityIco"; | |
| 22 | +import { TypeFallback } from "../components/PropertyImg"; | |
| 23 | + | |
| 24 | +// --- Lightbox: pinch to zoom + pan + swipe between photos --------------------- | |
| 25 | +function ZoomImg({ src, onSwipe }: { src: string; onSwipe: (dir: 1 | -1) => void }) { | |
| 26 | + const [t, setT] = useState({ scale: 1, x: 0, y: 0 }); | |
| 27 | + const pointers = useRef(new Map<number, { x: number; y: number }>()); | |
| 28 | + const start = useRef({ scale: 1, x: 0, y: 0, dist: 0, cx: 0, cy: 0, t: 0 }); | |
| 29 | + const lastTap = useRef(0); | |
| 30 | + | |
| 31 | + // reset when switching photos | |
| 32 | + useEffect(() => { setT({ scale: 1, x: 0, y: 0 }); }, [src]); | |
| 33 | + | |
| 34 | + const dist = () => { | |
| 35 | + const p = [...pointers.current.values()]; | |
| 36 | + return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); | |
| 37 | + }; | |
| 38 | + const center = () => { | |
| 39 | + const p = [...pointers.current.values()]; | |
| 40 | + return p.length < 2 | |
| 41 | + ? p[0] ?? { x: 0, y: 0 } | |
| 42 | + : { x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 }; | |
| 43 | + }; | |
| 44 | + | |
| 45 | + const onDown = (e: React.PointerEvent) => { | |
| 46 | + (e.target as HTMLElement).setPointerCapture(e.pointerId); | |
| 47 | + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); | |
| 48 | + const c = center(); | |
| 49 | + start.current = { scale: t.scale, x: t.x, y: t.y, dist: dist(), cx: c.x, cy: c.y, t: Date.now() }; | |
| 50 | + }; | |
| 51 | + const onMove = (e: React.PointerEvent) => { | |
| 52 | + if (!pointers.current.has(e.pointerId)) return; | |
| 53 | + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); | |
| 54 | + const s = start.current; | |
| 55 | + if (pointers.current.size >= 2 && s.dist > 0) { | |
| 56 | + // pinch: zoom around the two-finger midpoint | |
| 57 | + const scale = Math.min(4, Math.max(1, (dist() / s.dist) * s.scale)); | |
| 58 | + const c = center(); | |
| 59 | + setT({ scale, x: s.x + (c.x - s.cx), y: s.y + (c.y - s.cy) }); | |
| 60 | + } else if (pointers.current.size === 1 && t.scale > 1) { | |
| 61 | + // pan once zoomed | |
| 62 | + const p = pointers.current.get(e.pointerId)!; | |
| 63 | + setT({ scale: t.scale, x: s.x + (p.x - s.cx), y: s.y + (p.y - s.cy) }); | |
| 64 | + } | |
| 65 | + }; | |
| 66 | + const onUp = (e: React.PointerEvent) => { | |
| 67 | + const p = pointers.current.get(e.pointerId); | |
| 68 | + pointers.current.delete(e.pointerId); | |
| 69 | + const s = start.current; | |
| 70 | + if (pointers.current.size === 0 && p) { | |
| 71 | + const dx = p.x - s.cx, dy = p.y - s.cy, dt = Date.now() - s.t; | |
| 72 | + if (t.scale <= 1.05 && Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) { | |
| 73 | + onSwipe(dx < 0 ? 1 : -1); // swipe → next photo | |
| 74 | + } else if (dt < 260 && Math.abs(dx) < 8 && Math.abs(dy) < 8) { | |
| 75 | + const now = Date.now(); | |
| 76 | + if (now - lastTap.current < 320) // double-tap: ×2.4 zoom | |
| 77 | + setT(t.scale > 1 ? { scale: 1, x: 0, y: 0 } : { scale: 2.4, x: 0, y: 0 }); | |
| 78 | + lastTap.current = now; | |
| 79 | + } | |
| 80 | + if (t.scale <= 1.02) setT({ scale: 1, x: 0, y: 0 }); | |
| 81 | + } | |
| 82 | + }; | |
| 83 | + | |
| 84 | + return ( | |
| 85 | + <img | |
| 86 | + src={src} alt="" draggable={false} | |
| 87 | + style={{ | |
| 88 | + transform: `translate(${t.x}px, ${t.y}px) scale(${t.scale})`, | |
| 89 | + transition: pointers.current.size ? "none" : "transform 0.15s ease", | |
| 90 | + touchAction: "none", cursor: t.scale > 1 ? "grab" : "zoom-out", | |
| 91 | + }} | |
| 92 | + onClick={(e) => e.stopPropagation()} | |
| 93 | + onPointerDown={onDown} onPointerMove={onMove} | |
| 94 | + onPointerUp={onUp} onPointerCancel={onUp} | |
| 95 | + /> | |
| 96 | + ); | |
| 97 | +} | |
| 98 | + | |
| 99 | +// --- Gallery: native swipe (scroll-snap) + thumbnails + fullscreen ------------- | |
| 100 | +function Gallery({ images, captions, title, type }: | |
| 101 | + { images: string[]; captions?: string[]; title: string; type?: string }) { | |
| 102 | + const [idx, setIdx] = useState(0); | |
| 103 | + const [zoom, setZoom] = useState(false); | |
| 104 | + const [dead, setDead] = useState<Set<string>>(new Set()); | |
| 105 | + const track = useRef<HTMLDivElement>(null); | |
| 106 | + | |
| 107 | + // images that fail to load are removed on the fly (never a broken-image | |
| 108 | + // icon); captions kept aligned | |
| 109 | + const alive = images | |
| 110 | + .map((u, i) => ({ u, cap: captions && captions.length === images.length ? captions[i] : "" })) | |
| 111 | + .filter(({ u }) => !dead.has(u)); | |
| 112 | + const markDead = (u: string) => setDead((d) => new Set(d).add(u)); | |
| 113 | + | |
| 114 | + const onScroll = () => { | |
| 115 | + const el = track.current; | |
| 116 | + if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)); | |
| 117 | + }; | |
| 118 | + const goto = (i: number) => | |
| 119 | + track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" }); | |
| 120 | + | |
| 121 | + useEffect(() => { | |
| 122 | + if (!zoom) return; | |
| 123 | + const onKey = (e: KeyboardEvent) => { | |
| 124 | + if (e.key === "Escape") setZoom(false); | |
| 125 | + if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1)); | |
| 126 | + if (e.key === "ArrowRight") setIdx((i) => Math.min(alive.length - 1, i + 1)); | |
| 127 | + }; | |
| 128 | + window.addEventListener("keydown", onKey); | |
| 129 | + // freeze the background during fullscreen (mobile) | |
| 130 | + document.body.style.overflow = "hidden"; | |
| 131 | + return () => { | |
| 132 | + window.removeEventListener("keydown", onKey); | |
| 133 | + document.body.style.overflow = ""; | |
| 134 | + }; | |
| 135 | + }, [zoom, alive.length]); | |
| 136 | + | |
| 137 | + if (alive.length === 0) | |
| 138 | + return <div className="carousel"><div className="carousel-empty"><TypeFallback type={type} /></div></div>; | |
| 139 | + | |
| 140 | + const cur = Math.min(idx, alive.length - 1); | |
| 141 | + const swipe = (dir: 1 | -1) => | |
| 142 | + setIdx((i) => Math.min(alive.length - 1, Math.max(0, i + dir))); | |
| 143 | + | |
| 144 | + return ( | |
| 145 | + <> | |
| 146 | + <div className="carousel"> | |
| 147 | + <div className="carousel-track" ref={track} onScroll={onScroll}> | |
| 148 | + {alive.map(({ u }, i) => ( | |
| 149 | + <img key={u} src={u} loading={i <= 1 ? "eager" : "lazy"} decoding="async" | |
| 150 | + alt={`${title} — photo ${i + 1} of ${alive.length}`} | |
| 151 | + onError={() => markDead(u)} onClick={() => setZoom(true)} /> | |
| 152 | + ))} | |
| 153 | + </div> | |
| 154 | + {alive[cur]?.cap && <span className="carousel-caption">{alive[cur].cap}</span>} | |
| 155 | + <span className="carousel-count" aria-live="polite">{cur + 1}/{alive.length}</span> | |
| 156 | + {cur > 0 && <button className="carousel-nav prev" aria-label="Previous photo" onClick={() => goto(cur - 1)}>‹</button>} | |
| 157 | + {cur < alive.length - 1 && <button className="carousel-nav next" aria-label="Next photo" onClick={() => goto(cur + 1)}>›</button>} | |
| 158 | + </div> | |
| 159 | + {alive.length > 1 && ( | |
| 160 | + <div className="thumbs"> | |
| 161 | + {alive.map(({ u }, i) => ( | |
| 162 | + <button key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}> | |
| 163 | + <img src={u} alt="" loading="lazy" decoding="async" onError={() => markDead(u)} /> | |
| 164 | + </button> | |
| 165 | + ))} | |
| 166 | + </div> | |
| 167 | + )} | |
| 168 | + {zoom && ( | |
| 169 | + <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Enlarged photo"> | |
| 170 | + <button className="lb-close" aria-label="Close" onClick={() => setZoom(false)}>✕</button> | |
| 171 | + {cur > 0 && <button className="lb-nav prev" aria-label="Previous" onClick={(e) => { e.stopPropagation(); setIdx(cur - 1); }}>‹</button>} | |
| 172 | + <ZoomImg src={alive[cur].u} onSwipe={swipe} /> | |
| 173 | + {cur < alive.length - 1 && <button className="lb-nav next" aria-label="Next" onClick={(e) => { e.stopPropagation(); setIdx(cur + 1); }}>›</button>} | |
| 174 | + <span className="lb-count"> | |
| 175 | + {alive[cur]?.cap ? `${alive[cur].cap} · ` : ""}{cur + 1} / {alive.length} | |
| 176 | + </span> | |
| 177 | + </div> | |
| 178 | + )} | |
| 179 | + </> | |
| 180 | + ); | |
| 181 | +} | |
| 182 | + | |
| 183 | +// technical `details` keys never shown in "Practical details" | |
| 184 | +const DETAIL_HIDDEN = new Set([ | |
| 185 | + "price_from", "cover_thumb", "photo_captions", "img_audited", | |
| 186 | + "needs_image_review", "listing_origin_url", | |
| 187 | +]); | |
| 188 | + | |
| 189 | +// icon for each key-fact row (Icons.tsx) | |
| 190 | +const SPEC_ICONS: Record<string, string> = { | |
| 191 | + "Type": "home", "Bedrooms": "bed", "Bathrooms": "bath", | |
| 192 | + "Living area": "area", "Lot size": "land", | |
| 193 | + "Year built": "calendar", "MLS #": "tag", | |
| 194 | +}; | |
| 195 | + | |
| 196 | +const STATUS_CLASS: Record<string, string> = { | |
| 197 | + "active": "st-active", "pending": "st-pending", "sold": "st-sold", | |
| 198 | + "withdrawn": "st-withdrawn", "coming-soon": "st-coming", | |
| 199 | +}; | |
| 200 | + | |
| 201 | +function historyDate(ts: number | null): string { | |
| 202 | + if (ts == null) return "—"; | |
| 203 | + return fmtDate(ts > 1e12 ? ts / 1000 : ts); | |
| 204 | +} | |
| 205 | + | |
| 206 | +export default function ListingPage() { | |
| 207 | + const { uid } = useParams<{ uid: string }>(); | |
| 208 | + const [l, setL] = useState<Listing | null>(null); | |
| 209 | + const [error, setError] = useState<string | null>(null); | |
| 210 | + // re-render when source names arrive (otherwise Title Case fallback) | |
| 211 | + const [, setSrcTick] = useState(0); | |
| 212 | + | |
| 213 | + useEffect(() => { | |
| 214 | + fetchSources().then((r) => { registerSourceNames(r.sources); setSrcTick(1); }).catch(() => {}); | |
| 215 | + if (!uid) return; | |
| 216 | + setL(null); setError(null); | |
| 217 | + fetchListing(uid).then(setL).catch((e) => setError(String(e))); | |
| 218 | + window.scrollTo(0, 0); | |
| 219 | + }, [uid]); | |
| 220 | + | |
| 221 | + if (error) | |
| 222 | + return ( | |
| 223 | + <div className="notice container"> | |
| 224 | + <div className="big"><Ico name="alert" size={44} /></div> | |
| 225 | + <h2>Property not found</h2> | |
| 226 | + <p>{error}</p> | |
| 227 | + <Link className="btn btn-primary" to="/">Back to homes</Link> | |
| 228 | + </div> | |
| 229 | + ); | |
| 230 | + | |
| 231 | + if (!l) | |
| 232 | + return ( | |
| 233 | + <div className="container detail"> | |
| 234 | + <div className="fiche" aria-busy="true"> | |
| 235 | + <div className="skel"><div className="sk-img" /></div> | |
| 236 | + <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div> | |
| 237 | + </div> | |
| 238 | + </div> | |
| 239 | + ); | |
| 240 | + | |
| 241 | + // 2. key facts | |
| 242 | + const specs: { k: string; v: string }[] = []; | |
| 243 | + if (l.property_type) specs.push({ k: "Type", v: l.property_type }); | |
| 244 | + if (l.bedrooms != null) specs.push({ k: "Bedrooms", v: String(l.bedrooms) }); | |
| 245 | + if (l.bathrooms != null) specs.push({ k: "Bathrooms", v: fmtBaths(l.bathrooms) }); | |
| 246 | + if (l.living_area_sqft != null) specs.push({ k: "Living area", v: fmtArea(l.living_area_sqft)! }); | |
| 247 | + if (l.lot_size_sqft != null) specs.push({ k: "Lot size", v: fmtArea(l.lot_size_sqft)! }); | |
| 248 | + if (l.year_built != null) specs.push({ k: "Year built", v: String(l.year_built) }); | |
| 249 | + if (l.mls_id) specs.push({ k: "MLS #", v: l.mls_id }); | |
| 250 | + | |
| 251 | + // 5. practical details: structured fields first, then source `details` | |
| 252 | + const practical: { k: string; v: string }[] = []; | |
| 253 | + if (l.property_subtype) practical.push({ k: "Subtype", v: l.property_subtype }); | |
| 254 | + if (l.status) practical.push({ k: "Status", v: STATUS_LABELS[l.status] ?? l.status }); | |
| 255 | + if (l.county) practical.push({ k: "County", v: l.county }); | |
| 256 | + if (l.zip_code) practical.push({ k: "ZIP code", v: l.zip_code }); | |
| 257 | + if (l.apn) practical.push({ k: "APN (parcel #)", v: l.apn }); | |
| 258 | + if (l.mls_name) practical.push({ k: "MLS", v: l.mls_name }); | |
| 259 | + if (l.listed_at) practical.push({ k: "Listed on", v: l.listed_at }); | |
| 260 | + if (l.days_on_market != null) practical.push({ k: "Days on market", v: String(l.days_on_market) }); | |
| 261 | + if (l.brokerage_name) practical.push({ k: "Brokerage", v: l.brokerage_name }); | |
| 262 | + if (l.office_name) practical.push({ k: "Office", v: l.office_name }); | |
| 263 | + if (l.agent_name) practical.push({ k: "Listing agent", v: l.agent_name }); | |
| 264 | + const detEntries: [string, string][] = Object.entries(l.details ?? {}) | |
| 265 | + .filter(([k, v]) => !DETAIL_HIDDEN.has(k) | |
| 266 | + && (typeof v === "string" || typeof v === "number") && String(v).trim()) | |
| 267 | + .map(([k, v]) => [k, String(v)]); | |
| 268 | + | |
| 269 | + // 6. price history | |
| 270 | + const hist = (l.price_history ?? []).filter((h) => h.price != null); | |
| 271 | + const drop = hist.length >= 2 && hist[0].price !== hist[1].price | |
| 272 | + ? { from: hist[1].price!, to: hist[0].price! } : null; | |
| 273 | + const updated = l.updated_at ? fmtDate(l.updated_at) : null; | |
| 274 | + | |
| 275 | + const propHistory: PropertyListingRef[] = l.property?.listing_history ?? []; | |
| 276 | + const statusLabel = STATUS_LABELS[l.status] ?? l.status; | |
| 277 | + const addr = l.street_address || l.title; | |
| 278 | + const cityLine = fmtCityLine(l); | |
| 279 | + | |
| 280 | + return ( | |
| 281 | + <div className="container detail"> | |
| 282 | + <nav className="crumbs" aria-label="Breadcrumb"> | |
| 283 | + <Link to="/">Homes</Link> › | |
| 284 | + {l.state && <Link to={`/?state=${encodeURIComponent(l.state)}`}>{l.state}</Link>} › | |
| 285 | + {l.city && <span>{l.city}</span>} › | |
| 286 | + <span>{addr}</span> | |
| 287 | + </nav> | |
| 288 | + | |
| 289 | + <div className="fiche"> | |
| 290 | + {/* -------- main column: gallery, price/summary, description, ---------- | |
| 291 | + -------- features, details, price history — DOM order = visual ---- */} | |
| 292 | + <div className="f-col"> | |
| 293 | + {/* 1. photo gallery (hero) */} | |
| 294 | + <section className="f-bloc f-galerie" aria-label="Photos"> | |
| 295 | + <Gallery | |
| 296 | + images={l.images ?? []} | |
| 297 | + captions={Array.isArray(l.details?.photo_captions) | |
| 298 | + ? (l.details!.photo_captions as string[]) : undefined} | |
| 299 | + title={addr} | |
| 300 | + type={l.property_type} | |
| 301 | + /> | |
| 302 | + </section> | |
| 303 | + | |
| 304 | + {/* 2. price + status badge + address + key facts */} | |
| 305 | + <section className="f-bloc f-hero"> | |
| 306 | + <div className="price-kicker">List price</div> | |
| 307 | + <div className="price-row"> | |
| 308 | + <div className="price">{fmtPrice(l.list_price, l.price_label)}</div> | |
| 309 | + {l.status && ( | |
| 310 | + <span className={`status-chip ${STATUS_CLASS[l.status] ?? ""}`}>{statusLabel}</span> | |
| 311 | + )} | |
| 312 | + {l.property_type && ( | |
| 313 | + <span className="type-chip"> | |
| 314 | + <Ico name={SPEC_ICONS["Type"]} size={13} /> {l.property_type} | |
| 315 | + {l.details?.price_from ? " · from" : ""} | |
| 316 | + </span> | |
| 317 | + )} | |
| 318 | + </div> | |
| 319 | + {l.list_price != null && l.living_area_sqft != null && l.living_area_sqft > 200 && ( | |
| 320 | + <div className="price-sub">${Math.round(l.list_price / l.living_area_sqft).toLocaleString("en-US")} / sq ft</div> | |
| 321 | + )} | |
| 322 | + <h1>{addr}</h1> | |
| 323 | + <div className="loc"><Ico name="pin" size={13} /> {[cityLine, l.county ? `${l.county} County` : ""].filter(Boolean).join(" · ")}</div> | |
| 324 | + | |
| 325 | + <div className="spec-list"> | |
| 326 | + {specs.map((s) => ( | |
| 327 | + <div className="spec-row" key={s.k}> | |
| 328 | + <span className="spec-badge"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={15} /></span> | |
| 329 | + <span className="spec-k">{s.k}</span> | |
| 330 | + <b className="spec-v">{s.v}</b> | |
| 331 | + </div> | |
| 332 | + ))} | |
| 333 | + </div> | |
| 334 | + | |
| 335 | + {drop && ( | |
| 336 | + <div className={`prix-histo ${drop.to < drop.from ? "down" : ""}`}> | |
| 337 | + <Ico name={drop.to < drop.from ? "trenddown" : "trendup"} size={16} /> Price changed from {fmtPrice(drop.from)} to <b>{fmtPrice(drop.to)}</b> | |
| 338 | + </div> | |
| 339 | + )} | |
| 340 | + | |
| 341 | + {(l.agent_name || l.agent_phone || l.brokerage_name) && ( | |
| 342 | + <div className="broker"> | |
| 343 | + <div className="broker-k">Listed by</div> | |
| 344 | + {l.agent_name && <div className="broker-name">{l.agent_name}</div>} | |
| 345 | + {l.brokerage_name && <div className="broker-agency">{l.brokerage_name}</div>} | |
| 346 | + {l.agent_phone && <a className="broker-tel" href={`tel:${l.agent_phone.replace(/\s/g, "")}`}><Ico name="phone" size={14} /> {l.agent_phone}</a>} | |
| 347 | + </div> | |
| 348 | + )} | |
| 349 | + | |
| 350 | + <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer"> | |
| 351 | + View the original listing at {sourceName(l.source)} <Ico name="external" size={15} /> | |
| 352 | + </a> | |
| 353 | + <div className="fine"> | |
| 354 | + Aggregated by Home-Ka — {sourceName(l.source)}{updated ? ` · synced ${updated}` : ""}. | |
| 355 | + </div> | |
| 356 | + </section> | |
| 357 | + | |
| 358 | + {/* 3. description */} | |
| 359 | + {l.description && ( | |
| 360 | + <section className="f-bloc f-desc" id="description"> | |
| 361 | + <h2>Description</h2> | |
| 362 | + <p className="desc-text">{l.description}</p> | |
| 363 | + </section> | |
| 364 | + )} | |
| 365 | + | |
| 366 | + {/* 4. features */} | |
| 367 | + {l.features && l.features.length > 0 && ( | |
| 368 | + <section className="f-bloc" id="features"> | |
| 369 | + <h2>Features</h2> | |
| 370 | + <div className="amenity-grid"> | |
| 371 | + {l.features.map((f, i) => ( | |
| 372 | + <span className="amenity-it" key={i}> | |
| 373 | + <span className="am-ico"><AmenityIco label={f} /></span> | |
| 374 | + <span className="am-txt">{f}</span> | |
| 375 | + </span> | |
| 376 | + ))} | |
| 377 | + </div> | |
| 378 | + </section> | |
| 379 | + )} | |
| 380 | + | |
| 381 | + {/* 5. practical details */} | |
| 382 | + {(practical.length > 0 || detEntries.length > 0) && ( | |
| 383 | + <section className="f-bloc" id="details"> | |
| 384 | + <h2>Practical details</h2> | |
| 385 | + <div className="dtable"> | |
| 386 | + {practical.map(({ k, v }) => ( | |
| 387 | + <div className="drow" key={`p-${k}`}> | |
| 388 | + <span>{k}</span><b>{v}</b> | |
| 389 | + </div> | |
| 390 | + ))} | |
| 391 | + {detEntries.map(([k, v]) => ( | |
| 392 | + <div className="drow" key={k}> | |
| 393 | + <span>{k}</span> | |
| 394 | + {/^https?:\/\//.test(v) | |
| 395 | + ? <b><a href={v} target="_blank" rel="noopener noreferrer">Open ↗</a></b> | |
| 396 | + : <b>{v}</b>} | |
| 397 | + </div> | |
| 398 | + ))} | |
| 399 | + </div> | |
| 400 | + </section> | |
| 401 | + )} | |
| 402 | + | |
| 403 | + {/* 6. price history */} | |
| 404 | + {hist.length > 0 && ( | |
| 405 | + <section className="f-bloc" id="price-history"> | |
| 406 | + <h2>Price history</h2> | |
| 407 | + <div className="dtable"> | |
| 408 | + {hist.map((h, i) => ( | |
| 409 | + <div className="drow" key={`${h.ts}-${i}`}> | |
| 410 | + <span>{historyDate(h.ts)}</span> | |
| 411 | + <b>{fmtPrice(h.price)}</b> | |
| 412 | + </div> | |
| 413 | + ))} | |
| 414 | + </div> | |
| 415 | + </section> | |
| 416 | + )} | |
| 417 | + </div> | |
| 418 | + | |
| 419 | + {/* -------- second column (desktop): map, other publications ---------- */} | |
| 420 | + <div className="f-col"> | |
| 421 | + {/* 7. map */} | |
| 422 | + {l.lat != null && l.lng != null && ( | |
| 423 | + <section className="f-bloc" id="map"> | |
| 424 | + <h2>Location</h2> | |
| 425 | + <Suspense fallback={<div className="lmap3d lmap3d-skel map-loading">Loading the map…</div>}> | |
| 426 | + <PropertyMap | |
| 427 | + uid={l.uid} lat={l.lat} lng={l.lng} price={l.list_price} | |
| 428 | + propertyType={l.property_type} address={addr} | |
| 429 | + city={l.city} image={l.images?.[0]} | |
| 430 | + /> | |
| 431 | + </Suspense> | |
| 432 | + </section> | |
| 433 | + )} | |
| 434 | + | |
| 435 | + {/* 8a. also listed on (duplicates) */} | |
| 436 | + {l.duplicates && l.duplicates.length > 0 && ( | |
| 437 | + <section className="f-bloc" id="also-listed"> | |
| 438 | + <h2>Also listed on</h2> | |
| 439 | + <p className="dups-note"> | |
| 440 | + This property was found on {l.duplicates.length}{" "} | |
| 441 | + other platform{l.duplicates.length > 1 ? "s" : ""} — | |
| 442 | + Home-Ka shows the most complete version. | |
| 443 | + </p> | |
| 444 | + <div className="dups-list"> | |
| 445 | + {l.duplicates.map((d) => ( | |
| 446 | + <a key={d.uid} className="dup-item" href={d.url} target="_blank" rel="noopener noreferrer"> | |
| 447 | + <span className="dup-src">{sourceName(d.source)}</span> | |
| 448 | + {(d.agent_name || d.brokerage_name) && ( | |
| 449 | + <span className="dup-broker">{d.agent_name || d.brokerage_name}</span> | |
| 450 | + )} | |
| 451 | + <span className="dup-go">View listing <Ico name="external" size={13} /></span> | |
| 452 | + </a> | |
| 453 | + ))} | |
| 454 | + </div> | |
| 455 | + </section> | |
| 456 | + )} | |
| 457 | + | |
| 458 | + {/* 8b. PROPERTY history — every listing ever seen for this home */} | |
| 459 | + {propHistory.length > 0 && ( | |
| 460 | + <section className="f-bloc" id="property-history"> | |
| 461 | + <h2>Property history</h2> | |
| 462 | + <p className="dups-note"> | |
| 463 | + Home-Ka tracks the physical property behind each listing — every | |
| 464 | + publication of this home across sources, past and present. | |
| 465 | + </p> | |
| 466 | + <div className="dtable"> | |
| 467 | + {propHistory.map((h) => ( | |
| 468 | + <div className="drow" key={h.uid}> | |
| 469 | + <span> | |
| 470 | + {h.uid === l.uid | |
| 471 | + ? sourceName(h.source) | |
| 472 | + : <Link to={`/property/${encodeURIComponent(h.uid)}`}>{sourceName(h.source)}</Link>} | |
| 473 | + {" · "}{historyDate(h.first_seen)} | |
| 474 | + {h.active ? "" : " (inactive)"} | |
| 475 | + </span> | |
| 476 | + <b> | |
| 477 | + {fmtPrice(h.list_price, "—")} | |
| 478 | + {h.status ? ` · ${STATUS_LABELS[h.status] ?? h.status}` : ""} | |
| 479 | + </b> | |
| 480 | + </div> | |
| 481 | + ))} | |
| 482 | + </div> | |
| 483 | + </section> | |
| 484 | + )} | |
| 485 | + </div> | |
| 486 | + </div> | |
| 487 | + | |
| 488 | + <div className="fine f-foot"> | |
| 489 | + Prices and availability are those displayed by the source — every listing | |
| 490 | + links back to the original announcement. | |
| 491 | + </div> | |
| 492 | + | |
| 493 | + <div className="cta-sticky"> | |
| 494 | + <span className="cta-sticky-prix">{fmtPrice(l.list_price, l.price_label)}</span> | |
| 495 | + <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer"> | |
| 496 | + View at {sourceName(l.source)} ↗ | |
| 497 | + </a> | |
| 498 | + </div> | |
| 499 | + </div> | |
| 500 | + ); | |
| 501 | +} | |
added
frontend/src/pages/Sources.tsx
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Sources.tsx : public registry of data sources (/api/sources) | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | +import { useEffect, useState } from "react"; | |
| 7 | +import { Link } from "react-router-dom"; | |
| 8 | +import { Source, fetchSources, registerSourceNames } from "../api"; | |
| 9 | + | |
| 10 | +function ago(ts: number | null): string { | |
| 11 | + if (ts == null) return "never"; | |
| 12 | + const s = Math.max(0, Math.floor(Date.now() / 1000 - (ts > 1e12 ? ts / 1000 : ts))); | |
| 13 | + if (s < 3600) return `${Math.round(s / 60)} min ago`; | |
| 14 | + if (s < 172800) return `${Math.round(s / 3600)} h ago`; | |
| 15 | + return `${Math.round(s / 86400)} d ago`; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export default function SourcesPage() { | |
| 19 | + const [sources, setSources] = useState<Source[] | null>(null); | |
| 20 | + const [error, setError] = useState<string | null>(null); | |
| 21 | + | |
| 22 | + useEffect(() => { | |
| 23 | + fetchSources() | |
| 24 | + .then((r) => { registerSourceNames(r.sources); setSources(r.sources); }) | |
| 25 | + .catch((e) => setError(String(e))); | |
| 26 | + }, []); | |
| 27 | + | |
| 28 | + const totalListings = (sources ?? []).reduce((s, x) => s + x.active_listings, 0); | |
| 29 | + const enabled = (sources ?? []).filter((s) => s.enabled).length; | |
| 30 | + | |
| 31 | + return ( | |
| 32 | + <div className="container sources"> | |
| 33 | + <span className="kicker">Registry — data sources</span> | |
| 34 | + <h1>Data sources</h1> | |
| 35 | + <p className="sub"> | |
| 36 | + Every listing on Home-Ka comes from a registered source — brokerage | |
| 37 | + feeds, MLS/RESO APIs and listing platforms. Each source is synced by a | |
| 38 | + dedicated connector and de-duplicated by MLS number and property match. | |
| 39 | + Click a count to browse its homes. | |
| 40 | + </p> | |
| 41 | + | |
| 42 | + {error && <div className="notice">{error}</div>} | |
| 43 | + {!sources && !error && <div className="notice">Loading…</div>} | |
| 44 | + | |
| 45 | + {sources && ( | |
| 46 | + <> | |
| 47 | + <p className="stats-foot" style={{ marginTop: 0 }}> | |
| 48 | + <b>{totalListings.toLocaleString("en-US")}</b> active listings ·{" "} | |
| 49 | + <b>{sources.length}</b> sources ·{" "} | |
| 50 | + <b>{enabled}</b> enabled | |
| 51 | + </p> | |
| 52 | + | |
| 53 | + <div className="q-table-wrap"> | |
| 54 | + <table className="q-table"> | |
| 55 | + <thead> | |
| 56 | + <tr> | |
| 57 | + <th>Source</th><th>Connector</th><th>States</th> | |
| 58 | + <th>Active listings</th><th>Last sync</th> | |
| 59 | + </tr> | |
| 60 | + </thead> | |
| 61 | + <tbody> | |
| 62 | + {sources.map((s) => ( | |
| 63 | + <tr key={s.id}> | |
| 64 | + <td> | |
| 65 | + {s.name || s.id} | |
| 66 | + {!s.enabled && <span className="adm-badge adm-off"> disabled</span>} | |
| 67 | + </td> | |
| 68 | + <td>{s.connector_type ?? "—"}</td> | |
| 69 | + <td>{s.states?.length ? s.states.join(", ") : "—"}</td> | |
| 70 | + <td className="num"> | |
| 71 | + {s.active_listings > 0 | |
| 72 | + ? <Link className="count-pill" to={`/?source=${encodeURIComponent(s.id)}`}> | |
| 73 | + {s.active_listings.toLocaleString("en-US")} | |
| 74 | + </Link> | |
| 75 | + : "0"} | |
| 76 | + </td> | |
| 77 | + <td>{ago(s.last_sync)}</td> | |
| 78 | + </tr> | |
| 79 | + ))} | |
| 80 | + </tbody> | |
| 81 | + </table> | |
| 82 | + </div> | |
| 83 | + | |
| 84 | + <p className="stats-foot"> | |
| 85 | + Listings published by more than one source are de-duplicated by MLS | |
| 86 | + number and by matching the underlying property — no double counting. | |
| 87 | + </p> | |
| 88 | + </> | |
| 89 | + )} | |
| 90 | + </div> | |
| 91 | + ); | |
| 92 | +} | |
added
frontend/src/pages/Stats.tsx
+176 −0
@@ -0,0 +1,176 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Stats.tsx : simple live dashboard — totals, by state, by type, | |
| 5 | +// recent syncs (/api/stats). | |
| 6 | +// ----------------------------------------------------------------------------- | |
| 7 | +import { useEffect, useState } from "react"; | |
| 8 | +import { Stats, fetchStats, sourceName } from "../api"; | |
| 9 | + | |
| 10 | +const n = (x: number | null | undefined) => | |
| 11 | + x == null ? "—" : Math.round(x).toLocaleString("en-US"); | |
| 12 | +const usd = (x: number | null | undefined) => | |
| 13 | + x == null ? "—" : "$" + Math.round(x).toLocaleString("en-US"); | |
| 14 | + | |
| 15 | +function ago(ts: number): string { | |
| 16 | + const s = Math.max(0, Math.floor(Date.now() / 1000 - (ts > 1e12 ? ts / 1000 : ts))); | |
| 17 | + if (s < 3600) return `${Math.round(s / 60)} min ago`; | |
| 18 | + if (s < 172800) return `${Math.round(s / 3600)} h ago`; | |
| 19 | + return `${Math.round(s / 86400)} d ago`; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export default function StatsPage() { | |
| 23 | + const [stats, setStats] = useState<Stats | null>(null); | |
| 24 | + const [error, setError] = useState<string | null>(null); | |
| 25 | + | |
| 26 | + useEffect(() => { | |
| 27 | + fetchStats().then(setStats).catch((e) => setError(String(e))); | |
| 28 | + }, []); | |
| 29 | + | |
| 30 | + return ( | |
| 31 | + <div className="container stats-page" style={{ display: "grid", gap: 22, minWidth: 0 }}> | |
| 32 | + <header style={{ minWidth: 0 }}> | |
| 33 | + <span className="kicker">The aggregated market</span> | |
| 34 | + <h1 className="stats-title">Statistics</h1> | |
| 35 | + <p className="sub" style={{ marginBottom: 14 }}> | |
| 36 | + Live dashboard of the homes for sale aggregated by Home-Ka — volumes | |
| 37 | + and prices computed on the platform's real data (source duplicates | |
| 38 | + excluded, "price on request" excluded). | |
| 39 | + </p> | |
| 40 | + </header> | |
| 41 | + | |
| 42 | + {error && ( | |
| 43 | + <div className="notice"> | |
| 44 | + <h2>Statistics unavailable</h2> | |
| 45 | + <p>{error}</p> | |
| 46 | + </div> | |
| 47 | + )} | |
| 48 | + {!stats && !error && <div className="notice">Loading…</div>} | |
| 49 | + | |
| 50 | + {stats && ( | |
| 51 | + <> | |
| 52 | + {/* --- totals row -------------------------------------------------- */} | |
| 53 | + <section aria-label="Key totals" className="tiles"> | |
| 54 | + <div className="tile hero-tile"> | |
| 55 | + <div className="tile-v">{n(stats.total)}</div> | |
| 56 | + <div className="tile-k">Homes for sale</div> | |
| 57 | + </div> | |
| 58 | + <div className="tile"> | |
| 59 | + <div className="tile-v">{n(stats.properties)}</div> | |
| 60 | + <div className="tile-k">Physical properties tracked</div> | |
| 61 | + </div> | |
| 62 | + <div className="tile"> | |
| 63 | + <div className="tile-v">{n(stats.states)}</div> | |
| 64 | + <div className="tile-k">States</div> | |
| 65 | + </div> | |
| 66 | + <div className="tile"> | |
| 67 | + <div className="tile-v">{n(stats.cities)}</div> | |
| 68 | + <div className="tile-k">Cities</div> | |
| 69 | + </div> | |
| 70 | + <div className="tile"> | |
| 71 | + <div className="tile-v">{n(stats.sources)}</div> | |
| 72 | + <div className="tile-k">Sources</div> | |
| 73 | + </div> | |
| 74 | + <div className="tile"> | |
| 75 | + <div className="tile-v">{usd(stats.avg_price)}</div> | |
| 76 | + <div className="tile-k">Average price</div> | |
| 77 | + </div> | |
| 78 | + <div className="tile"> | |
| 79 | + <div className="tile-v">{usd(stats.min_price)}</div> | |
| 80 | + <div className="tile-k">Lowest price</div> | |
| 81 | + </div> | |
| 82 | + <div className="tile"> | |
| 83 | + <div className="tile-v">{usd(stats.max_price)}</div> | |
| 84 | + <div className="tile-k">Highest price</div> | |
| 85 | + </div> | |
| 86 | + </section> | |
| 87 | + | |
| 88 | + {/* --- by state ----------------------------------------------------- */} | |
| 89 | + {stats.by_state.length > 0 && ( | |
| 90 | + <section aria-label="By state"> | |
| 91 | + <h2>By state</h2> | |
| 92 | + <div className="q-table-wrap"> | |
| 93 | + <table className="q-table"> | |
| 94 | + <thead> | |
| 95 | + <tr><th>State</th><th>Homes</th><th>Average price</th></tr> | |
| 96 | + </thead> | |
| 97 | + <tbody> | |
| 98 | + {stats.by_state.map((r) => ( | |
| 99 | + <tr key={r.state}> | |
| 100 | + <td>{r.state}</td> | |
| 101 | + <td className="num">{n(r.n)}</td> | |
| 102 | + <td className="num">{usd(r.avg_price)}</td> | |
| 103 | + </tr> | |
| 104 | + ))} | |
| 105 | + </tbody> | |
| 106 | + </table> | |
| 107 | + </div> | |
| 108 | + </section> | |
| 109 | + )} | |
| 110 | + | |
| 111 | + {/* --- by property type ---------------------------------------------- */} | |
| 112 | + {stats.by_type.length > 0 && ( | |
| 113 | + <section aria-label="By property type"> | |
| 114 | + <h2>By property type</h2> | |
| 115 | + <div className="q-table-wrap"> | |
| 116 | + <table className="q-table"> | |
| 117 | + <thead> | |
| 118 | + <tr><th>Type</th><th>Homes</th><th>Average price</th></tr> | |
| 119 | + </thead> | |
| 120 | + <tbody> | |
| 121 | + {stats.by_type.map((r) => ( | |
| 122 | + <tr key={r.property_type}> | |
| 123 | + <td>{r.property_type}</td> | |
| 124 | + <td className="num">{n(r.n)}</td> | |
| 125 | + <td className="num">{usd(r.avg_price)}</td> | |
| 126 | + </tr> | |
| 127 | + ))} | |
| 128 | + </tbody> | |
| 129 | + </table> | |
| 130 | + </div> | |
| 131 | + </section> | |
| 132 | + )} | |
| 133 | + | |
| 134 | + {/* --- recent syncs --------------------------------------------------- */} | |
| 135 | + {(stats.recent_syncs?.length ?? 0) > 0 && ( | |
| 136 | + <section aria-label="Recent syncs"> | |
| 137 | + <h2>Recent syncs</h2> | |
| 138 | + <div className="q-table-wrap"> | |
| 139 | + <table className="q-table"> | |
| 140 | + <thead> | |
| 141 | + <tr> | |
| 142 | + <th>Source</th><th>When</th><th>Found</th> | |
| 143 | + <th>Added</th><th>Removed</th><th>Status</th> | |
| 144 | + </tr> | |
| 145 | + </thead> | |
| 146 | + <tbody> | |
| 147 | + {stats.recent_syncs!.map((s, i) => ( | |
| 148 | + <tr key={`${s.source}-${s.ts}-${i}`}> | |
| 149 | + <td>{sourceName(s.source)}</td> | |
| 150 | + <td>{ago(s.ts)}</td> | |
| 151 | + <td className="num">{n(s.found)}</td> | |
| 152 | + <td className="num">{n(s.added)}</td> | |
| 153 | + <td className="num">{n(s.removed)}</td> | |
| 154 | + <td> | |
| 155 | + {s.ok | |
| 156 | + ? <span className="adm-badge adm-ok">OK</span> | |
| 157 | + : <span className="adm-badge adm-err" title={s.message}>Error</span>} | |
| 158 | + </td> | |
| 159 | + </tr> | |
| 160 | + ))} | |
| 161 | + </tbody> | |
| 162 | + </table> | |
| 163 | + </div> | |
| 164 | + </section> | |
| 165 | + )} | |
| 166 | + </> | |
| 167 | + )} | |
| 168 | + | |
| 169 | + <p className="stats-foot"> | |
| 170 | + Updated automatically — statistics are computed on the aggregated | |
| 171 | + listings. Every listing links back to the original announcement at | |
| 172 | + the source. | |
| 173 | + </p> | |
| 174 | + </div> | |
| 175 | + ); | |
| 176 | +} | |
added
frontend/src/styles.css
+1874 −0
@@ -0,0 +1,1874 @@ | ||
| 1 | +/* ----------------------------------------------------------------------------- | |
| 2 | + Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | + Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | + styles.css : Home-Ka classes on top of the shared Groupe KA design system | |
| 5 | + (ka/tokens.css, imported BEFORE this file in main.tsx). | |
| 6 | + · The base (paper/ink palette, type, grain, selection, focus, component | |
| 7 | + base) comes from ka/tokens.css — do NOT duplicate it here. | |
| 8 | + · ONLY the brand accent is overridden: Home-Ka FOREST GREEN. | |
| 9 | +----------------------------------------------------------------------------- */ | |
| 10 | +:root { | |
| 11 | + /* ---- Home-Ka FOREST GREEN accent (only override of ka/tokens.css) ---- */ | |
| 12 | + --accent: #0f7b4a; | |
| 13 | + --accent-soft: #dcefe5; | |
| 14 | + --accent-deep: #0b5e38; | |
| 15 | + --on-accent: #ffffff; | |
| 16 | + /* legacy aliases — the CSS below uses --lime */ | |
| 17 | + --lime: var(--accent); | |
| 18 | + --lime-soft: var(--accent-soft); | |
| 19 | +} | |
| 20 | + | |
| 21 | +/* Ancre sous le header collant (spécifique Immo-Ka) */ | |
| 22 | +html { scroll-padding-top: 76px; } | |
| 23 | +h1, h2, h3, h4 { margin: 0; } | |
| 24 | +a { text-decoration: none; } | |
| 25 | +button { font-family: inherit; } | |
| 26 | + | |
| 27 | +.mono { font-family: var(--font-mono); } | |
| 28 | +.kicker { | |
| 29 | + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500; | |
| 30 | + text-transform: uppercase; letter-spacing: 0.14em; color: var(--green); | |
| 31 | + display: inline-flex; align-items: center; gap: 8px; | |
| 32 | +} | |
| 33 | +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); } | |
| 34 | + | |
| 35 | +.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; } | |
| 36 | +@media (max-width: 640px) { .container { padding: 0 16px; } } | |
| 37 | + | |
| 38 | +/* ================= Header ================= */ | |
| 39 | +.header { | |
| 40 | + position: sticky; top: 0; z-index: var(--z-header, 500); | |
| 41 | + background: var(--paper); | |
| 42 | + border-bottom: 2px solid var(--ink); | |
| 43 | +} | |
| 44 | +/* Menu mobile ouvert : le panneau vit DANS le header — on monte tout le header | |
| 45 | + au niveau modale pour qu il passe au-dessus du voile (--z-overlay). */ | |
| 46 | +.header:has(.mobile-menu.open) { z-index: var(--z-modal, 900); } | |
| 47 | +.header-inner { display: flex; align-items: center; gap: 20px; height: 64px; } | |
| 48 | +.brand { font-family: var(--font-display); font-weight: 700; font-size: 26px; letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1; } | |
| 49 | +.brand .ka { background: var(--ink); color: var(--lime); padding: 2px 7px 4px; border-radius: 6px; margin-left: 3px; transform: rotate(-2deg); transition: transform 0.2s ease; } | |
| 50 | +.brand:hover .ka { transform: rotate(0deg); } | |
| 51 | +.brand-tag { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.08em; text-transform: uppercase; margin-left: 12px; } | |
| 52 | +@media (max-width: 860px) { .brand-tag { display: none; } } | |
| 53 | +.nav { margin-left: auto; display: flex; gap: 4px; } | |
| 54 | +.nav a { padding: 9px 16px; border-radius: 999px; font-weight: 600; font-size: 14px; color: var(--ink-2); border: 1.5px solid transparent; transition: all 0.15s ease; min-height: 40px; display: inline-flex; align-items: center; } | |
| 55 | +.nav a:hover { border-color: var(--ink); color: var(--ink); } | |
| 56 | +.nav a.active { background: var(--ink); color: var(--lime); } | |
| 57 | + | |
| 58 | +/* ================= Ticker ================= */ | |
| 59 | +.ticker { background: var(--ink); color: var(--lime); overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; padding: 7px 0; white-space: nowrap; border-bottom: 1px solid rgba(15, 123, 74, 0.25); } | |
| 60 | +.ticker-track { display: inline-flex; gap: 0; animation: ticker 48s linear infinite; will-change: transform; } | |
| 61 | +.ticker span { padding: 0 26px; position: relative; } | |
| 62 | +.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; } | |
| 63 | +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } } | |
| 64 | +@media (prefers-reduced-motion: reduce) { .ticker-track { animation: none; } * { transition-duration: 0.01ms !important; } } | |
| 65 | + | |
| 66 | +/* ================= Hero ================= */ | |
| 67 | +.hero { position: relative; padding: 64px 0 26px; } | |
| 68 | +@media (max-width: 640px) { .hero { padding: 36px 0 14px; } } | |
| 69 | +/* halo cerise + trame de points en fond — décoratif, sous le texte */ | |
| 70 | +.hero::before { | |
| 71 | + content: ""; position: absolute; z-index: -1; top: -60px; right: -120px; | |
| 72 | + width: 580px; height: 460px; pointer-events: none; | |
| 73 | + background: radial-gradient(closest-side, rgba(15, 123, 74, 0.12), transparent 72%); | |
| 74 | +} | |
| 75 | +.hero::after { | |
| 76 | + content: ""; position: absolute; z-index: -1; inset: -20px -40px 0 0; pointer-events: none; | |
| 77 | + background-image: radial-gradient(rgba(20, 24, 20, 0.18) 1.2px, transparent 1.7px); | |
| 78 | + background-size: 22px 22px; | |
| 79 | + -webkit-mask-image: linear-gradient(112deg, transparent 58%, rgba(0, 0, 0, 0.9)); | |
| 80 | + mask-image: linear-gradient(112deg, transparent 58%, rgba(0, 0, 0, 0.9)); | |
| 81 | +} | |
| 82 | +.hero h1 { font-size: clamp(38px, 6.4vw, 78px); font-weight: 700; line-height: 0.98; text-transform: uppercase; letter-spacing: -0.035em; max-width: 940px; margin-top: 14px; } | |
| 83 | +.hero h1 .outline { color: transparent; -webkit-text-stroke: 2px var(--ink); } | |
| 84 | +.hero h1 .hl { background: var(--lime); color: #fff; padding: 0 10px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); } | |
| 85 | +.hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 680px; margin: 20px 0 0; } | |
| 86 | + | |
| 87 | +@media (max-width: 640px) { | |
| 88 | + .hero::before { width: 320px; height: 260px; top: -30px; right: -90px; } | |
| 89 | +} | |
| 90 | + | |
| 91 | +.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; } | |
| 92 | +.stat-chip { background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px; padding: 8px 16px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); display: flex; gap: 8px; align-items: center; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.12); transition: transform 0.13s ease, box-shadow 0.13s ease; } | |
| 93 | +@media (hover: hover) { .stat-chip:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(26, 18, 20, 0.2); } } | |
| 94 | +.stat-chip b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; } | |
| 95 | +.stat-chip .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite; } | |
| 96 | +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(15, 123, 74, 0.4); } } | |
| 97 | + | |
| 98 | +/* ================= Filter bar ================= */ | |
| 99 | +.filterbar { background: var(--surface); border: 2px solid var(--ink); border-radius: 14px; box-shadow: var(--shadow-off-soft); padding: 14px; margin: 34px 0 6px; display: flex; flex-direction: column; gap: 0; min-width: 0; } | |
| 100 | +.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; } | |
| 101 | +.f-search { flex: 1 1 240px; min-width: 0; display: flex; align-items: center; gap: 9px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface-2); padding: 0 14px; min-height: 52px; color: var(--ink-2); transition: box-shadow 0.15s ease, background 0.15s ease, color 0.15s ease; } | |
| 102 | +.f-search:focus-within { box-shadow: 3px 3px 0 var(--lime); background: var(--surface); color: var(--accent-deep); } | |
| 103 | +.f-search input { border: none; background: none; outline: none; flex: 1; min-width: 0; font-size: 15px; color: var(--ink); font-family: inherit; } | |
| 104 | +.f-search input::placeholder { color: var(--ink-3); } | |
| 105 | +.f-clear { border: none; background: var(--line); color: var(--ink-2); border-radius: 50%; width: 22px; height: 22px; font-size: 10px; cursor: pointer; flex: none; display: grid; place-items: center; transition: background 0.12s ease, color 0.12s ease; } | |
| 106 | +.f-clear:hover { background: var(--ink); color: #fff; } | |
| 107 | +.f-ctl { flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 2px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); padding: 7px 12px 6px; min-height: 52px; cursor: pointer; transition: box-shadow 0.15s ease, background 0.15s ease; } | |
| 108 | +@media (hover: hover) { .f-ctl:hover { background: var(--surface-2); } } | |
| 109 | +.f-ctl:focus-within { box-shadow: 3px 3px 0 var(--lime); background: var(--surface); } | |
| 110 | +.f-ctl > span { font-family: var(--font-mono); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); } | |
| 111 | +.f-ctl select { border: none; background: transparent; outline: none; font-family: var(--font-display); font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer; appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 180px; text-overflow: ellipsis; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%23141814'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right center; } | |
| 112 | +.range-pair { display: flex; align-items: center; gap: 4px; } | |
| 113 | +.range-pair select { max-width: 92px; } | |
| 114 | +.range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; } | |
| 115 | +.f-more { flex: none; align-self: stretch; display: inline-flex; align-items: center; gap: 8px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); color: var(--ink); padding: 0 18px; cursor: pointer; font-family: var(--font-display); font-weight: 700; font-size: 14px; min-height: 52px; transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.13s ease, color 0.13s ease; } | |
| 116 | +.f-more:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); background: var(--surface); } | |
| 117 | +.f-more:active { transform: none; box-shadow: none; } | |
| 118 | +.f-more.on { background: var(--ink); border-color: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 var(--lime); } | |
| 119 | +.f-more.on:hover { transform: none; } | |
| 120 | +.f-more-badge { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--lime); color: var(--on-accent); font-size: 11.5px; font-weight: 700; font-variant-numeric: tabular-nums; } | |
| 121 | +.f-chev { width: 8px; height: 8px; flex: none; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); margin-top: -4px; transition: transform 0.18s ease, margin 0.18s ease; } | |
| 122 | +.f-chev.up { transform: rotate(-135deg); margin-top: 4px; } | |
| 123 | +.f-adv { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 18px 26px; border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 16px; min-width: 0; animation: adv-in 0.22s cubic-bezier(0.2, 0.9, 0.3, 1); } | |
| 124 | +@keyframes adv-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } } | |
| 125 | +.f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; } | |
| 126 | +.f-group > label { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); } | |
| 127 | +.f-group-end { justify-content: flex-end; } | |
| 128 | +.f-group .btn:disabled { opacity: 0.4; cursor: default; } | |
| 129 | +.f-native { border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl); padding: 10px 30px 10px 12px; font-size: 14px; color: var(--ink); outline: none; font-family: inherit; min-height: 42px; width: 100%; min-width: 0; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; } | |
| 130 | +.f-native:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); } | |
| 131 | +.seg { display: inline-flex; flex-wrap: wrap; row-gap: 6px; } | |
| 132 | +.seg button { border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink-2); padding: 8px 13px; font-family: var(--font-display); font-weight: 600; font-size: 13px; cursor: pointer; margin-left: -1.5px; white-space: nowrap; min-height: 40px; transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; } | |
| 133 | +.seg button:first-child { border-radius: 8px 0 0 8px; margin-left: 0; } | |
| 134 | +.seg button:last-child { border-radius: 0 8px 8px 0; } | |
| 135 | +.seg button:hover { background: var(--lime-soft); color: var(--ink); } | |
| 136 | +.seg button.on { background: var(--ink); color: var(--lime); position: relative; z-index: 1; box-shadow: inset 0 -2.5px 0 var(--lime); } | |
| 137 | + | |
| 138 | +/* pastilles de filtres actifs — survol = intention de retrait (danger) */ | |
| 139 | +.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 2px; } | |
| 140 | +.pills .pill { display: inline-flex; align-items: center; gap: 7px; border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; font-family: var(--font-mono); text-transform: none; letter-spacing: normal; cursor: pointer; transition: all 0.13s ease; box-shadow: 2px 2px 0 var(--lime-soft); animation: pill-in 0.18s ease; } | |
| 141 | +@keyframes pill-in { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: none; } } | |
| 142 | +.pills .pill:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); box-shadow: none; } | |
| 143 | +.pills .pill:hover .pill-x { opacity: 1; } | |
| 144 | +.pill-x { font-size: 10px; opacity: 0.6; } | |
| 145 | +.pills .pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); box-shadow: none; } | |
| 146 | +.pills .pill-clear:hover { background: var(--danger); color: #fff; } | |
| 147 | +.link-btn { background: none; border: none; padding: 0; color: var(--green); font: inherit; text-decoration: underline; cursor: pointer; } | |
| 148 | + | |
| 149 | +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px; font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px; background: var(--surface); color: var(--ink); transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease, color 0.15s ease; font-family: var(--font-display); letter-spacing: 0.01em; text-decoration: none; } | |
| 150 | +.btn:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); } | |
| 151 | +.btn:active { transform: translate(2px, 2px); box-shadow: none !important; } | |
| 152 | +.btn:disabled { opacity: 0.45; pointer-events: none; } | |
| 153 | +.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); } | |
| 154 | +.btn-primary:hover { background: var(--green-deep); box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); } | |
| 155 | +.btn-ghost { background: transparent; color: var(--ink); } | |
| 156 | +.btn-ghost:hover { background: var(--lime); color: #fff; box-shadow: 4px 4px 0 rgba(26, 18, 20,0.2); } | |
| 157 | + | |
| 158 | +/* ================= Chips ================= */ | |
| 159 | +.chips { display: flex; gap: 8px; margin: 16px 0 4px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: none; } | |
| 160 | +.chips::-webkit-scrollbar { display: none; } | |
| 161 | +.chip { display: inline-flex; align-items: center; gap: 7px; border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); border-radius: 999px; padding: 9px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer; font-family: var(--font-display); white-space: nowrap; min-height: 42px; transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.13s ease, color 0.13s ease; } | |
| 162 | +.chip:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); } | |
| 163 | +.chip:active { transform: none; box-shadow: none; } | |
| 164 | +.chip.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 var(--lime); } | |
| 165 | + | |
| 166 | +/* ================= Results ================= */ | |
| 167 | +.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; flex-wrap: wrap; } | |
| 168 | +.results-head h2 { font-size: 22px; text-transform: uppercase; letter-spacing: -0.02em; } | |
| 169 | +.results-head > span { font-family: var(--font-mono); color: var(--ink-3); font-size: 12px; letter-spacing: 0.06em; } | |
| 170 | +.results-tools { display: flex; align-items: center; gap: 12px; margin-left: auto; } | |
| 171 | +.sort-ctl select { border: 1.5px solid var(--ink); background: var(--surface); border-radius: var(--r-ctl); padding: 8px 28px 8px 12px; min-height: 36px; font-family: var(--font-mono); font-size: 12px; color: var(--ink); cursor: pointer; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; transition: box-shadow 0.13s ease; } | |
| 172 | +.sort-ctl select:hover { box-shadow: 2px 2px 0 rgba(26, 18, 20, 0.18); } | |
| 173 | +.sort-ctl select:focus { outline: none; box-shadow: 3px 3px 0 var(--lime); } | |
| 174 | +.view-toggle { display: inline-flex; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); overflow: hidden; background: var(--surface); box-shadow: var(--shadow-flat); } | |
| 175 | +.view-toggle button { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; padding: 8px 14px; min-height: 36px; cursor: pointer; font-family: var(--font-mono); font-size: 12px; font-weight: 700; color: var(--ink-2); transition: background 0.12s ease, color 0.12s ease; } | |
| 176 | +.view-toggle button:hover { background: var(--surface-2); color: var(--ink); } | |
| 177 | +.view-toggle button + button { border-left: 1.5px solid var(--ink); } | |
| 178 | +.view-toggle button.on { background: var(--ink); color: var(--lime); box-shadow: inset 0 -2.5px 0 var(--lime); } | |
| 179 | + | |
| 180 | +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 22px; padding-bottom: 20px; } | |
| 181 | +@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; } } | |
| 182 | +.more-wrap { display: flex; justify-content: center; padding: 8px 0 70px; } | |
| 183 | + | |
| 184 | +.card { background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-flat); transition: transform 0.16s ease, box-shadow 0.16s ease; } | |
| 185 | +.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); } | |
| 186 | +.card:focus-visible { outline: 3px solid var(--lime); outline-offset: 2px; } | |
| 187 | +.card-img { position: relative; aspect-ratio: 16/10.5; background: repeating-linear-gradient(45deg, #eceae3 0 12px, #f3f1ea 12px 24px); overflow: hidden; } | |
| 188 | +.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; } | |
| 189 | +.card:hover .card-img img { transform: scale(1.05); } | |
| 190 | +.card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; } | |
| 191 | +.badge { position: absolute; top: 12px; left: 12px; border-radius: 6px; padding: 4px 10px; font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; background: rgba(255, 255, 255, 0.95); color: var(--ink); border: 1px solid var(--ink); } | |
| 192 | +.badge.type { background: var(--ink); color: var(--lime); border-color: var(--ink); } | |
| 193 | +.badge.right { left: auto; right: 12px; background: rgba(255,255,255,0.92); border-color: transparent; } | |
| 194 | +.card-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; } | |
| 195 | +.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.02em; } | |
| 196 | +.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 197 | +.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; font-family: var(--font-mono); } | |
| 198 | +.card-meta .sep { width: 4px; height: 4px; background: var(--lime); border: 1px solid var(--ink); border-radius: 1px; transform: rotate(45deg); } | |
| 199 | +.card-specs { display: flex; gap: 12px; font-size: 12.5px; color: var(--ink-2); font-family: var(--font-mono); } | |
| 200 | +.card-foot { margin-top: auto; padding-top: 11px; border-top: 1.5px dashed var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; } | |
| 201 | +.source-tag { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--green-deep); background: var(--lime-soft); border: 1px solid var(--green); border-radius: 4px; padding: 3px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 62%; } | |
| 202 | +.avail { font-size: 11px; color: var(--ink-3); font-weight: 500; text-align: right; font-family: var(--font-mono); } | |
| 203 | + | |
| 204 | +/* ================= Skeletons ================= */ | |
| 205 | +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } } | |
| 206 | +.skel { border-radius: var(--r-card); border: 1.5px solid var(--line); overflow: hidden; background: var(--surface); } | |
| 207 | +.skel .sk-img, .skel .sk-line { background: linear-gradient(90deg, #eeece5 25%, #f7f5ef 50%, #eeece5 75%); background-size: 800px 100%; animation: shimmer 1.4s infinite linear; } | |
| 208 | +.skel .sk-img { aspect-ratio: 16/10.5; } | |
| 209 | +.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; } | |
| 210 | +.skel .sk-line.short { width: 45%; } | |
| 211 | + | |
| 212 | +/* ================= Empty / error ================= */ | |
| 213 | +.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); } | |
| 214 | +.notice .big { font-size: 44px; margin-bottom: 10px; } | |
| 215 | +.notice h2 { text-transform: uppercase; } | |
| 216 | + | |
| 217 | +/* ================= Detail page ================= */ | |
| 218 | +.detail { padding: 30px 0 90px; } | |
| 219 | +.crumbs { font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } | |
| 220 | +.crumbs a { border-bottom: 1.5px solid transparent; } | |
| 221 | +.crumbs a:hover { color: var(--green); border-color: var(--green); } | |
| 222 | + | |
| 223 | +/* Ordre DOM = ordre visuel sur TOUS les breakpoints (standard Groupe Ka | |
| 224 | + « Ordre des sections — pages détail »). Interdit : `order` / `column-reverse` | |
| 225 | + / `display:contents` pour réordonner — un bloc sans `order` retombe à | |
| 226 | + order:0 et passe devant la galerie sur mobile (bug corrigé sur Lou-Ka). | |
| 227 | + Mobile : colonnes empilées ; desktop : grid 2 colonnes (fiche | annexes). */ | |
| 228 | +.fiche { display: flex; flex-direction: column; gap: 22px; } | |
| 229 | +.f-col { display: flex; flex-direction: column; gap: 22px; min-width: 0; } | |
| 230 | +@media (min-width: 900px) { | |
| 231 | + .fiche { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; } | |
| 232 | + .f-col { gap: 26px; } | |
| 233 | + .f-col:last-child { position: sticky; top: 88px; } | |
| 234 | +} | |
| 235 | +.f-bloc { min-width: 0; } | |
| 236 | +.f-bloc h2 { font-size: 21px; letter-spacing: -0.02em; margin-bottom: 12px; border-left: 4px solid var(--lime); padding-left: 10px; } | |
| 237 | +.f-bloc:empty { display: none; } | |
| 238 | + | |
| 239 | +/* galerie */ | |
| 240 | +.carousel { position: relative; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); background: var(--surface-2); box-shadow: var(--shadow-off-soft); } | |
| 241 | +.carousel-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scrollbar-width: none; aspect-ratio: 16/11; } | |
| 242 | +.carousel-track::-webkit-scrollbar { display: none; } | |
| 243 | +.carousel-track img { flex: 0 0 100%; width: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; } | |
| 244 | +.carousel-empty { display: flex; align-items: center; justify-content: center; aspect-ratio: 16/11; font-size: 48px; } | |
| 245 | +.carousel-count { position: absolute; right: 12px; bottom: 12px; z-index: 2; background: rgba(26, 18, 20, 0.82); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; } | |
| 246 | +.carousel-nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid var(--ink); background: rgba(255, 255, 255, 0.92); font-size: 22px; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; } | |
| 247 | +.carousel-nav.prev { left: 10px; } .carousel-nav.next { right: 10px; } | |
| 248 | +@media (max-width: 640px) { .carousel-nav { display: none; } } | |
| 249 | +.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; margin-top: 10px; } | |
| 250 | +.thumbs button { border: 2px solid var(--line); border-radius: 8px; overflow: hidden; padding: 0; cursor: pointer; aspect-ratio: 4/3; background: #eceae3; transition: border-color 0.12s ease, transform 0.12s ease; } | |
| 251 | +.thumbs button:hover { transform: translateY(-2px); } | |
| 252 | +.thumbs button.on { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); } | |
| 253 | +.thumbs img { width: 100%; height: 100%; object-fit: cover; } | |
| 254 | +@media (max-width: 640px) { .thumbs { display: flex; overflow-x: auto; scrollbar-width: none; } .thumbs::-webkit-scrollbar { display: none; } .thumbs button { flex: 0 0 84px; } } | |
| 255 | + | |
| 256 | +/* lightbox */ | |
| 257 | +.lightbox { position: fixed; inset: 0; background: rgba(16, 18, 16, 0.94); z-index: 100; display: flex; align-items: center; justify-content: center; cursor: zoom-out; padding: max(16px, env(safe-area-inset-top)) 16px; } | |
| 258 | +.lightbox img { max-width: 94vw; max-height: 86vh; border-radius: 6px; border: 2px solid var(--lime); object-fit: contain; } | |
| 259 | +.lb-close { position: absolute; top: 18px; right: 22px; background: none; border: none; color: #fff; font-size: 30px; cursor: pointer; } | |
| 260 | +.lb-nav { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--lime); font-size: 52px; cursor: pointer; padding: 0 22px; user-select: none; } | |
| 261 | +.lb-nav.prev { left: 6px; } .lb-nav.next { right: 6px; } | |
| 262 | +.lb-count { position: absolute; bottom: 24px; color: #c9ccc2; font-family: var(--font-mono); font-size: 13px; } | |
| 263 | + | |
| 264 | +/* hero panneau (colonne droite) */ | |
| 265 | +.f-hero { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px; } | |
| 266 | +.price-kicker { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--green); } | |
| 267 | +.price-kicker::before { content: ""; display: inline-block; width: 16px; height: 2px; background: var(--lime); vertical-align: 3px; margin-right: 7px; } | |
| 268 | +.price-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } | |
| 269 | +.f-hero .price { font-family: var(--font-display); font-size: clamp(30px, 6vw, 40px); font-weight: 700; letter-spacing: -0.03em; } | |
| 270 | +.type-chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 11px; background: var(--lime-soft); color: var(--green-deep); border: 1px solid rgba(179, 32, 43, 0.25); border-radius: 999px; font-size: 12px; font-weight: 600; } | |
| 271 | +.price-sub { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-3); margin-top: 2px; } | |
| 272 | +.f-hero h1 { font-size: clamp(20px, 4.5vw, 24px); margin-top: 10px; } | |
| 273 | +.f-hero .loc { color: var(--ink-2); font-size: 13.5px; margin-top: 3px; display: flex; align-items: center; gap: 5px; } | |
| 274 | +.f-hero .loc .ico { color: var(--lime); } | |
| 275 | + | |
| 276 | +/* liste de specs — fiche technique premium */ | |
| 277 | +.spec-list { margin: 18px 0 4px; border-top: 1.5px solid var(--line-strong); } | |
| 278 | +.spec-row { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 11px; padding: 8.5px 0; border-bottom: 1px dashed var(--line); } | |
| 279 | +.spec-row:last-child { border-bottom: 1.5px solid var(--line-strong); } | |
| 280 | +.spec-badge { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 9px; background: var(--lime-soft); color: var(--green-deep); } | |
| 281 | +.spec-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-2); } | |
| 282 | +.spec-v { font-family: var(--font-display); font-size: 15.5px; font-weight: 700; text-align: right; } | |
| 283 | +.deal-badge { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 4px; border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; border: 1.5px solid var(--line-strong); } | |
| 284 | +.deal-ok { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); } | |
| 285 | + | |
| 286 | +.specs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; margin: 18px 0; } | |
| 287 | +.spec { border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); padding: 9px 11px; background: var(--surface-2); } | |
| 288 | +.spec b { display: block; font-family: var(--font-display); font-size: 16px; } | |
| 289 | +.spec span { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); } | |
| 290 | + | |
| 291 | +.broker { margin: 16px 0; padding: 14px; border: 1.5px dashed var(--line-strong); border-radius: var(--r-ctl); background: var(--surface-2); } | |
| 292 | +.broker-k { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); } | |
| 293 | +.broker-name { font-family: var(--font-display); font-weight: 700; font-size: 15px; margin-top: 3px; } | |
| 294 | +.broker-tel { display: inline-block; margin-top: 6px; color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); } | |
| 295 | + | |
| 296 | +.cta { display: block; text-align: center; background: var(--ink); color: var(--lime); font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl); padding: 15px; border: 1.5px solid var(--ink); min-height: 48px; box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); transition: all 0.14s ease; margin-top: 6px; } | |
| 297 | +.cta:hover { background: var(--lime); color: #fff; } | |
| 298 | +.cta:active { transform: translate(2px, 2px); box-shadow: none; } | |
| 299 | +.f-hero .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; font-family: var(--font-mono); letter-spacing: 0.02em; } | |
| 300 | + | |
| 301 | +.desc-text { color: var(--ink-2); font-size: 14.5px; white-space: pre-line; line-height: 1.6; margin: 0; } | |
| 302 | + | |
| 303 | +/* tableau caractéristiques */ | |
| 304 | +.dtable { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0 22px; } | |
| 305 | +.drow { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; } | |
| 306 | +.drow span { color: var(--ink-3); } .drow b { text-align: right; font-family: var(--font-display); } | |
| 307 | + | |
| 308 | +/* tableau pièces */ | |
| 309 | +.rooms-wrap { overflow-x: auto; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); } | |
| 310 | +table.rooms { width: 100%; border-collapse: collapse; font-size: 13.5px; min-width: 460px; } | |
| 311 | +table.rooms th { text-align: left; background: var(--ink); color: var(--paper); padding: 9px 12px; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; } | |
| 312 | +table.rooms td { padding: 8px 12px; border-bottom: 1px solid var(--line); } | |
| 313 | +table.rooms tr:last-child td { border-bottom: none; } | |
| 314 | +table.rooms tr:nth-child(even) td { background: var(--surface-2); } | |
| 315 | + | |
| 316 | +.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; } | |
| 317 | +.amenity { background: var(--lime-soft); color: var(--green-deep); font-size: 12px; font-weight: 600; border: 1px solid var(--green); border-radius: 999px; padding: 5px 12px; } | |
| 318 | + | |
| 319 | +.prix-histo { margin-top: 14px; padding: 10px 14px; border-radius: var(--r-card); border: 1.5px solid var(--line-strong); font-size: 13.5px; background: var(--surface); } | |
| 320 | +.prix-histo.down { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); } | |
| 321 | + | |
| 322 | +.mini-map { height: 320px; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; } | |
| 323 | +.mini-map .mapview { border: none; border-radius: 0; box-shadow: none; height: 100%; } | |
| 324 | + | |
| 325 | +.cta-sticky { position: fixed; left: 0; right: 0; bottom: 0; z-index: 45; display: flex; align-items: center; gap: 12px; background: rgba(247, 241, 239, 0.94); backdrop-filter: blur(12px); border-top: 2px solid var(--ink); padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); } | |
| 326 | +.cta-sticky .cta { flex: 1; margin: 0; } | |
| 327 | +.cta-sticky-prix { font-family: var(--font-display); font-weight: 700; font-size: 19px; white-space: nowrap; } | |
| 328 | +@media (min-width: 900px) { .cta-sticky { display: none; } } | |
| 329 | +@media (max-width: 899px) { .detail { padding-bottom: 84px; } } | |
| 330 | +.f-foot { margin-top: 26px; } | |
| 331 | +.fine { font-size: 12px; color: var(--ink-3); } | |
| 332 | + | |
| 333 | +/* Box « Aussi publiée sur… » : autres publications de la même propriété */ | |
| 334 | +.dups-note { font-size: 13px; color: var(--ink-3); margin: 4px 0 10px; } | |
| 335 | +.dups-list { display: flex; flex-direction: column; gap: 8px; } | |
| 336 | +.dup-item { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 11px 13px; border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); background: var(--surface-2); transition: all 0.14s ease; min-height: 44px; } | |
| 337 | +.dup-item:hover { border-color: var(--ink); box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.18); } | |
| 338 | +.dup-src { font-family: var(--font-display); font-weight: 700; font-size: 14px; } | |
| 339 | +.dup-broker { font-size: 12.5px; color: var(--ink-3); } | |
| 340 | +.dup-go { margin-left: auto; font-size: 12.5px; font-weight: 600; color: var(--green-deep); border-bottom: 1.5px solid var(--lime); white-space: nowrap; } | |
| 341 | +.dup-go .ico { vertical-align: -2px; } | |
| 342 | + | |
| 343 | +/* ================= Sources / Agences ================= */ | |
| 344 | +.sources { padding: 44px 0 90px; } | |
| 345 | +.sources h1 { font-size: clamp(28px, 4vw, 40px); text-transform: uppercase; margin: 10px 0 6px; } | |
| 346 | +.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 720px; } | |
| 347 | +.src-wrap { overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); background: var(--surface); } | |
| 348 | +.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 760px; } | |
| 349 | +.src-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); padding: 14px 18px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2); } | |
| 350 | +.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; } | |
| 351 | +.src-table tr:last-child td { border-bottom: none; } | |
| 352 | +.src-table tr:hover td { background: var(--surface-2); } | |
| 353 | +.src-table a { color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); } | |
| 354 | +.pill.ok { background: var(--lime); color: #fff; border: 1px solid var(--ink); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; } | |
| 355 | +.pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; } | |
| 356 | +.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; border-bottom: 1.5px solid var(--lime); } | |
| 357 | + | |
| 358 | +/* ================= Stats ================= */ | |
| 359 | +.stats-page { padding: 44px 0 90px; } | |
| 360 | +.stats-title { font-size: clamp(30px, 4.6vw, 46px); text-transform: uppercase; margin: 10px 0 6px; } | |
| 361 | +.stats-page .sub { color: var(--ink-2); max-width: 660px; margin-bottom: 30px; } | |
| 362 | +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; } | |
| 363 | +.tile { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 18px 20px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.1); } | |
| 364 | +.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(22px, 3vw, 32px); letter-spacing: -0.03em; line-height: 1.05; } | |
| 365 | +.tile-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; } | |
| 366 | +.hero-tile { background: var(--ink); color: var(--lime); border-color: var(--ink); } | |
| 367 | +.hero-tile .tile-k { color: rgba(15, 123, 74, 0.7); } | |
| 368 | +.viz-card { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px; min-width: 0; max-width: 100%; overflow-x: auto; } | |
| 369 | +/* mobile : une table large ne doit JAMAIS déborder la page (grid blowout) — | |
| 370 | + chaque bloc de la page Stats défile horizontalement en interne au besoin */ | |
| 371 | +.stats-page section, .stats-page > * { min-width: 0; max-width: 100%; } | |
| 372 | +@media (max-width: 640px) { .stats-page .viz-card { padding: 18px 14px 14px; } } | |
| 373 | +.viz-card h2 { font-size: 19px; text-transform: uppercase; letter-spacing: -0.01em; } | |
| 374 | +.viz-sub { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; } | |
| 375 | +.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; } | |
| 376 | +@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } } | |
| 377 | +.hbars { display: flex; flex-direction: column; gap: 7px; } | |
| 378 | +.hbar-row { display: grid; grid-template-columns: minmax(96px, 180px) 1fr auto; gap: 12px; align-items: center; min-height: 26px; } | |
| 379 | +.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 380 | +.hbar-label a { border-bottom: 1.5px solid var(--lime); } | |
| 381 | +.hbar-label a:hover { color: var(--green-deep); } | |
| 382 | +.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; } | |
| 383 | +.hbar-fill { display: block; height: 100%; background: var(--green); border-radius: 0 4px 4px 0; min-width: 2px; transition: background 0.12s ease; } | |
| 384 | +.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); } | |
| 385 | +.hbar-value { font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; white-space: nowrap; } | |
| 386 | +.hbar-value em { font-style: normal; font-weight: 500; color: var(--ink-3); } | |
| 387 | +.viz-table { margin-top: 4px; } | |
| 388 | +.viz-table table { width: 100%; border-collapse: collapse; font-size: 13px; } | |
| 389 | +.viz-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--ink); } | |
| 390 | +.viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); } | |
| 391 | +.stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; } | |
| 392 | + | |
| 393 | +/* ---- Iconographie maison (Icons.tsx) ---- */ | |
| 394 | +.ico { vertical-align: -3px; flex-shrink: 0; } | |
| 395 | +.card-specs span { display: inline-flex; align-items: center; gap: 5px; } | |
| 396 | +.card-specs .ico { color: var(--green); } | |
| 397 | +.badge.right .ico { vertical-align: -2px; } | |
| 398 | +.noimg .ico, .carousel-empty .ico { color: var(--ink-3); } | |
| 399 | +.notice .big .ico { color: var(--lime); } | |
| 400 | +.amenity .ico { color: var(--green); margin-right: 2px; } | |
| 401 | +.q-badge .ico { vertical-align: -3px; margin-right: 2px; } | |
| 402 | +.q-bar-label { display: inline-flex; align-items: center; gap: 7px; } | |
| 403 | +.q-bar-label .ico { color: var(--green); } | |
| 404 | +.mm-ico .ico { vertical-align: -4px; } | |
| 405 | +.mv-noimg svg { color: var(--ink-3); } | |
| 406 | +.broker-tel .ico { vertical-align: -2.5px; } | |
| 407 | +.cta .ico { vertical-align: -2.5px; margin-left: 3px; } | |
| 408 | +.prix-histo .ico { vertical-align: -3.5px; } | |
| 409 | +.spec { position: relative; } | |
| 410 | +.spec-ico { position: absolute; top: 10px; right: 10px; color: var(--lime); opacity: 0.85; } | |
| 411 | + | |
| 412 | +/* ---- Survalorisation vs Vrai-Prix (jauges divergentes) ---- */ | |
| 413 | +.vpg-axis { display: grid; grid-template-columns: 220px 1fr 74px 90px; gap: 10px; margin-bottom: 2px; } | |
| 414 | +.vpg-axis > div { grid-column: 2; display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); } | |
| 415 | +.vpg-row { display: grid; grid-template-columns: 220px 1fr 74px 90px; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px dashed var(--line); } | |
| 416 | +.vpg-row:last-of-type { border-bottom: none; } | |
| 417 | +.vpg-name { font-size: 13.5px; font-weight: 600; } | |
| 418 | +.vpg-name em { display: block; font-style: normal; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); } | |
| 419 | +.vpg-track { position: relative; height: 18px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; overflow: hidden; } | |
| 420 | +.vpg-zero { position: absolute; left: 50%; top: 0; bottom: 0; width: 1.5px; background: var(--line-strong); opacity: 0.5; } | |
| 421 | +.vpg-band { position: absolute; top: 3px; bottom: 3px; background: rgba(26, 18, 20, 0.14); border-radius: 999px; } | |
| 422 | +.vpg-median { position: absolute; top: 1px; bottom: 1px; width: 4px; margin-left: -2px; border-radius: 2px; } | |
| 423 | +.vpg-median.vp-sur { background: var(--lime); } | |
| 424 | +.vpg-median.vp-juste { background: #d9a942; } | |
| 425 | +.vpg-median.vp-sous { background: #4c8b4f; } | |
| 426 | +.vpg-value { font-family: var(--font-display); font-weight: 700; font-size: 15px; text-align: right; } | |
| 427 | +.vpg-value.vp-sur { color: var(--green); } | |
| 428 | +.vpg-value.vp-juste { color: #a97b1e; } | |
| 429 | +.vpg-value.vp-sous { color: #3c7440; } | |
| 430 | +.vpg-split { display: flex; height: 10px; border-radius: 999px; overflow: hidden; border: 1px solid var(--line); } | |
| 431 | +.vps-sous { background: #7fb283; } | |
| 432 | +.vps-juste { background: #d9cfc7; } | |
| 433 | +.vps-sur { background: var(--lime); } | |
| 434 | +.vpg-sep { border-top: 1.5px solid var(--line-strong); margin: 4px 0; } | |
| 435 | +.vpg-legend { display: flex; gap: 16px; margin-top: 10px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); flex-wrap: wrap; } | |
| 436 | +.vpg-legend i { display: inline-block; width: 14px; height: 9px; border-radius: 999px; margin-right: 5px; vertical-align: -1px; } | |
| 437 | +@media (max-width: 780px) { .vpg-row { grid-template-columns: 1fr 64px; } .vpg-track { grid-column: 1 / -1; } .vpg-split { grid-column: 1 / -1; } .vpg-axis { display: none; } } | |
| 438 | + | |
| 439 | +/* ================= Carte (Ka Maps — framework partagé Groupe Ka) ========= | |
| 440 | + Jetons du design system appliqués au chrome de la carte, mêmes réglages | |
| 441 | + que Lou-Ka Maps. */ | |
| 442 | +.mapview .ka-map, .lmap3d .ka-map { | |
| 443 | + --ka-accent: var(--accent); | |
| 444 | + --ka-on-accent: var(--on-accent); | |
| 445 | + --ka-surface: var(--surface); | |
| 446 | + --ka-ink: var(--ink); | |
| 447 | + --ka-line: var(--line-strong); | |
| 448 | + --ka-radius: var(--r-ctl); | |
| 449 | + --ka-shadow: 0 8px 24px rgba(26, 18, 20, 0.14); | |
| 450 | + --ka-font: var(--font-body); | |
| 451 | +} | |
| 452 | +.mapview { position: relative; overflow: hidden; } | |
| 453 | +.mapview .ka-map { position: absolute; inset: 0; } | |
| 454 | + | |
| 455 | +/* mini-carte 3D de la fiche (Emplacement) — bâtiment de l'annonce en cerise */ | |
| 456 | +.lmap3d { | |
| 457 | + position: relative; height: 340px; border: 1.5px solid var(--ink); | |
| 458 | + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); | |
| 459 | + overflow: hidden; background: var(--surface-2); | |
| 460 | +} | |
| 461 | +.lmap3d-skel { | |
| 462 | + background: linear-gradient(90deg, #eeece5 25%, #f7f5ef 50%, #eeece5 75%); | |
| 463 | + background-size: 800px 100%; animation: shimmer 1.4s infinite linear; | |
| 464 | +} | |
| 465 | +.lmap3d-legende { | |
| 466 | + position: absolute; left: 10px; bottom: 10px; z-index: 5; | |
| 467 | + display: inline-flex; align-items: center; gap: 6px; | |
| 468 | + padding: 4px 10px; border: 1.5px solid var(--ink); | |
| 469 | + border-radius: 999px; background: var(--surface); | |
| 470 | + font-size: 10.5px; font-weight: 500; color: var(--ink-2); | |
| 471 | + pointer-events: none; | |
| 472 | +} | |
| 473 | +.lmap3d-legende i { | |
| 474 | + width: 10px; height: 10px; border-radius: 3px; | |
| 475 | + background: var(--accent); border: 1px solid rgba(26, 18, 20, 0.25); | |
| 476 | +} | |
| 477 | +@media (max-width: 780px) { .lmap3d { height: 280px; } } | |
| 478 | + | |
| 479 | +/* ================= Carte ================= */ | |
| 480 | +.map-split { display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 18px; height: calc(100dvh - 200px); min-height: 420px; } | |
| 481 | +.map-list { overflow-y: auto; display: flex; flex-direction: column; gap: 14px; padding-right: 4px; scrollbar-width: thin; } | |
| 482 | +.map-list .card { margin: 0; flex: 0 0 auto; } | |
| 483 | +/* sync liste ↔ carte : fiche sélectionnée depuis une pastille de la carte */ | |
| 484 | +.map-card { border-radius: var(--r-card); } | |
| 485 | +.map-card-sel .card { border-color: var(--accent); box-shadow: 4px 4px 0 var(--accent-soft), 0 0 0 2px var(--accent); } | |
| 486 | +.mapwrap { position: relative; width: 100%; height: 100%; } | |
| 487 | +.mapview { width: 100%; height: 100%; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); overflow: hidden; background: var(--surface-2); } | |
| 488 | +.mv-brand { position: absolute; top: 10px; left: 10px; z-index: 3; display: flex; align-items: center; gap: 6px; padding: 5px 11px; background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: 999px; box-shadow: var(--shadow-off-soft); font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.04em; color: var(--ink-2); pointer-events: none; } | |
| 489 | +.mv-brand b { color: var(--ink); } | |
| 490 | +.mv-brand-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 0 3px var(--lime-soft); } | |
| 491 | +.mv-legend { position: absolute; bottom: 26px; left: 10px; z-index: 3; display: flex; flex-direction: column; gap: 4px; padding: 8px 11px; background: rgba(255, 255, 255, 0.92); border: 1px solid var(--line); border-radius: var(--r-ctl); font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); pointer-events: none; } | |
| 492 | +.mv-legend span { display: flex; align-items: center; gap: 7px; } | |
| 493 | +.mv-lg-pill { display: inline-block; width: 22px; height: 12px; border-radius: 999px; background: #fff; border: 1.5px solid rgba(26, 18, 20, 0.35); } | |
| 494 | +.mv-lg-deal { background: var(--lime); border-color: var(--green); } | |
| 495 | +.mv-credit { position: absolute; bottom: 8px; right: 8px; z-index: 3; padding: 4px 10px; background: rgba(255, 255, 255, 0.9); border-radius: 999px; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.04em; color: var(--ink-2); pointer-events: none; } | |
| 496 | +.mv-credit b { color: var(--ink); } | |
| 497 | +.mini-map .mv-brand, .mini-map .mv-legend { display: none; } | |
| 498 | +.mini-map .mapwrap { height: 100%; } | |
| 499 | + | |
| 500 | +/* ---- carte propriété (fiche) : marqueur pulsant + anneaux piéton ---- */ | |
| 501 | +.pm-wrap { position: relative; width: 100%; height: 100%; } | |
| 502 | +.pm-marker { display: flex; flex-direction: column; align-items: center; } | |
| 503 | +.pm-pill { padding: 5px 13px; background: #fff; border: 1.5px solid var(--line-strong); border-radius: 999px; font-family: var(--font-display); font-weight: 700; font-size: 14px; color: var(--ink); box-shadow: var(--shadow-off-soft); white-space: nowrap; margin-bottom: 4px; } | |
| 504 | +.pm-pill-deal { background: var(--lime); border-color: var(--green); color: #fff; } | |
| 505 | +.pm-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--lime); border: 2.5px solid #fff; box-shadow: 0 1px 4px rgba(26, 18, 20, 0.4); } | |
| 506 | +.pm-pulse { position: absolute; bottom: -8px; width: 30px; height: 30px; border-radius: 50%; background: var(--lime); opacity: 0.35; animation: pm-pulse 2s ease-out infinite; } | |
| 507 | +@keyframes pm-pulse { 0% { transform: scale(0.5); opacity: 0.45; } 80% { transform: scale(1.9); opacity: 0; } 100% { opacity: 0; } } | |
| 508 | +.pm-rings-legend { position: absolute; bottom: 8px; left: 8px; z-index: 3; display: flex; gap: 12px; padding: 4px 10px; background: rgba(255, 255, 255, 0.9); border-radius: 999px; font-family: var(--font-mono); font-size: 10px; color: var(--green); pointer-events: none; } | |
| 509 | +.mini-map .pm-wrap .mv-credit { display: block; } | |
| 510 | +.map-loading { display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-family: var(--font-mono); font-size: 13px; } | |
| 511 | +@media (max-width: 780px) { .map-split { grid-template-columns: 1fr; height: calc(100dvh - 230px); } .map-list { display: none; } .results-tools { width: 100%; justify-content: space-between; } } | |
| 512 | +.maplibregl-popup-content { padding: 0; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); box-shadow: var(--shadow-off); font-family: var(--font-body); } | |
| 513 | +.maplibregl-popup-close-button { font-size: 18px; padding: 2px 8px; color: var(--paper); z-index: 2; text-shadow: 0 0 4px rgba(26, 18, 20, 0.8); } | |
| 514 | +.mv-pop img, .mv-noimg { width: 100%; height: 130px; object-fit: cover; } | |
| 515 | +.mv-noimg { display: flex; align-items: center; justify-content: center; font-size: 34px; background: var(--surface-2); } | |
| 516 | +.mv-pop-body { padding: 10px 12px 12px; } | |
| 517 | +.mv-pop-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; } | |
| 518 | +.mv-pop-title { font-size: 13px; color: var(--ink-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 519 | +.mv-pop-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); margin-top: 6px; } | |
| 520 | +.mv-pop-src { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; } | |
| 521 | +.mv-pop-cta { display: block; margin-top: 10px; padding: 8px 10px; text-align: center; background: var(--ink); color: #fff; border-radius: var(--r-ctl); font-weight: 600; font-size: 13px; } | |
| 522 | +.mv-pop-cta:hover { background: var(--green-deep); } | |
| 523 | +.mv-pop-deal { display: inline-block; margin-left: 6px; padding: 2px 8px; vertical-align: 3px; background: var(--lime); color: #fff; border-radius: 999px; font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.05em; } | |
| 524 | +.maplibregl-ctrl-attrib { font-size: 10px; } | |
| 525 | + | |
| 526 | +/* ================= Menu mobile ================= */ | |
| 527 | +.menu-btn { display: none; position: relative; z-index: 60; width: 44px; height: 44px; margin-left: auto; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface); cursor: pointer; padding: 0; flex-direction: column; align-items: center; justify-content: center; gap: 5px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.15); transition: box-shadow 0.15s ease, transform 0.15s ease; } | |
| 528 | +.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(26, 18, 20,0.15); } | |
| 529 | +.menu-btn span { display: block; width: 18px; height: 2px; background: var(--ink); border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease; } | |
| 530 | +.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); } | |
| 531 | +.menu-btn.open span:nth-child(2) { opacity: 0; } | |
| 532 | +.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); } | |
| 533 | +/* KA Nav v2 (2026-08-25) : panneau PLEIN ECRAN fixed inset:0 - visible peu | |
| 534 | + importe le scroll (l'ancien dropdown absolute 480px clippait/se coupait). */ | |
| 535 | +.mobile-menu { | |
| 536 | + display: none; position: fixed; inset: 0; | |
| 537 | + background: var(--paper); | |
| 538 | + padding: 76px 16px calc(24px + env(safe-area-inset-bottom)); | |
| 539 | + overflow-y: auto; overscroll-behavior: contain; | |
| 540 | +} | |
| 541 | +.mobile-menu.open { display: block; animation: mm-in 0.16s ease; } | |
| 542 | +@keyframes mm-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } | |
| 543 | +/* burger masque a l'ouverture : la fermeture passe par le X fixe du panneau */ | |
| 544 | +.header:has(.mobile-menu.open) .menu-btn { visibility: hidden; } | |
| 545 | +/* bouton de fermeture du panneau : fixe en haut a droite du viewport */ | |
| 546 | +.mm-close { | |
| 547 | + position: fixed; top: 10px; right: 14px; z-index: 2; | |
| 548 | + width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; | |
| 549 | + border: 1.5px solid var(--ink); border-radius: 999px; | |
| 550 | + background: var(--ink); color: var(--paper); | |
| 551 | + font-size: 17px; font-weight: 700; line-height: 1; cursor: pointer; padding: 0; | |
| 552 | +} | |
| 553 | +/* verrou du scroll d'arriere-plan quand le menu est ouvert (CSS pur) */ | |
| 554 | +html:has(.mobile-menu.open) { overflow: hidden; } | |
| 555 | +.mm-link { display: flex; align-items: center; gap: 12px; padding: 15px 10px; min-height: 52px; font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; border-bottom: 1.5px dashed var(--line); opacity: 0; transform: translateY(-8px); transition: opacity 0.25s ease, transform 0.25s ease; } | |
| 556 | +.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); } | |
| 557 | +.mm-link.active { color: var(--green); } | |
| 558 | +.mm-ico { width: 26px; text-align: center; font-size: 17px; } | |
| 559 | +.mm-arrow { margin-left: auto; opacity: 0.25; } | |
| 560 | +.mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); } | |
| 561 | +.mm-foot { padding: 12px 10px 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em; } | |
| 562 | +.mm-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay, 800); background: rgba(26, 18, 20, 0.35); backdrop-filter: blur(2px); } | |
| 563 | +@media (max-width: 760px) { .menu-btn { display: flex; } .nav { display: none; } } | |
| 564 | + | |
| 565 | +/* ================= Footer ================= | |
| 566 | + Remplacé par le footer commun Groupe KA (ka/KaFooter.tsx — styles .ka-footer | |
| 567 | + dans ka/tokens.css). */ | |
| 568 | + | |
| 569 | +/* ================= Mobile : feuille de filtres + FAB ================= */ | |
| 570 | +.sheet-head { display: none; } | |
| 571 | +.sheet-handle { display: none; } | |
| 572 | +.sheet-apply { display: none; } | |
| 573 | +.sheet-backdrop { display: none; } | |
| 574 | +.fab { display: none; } | |
| 575 | +.fab-badge { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--lime); color: #fff; font-size: 11.5px; font-weight: 700; font-variant-numeric: tabular-nums; margin-left: 2px; } | |
| 576 | +@media (max-width: 640px) { | |
| 577 | + .header-inner { height: 56px; } | |
| 578 | + .brand { font-size: 21px; } | |
| 579 | + .hero h1 { font-size: clamp(30px, 9.4vw, 44px); } | |
| 580 | + .hero p.lede { font-size: 15px; } | |
| 581 | + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; } | |
| 582 | + .stat-row::-webkit-scrollbar { display: none; } | |
| 583 | + .stat-chip { flex: 0 0 auto; white-space: nowrap; } | |
| 584 | + .filterbar { display: none; } | |
| 585 | + .filterbar.open .f-primary { flex-direction: column; } | |
| 586 | + .filterbar.open .f-ctl select { max-width: none; width: 100%; } | |
| 587 | + .filterbar.open .f-more { display: none; } | |
| 588 | + .filterbar.open { display: flex; flex-direction: column; gap: 12px; position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900); margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0; max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 10px 18px calc(18px + env(safe-area-inset-bottom)); box-shadow: 0 -16px 48px rgba(16, 18, 16, 0.35); animation: sheet-up 0.24s cubic-bezier(0.2, 0.9, 0.3, 1); } | |
| 589 | + @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } } | |
| 590 | + .filterbar.open .sheet-handle { display: block; flex: none; width: 44px; height: 5px; border-radius: 999px; background: var(--line); margin: 0 auto 2px; } | |
| 591 | + .filterbar.open .sheet-head { display: flex; justify-content: space-between; align-items: center; font-family: var(--font-display); font-weight: 700; font-size: 17px; text-transform: uppercase; position: sticky; top: -10px; background: var(--surface); padding: 6px 0 8px; border-bottom: 1.5px solid var(--line); margin-bottom: 2px; z-index: 1; } | |
| 592 | + .sheet-close { border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%; width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; } | |
| 593 | + .filterbar.open .sheet-apply { display: flex; width: 100%; min-height: 50px; position: sticky; bottom: 0; z-index: 1; box-shadow: 0 -14px 18px -12px rgba(16, 18, 16, 0.4), 5px 5px 0 var(--lime); } | |
| 594 | + .sheet-backdrop { display: block; position: fixed; inset: 0; z-index: 90; background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px); } | |
| 595 | + .fab { display: flex; align-items: center; gap: 8px; position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80; background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink); border-radius: 999px; padding: 13px 24px; font-family: var(--font-display); font-weight: 700; font-size: 15px; cursor: pointer; box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(15, 123, 74, 0.55); } | |
| 596 | + .fab:active { transform: translateX(-50%) scale(0.97); } | |
| 597 | + .results-head { margin-top: 20px; } | |
| 598 | + .chips { margin-right: -16px; padding-right: 16px; } | |
| 599 | + .notice { padding: 48px 16px; } | |
| 600 | +} | |
| 601 | +@media (max-width: 900px) { .f-search input, .f-native, .f-ctl select { font-size: 16px; } } | |
| 602 | + | |
| 603 | +/* --- Sources : bannières → sous-agences -------------------------------- */ | |
| 604 | +.franchise-list { display: flex; flex-direction: column; gap: 10px; margin: 18px 0; } | |
| 605 | +.franchise { border: 2px solid var(--ink); background: var(--surface); box-shadow: var(--shadow-off-soft); } | |
| 606 | +.franchise-head { | |
| 607 | + width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px; | |
| 608 | + background: none; border: 0; cursor: pointer; font-family: var(--font-display); | |
| 609 | + font-size: 16px; font-weight: 600; color: var(--ink); text-align: left; | |
| 610 | +} | |
| 611 | +.franchise-head:hover { background: var(--surface-2); } | |
| 612 | +.fh-caret { color: var(--green); width: 14px; } | |
| 613 | +.fh-name { flex: 1; } | |
| 614 | +.fh-sub { font-size: 12px; font-weight: 500; color: var(--ink-3); font-family: var(--font-mono); } | |
| 615 | +.fh-count { | |
| 616 | + font-family: var(--font-mono); font-weight: 700; background: var(--ink); | |
| 617 | + color: var(--lime); padding: 3px 10px; min-width: 64px; text-align: right; | |
| 618 | +} | |
| 619 | +.subagency-list { list-style: none; margin: 0; padding: 0 16px 10px 40px; border-top: 1px solid var(--line); } | |
| 620 | +.subagency-list li { | |
| 621 | + display: flex; align-items: center; justify-content: space-between; gap: 12px; | |
| 622 | + padding: 8px 0; border-bottom: 1px dotted var(--line); font-size: 14px; | |
| 623 | +} | |
| 624 | +.subagency-list li:last-child { border-bottom: 0; } | |
| 625 | +.sa-name { color: var(--ink-2); } | |
| 626 | + | |
| 627 | +/* --- Pagination (accueil) ---------------------------------------------- */ | |
| 628 | +.pager { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; | |
| 629 | + gap: 6px; margin: 32px 0 8px; } | |
| 630 | +.pager-btn { | |
| 631 | + font-family: var(--font-mono); font-size: 14px; min-width: 42px; min-height: 42px; padding: 8px 12px; | |
| 632 | + border: 2px solid var(--ink); border-radius: 8px; background: var(--surface); color: var(--ink); | |
| 633 | + cursor: pointer; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.14); | |
| 634 | + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease; | |
| 635 | +} | |
| 636 | +.pager-btn:hover:not(:disabled):not(.on) { background: var(--lime-soft); transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(26, 18, 20, 0.2); } | |
| 637 | +.pager-btn:active:not(:disabled) { transform: none; box-shadow: none; } | |
| 638 | +.pager-btn.on { background: var(--ink); color: var(--lime); font-weight: 700; box-shadow: 3px 3px 0 var(--lime); } | |
| 639 | +.pager-btn:disabled { opacity: .4; cursor: not-allowed; box-shadow: none; } | |
| 640 | +.pager-gap { padding: 0 4px; color: var(--ink-3); } | |
| 641 | +.pager-info { width: 100%; text-align: center; margin-top: 8px; font-size: 12px; | |
| 642 | + color: var(--ink-3); font-family: var(--font-mono); } | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | +/* --- Le quartier (census / proximité / chaleur / crime) — porté de Lou-Ka --- */ | |
| 647 | +/* --- Section « Le quartier » (fiche) --------------------------------------- */ | |
| 648 | +.quartier { margin-top: 34px; } | |
| 649 | +.quartier h2 { font-size: 24px; letter-spacing: -0.02em; } | |
| 650 | +.q-sub { color: var(--ink-3); font-size: 13px; margin: 4px 0 16px; } | |
| 651 | +.q-grid { | |
| 652 | + display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; | |
| 653 | +} | |
| 654 | +.q-grid { grid-template-columns: repeat(2, 1fr); } | |
| 655 | +.q-cell { | |
| 656 | + background: var(--surface); border: 1.5px solid var(--line-strong); | |
| 657 | + border-radius: var(--r-card); padding: 12px 14px; box-shadow: var(--shadow-flat); | |
| 658 | +} | |
| 659 | +.q-val { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; } | |
| 660 | +.q-label { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); | |
| 661 | + text-transform: uppercase; letter-spacing: 0.06em; margin-top: 3px; } | |
| 662 | +.q-prox { margin-top: 18px; display: flex; flex-direction: column; gap: 8px; } | |
| 663 | +.q-bar { display: flex; align-items: center; gap: 10px; font-size: 13px; } | |
| 664 | +.q-bar-label { flex: 0 0 150px; color: var(--ink-2); } | |
| 665 | +.q-bar-label { flex-basis: 120px; font-size: 12px; } | |
| 666 | +.q-bar-track { | |
| 667 | + flex: 1; height: 10px; background: var(--surface-2); | |
| 668 | + border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden; | |
| 669 | +} | |
| 670 | +.q-bar-fill { display: block; height: 100%; background: var(--green); border-radius: 999px; } | |
| 671 | +.q-bar-num { flex: 0 0 30px; text-align: right; font-family: var(--font-mono); | |
| 672 | + font-size: 11.5px; font-weight: 700; } | |
| 673 | +.q-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } | |
| 674 | +.q-badge { | |
| 675 | + display: inline-flex; align-items: center; gap: 6px; | |
| 676 | + border: 1.5px solid var(--line-strong); border-radius: 999px; | |
| 677 | + padding: 7px 13px; font-size: 12.5px; background: var(--surface); | |
| 678 | +} | |
| 679 | +.q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); } | |
| 680 | +.q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; } | |
| 681 | +.quartier .fine { margin-top: 10px; } | |
| 682 | +.q-cell { padding: 9px 11px; } | |
| 683 | +.q-val { font-size: 16px; } | |
| 684 | +.q-bar { gap: 8px; font-size: 12px; } | |
| 685 | +.q-bar-track { height: 8px; } | |
| 686 | +.q-badge-sub { display: block; font-size: 10.5px; color: var(--ink-3); font-weight: 400; margin-left: 4px; } | |
| 687 | + | |
| 688 | +/* --- Vrai-Prix : estimation de valeur ---------------------------------- */ | |
| 689 | +.vraiprix { border: 2px solid var(--ink); background: var(--surface-2); | |
| 690 | + box-shadow: var(--shadow-off-soft); padding: 14px; margin: 14px 0; } | |
| 691 | +.vp-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } | |
| 692 | +.vp-logo { font-family: var(--font-display); font-weight: 700; color: var(--green-deep); | |
| 693 | + letter-spacing: -.3px; } | |
| 694 | +.vp-conf { font-family: var(--font-mono); font-size: 11px; padding: 2px 7px; border: 1px solid var(--ink); } | |
| 695 | +.vp-conf-A { background: var(--lime); color: #fff; } .vp-conf-B { background: var(--lime-soft); } | |
| 696 | +.vp-conf-C { background: var(--amber-soft); } .vp-conf-D { background: #f0d9d9; } | |
| 697 | +.vp-k { font-size: 11px; color: var(--ink-3); text-transform: uppercase; | |
| 698 | + letter-spacing: .5px; margin-top: 8px; } | |
| 699 | +.vp-value { font-family: var(--font-display); font-size: 26px; font-weight: 700; | |
| 700 | + letter-spacing: -1px; color: var(--ink); } | |
| 701 | +.vp-range { font-size: 12px; color: var(--ink-2); font-family: var(--font-mono); } | |
| 702 | +/* jauge Vrai-Prix : fourchette P10–P90 + marqueurs estimation / prix demandé */ | |
| 703 | +.vp-gauge { margin-top: 10px; } | |
| 704 | +.vp-gauge-track { position: relative; height: 14px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line-strong); overflow: hidden; } | |
| 705 | +.vp-gauge-band { position: absolute; inset: 3px 6%; background: linear-gradient(90deg, var(--lime-soft), rgba(15, 123, 74, 0.35), var(--lime-soft)); border-radius: 999px; } | |
| 706 | +.vp-gauge-est { position: absolute; top: 0; bottom: 0; width: 4px; margin-left: -2px; background: var(--lime); border-radius: 2px; } | |
| 707 | +.vp-gauge-ask { position: absolute; top: 2px; bottom: 2px; width: 4px; margin-left: -2px; background: var(--ink); border-radius: 2px; } | |
| 708 | +.vp-gauge-ends { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); margin-top: 4px; } | |
| 709 | +.vp-gauge-legend { display: flex; gap: 14px; font-family: var(--font-mono); font-size: 10px; color: var(--ink-2); margin-top: 5px; } | |
| 710 | +.vp-dot-est, .vp-dot-ask { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 4px; vertical-align: -1px; } | |
| 711 | +.vp-dot-est { background: var(--lime); } | |
| 712 | +.vp-dot-ask { background: var(--ink); } | |
| 713 | +.vp-delta { font-size: 13px; margin-top: 8px; padding: 6px 8px; border-radius: var(--r-ctl); } | |
| 714 | +.vp-over { background: #f6e2e2; color: #8a2b2b; } | |
| 715 | +.vp-under { background: #e2f0e6; color: #1c5c41; } | |
| 716 | +.vp-fair { background: var(--surface); color: var(--ink-2); } | |
| 717 | +.vp-link { display: block; text-align: center; margin-top: 10px; font-weight: 700; | |
| 718 | + font-size: 13px; padding: 8px; background: var(--green-deep); color: #fff; | |
| 719 | + text-decoration: none; } | |
| 720 | +.vp-link:hover { background: var(--green); } | |
| 721 | + | |
| 722 | +/* ============================================================================= | |
| 723 | + Harmonisation Groupe KA — header (badge + compte KA ID), pages Profil, | |
| 724 | + Contact et légales. Le socle vient de ka/tokens.css. | |
| 725 | +============================================================================= */ | |
| 726 | + | |
| 727 | +/* ---- Header : badge « Un service Groupe KA » + zone compte ---- */ | |
| 728 | +.header-inner .gk-badge { flex: none; } | |
| 729 | +@media (max-width: 900px) { .header-inner .gk-badge { display: none; } } | |
| 730 | +.header-acct { display: flex; align-items: center; gap: 10px; flex: none; } | |
| 731 | +.ka-auth { display: flex; align-items: center; gap: 10px; } | |
| 732 | +.ka-login { | |
| 733 | + display: inline-flex; align-items: center; gap: 6px; min-height: 40px; | |
| 734 | + padding: 8px 15px; border: 1.5px solid var(--ink); border-radius: 999px; | |
| 735 | + background: var(--surface); color: var(--ink); cursor: pointer; | |
| 736 | + font-family: var(--font-display); font-weight: 600; font-size: 13.5px; | |
| 737 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15); | |
| 738 | + transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.15s ease; | |
| 739 | +} | |
| 740 | +.ka-login b { background: var(--ink); color: var(--accent); border-radius: 5px; padding: 0 6px 1px; transform: rotate(-2deg); transition: transform 0.15s ease; } | |
| 741 | +.ka-login:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.2); } | |
| 742 | +.ka-login:hover b { transform: rotate(0); } | |
| 743 | +.ka-login:active { transform: translate(2px, 2px); box-shadow: none; } | |
| 744 | +.ka-signup { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); text-decoration: underline; text-underline-offset: 3px; white-space: nowrap; } | |
| 745 | +.ka-signup:hover { color: var(--accent-deep); } | |
| 746 | +@media (max-width: 1100px) { .header-acct .ka-signup { display: none; } } | |
| 747 | +.ka-acct { | |
| 748 | + display: inline-flex; align-items: center; gap: 8px; min-height: 40px; | |
| 749 | + padding: 6px 14px; border: 1.5px solid var(--ink); border-radius: 999px; | |
| 750 | + background: var(--surface); font-family: var(--font-display); | |
| 751 | + font-weight: 600; font-size: 13.5px; color: var(--ink); | |
| 752 | + max-width: 160px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; | |
| 753 | +} | |
| 754 | +.ka-acct:hover, .ka-acct.active { background: var(--ink); color: var(--accent); } | |
| 755 | +.ka-acct-pic { width: 24px; height: 24px; border-radius: 50%; flex: none; border: 1px solid var(--line); } | |
| 756 | +@media (max-width: 760px) { | |
| 757 | + .header-acct { margin-left: auto; } | |
| 758 | + .menu-btn { margin-left: 0; } | |
| 759 | + .ka-login { min-height: 44px; } | |
| 760 | +} | |
| 761 | +@media (max-width: 380px) { .header-acct { display: none; } } /* → menu mobile */ | |
| 762 | + | |
| 763 | +/* ---- Menu mobile : compte + badge groupe ---- */ | |
| 764 | +.mm-auth { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 15px 10px; border-bottom: 1.5px dashed var(--line); } | |
| 765 | +.mm-auth .ka-login { min-height: 44px; } | |
| 766 | +.mm-foot p { margin: 10px 0 0; } | |
| 767 | + | |
| 768 | +/* ---- Générique : lede (Profil / Contact) ---- */ | |
| 769 | +.lede { color: var(--ink-2); font-size: 15.5px; max-width: 640px; } | |
| 770 | + | |
| 771 | +/* ================= Page Profil (KA ID) ================= */ | |
| 772 | +.profil { padding: 44px 0 90px; } | |
| 773 | +.profil h1 { font-size: clamp(28px, 4.6vw, 44px); margin: 10px 0 24px; letter-spacing: -0.03em; } | |
| 774 | +.profil .lede { margin-bottom: 22px; } | |
| 775 | +.profil .notice { padding: 60px 0; } | |
| 776 | + | |
| 777 | +/* Carte de membre — encre + cerise */ | |
| 778 | +.pc { | |
| 779 | + position: relative; overflow: hidden; max-width: 460px; | |
| 780 | + background: var(--ink); color: var(--paper); | |
| 781 | + border: 1.5px solid var(--ink); border-radius: var(--r-card); | |
| 782 | + box-shadow: var(--shadow-off-soft); padding: 22px 26px 0; | |
| 783 | +} | |
| 784 | +.pc-watermark { | |
| 785 | + position: absolute; right: -18px; bottom: 14px; font-family: var(--font-display); | |
| 786 | + font-weight: 700; font-size: 150px; line-height: 1; letter-spacing: -0.06em; | |
| 787 | + color: rgba(245, 243, 238, 0.05); pointer-events: none; user-select: none; | |
| 788 | +} | |
| 789 | +.pc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; } | |
| 790 | +.pc-brand { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.03em; } | |
| 791 | +.pc-ka { display: inline-block; background: var(--accent); color: var(--on-accent); border-radius: 6px; padding: 0 7px 2px; margin-left: 3px; transform: rotate(-2deg); } | |
| 792 | +.pc-label { font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.16em; color: rgba(245, 243, 238, 0.5); } | |
| 793 | +.pc-id-block { margin: 26px 0 22px; display: flex; flex-direction: column; gap: 4px; } | |
| 794 | +.pc-id-label { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.2em; color: var(--accent); } | |
| 795 | +.pc-id { font-family: var(--font-mono); font-weight: 700; font-size: clamp(17px, 4.4vw, 22px); letter-spacing: 0.04em; word-break: break-all; } | |
| 796 | +.pc-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; padding-bottom: 18px; } | |
| 797 | +.pc-holder { display: flex; flex-direction: column; gap: 3px; min-width: 0; } | |
| 798 | +.pc-holder-name { font-family: var(--font-display); font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 799 | +.pc-role-badge { align-self: flex-start; font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; background: var(--accent); color: var(--on-accent); border-radius: 999px; padding: 2px 9px; } | |
| 800 | +.pc-holder-since { font-family: var(--font-mono); font-size: 10.5px; color: rgba(245, 243, 238, 0.5); letter-spacing: 0.06em; } | |
| 801 | +.pc-avatar { width: 52px; height: 52px; border-radius: 50%; border: 2px solid var(--accent); flex: none; object-fit: cover; } | |
| 802 | +.pc-strip { display: flex; align-items: flex-end; gap: 5px; margin: 0 -26px; padding: 9px 18px; background: rgba(15, 123, 74, 0.14); border-top: 1px solid rgba(15, 123, 74, 0.4); overflow: hidden; } | |
| 803 | +.pc-strip i { display: block; width: 3px; border-radius: 1px; background: var(--accent); opacity: 0.75; } | |
| 804 | +.pc-strip i:nth-child(3n) { height: 14px; opacity: 0.4; } | |
| 805 | +.pc-strip i:nth-child(3n+1) { height: 9px; } | |
| 806 | +.pc-strip i:nth-child(3n+2) { height: 17px; opacity: 0.95; } | |
| 807 | +.pc-copy { margin-top: 16px; } | |
| 808 | +.pc-copy.ok { background: var(--ink); color: var(--accent); } | |
| 809 | + | |
| 810 | +.profil-grid { | |
| 811 | + display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); | |
| 812 | + gap: 12px; margin-top: 28px; max-width: 720px; | |
| 813 | +} | |
| 814 | +.pg-item { background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; min-width: 0; } | |
| 815 | +.pg-label { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); } | |
| 816 | +.pg-value { font-weight: 600; font-size: 14.5px; word-break: break-word; } | |
| 817 | +.pg-value.mono { font-family: var(--font-mono); color: var(--accent-deep); } | |
| 818 | +.profil-note { max-width: 640px; color: var(--ink-2); font-size: 13.5px; margin-top: 22px; } | |
| 819 | +.profil-note a { text-decoration: underline; text-underline-offset: 3px; } | |
| 820 | +.profil-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 26px; } | |
| 821 | + | |
| 822 | +/* Profil hub Groupe KA (lecture seule) */ | |
| 823 | +.hub-profile { margin-top: 34px; padding-top: 24px; border-top: 1.5px solid var(--line-strong); max-width: 720px; display: flex; flex-direction: column; gap: 14px; } | |
| 824 | +.hub-profile h3 { font-size: 18px; } | |
| 825 | +.hub-profile .hub-bio { color: var(--ink-2); font-size: 14px; margin: 0; max-width: 560px; } | |
| 826 | +.hub-profile .pub-meta { display: flex; gap: 8px; flex-wrap: wrap; } | |
| 827 | +.hub-profile .stat-chip { display: inline-flex; align-items: center; gap: 6px; } | |
| 828 | +.hub-profile a.stat-chip:hover { background: var(--accent-soft); } | |
| 829 | +.hub-hint { color: var(--ink-3); font-size: 12.5px; margin: 0; } | |
| 830 | + | |
| 831 | +/* ================= Pages légales ================= */ | |
| 832 | +.legal { padding: 48px 0 80px; max-width: 780px; } | |
| 833 | +.legal h1 { font-size: clamp(28px, 4.6vw, 44px); margin: 8px 0 6px; } | |
| 834 | +.legal .legal-meta { color: var(--ink-3); font-family: var(--font-mono); font-size: 12px; margin: 0 0 26px; } | |
| 835 | +.legal h2 { font-size: 20px; margin: 30px 0 8px; border-left: 4px solid var(--accent); padding-left: 10px; } | |
| 836 | +.legal p, .legal li { color: var(--ink-2); font-size: 14.5px; } | |
| 837 | +.legal ul { padding-left: 20px; display: flex; flex-direction: column; gap: 6px; } | |
| 838 | +.legal a { text-decoration: underline; text-underline-offset: 3px; } | |
| 839 | +.legal b { color: var(--ink); } | |
| 840 | + | |
| 841 | +/* ================= Page Contact (écosystème Groupe KA) ================= */ | |
| 842 | +.contact { padding: 48px 0 90px; } | |
| 843 | +.contact h1 { font-size: clamp(30px, 5vw, 52px); margin: 12px 0 14px; text-transform: uppercase; letter-spacing: -0.03em; } | |
| 844 | +.contact-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; margin: 28px 0 6px; max-width: 900px; } | |
| 845 | +.contact-card { | |
| 846 | + display: flex; flex-direction: column; gap: 8px; min-height: var(--touch, 44px); | |
| 847 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 848 | + border-radius: var(--r-card); padding: 18px 20px; | |
| 849 | + box-shadow: var(--shadow-off-soft); | |
| 850 | + transition: transform 0.15s ease, box-shadow 0.15s ease; | |
| 851 | +} | |
| 852 | +.contact-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); } | |
| 853 | +.contact-role { font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--green); } | |
| 854 | +.contact-mail { font-family: var(--font-display); font-weight: 700; font-size: clamp(14px, 2vw, 17px); word-break: break-all; border-bottom: 2px solid var(--accent); align-self: flex-start; } | |
| 855 | +.contact-disclaimer { max-width: 720px; margin: 24px 0 0; padding: 14px 16px; border-left: 3px solid var(--accent); background: var(--accent-soft); color: var(--ink-2); font-size: 13.5px; } | |
| 856 | +.contact-disclaimer b { color: var(--ink); } | |
| 857 | +.contact-hub { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 26px; } | |
| 858 | +.contact-sites { margin-top: 46px; } | |
| 859 | +.contact-sites h2 { font-size: 21px; text-transform: uppercase; margin-bottom: 14px; } | |
| 860 | +.contact-sites ul { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; } | |
| 861 | +.contact-sites a { display: flex; flex-direction: column; gap: 3px; min-height: var(--touch, 44px); background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); padding: 12px 14px; transition: transform 0.14s ease, box-shadow 0.14s ease; } | |
| 862 | +.contact-sites a:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--ink); } | |
| 863 | +.contact-sites b { font-family: var(--font-display); font-size: 15px; } | |
| 864 | +.contact-sites span { font-size: 12px; color: var(--ink-3); } | |
| 865 | +@media (max-width: 480px) { | |
| 866 | + .header-inner { gap: 10px; } | |
| 867 | + .ka-login { font-size: 12px; padding: 6px 10px; gap: 4px; min-height: 44px; } | |
| 868 | +} | |
| 869 | + | |
| 870 | +/* Bulle KA Agent : sur mobile, remontée au-dessus de la barre CTA collante des | |
| 871 | + fiches (sinon elle chevauche le bouton « Voir chez … »). */ | |
| 872 | +@media (max-width: 899px) { | |
| 873 | + .kaa-btn { | |
| 874 | + bottom: calc(88px + env(safe-area-inset-bottom, 0px)) !important; | |
| 875 | + } | |
| 876 | +} | |
| 877 | + | |
| 878 | +/* Footer commun KA : zones tactiles ≥ 44 px pour les petits liens (Ka2, Ka4…) | |
| 879 | + — padding compensé par une marge négative : aucun changement visuel. */ | |
| 880 | +.ka-footer .sites a, .ka-footer .legal a, .ka-footer .contacts a { | |
| 881 | + display: inline-block; padding: 12px 8px; margin: -12px -8px; | |
| 882 | +} | |
| 883 | + | |
| 884 | +/* ================= Qualité des annonces / images ================= */ | |
| 885 | +/* Image de secours par TYPE DE BIEN (jamais d'icône d'image cassée) */ | |
| 886 | +.type-fallback { | |
| 887 | + display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| 888 | + gap: 9px; width: 100%; height: 100%; min-height: 120px; | |
| 889 | + background: | |
| 890 | + radial-gradient(circle at 78% 18%, rgba(15, 123, 74, 0.14), transparent 52%), | |
| 891 | + repeating-linear-gradient(45deg, #f3ece9 0 14px, #f7f1ef 14px 28px); | |
| 892 | + color: var(--accent-deep); | |
| 893 | +} | |
| 894 | +.type-fallback .ico { opacity: 0.75; } | |
| 895 | +.type-fallback span { | |
| 896 | + font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; | |
| 897 | + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); | |
| 898 | +} | |
| 899 | +.carousel-empty .type-fallback { aspect-ratio: 16/11; } | |
| 900 | + | |
| 901 | +/* légende de photo (étiquette de pièce de la source) */ | |
| 902 | +.carousel-caption { | |
| 903 | + position: absolute; left: 12px; bottom: 12px; z-index: 2; max-width: 60%; | |
| 904 | + background: rgba(26, 18, 20, 0.82); color: #f5f3ee; | |
| 905 | + font-family: var(--font-mono); font-size: 11px; font-weight: 600; | |
| 906 | + padding: 4px 10px; border-radius: 999px; | |
| 907 | + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 908 | +} | |
| 909 | + | |
| 910 | +/* mention loyer mensuel (transaction = location) */ | |
| 911 | +.per-month { font-size: 0.55em; font-weight: 600; color: var(--ink-3); letter-spacing: 0; } | |
| 912 | + | |
| 913 | +/* lightbox : l'image zoomable ne doit pas déclencher le scroll de la page */ | |
| 914 | +.lightbox img { user-select: none; -webkit-user-drag: none; } | |
| 915 | + | |
| 916 | +/* ---- Stats : qualité des données ---- */ | |
| 917 | +.q-table-wrap { overflow-x: auto; } | |
| 918 | +.q-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 520px; } | |
| 919 | +.q-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--ink); } | |
| 920 | +.q-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); } | |
| 921 | +.q-table .num { text-align: right; font-family: var(--font-mono); } | |
| 922 | +.q-meter { display: inline-block; width: 74px; height: 9px; background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden; vertical-align: -1px; margin-right: 7px; } | |
| 923 | +.q-meter i { display: block; height: 100%; background: var(--green); } | |
| 924 | + | |
| 925 | + | |
| 926 | +/* ---- Criminalité détaillée SPVM (bloc quartier) ---- */ | |
| 927 | +.q-crime { width: 100%; } | |
| 928 | +.q-crime-cats { list-style: none; margin: 8px 0 0; padding: 0; } | |
| 929 | +.q-crime-cats li { display: flex; align-items: center; gap: 8px; | |
| 930 | + padding: 3px 0; font-size: 12.5px; } | |
| 931 | +.q-crime-nom { flex: 0 0 46%; color: var(--ink-2, #555); overflow: hidden; | |
| 932 | + text-overflow: ellipsis; white-space: nowrap; } | |
| 933 | +.q-crime-barre { flex: 1 1 auto; height: 10px; background: var(--surface-2, #f0ede8); | |
| 934 | + border-radius: 5px; overflow: hidden; } | |
| 935 | +.q-crime-barre i { display: block; height: 100%; border-radius: 5px; | |
| 936 | + background: var(--accent, #0f7b4a); opacity: 0.55; } | |
| 937 | +.q-crime-n { flex: 0 0 52px; text-align: right; font-weight: 600; | |
| 938 | + color: var(--ink, #222); white-space: nowrap; } | |
| 939 | +.q-crime-n small { font-weight: 400; color: var(--ink-3, #888); } | |
| 940 | +.q-crime-src { margin-top: 8px; } | |
| 941 | + | |
| 942 | + | |
| 943 | +/* ---- Risque d'inondation (fiche) — BDZI gouv. du Québec ---- */ | |
| 944 | +.f-zi .zi-head { display: flex; align-items: center; gap: 10px; } | |
| 945 | +.zi-badge { display: inline-block; padding: 3px 10px; border-radius: 999px; | |
| 946 | + font-size: 12.5px; font-weight: 700; } | |
| 947 | +.zi-eleve { background: #fde8e8; color: #a12622; } | |
| 948 | +.zi-modere { background: #fdf3e0; color: #8a5a00; } | |
| 949 | +.zi-present { background: #fdf3e0; color: #8a5a00; } | |
| 950 | +.zi-ok { background: #e7f4ea; color: #1e6b34; } | |
| 951 | +.zi-nc { background: var(--surface-2, #f0ede8); color: var(--ink-3, #888); } | |
| 952 | +.zi-liste { margin: 8px 0 0; padding: 0; list-style: none; font-size: 13px; } | |
| 953 | +.zi-liste li { padding: 4px 0; border-top: 1px solid var(--line, #e6e4df); } | |
| 954 | + | |
| 955 | + | |
| 956 | +/* ---- tuiles KPI + tableau compacts (partagés air/essence) ---- */ | |
| 957 | +.rdl-kpis { display: flex; flex-wrap: wrap; gap: 10px; margin: 2px 0 12px; } | |
| 958 | +.rdl-kpi { flex: 1 1 90px; min-width: 90px; background: var(--surface-2, #f7f5f1); | |
| 959 | + border-radius: 10px; padding: 10px 12px; } | |
| 960 | +.rdl-kpi-v { display: block; font-size: 20px; font-weight: 700; | |
| 961 | + letter-spacing: -0.02em; color: var(--ink, #222); } | |
| 962 | +.rdl-kpi-l { display: block; font-size: 11px; line-height: 1.35; | |
| 963 | + color: var(--ink-3, #888); margin-top: 2px; } | |
| 964 | +.rdl-table { width: 100%; border-collapse: collapse; font-size: 13px; } | |
| 965 | +.rdl-table td { padding: 5px 8px 5px 0; border-top: 1px solid var(--line, #e6e4df); | |
| 966 | + vertical-align: top; } | |
| 967 | +.rdl-table .rdl-addr { max-width: 46%; } | |
| 968 | +.rdl-table .rdl-prix { font-weight: 600; white-space: nowrap; } | |
| 969 | +.rdl-table .rdl-date { white-space: nowrap; color: var(--ink-2, #555); } | |
| 970 | +.rdl-table .rdl-dist { color: var(--ink-3, #888); white-space: nowrap; text-align: right; } | |
| 971 | +.rdl-cap { caption-side: top; text-align: left; font-size: 13px; font-weight: 700; | |
| 972 | + color: var(--ink, #222); padding: 6px 0; } | |
| 973 | + | |
| 974 | +/* ---- Qualité de l'air (RSQAQ) + Essence à proximité ---- */ | |
| 975 | +.air-liste { list-style: none; margin: 10px 0 0; padding: 0; } | |
| 976 | +.air-liste li { display: flex; align-items: center; gap: 8px; | |
| 977 | + padding: 4px 0; font-size: 12.5px; } | |
| 978 | +.air-nom { flex: 0 0 44%; color: var(--ink-2); overflow: hidden; | |
| 979 | + text-overflow: ellipsis; white-space: nowrap; } | |
| 980 | +.air-barre { flex: 1 1 auto; height: 10px; background: var(--surface-2, #f0ede8); | |
| 981 | + border-radius: 5px; overflow: hidden; } | |
| 982 | +.air-barre i { display: block; height: 100%; border-radius: 5px; | |
| 983 | + background: #4d9e64; } | |
| 984 | +.air-barre i.air-sur { background: #c96a1f; } | |
| 985 | +.air-val { flex: 0 0 34%; text-align: right; font-weight: 600; | |
| 986 | + color: var(--ink); white-space: nowrap; overflow: hidden; | |
| 987 | + text-overflow: ellipsis; } | |
| 988 | +.air-val small { font-weight: 400; color: var(--ink-3); } | |
| 989 | +.air-val .air-ref { display: block; font-size: 10px; } | |
| 990 | +.gaz-head th { text-align: left; font-size: 11px; color: var(--ink-3); | |
| 991 | + font-weight: 600; padding: 2px 8px 2px 0; } | |
| 992 | +.gaz-best { display: inline-block; margin-left: 6px; padding: 1px 7px; | |
| 993 | + border-radius: 999px; background: #e7f4ea; color: #1e6b34; | |
| 994 | + font-size: 10.5px; font-weight: 700; } | |
| 995 | +.gaz-adr { display: block; font-size: 11px; color: var(--ink-3); } | |
| 996 | + | |
| 997 | + | |
| 998 | +/* ---- Commerces et transport (fiche) ---- */ | |
| 999 | +.cm-grille { list-style: none; margin: 6px 0 0; padding: 0; | |
| 1000 | + display: grid; grid-template-columns: 1fr 1fr; gap: 4px 18px; } | |
| 1001 | +@media (max-width: 560px) { .cm-grille { grid-template-columns: 1fr; } } | |
| 1002 | +.cm-item { display: flex; align-items: center; gap: 9px; padding: 5px 0; | |
| 1003 | + border-bottom: 1px solid var(--line, #eeece7); min-width: 0; } | |
| 1004 | +.cm-ico { flex: 0 0 28px; } | |
| 1005 | +.cm-txt { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; } | |
| 1006 | +.cm-nom { font-size: 13px; font-weight: 600; color: var(--ink, #222); | |
| 1007 | + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 1008 | +.cm-poi { font-size: 11px; color: var(--ink-3, #8a877f); | |
| 1009 | + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 1010 | +.cm-dist { flex: 0 0 auto; font-size: 13px; font-weight: 700; | |
| 1011 | + color: var(--ink-2, #4c4a45); white-space: nowrap; } | |
| 1012 | + | |
| 1013 | + | |
| 1014 | +/* ---- Coût d'électricité (Hydro-Québec) ---- */ | |
| 1015 | +.hydro-intro { margin: 2px 0 12px; color: var(--ink-2, #4c4a45); } | |
| 1016 | +.hydro-btn { appearance: none; border: 0; border-radius: 10px; cursor: pointer; | |
| 1017 | + background: var(--accent, #2b7a3b); color: #fff; font-weight: 700; | |
| 1018 | + font-size: 14px; padding: 11px 18px; } | |
| 1019 | +.hydro-btn:disabled { opacity: 0.6; cursor: progress; } | |
| 1020 | +.hydro-montant { font-size: 26px; font-weight: 800; letter-spacing: -0.02em; | |
| 1021 | + color: var(--navy, #1c1b18); display: flex; align-items: baseline; | |
| 1022 | + gap: 10px; flex-wrap: wrap; } | |
| 1023 | +.hydro-montant small { font-size: 14px; font-weight: 500; color: var(--ink-3); } | |
| 1024 | +.hydro-an { font-size: 14px; font-weight: 600; color: var(--ink-2, #4c4a45); } | |
| 1025 | +.hydro-vide { color: var(--ink-3, #8a877f); } | |
| 1026 | + | |
| 1027 | + | |
| 1028 | +/* ============================================================================ | |
| 1029 | + PASSE DE DESIGN — « éditorial sharp » appliqué à toute la fiche | |
| 1030 | + Blocs = cartes encre (bordure 1,5 px + ombre décalée douce), titres à | |
| 1031 | + marqueur accent + filet, tuiles KPI blanches à ombre dure, tableaux zébrés, | |
| 1032 | + barres en dégradé, pastilles unifiées (mono, point d'état, liseré). | |
| 1033 | + Aucune propriété `order` (règle Groupe Ka : ordre DOM = ordre visuel). | |
| 1034 | + ========================================================================== */ | |
| 1035 | + | |
| 1036 | +/* --- Blocs de la fiche : cartes posées sur le papier --- */ | |
| 1037 | +.f-bloc:not(.f-galerie):not(.f-hero), | |
| 1038 | +.quartier { | |
| 1039 | + background: var(--surface); | |
| 1040 | + border: 1.5px solid var(--ink); | |
| 1041 | + border-radius: var(--r-card); | |
| 1042 | + padding: 18px 20px 16px; | |
| 1043 | + box-shadow: var(--shadow-off-soft); | |
| 1044 | +} | |
| 1045 | +@media (max-width: 640px) { | |
| 1046 | + .f-bloc:not(.f-galerie):not(.f-hero), | |
| 1047 | + .quartier { padding: 15px 15px 13px; } | |
| 1048 | +} | |
| 1049 | +/* blocs pleine largeur sous la grille : respiration entre les cartes */ | |
| 1050 | +.detail > .f-bloc { margin-top: 22px; } | |
| 1051 | + | |
| 1052 | +/* --- Titres de section : carré accent + filet éditorial --- */ | |
| 1053 | +.f-bloc h2, .quartier h2 { | |
| 1054 | + display: flex; align-items: center; gap: 10px; | |
| 1055 | + font-size: 19px; letter-spacing: -0.015em; margin-bottom: 14px; | |
| 1056 | + border-left: 0; padding-left: 0; | |
| 1057 | +} | |
| 1058 | +.f-bloc h2::before, .quartier h2::before { | |
| 1059 | + content: ""; flex: 0 0 9px; width: 9px; height: 9px; | |
| 1060 | + background: var(--accent); border: 1.5px solid var(--ink); | |
| 1061 | + box-shadow: 2px 2px 0 var(--ink); | |
| 1062 | +} | |
| 1063 | +.f-bloc h2::after, .quartier h2::after { | |
| 1064 | + content: ""; flex: 1 1 auto; height: 1.5px; min-width: 20px; | |
| 1065 | + background: var(--line); | |
| 1066 | +} | |
| 1067 | + | |
| 1068 | +/* --- Tuiles KPI : blanches, bord encre, ombre dure accent --- */ | |
| 1069 | +.rdl-kpi { | |
| 1070 | + background: var(--surface); | |
| 1071 | + border: 1.5px solid var(--ink); | |
| 1072 | + border-radius: var(--r-ctl); | |
| 1073 | + box-shadow: 3px 3px 0 var(--accent-soft); | |
| 1074 | +} | |
| 1075 | +.rdl-kpi-v { font-family: var(--font-display); } | |
| 1076 | +.q-cell { box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1); border-radius: var(--r-ctl); } | |
| 1077 | +.q-val { font-size: 20px; } | |
| 1078 | + | |
| 1079 | +/* --- Tableaux : en-têtes mono, zébrure, survol accent --- */ | |
| 1080 | +.rdl-table td { padding: 7px 8px 7px 0; } | |
| 1081 | +.rdl-table tbody tr:nth-child(even) td, | |
| 1082 | +.rdl-table > tr:nth-child(even) td { background: var(--surface-2); } | |
| 1083 | +.rdl-table tr:hover td { background: var(--accent-soft); } | |
| 1084 | +.gaz-head th { | |
| 1085 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; | |
| 1086 | + text-transform: uppercase; letter-spacing: 0.08em; | |
| 1087 | + border-bottom: 1.5px solid var(--ink); | |
| 1088 | +} | |
| 1089 | +.rdl-cap { font-family: var(--font-display); } | |
| 1090 | +table.rooms tbody tr:hover td { background: var(--accent-soft); } | |
| 1091 | + | |
| 1092 | +/* --- Barres : pistes bordées + remplissage en dégradé --- */ | |
| 1093 | +.q-bar-track, .q-crime-barre, .air-barre { | |
| 1094 | + background: var(--surface-2); | |
| 1095 | + border: 1px solid var(--line); | |
| 1096 | + height: 12px; | |
| 1097 | +} | |
| 1098 | +.q-bar-fill { | |
| 1099 | + background: linear-gradient(90deg, var(--accent), var(--accent-deep)); | |
| 1100 | +} | |
| 1101 | +.q-crime-barre i { | |
| 1102 | + background: linear-gradient(90deg, #f0808a, var(--accent)); | |
| 1103 | + opacity: 0.9; | |
| 1104 | +} | |
| 1105 | +.air-barre i { background: linear-gradient(90deg, #6fbc85, #2e7d4a); } | |
| 1106 | +.air-barre i.air-sur { background: linear-gradient(90deg, #e08a48, #c96a1f); } | |
| 1107 | + | |
| 1108 | +/* --- Pastilles d'état unifiées (zi-badge & cie) : mono + point d'état --- */ | |
| 1109 | +.zi-badge { | |
| 1110 | + display: inline-flex; align-items: center; gap: 6px; | |
| 1111 | + font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; | |
| 1112 | + text-transform: uppercase; letter-spacing: 0.06em; | |
| 1113 | + padding: 4px 10px; border: 1.5px solid currentColor; | |
| 1114 | +} | |
| 1115 | +.zi-badge::before { | |
| 1116 | + content: ""; width: 6px; height: 6px; border-radius: 50%; | |
| 1117 | + background: currentColor; | |
| 1118 | +} | |
| 1119 | +.gaz-best { border: 1px solid currentColor; } | |
| 1120 | + | |
| 1121 | +/* --- Listes riches (zones d'inondation) : mini-cartes à liseré accent --- */ | |
| 1122 | +.zi-liste { display: flex; flex-direction: column; gap: 8px; } | |
| 1123 | +.zi-liste li { | |
| 1124 | + border: 1px solid var(--line); border-left: 3px solid var(--accent); | |
| 1125 | + border-top: 1px solid var(--line); | |
| 1126 | + border-radius: var(--r-ctl); padding: 9px 12px; | |
| 1127 | + background: var(--surface-2); | |
| 1128 | +} | |
| 1129 | + | |
| 1130 | +/* --- Inclusions : icône et libellé alignés sur une seule ligne --- */ | |
| 1131 | +.amenity { display: inline-flex; align-items: center; gap: 6px; } | |
| 1132 | + | |
| 1133 | +/* --- Notes de bas de bloc : filet pointillé --- */ | |
| 1134 | +.f-bloc .fine, .quartier .fine { | |
| 1135 | + margin-top: 12px; padding-top: 9px; | |
| 1136 | + border-top: 1px dashed var(--line); | |
| 1137 | +} | |
| 1138 | + | |
| 1139 | +/* --- CTA secondaire : téléchargement de la fiche PDF --- */ | |
| 1140 | +.cta-pdf { | |
| 1141 | + background: var(--surface); color: var(--ink); | |
| 1142 | + box-shadow: 4px 4px 0 rgba(26, 18, 20, 0.12); | |
| 1143 | + margin-top: 10px; | |
| 1144 | +} | |
| 1145 | +.cta-pdf:hover { background: var(--ink); color: var(--paper); } | |
| 1146 | + | |
| 1147 | +/* ================= Financement (fiche) + page Taux hypothécaires ========= */ | |
| 1148 | +.f-mtg .mtg-intro { color: var(--ink-2); font-size: 14px; margin: 4px 0 14px; } | |
| 1149 | +.f-mtg .mtg-intro a { color: var(--lime); font-weight: 600; } | |
| 1150 | + | |
| 1151 | +.mtg-form { | |
| 1152 | + display: grid; gap: 10px; | |
| 1153 | + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); | |
| 1154 | +} | |
| 1155 | +.mtg-form label { display: flex; flex-direction: column; gap: 3px; min-width: 0; } | |
| 1156 | +.mtg-form label > span { | |
| 1157 | + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; | |
| 1158 | + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); | |
| 1159 | +} | |
| 1160 | +.mtg-form input, .mtg-form select { | |
| 1161 | + border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); | |
| 1162 | + padding: 9px 10px; font-size: 14.5px; font-family: inherit; color: var(--ink); | |
| 1163 | + min-height: 42px; min-width: 0; | |
| 1164 | +} | |
| 1165 | +.mtg-form input:focus, .mtg-form select:focus { | |
| 1166 | + outline: none; box-shadow: 3px 3px 0 var(--lime); | |
| 1167 | +} | |
| 1168 | + | |
| 1169 | +.mtg-err { color: var(--accent-deep); font-size: 13.5px; margin: 12px 0 0; } | |
| 1170 | + | |
| 1171 | +.mtg-resultat { | |
| 1172 | + display: grid; gap: 10px; margin-top: 16px; | |
| 1173 | + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| 1174 | +} | |
| 1175 | +.mtg-kpi { | |
| 1176 | + border: 1.5px solid var(--line-strong); border-radius: var(--r-card); | |
| 1177 | + background: var(--surface-2); padding: 12px 14px; | |
| 1178 | + display: flex; flex-direction: column; gap: 2px; min-width: 0; | |
| 1179 | +} | |
| 1180 | +.mtg-kpi-k { | |
| 1181 | + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; | |
| 1182 | + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); | |
| 1183 | +} | |
| 1184 | +.mtg-kpi-v { | |
| 1185 | + font-family: var(--font-display); font-weight: 700; font-size: 22px; | |
| 1186 | + letter-spacing: -0.02em; | |
| 1187 | +} | |
| 1188 | +.mtg-kpi-sub { font-size: 12px; color: var(--ink-3); } | |
| 1189 | +.mtg-kpi:first-child { background: var(--ink); border-color: var(--ink); } | |
| 1190 | +.mtg-kpi:first-child .mtg-kpi-v { color: var(--lime); } | |
| 1191 | +.mtg-kpi:first-child .mtg-kpi-k, .mtg-kpi:first-child .mtg-kpi-sub { color: rgba(255,255,255,0.75); } | |
| 1192 | + | |
| 1193 | +.mtg-source { margin-top: 10px; } | |
| 1194 | + | |
| 1195 | +.mtg-schl { | |
| 1196 | + margin-top: 14px; border: 1.5px dashed var(--line-strong); | |
| 1197 | + border-radius: var(--r-card); padding: 12px 14px; font-size: 13.5px; | |
| 1198 | + background: var(--surface); | |
| 1199 | +} | |
| 1200 | +.mtg-schl ul { margin: 8px 0 0; padding-left: 18px; display: grid; gap: 3px; } | |
| 1201 | +.mtg-schl-no { border-color: var(--accent-deep); } | |
| 1202 | +.mtg-issue { margin: 8px 0 0; color: var(--accent-deep); font-size: 13px; } | |
| 1203 | + | |
| 1204 | +.mtg-detail { | |
| 1205 | + margin-top: 10px; border: 1.5px solid var(--line-strong); | |
| 1206 | + border-radius: var(--r-card); background: var(--surface); | |
| 1207 | +} | |
| 1208 | +.mtg-detail > summary { | |
| 1209 | + cursor: pointer; padding: 11px 14px; font-weight: 700; font-size: 14px; | |
| 1210 | + list-style: none; display: flex; align-items: center; gap: 8px; | |
| 1211 | +} | |
| 1212 | +.mtg-detail > summary::before { content: "+"; font-family: var(--font-mono); color: var(--lime); font-weight: 700; } | |
| 1213 | +.mtg-detail[open] > summary::before { content: "−"; } | |
| 1214 | +.mtg-detail > summary::-webkit-details-marker { display: none; } | |
| 1215 | +.mtg-detail > *:not(summary) { margin: 0 14px 12px; } | |
| 1216 | +.mtg-detail .fine { border-top: none; padding-top: 0; } | |
| 1217 | +.mtg-total { border-top: 2px solid var(--ink); } | |
| 1218 | +.mtg-comp td, .mtg-comp th { white-space: nowrap; } | |
| 1219 | +.mtg-stale { color: var(--accent-deep); } | |
| 1220 | + | |
| 1221 | +.mtg-chart svg { width: 100%; height: auto; display: block; } | |
| 1222 | +.mtg-grid { stroke: var(--line); stroke-width: 1; } | |
| 1223 | +.mtg-tick { font-family: var(--font-mono); font-size: 10px; fill: var(--ink-3); } | |
| 1224 | +.mtg-line { fill: none; stroke: var(--lime); stroke-width: 2.5; stroke-linejoin: round; } | |
| 1225 | +.mtg-dot { fill: var(--ink); stroke: var(--lime); stroke-width: 2.5; } | |
| 1226 | + | |
| 1227 | +/* ---- page /taux-hypothecaires ---- */ | |
| 1228 | +.taux-page { padding-bottom: 40px; } | |
| 1229 | +.taux-head { margin: 28px 0 6px; max-width: 760px; } | |
| 1230 | +.taux-head h1 { font-family: var(--font-display); font-size: clamp(28px, 5vw, 40px); letter-spacing: -0.03em; } | |
| 1231 | +.taux-head p { color: var(--ink-2); font-size: 15px; margin: 12px 0 0; } | |
| 1232 | +.taux-page > .f-bloc { margin-top: 22px; } | |
| 1233 | + | |
| 1234 | +.taux-grid { | |
| 1235 | + display: grid; gap: 10px; | |
| 1236 | + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); | |
| 1237 | +} | |
| 1238 | +.taux-card { | |
| 1239 | + border: 1.5px solid var(--ink); border-radius: var(--r-card); | |
| 1240 | + background: var(--surface); padding: 12px 14px; text-align: left; | |
| 1241 | + cursor: pointer; display: flex; flex-direction: column; gap: 2px; | |
| 1242 | + transition: box-shadow 0.15s ease; font-family: inherit; min-width: 0; | |
| 1243 | +} | |
| 1244 | +.taux-card:hover { box-shadow: 3px 3px 0 var(--lime); } | |
| 1245 | +.taux-card.on { background: var(--ink); color: var(--paper); } | |
| 1246 | +.taux-card.on .taux-card-v { color: var(--lime); } | |
| 1247 | +.taux-card-k { | |
| 1248 | + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; | |
| 1249 | + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); | |
| 1250 | +} | |
| 1251 | +.taux-card.on .taux-card-k, .taux-card.on .taux-card-sub { color: rgba(255,255,255,0.7); } | |
| 1252 | +.taux-card-v { font-family: var(--font-display); font-weight: 700; font-size: 24px; } | |
| 1253 | +.taux-card-sub { font-size: 11.5px; color: var(--ink-3); } | |
| 1254 | + | |
| 1255 | +.taux-filtres { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; } | |
| 1256 | +.taux-filtres label { display: flex; flex-direction: column; gap: 3px; } | |
| 1257 | +.taux-filtres label > span { | |
| 1258 | + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; | |
| 1259 | + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); | |
| 1260 | +} | |
| 1261 | +.taux-filtres select { | |
| 1262 | + border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); | |
| 1263 | + padding: 9px 12px; font-size: 14.5px; font-family: inherit; min-height: 42px; | |
| 1264 | +} | |
| 1265 | +.taux-best td { background: var(--lime-soft); } | |
| 1266 | +.taux-badge { | |
| 1267 | + font-family: var(--font-mono); font-size: 9px; font-weight: 700; | |
| 1268 | + text-transform: uppercase; letter-spacing: 0.1em; background: var(--ink); | |
| 1269 | + color: var(--lime); border-radius: 999px; padding: 2px 8px; margin-left: 8px; | |
| 1270 | +} | |
| 1271 | +.taux-sante { display: flex; flex-wrap: wrap; gap: 8px; } | |
| 1272 | +.taux-src { | |
| 1273 | + border: 1.5px solid var(--line-strong); border-radius: 999px; | |
| 1274 | + padding: 6px 12px; font-size: 12.5px; font-weight: 600; | |
| 1275 | + display: inline-flex; align-items: center; gap: 7px; | |
| 1276 | +} | |
| 1277 | +.taux-src::before { content: ""; width: 8px; height: 8px; border-radius: 50%; } | |
| 1278 | +.taux-src-ok::before { background: var(--green, #2f9e44); } | |
| 1279 | +.taux-src-warning::before { background: #e8a000; } | |
| 1280 | +.taux-src-error::before { background: var(--accent-deep); } | |
| 1281 | + | |
| 1282 | +/* ============================================================================= | |
| 1283 | + REFONTE ÉDITORIALE v2 (2026-08-25) — accueil « produit premium » | |
| 1284 | + Personnalité Immo-Ka : architecturale et financière — composition | |
| 1285 | + typographique majuscule plein/outline décalée, colonne de données mono | |
| 1286 | + façon terminal, cerise en couleur de SIGNAL (jamais en aplat décoratif). | |
| 1287 | + Primitives locales : rayons 0/6/10/18, durées 150/240/350 ms. | |
| 1288 | + Couche posée en FIN de fichier : elle a le dernier mot sur la cascade. | |
| 1289 | +============================================================================= */ | |
| 1290 | +:root { | |
| 1291 | + --r-0: 0px; --r-1: 6px; --r-2: 10px; --r-3: 18px; | |
| 1292 | + --dur-1: 150ms; --dur-2: 240ms; --dur-3: 350ms; | |
| 1293 | + --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1); | |
| 1294 | + --hairline: rgba(20, 24, 20, 0.14); | |
| 1295 | + --hairline-strong: rgba(20, 24, 20, 0.6); | |
| 1296 | +} | |
| 1297 | + | |
| 1298 | +/* ---- Héro : composition typographique + colonne de données ---- */ | |
| 1299 | +.hero-wrap { display: grid; grid-template-columns: 1fr auto; gap: 24px 56px; align-items: end; } | |
| 1300 | +.hero h1.hero-display { | |
| 1301 | + display: flex; flex-direction: column; | |
| 1302 | + font-size: clamp(40px, 6.8vw, 92px); line-height: 0.96; | |
| 1303 | + text-transform: uppercase; letter-spacing: -0.04em; | |
| 1304 | + max-width: none; margin-top: 16px; | |
| 1305 | +} | |
| 1306 | +.hero-display .hd-l1, .hero-display .hd-l2, | |
| 1307 | +.hero-display .hd-l3, .hero-display .hd-l4 { display: block; } | |
| 1308 | +.hero-display .hd-l2 { color: transparent; -webkit-text-stroke: 2.5px var(--ink); } | |
| 1309 | +.hero-display .hd-l3 { margin-left: clamp(24px, 6vw, 110px); } | |
| 1310 | +.hero-display .hd-l4 { | |
| 1311 | + font-size: 0.5em; letter-spacing: -0.02em; margin-top: 0.35em; | |
| 1312 | + margin-left: clamp(2px, 1vw, 8px); | |
| 1313 | +} | |
| 1314 | +.hero-display .signal { | |
| 1315 | + font-style: normal; color: var(--lime); position: relative; | |
| 1316 | + border-bottom: 4px solid var(--lime); padding-bottom: 1px; | |
| 1317 | +} | |
| 1318 | +.hero p.lede { font-size: 16px; max-width: 560px; } | |
| 1319 | +.hero::before { opacity: 0.7; } | |
| 1320 | +.hero::after { opacity: 0.5; } | |
| 1321 | + | |
| 1322 | +/* colonne « terminal » : les chiffres du marché comme élément graphique */ | |
| 1323 | +.hero-data { | |
| 1324 | + display: flex; flex-direction: column; gap: 18px; | |
| 1325 | + border-left: 1px solid var(--hairline-strong); padding-left: 28px; | |
| 1326 | + min-width: 190px; | |
| 1327 | +} | |
| 1328 | +.hd-row b { | |
| 1329 | + display: block; font-family: var(--font-display); font-weight: 700; | |
| 1330 | + font-size: clamp(20px, 2vw, 27px); letter-spacing: -0.03em; color: var(--ink); | |
| 1331 | + font-variant-numeric: tabular-nums; line-height: 1.1; | |
| 1332 | +} | |
| 1333 | +.hd-row span { | |
| 1334 | + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; | |
| 1335 | + text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); | |
| 1336 | +} | |
| 1337 | +@media (max-width: 900px) { | |
| 1338 | + .hero-wrap { grid-template-columns: 1fr; align-items: start; } | |
| 1339 | + .hero-data { | |
| 1340 | + flex-direction: row; flex-wrap: wrap; gap: 16px 28px; | |
| 1341 | + border-left: 0; border-top: 1px solid var(--hairline); | |
| 1342 | + padding: 16px 0 0; min-width: 0; margin-top: 6px; | |
| 1343 | + } | |
| 1344 | +} | |
| 1345 | + | |
| 1346 | +/* ---- Ligne de données vivantes (mono + filets) ---- */ | |
| 1347 | +.live-line { | |
| 1348 | + display: flex; flex-wrap: wrap; align-items: baseline; | |
| 1349 | + margin-top: 30px; font-family: var(--font-mono); font-size: 12px; | |
| 1350 | + color: var(--ink-2); letter-spacing: 0.02em; row-gap: 10px; | |
| 1351 | +} | |
| 1352 | +.live-flag { | |
| 1353 | + display: inline-flex; align-items: center; gap: 7px; | |
| 1354 | + font-weight: 700; font-size: 10.5px; letter-spacing: 0.18em; | |
| 1355 | + text-transform: uppercase; color: var(--accent-deep); | |
| 1356 | + padding-right: 14px; margin-right: 14px; border-right: 1px solid var(--hairline-strong); | |
| 1357 | +} | |
| 1358 | +.live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--lime); animation: pulse 2.4s ease infinite; } | |
| 1359 | +.live-item { padding-right: 14px; margin-right: 14px; border-right: 1px solid var(--hairline); } | |
| 1360 | +.live-item:last-child { border-right: 0; padding-right: 0; margin-right: 0; } | |
| 1361 | +.live-item b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; } | |
| 1362 | +@media (max-width: 640px) { | |
| 1363 | + .live-line { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; | |
| 1364 | + white-space: nowrap; margin-right: -16px; padding-right: 16px; } | |
| 1365 | + .live-line::-webkit-scrollbar { display: none; } | |
| 1366 | +} | |
| 1367 | + | |
| 1368 | +/* ---- Recherche au cœur : grand champ souligné + critères en filets ---- */ | |
| 1369 | +.search-zone { margin: 42px 0 0; } | |
| 1370 | +.q-big { | |
| 1371 | + display: flex; align-items: center; gap: 14px; | |
| 1372 | + border: 0; border-bottom: 2px solid var(--ink); border-radius: 0; | |
| 1373 | + background: transparent; padding: 4px 2px 14px; color: var(--ink-3); | |
| 1374 | + transition: border-color var(--dur-2) var(--ease-out), color var(--dur-2) ease; | |
| 1375 | +} | |
| 1376 | +.q-big:focus-within { border-color: var(--lime); color: var(--accent-deep); } | |
| 1377 | +.q-big svg { flex: none; } | |
| 1378 | +.q-big input { | |
| 1379 | + flex: 1; min-width: 0; border: 0; background: none; outline: none; padding: 0; | |
| 1380 | + font-family: var(--font-display); font-weight: 600; | |
| 1381 | + font-size: clamp(19px, 2.8vw, 28px); letter-spacing: -0.02em; color: var(--ink); | |
| 1382 | +} | |
| 1383 | +.q-big input::placeholder { color: var(--ink-3); font-weight: 500; } | |
| 1384 | +.crit-line { display: flex; align-items: stretch; flex-wrap: wrap; } | |
| 1385 | +.crit { | |
| 1386 | + display: flex; flex-direction: column; justify-content: center; gap: 1px; | |
| 1387 | + padding: 12px 26px 12px 0; margin-right: 26px; | |
| 1388 | + border-right: 1px solid var(--hairline); min-width: 0; cursor: pointer; | |
| 1389 | +} | |
| 1390 | +.crit > span { | |
| 1391 | + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; | |
| 1392 | + text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); | |
| 1393 | +} | |
| 1394 | +.crit select { | |
| 1395 | + border: 0; background: transparent; outline: none; cursor: pointer; | |
| 1396 | + font-family: var(--font-display); font-weight: 700; font-size: 15.5px; | |
| 1397 | + color: var(--ink); appearance: none; -webkit-appearance: none; | |
| 1398 | + padding: 2px 18px 2px 0; min-width: 0; max-width: 190px; text-overflow: ellipsis; | |
| 1399 | + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%238b928c'/%3E%3C/svg%3E"); | |
| 1400 | + background-repeat: no-repeat; background-position: right center; | |
| 1401 | +} | |
| 1402 | +.crit:hover select { color: var(--accent-deep); } | |
| 1403 | +.crit .range-pair select { max-width: 92px; } | |
| 1404 | +.crit-more { | |
| 1405 | + display: inline-flex; align-items: center; gap: 9px; align-self: center; | |
| 1406 | + border: 0; background: none; padding: 12px 0; cursor: pointer; | |
| 1407 | + font-family: var(--font-display); font-weight: 700; font-size: 14.5px; color: var(--ink); | |
| 1408 | + transition: color var(--dur-1) ease; | |
| 1409 | +} | |
| 1410 | +.crit-more:hover, .crit-more.on { color: var(--accent-deep); } | |
| 1411 | +.crit-badge, .rb-badge { | |
| 1412 | + display: inline-grid; place-items: center; min-width: 19px; height: 19px; padding: 0 5px; | |
| 1413 | + border-radius: 999px; background: var(--lime); color: #fff; | |
| 1414 | + font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; | |
| 1415 | +} | |
| 1416 | +.search-zone .f-adv { border-top: 1px solid var(--hairline); margin-top: 2px; padding-top: 18px; } | |
| 1417 | +.search-zone .seg button { | |
| 1418 | + border: 1px solid var(--hairline-strong); background: transparent; color: var(--ink-2); | |
| 1419 | + min-height: 38px; font-weight: 600; margin-left: -1px; | |
| 1420 | +} | |
| 1421 | +.search-zone .seg button:first-child { border-radius: var(--r-1) 0 0 var(--r-1); } | |
| 1422 | +.search-zone .seg button:last-child { border-radius: 0 var(--r-1) var(--r-1) 0; } | |
| 1423 | +.search-zone .seg button:hover { background: var(--lime-soft); color: var(--accent-deep); } | |
| 1424 | +.search-zone .seg button.on { background: var(--ink); border-color: var(--ink); color: var(--paper); box-shadow: none; } | |
| 1425 | +.search-zone .f-native { border: 1px solid var(--hairline-strong); background: transparent; } | |
| 1426 | + | |
| 1427 | +/* résumé mobile des critères — remplace le FAB (voir media 640 plus bas) */ | |
| 1428 | +.crit-summary { display: none; } | |
| 1429 | + | |
| 1430 | +/* ---- Barre de résultats sticky : N propriétés · tri · Liste|Carte ---- */ | |
| 1431 | +.results-bar { | |
| 1432 | + position: sticky; top: 64px; z-index: var(--z-sticky, 300); | |
| 1433 | + display: flex; align-items: center; gap: 16px; | |
| 1434 | + margin: 30px 0 22px; padding: 12px 0 11px; | |
| 1435 | + border-bottom: 1px solid var(--hairline-strong); | |
| 1436 | + background: color-mix(in srgb, var(--paper) 86%, transparent); | |
| 1437 | + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); | |
| 1438 | +} | |
| 1439 | +@supports not (background: color-mix(in srgb, red 50%, blue)) { | |
| 1440 | + .results-bar { background: rgba(245, 243, 238, 0.92); } | |
| 1441 | +} | |
| 1442 | +.rb-count { margin: 0; font-size: clamp(16px, 2vw, 20px); letter-spacing: -0.02em; color: var(--ink); text-transform: none; } | |
| 1443 | +.rb-count b { font-variant-numeric: tabular-nums; } | |
| 1444 | +.rb-tools { margin-left: auto; display: flex; align-items: center; gap: 20px; } | |
| 1445 | +.rb-sort select { | |
| 1446 | + border: 0; background: transparent; outline: none; cursor: pointer; | |
| 1447 | + font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; | |
| 1448 | + text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-2); | |
| 1449 | + appearance: none; -webkit-appearance: none; padding: 8px 16px 8px 0; | |
| 1450 | + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%238b928c'/%3E%3C/svg%3E"); | |
| 1451 | + background-repeat: no-repeat; background-position: right center; | |
| 1452 | +} | |
| 1453 | +.rb-sort select:hover { color: var(--ink); } | |
| 1454 | +.rb-tabs { display: flex; gap: 18px; } | |
| 1455 | +.rb-tab { | |
| 1456 | + position: relative; display: inline-flex; align-items: center; gap: 6px; | |
| 1457 | + border: 0; background: none; padding: 8px 0; cursor: pointer; | |
| 1458 | + font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink-3); | |
| 1459 | + transition: color var(--dur-1) ease; | |
| 1460 | +} | |
| 1461 | +.rb-tab:hover, .rb-tab.on { color: var(--ink); } | |
| 1462 | +.rb-tab::after { | |
| 1463 | + content: ""; position: absolute; left: 0; right: 0; bottom: -12px; height: 2.5px; | |
| 1464 | + background: var(--lime); transform: scaleX(0); transform-origin: left; | |
| 1465 | + transition: transform var(--dur-2) var(--ease-out); | |
| 1466 | +} | |
| 1467 | +.rb-tab.on::after { transform: scaleX(1); } | |
| 1468 | +.rb-filters { | |
| 1469 | + display: inline-flex; align-items: center; gap: 7px; | |
| 1470 | + border: 0; background: none; padding: 8px 0; cursor: pointer; | |
| 1471 | + font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink); | |
| 1472 | +} | |
| 1473 | +.rb-filters:hover { color: var(--accent-deep); } | |
| 1474 | +@media (min-width: 641px) { .rb-filters { display: none; } } | |
| 1475 | + | |
| 1476 | +/* ---- Transition douce liste ↔ carte ---- */ | |
| 1477 | +.view-pane { animation: pane-in var(--dur-2) var(--ease-out); } | |
| 1478 | +@keyframes pane-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } | |
| 1479 | + | |
| 1480 | +/* ---- Chips rapides : plus légères ---- */ | |
| 1481 | +.chip { | |
| 1482 | + border: 1px solid var(--hairline-strong); background: transparent; | |
| 1483 | + min-height: 38px; padding: 7px 15px; font-size: 12.5px; box-shadow: none; | |
| 1484 | +} | |
| 1485 | +.chip:hover { transform: none; box-shadow: none; background: var(--lime-soft); color: var(--accent-deep); } | |
| 1486 | +.chip.on { background: var(--ink); border-color: var(--ink); color: var(--paper); box-shadow: none; } | |
| 1487 | +.chips { margin: 20px 0 0; } | |
| 1488 | + | |
| 1489 | +/* ---- Pastilles actives : discrètes, survol = retrait ---- */ | |
| 1490 | +.pills { margin: 14px 0 0; } | |
| 1491 | +.pills .pill { border: 1px solid var(--hairline-strong); background: transparent; box-shadow: none; padding: 5px 12px; font-size: 12px; } | |
| 1492 | +.pills .pill:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); } | |
| 1493 | + | |
| 1494 | +/* ---- Fiches éditoriales : l'image domine, plus de boîte ---- */ | |
| 1495 | +.card, .card:hover { background: transparent; border: 0; border-radius: 0; box-shadow: none; transform: none; } | |
| 1496 | +.card-img { border-radius: var(--r-2); aspect-ratio: 4/3; } | |
| 1497 | +.card-img img { transition: transform var(--dur-3) var(--ease-out); } | |
| 1498 | +@media (hover: hover) { | |
| 1499 | + .card:hover .card-img img { transform: scale(1.025); } | |
| 1500 | + .card:hover .card-price { color: var(--accent-deep); } | |
| 1501 | +} | |
| 1502 | +.card:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; border-radius: var(--r-2); } | |
| 1503 | +.card-body { padding: 13px 2px 0; gap: 3px; } | |
| 1504 | +.card-price { font-size: 22px; letter-spacing: -0.03em; color: var(--ink); transition: color var(--dur-1) ease; } | |
| 1505 | +.card-title { font-size: 14px; font-weight: 500; color: var(--ink-2); } | |
| 1506 | +.card-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); } | |
| 1507 | +.card-meta .sep { width: 3px; height: 3px; border: 0; border-radius: 50%; background: var(--lime); transform: none; } | |
| 1508 | +.card-specs { font-size: 11.5px; color: var(--ink-2); } | |
| 1509 | +.card-foot { border-top: 0; padding-top: 8px; } | |
| 1510 | +.source-tag { background: none; border: 0; border-radius: 0; padding: 0; color: var(--ink-3); font-size: 10px; letter-spacing: 0.08em; max-width: 62%; } | |
| 1511 | +.avail { font-size: 11px; color: var(--ink-3); } | |
| 1512 | +.badge { border: 0; border-radius: 4px; background: rgba(20, 24, 20, 0.78); color: var(--paper); | |
| 1513 | + backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); | |
| 1514 | + font-size: 9.5px; padding: 4px 9px; letter-spacing: 0.08em; } | |
| 1515 | +.badge.type { background: var(--lime); color: #fff; } | |
| 1516 | +.badge.right { background: rgba(20, 24, 20, 0.55); } | |
| 1517 | +/* la liste latérale de la vue carte garde ses fiches synchronisées lisibles */ | |
| 1518 | +.map-card { border-radius: var(--r-2); } | |
| 1519 | +.map-card-sel { outline: 2px solid var(--lime); outline-offset: 3px; border-radius: var(--r-2); } | |
| 1520 | + | |
| 1521 | +/* ---- Grille à rythme éditorial (accueil) ---- */ | |
| 1522 | +.grid.grid-edito { grid-template-columns: repeat(3, 1fr); gap: 36px 26px; align-items: start; } | |
| 1523 | +@media (max-width: 980px) { | |
| 1524 | + .grid.grid-edito { grid-template-columns: 1fr 1fr; gap: 30px 20px; } | |
| 1525 | +} | |
| 1526 | +@media (max-width: 640px) { | |
| 1527 | + .grid.grid-edito { grid-template-columns: 1fr; gap: 32px; } | |
| 1528 | +} | |
| 1529 | +@media (prefers-reduced-motion: no-preference) { | |
| 1530 | + .grid-edito > * { animation: rise 0.5s var(--ease-out) backwards; } | |
| 1531 | + .grid-edito > *:nth-child(3n+2) { animation-delay: 60ms; } | |
| 1532 | + .grid-edito > *:nth-child(3n) { animation-delay: 120ms; } | |
| 1533 | +} | |
| 1534 | +@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } | |
| 1535 | + | |
| 1536 | +/* ---- Squelettes sans boîte ---- */ | |
| 1537 | +.skel { border: 0; border-radius: 0; background: transparent; } | |
| 1538 | +.skel .sk-img { aspect-ratio: 4/3; border-radius: var(--r-2); } | |
| 1539 | +.skel .sk-line { margin: 12px 2px; } | |
| 1540 | + | |
| 1541 | +/* ---- Pagination éditoriale : chiffres nus, page courante soulignée ---- */ | |
| 1542 | +.pager { border-top: 1px solid var(--hairline); padding-top: 16px; margin-top: 26px; } | |
| 1543 | +.pager-info { font-family: var(--font-mono); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); } | |
| 1544 | +.pager-btn { | |
| 1545 | + min-width: 34px; min-height: 38px; height: auto; padding: 0 10px; border: 0; border-radius: var(--r-1); | |
| 1546 | + background: none; color: var(--ink-2); box-shadow: none; | |
| 1547 | + font-family: var(--font-display); font-size: 13.5px; font-weight: 600; position: relative; | |
| 1548 | +} | |
| 1549 | +.pager-btn:hover:not(:disabled):not(.on) { background: var(--lime-soft); color: var(--accent-deep); transform: none; box-shadow: none; } | |
| 1550 | +.pager-btn.on { background: none; border: 0; color: var(--ink); font-weight: 700; box-shadow: none; } | |
| 1551 | +.pager-btn.on::after { content: ""; position: absolute; left: 9px; right: 9px; bottom: 3px; height: 2.5px; background: var(--lime); } | |
| 1552 | +.pager-btn:disabled { box-shadow: none; } | |
| 1553 | + | |
| 1554 | +/* ---- Mobile : recherche condensée + feuille de critères premium ---- */ | |
| 1555 | +@media (max-width: 640px) { | |
| 1556 | + .hero h1.hero-display { font-size: clamp(38px, 11vw, 52px); } | |
| 1557 | + .results-bar { top: 64px; gap: 10px; flex-wrap: wrap; } | |
| 1558 | + .rb-tools { gap: 14px; } | |
| 1559 | + .search-zone { margin: 30px 0 0; } | |
| 1560 | + .crit-line, .search-zone .f-adv, .search-zone .sheet-apply { display: none; } | |
| 1561 | + .crit-summary { | |
| 1562 | + display: flex; align-items: center; gap: 10px; width: 100%; | |
| 1563 | + border: 0; border-bottom: 1px solid var(--hairline); background: none; | |
| 1564 | + padding: 14px 2px; margin: 0; cursor: pointer; text-align: left; | |
| 1565 | + font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); | |
| 1566 | + } | |
| 1567 | + .crit-summary svg { flex: none; color: var(--ink); } | |
| 1568 | + .crit-summary .cs-txt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1569 | + .crit-summary .f-chev { margin: 0 2px 0 0; } | |
| 1570 | + | |
| 1571 | + .search-zone.open { | |
| 1572 | + display: flex; flex-direction: column; gap: 12px; | |
| 1573 | + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900); | |
| 1574 | + margin: 0; border-radius: var(--r-3) var(--r-3) 0 0; background: var(--surface); | |
| 1575 | + border: 0; | |
| 1576 | + max-height: 86dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; | |
| 1577 | + padding: 10px 18px calc(16px + env(safe-area-inset-bottom)); | |
| 1578 | + box-shadow: 0 -18px 50px rgba(20, 24, 20, 0.3); | |
| 1579 | + animation: sheet-up 0.26s var(--ease-out); | |
| 1580 | + } | |
| 1581 | + .search-zone.open .sheet-handle { | |
| 1582 | + display: block; flex: none; width: 40px; height: 4.5px; | |
| 1583 | + border-radius: 999px; background: var(--line); margin: 0 auto; | |
| 1584 | + } | |
| 1585 | + .search-zone.open .sheet-head { | |
| 1586 | + display: flex; justify-content: space-between; align-items: center; | |
| 1587 | + font-family: var(--font-display); font-weight: 700; font-size: 17px; | |
| 1588 | + letter-spacing: -0.01em; color: var(--ink); text-transform: none; | |
| 1589 | + position: sticky; top: -10px; background: var(--surface); padding: 6px 0 8px; | |
| 1590 | + border-bottom: 1px solid var(--hairline); z-index: 1; | |
| 1591 | + } | |
| 1592 | + .search-zone.open .crit-summary { display: none; } | |
| 1593 | + .search-zone.open .q-big { padding: 2px 0 10px; } | |
| 1594 | + .search-zone.open .q-big input { font-size: 17px; } | |
| 1595 | + .search-zone.open .crit-line { display: flex; flex-direction: column; } | |
| 1596 | + .search-zone.open .crit { border-right: 0; border-bottom: 1px solid var(--hairline); margin: 0; padding: 10px 0; } | |
| 1597 | + .search-zone.open .crit select { max-width: none; width: 100%; font-size: 16px; } | |
| 1598 | + .search-zone.open .crit .range-pair select { width: auto; flex: 1; } | |
| 1599 | + .search-zone.open .crit-more { display: none; } | |
| 1600 | + .search-zone.open .f-adv { display: grid; border-top: 0; padding-top: 4px; margin-top: 0; } | |
| 1601 | + .search-zone.open .sheet-apply { | |
| 1602 | + display: flex; position: sticky; bottom: 0; z-index: 1; width: 100%; min-height: 50px; | |
| 1603 | + box-shadow: 0 -14px 18px -12px rgba(20, 24, 20, 0.35); | |
| 1604 | + } | |
| 1605 | +} | |
| 1606 | + | |
| 1607 | +/* ---- Navigation mobile flottante (pilule détachée du bord) ---- */ | |
| 1608 | +.tabbar { display: none; } | |
| 1609 | +@media (max-width: 760px) { | |
| 1610 | + .tabbar { | |
| 1611 | + display: grid; grid-template-columns: repeat(4, 1fr); | |
| 1612 | + position: fixed; left: 12px; right: 12px; | |
| 1613 | + bottom: calc(10px + env(safe-area-inset-bottom)); | |
| 1614 | + z-index: var(--z-bottombar, 600); | |
| 1615 | + border: 1px solid var(--hairline-strong); border-radius: var(--r-3); | |
| 1616 | + background: rgba(250, 249, 245, 0.88); | |
| 1617 | + backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); | |
| 1618 | + box-shadow: 0 14px 34px rgba(20, 24, 20, 0.16); | |
| 1619 | + padding: 6px; | |
| 1620 | + } | |
| 1621 | + .tabbar a { | |
| 1622 | + display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| 1623 | + gap: 2px; padding: 5px 2px; min-height: 48px; border-radius: 12px; | |
| 1624 | + font-family: var(--font-display); font-size: 10px; font-weight: 600; | |
| 1625 | + color: var(--ink-3); text-decoration: none; transition: color var(--dur-1) ease; | |
| 1626 | + } | |
| 1627 | + .tabbar a svg { display: block; } | |
| 1628 | + .tabbar a.active { color: var(--ink); } | |
| 1629 | + .tabbar a.active::after { content: ""; width: 16px; height: 2.5px; border-radius: 2px; background: var(--lime); } | |
| 1630 | + body { padding-bottom: calc(80px + env(safe-area-inset-bottom)); } | |
| 1631 | + .ka-footer { padding-bottom: calc(56px + env(safe-area-inset-bottom)); } | |
| 1632 | + | |
| 1633 | + /* widget KA Agent : dégagé de la tabbar (style injecté après le bundle → | |
| 1634 | + spécificité doublée pour gagner) */ | |
| 1635 | + .kaa-btn.kaa-btn { bottom: calc(86px + env(safe-area-inset-bottom, 0px)); right: 14px; } | |
| 1636 | + .kaa-panel.kaa-panel:not(.kaa-full) { bottom: calc(152px + env(safe-area-inset-bottom, 0px)); } | |
| 1637 | +} | |
| 1638 | + | |
| 1639 | +/* ---- Header : jamais de débordement à droite ---- | |
| 1640 | + La nav (8 entrées) + connexion dépassaient le conteneur même à 1440px | |
| 1641 | + (masqué par overflow-x: clip). Resserrage, badge Groupe KA masqué quand | |
| 1642 | + l'espace manque, burger sous 1200px. ---- */ | |
| 1643 | +.nav a { white-space: nowrap; padding: 8px 11px; font-size: 13px; } | |
| 1644 | +@media (max-width: 1400px) { .header .gk-badge { display: none; } } | |
| 1645 | +@media (max-width: 1200px) { | |
| 1646 | + .nav { display: none; } | |
| 1647 | + .menu-btn { display: flex; } | |
| 1648 | +} | |
| 1649 | + | |
| 1650 | +/* header pleine largeur : la nav dense s'aligne aux bords de l'écran au lieu | |
| 1651 | + de déborder du conteneur 1240px sur les grands écrans */ | |
| 1652 | +.header .header-inner { max-width: none; } | |
| 1653 | + | |
| 1654 | +/* ---- Fiche propriété : DA v2 — sections en filets, icônes contextuelles ---- */ | |
| 1655 | +.f-bloc h2 { border-left: 0; padding-left: 0; display: flex; align-items: center; gap: 10px; } | |
| 1656 | +.f-bloc h2::before { content: ""; width: 20px; height: 3px; border-radius: 2px; background: var(--lime); flex: none; } | |
| 1657 | +/* le panneau héros perd sa boîte : la typographie structure */ | |
| 1658 | +.f-hero { background: transparent; border: 0; box-shadow: none; padding: 0; } | |
| 1659 | +.f-hero .price { font-size: clamp(36px, 7vw, 54px); letter-spacing: -0.04em; } | |
| 1660 | +/* inclusions : grille d'items en filets — chaque inclusion a SON icône */ | |
| 1661 | +.amenity-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 0 28px; margin-bottom: 8px; } | |
| 1662 | +.amenity-it { | |
| 1663 | + display: flex; align-items: center; gap: 11px; padding: 7px 0; | |
| 1664 | + border-bottom: 1px solid var(--hairline); font-size: 13.5px; color: var(--ink); min-height: 46px; | |
| 1665 | +} | |
| 1666 | +.am-ico { | |
| 1667 | + display: inline-grid; place-items: center; width: 32px; height: 32px; | |
| 1668 | + border-radius: 10px; background: var(--lime-soft); color: var(--green-deep); flex: none; | |
| 1669 | +} | |
| 1670 | +.am-txt { min-width: 0; } | |
| 1671 | +/* caractéristiques : clés en micro-mono */ | |
| 1672 | +.dtable { gap: 0 28px; } | |
| 1673 | +.drow { padding: 9px 0; } | |
| 1674 | +.drow span { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); align-self: center; } | |
| 1675 | +/* galerie : cadre allégé */ | |
| 1676 | +.carousel { border: 1px solid var(--hairline-strong); box-shadow: none; border-radius: var(--r-2); } | |
| 1677 | +.thumbs button { border-radius: 8px; } | |
| 1678 | +/* CTA sticky : sur la fiche, il prend le bas d'écran — la tabbar s'efface */ | |
| 1679 | +body:has(.cta-sticky) .tabbar { display: none; } | |
| 1680 | +.cta-sticky { z-index: var(--z-dropdown, 700); border-top: 1px solid var(--hairline-strong); background: rgba(250, 249, 245, 0.92); } | |
| 1681 | +@media (max-width: 760px) { | |
| 1682 | + body:has(.cta-sticky) { padding-bottom: 0; } | |
| 1683 | +} | |
| 1684 | + | |
| 1685 | +/* colonnes de la fiche : les sections respirent sans boîte — les modules | |
| 1686 | + pleine largeur (.detail > .f-bloc : financement, quartier…) restent en cartes */ | |
| 1687 | +.fiche > .f-col > .f-bloc:not(.f-galerie) { | |
| 1688 | + background: transparent; border: 0; border-radius: 0; padding: 0; box-shadow: none; | |
| 1689 | +} | |
| 1690 | +@media (max-width: 640px) { | |
| 1691 | + .fiche > .f-col > .f-bloc:not(.f-galerie) { padding: 0; } | |
| 1692 | +} | |
| 1693 | +/* tiret accent des titres : sans bordure ni ombre héritées de la couche fiche */ | |
| 1694 | +.f-bloc h2::before, .quartier h2::before { border: 0; box-shadow: none; } | |
| 1695 | + | |
| 1696 | +/* ============================================================================= | |
| 1697 | + KA MAP SYSTEM v2 — MODE CARTE IMMO-KA (shell plein viewport) | |
| 1698 | + Jetons du design system appliqués au shell ; le sélecteur .ka-map du | |
| 1699 | + framework pose ses défauts directement sur le canevas — re-déclarer les | |
| 1700 | + jetons dessus, sinon l'accent retombe au noir générique dans la carte. | |
| 1701 | +============================================================================= */ | |
| 1702 | +.ka-shell, | |
| 1703 | +.ka-shell .ka-map { | |
| 1704 | + --ka-accent: var(--accent); | |
| 1705 | + --ka-on-accent: var(--on-accent, #fff); | |
| 1706 | + --ka-surface: var(--surface); | |
| 1707 | + --ka-ink: var(--ink); | |
| 1708 | + --ka-line: var(--line-strong); | |
| 1709 | + --ka-radius: var(--r-ctl); | |
| 1710 | + --ka-shadow: 0 8px 24px rgba(26, 18, 20, 0.14); | |
| 1711 | + --ka-font: var(--font-body); | |
| 1712 | +} | |
| 1713 | +.ka-shell-fallback { | |
| 1714 | + position: fixed; inset: 0; z-index: 640; | |
| 1715 | + display: grid; place-items: center; | |
| 1716 | + background: var(--surface); color: var(--ink-3); | |
| 1717 | + font-family: var(--font-mono, inherit); font-size: 13px; | |
| 1718 | +} | |
| 1719 | +.ms2-brand { display: inline-flex; align-items: baseline; gap: 7px; font-family: var(--font-display); min-width: 0; } | |
| 1720 | +.ms2-brand b { font-size: 15px; letter-spacing: -0.01em; } | |
| 1721 | +.ms2-brand span { | |
| 1722 | + font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.14em; | |
| 1723 | + color: var(--ink-3); font-weight: 600; | |
| 1724 | +} | |
| 1725 | +.ka-top-btn { | |
| 1726 | + display: inline-flex; align-items: center; gap: 6px; | |
| 1727 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 1728 | + background: var(--surface); color: var(--ink); | |
| 1729 | + font-family: var(--font-display); font-weight: 600; font-size: 12.5px; | |
| 1730 | + padding: 8px 13px; cursor: pointer; min-height: 38px; white-space: nowrap; | |
| 1731 | +} | |
| 1732 | +.ka-top-btn:hover { background: var(--ink); color: #fff; } | |
| 1733 | +.ka-top-badge { | |
| 1734 | + min-width: 17px; height: 17px; border-radius: 999px; | |
| 1735 | + background: var(--accent); color: var(--on-accent, #fff); | |
| 1736 | + font-size: 10.5px; font-weight: 700; line-height: 17px; | |
| 1737 | + text-align: center; padding: 0 4px; | |
| 1738 | +} | |
| 1739 | +.ms2-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-width: 0; } | |
| 1740 | +.ms2-count { font-size: 13px; color: var(--ink-2); min-width: 0; } | |
| 1741 | +.ms2-count b { color: var(--ink); font-size: 15px; } | |
| 1742 | +.ms2-zone { | |
| 1743 | + margin-left: 8px; font-size: 11px; color: var(--accent-deep, var(--accent)); | |
| 1744 | + border: 1px dashed var(--accent); border-radius: 999px; padding: 2px 8px; | |
| 1745 | +} | |
| 1746 | +.ms2-sort select { | |
| 1747 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 1748 | + background: var(--surface); padding: 6px 8px; font-size: 12.5px; | |
| 1749 | + font-weight: 600; color: var(--ink); max-width: 168px; | |
| 1750 | +} | |
| 1751 | +.ms2-list { display: flex; flex-direction: column; gap: 14px; position: relative; } | |
| 1752 | +.ms2-list .map-card { flex: 0 0 auto; } | |
| 1753 | +.ka-sheet .ms2-list { gap: 12px; padding-bottom: 10px; } | |
| 1754 | +.ms2-pager { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 6px 0 4px; } | |
| 1755 | + | |
| 1756 | +/* mode carte : le widget Ka Agent s'efface (le sheet occupe son coin) */ | |
| 1757 | +body.ka-map-mode .kaa-btn, body.ka-map-mode .kaa-hello { display: none !important; } | |
| 1758 | + | |
| 1759 | +/* filtres de la page AU-DESSUS du mode carte — desktop : modale centrée */ | |
| 1760 | +@media (min-width: 781px) { | |
| 1761 | + body.ka-map-mode .sheet-backdrop { | |
| 1762 | + display: block; position: fixed; inset: 0; | |
| 1763 | + z-index: var(--z-overlay, 800); background: rgba(26, 18, 20, 0.45); | |
| 1764 | + } | |
| 1765 | + body.ka-map-mode .search-zone { display: none; } | |
| 1766 | + body.ka-map-mode .search-zone.open { | |
| 1767 | + display: block; position: fixed; top: 7dvh; left: 50%; | |
| 1768 | + transform: translateX(-50%); width: min(760px, 94vw); | |
| 1769 | + max-height: 84dvh; overflow-y: auto; z-index: var(--z-modal, 900); | |
| 1770 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 1771 | + border-radius: var(--r-card); box-shadow: var(--shadow-off); | |
| 1772 | + padding: 16px 22px 20px; margin: 0; | |
| 1773 | + } | |
| 1774 | + body.ka-map-mode .search-zone.open .sheet-head { | |
| 1775 | + display: flex; align-items: center; justify-content: space-between; | |
| 1776 | + font-weight: 700; font-size: 14px; margin-bottom: 10px; | |
| 1777 | + } | |
| 1778 | + body.ka-map-mode .search-zone.open .sheet-close { | |
| 1779 | + display: grid; place-items: center; width: 34px; height: 34px; | |
| 1780 | + border: 1.5px solid var(--ink); border-radius: 50%; | |
| 1781 | + background: var(--surface); cursor: pointer; font-size: 14px; | |
| 1782 | + } | |
| 1783 | +} | |
| 1784 | + | |
| 1785 | +/* --- KA ID v2 : badge « Recommandé pour vous » (parcimonieux, serveur) --- */ | |
| 1786 | +.badge.ka-reco { top: auto; bottom: 12px; background: var(--ink); color: var(--accent); } | |
| 1787 | + | |
| 1788 | +/* ================= Home-Ka specifics ================= */ | |
| 1789 | +/* status chip on the detail page (2. price + status badge) */ | |
| 1790 | +.status-chip { | |
| 1791 | + display: inline-flex; align-items: center; gap: 6px; | |
| 1792 | + font-family: var(--font-mono); font-size: 11px; font-weight: 700; | |
| 1793 | + text-transform: uppercase; letter-spacing: 0.08em; | |
| 1794 | + padding: 5px 10px; border-radius: 999px; | |
| 1795 | + background: var(--accent); color: var(--on-accent); | |
| 1796 | +} | |
| 1797 | +.status-chip.st-pending { background: #b96a00; } | |
| 1798 | +.status-chip.st-sold { background: var(--ink); color: var(--paper); } | |
| 1799 | +.status-chip.st-withdrawn { background: var(--ink-3); color: var(--paper); } | |
| 1800 | +.status-chip.st-coming { background: var(--accent-deep); } | |
| 1801 | + | |
| 1802 | +/* status badge on result cards */ | |
| 1803 | +.card-img .badge.status { | |
| 1804 | + left: auto; right: 10px; bottom: 10px; top: auto; | |
| 1805 | + background: var(--ink); color: var(--paper); | |
| 1806 | +} | |
| 1807 | + | |
| 1808 | +.broker-agency { font-size: 13.5px; color: var(--ink-2); } | |
| 1809 | + | |
| 1810 | +/* ================= Admin (/admin) ================= */ | |
| 1811 | +.admin { padding-top: 30px; padding-bottom: 46px; } | |
| 1812 | +.admin h1 { font-family: var(--font-display); font-size: 30px; margin: 4px 0 6px; } | |
| 1813 | +.admin .sub { color: var(--ink-2); max-width: 620px; } | |
| 1814 | + | |
| 1815 | +.adm-token { | |
| 1816 | + display: flex; flex-wrap: wrap; gap: 10px; align-items: center; | |
| 1817 | + margin: 18px 0 20px; padding: 12px 14px; | |
| 1818 | + border: 1.5px dashed var(--ink-3); border-radius: var(--r-card); | |
| 1819 | + background: var(--surface); | |
| 1820 | +} | |
| 1821 | +.adm-token label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); } | |
| 1822 | +.adm-token input { | |
| 1823 | + flex: 1; min-width: 220px; padding: 9px 12px; font: inherit; | |
| 1824 | + border: 1.5px solid var(--ink); border-radius: 8px; background: var(--paper); | |
| 1825 | +} | |
| 1826 | + | |
| 1827 | +.adm-tiles { margin-bottom: 20px; } | |
| 1828 | +.adm-tile-wide { grid-column: 1 / -1; } | |
| 1829 | +.adm-status-row { display: flex; flex-wrap: wrap; gap: 8px; } | |
| 1830 | + | |
| 1831 | +.adm-tabs { display: flex; gap: 6px; margin: 6px 0 18px; border-bottom: 2px solid var(--ink); } | |
| 1832 | +.adm-tabs a { | |
| 1833 | + padding: 9px 16px; font-weight: 600; font-size: 14px; color: var(--ink-2); | |
| 1834 | + border: 1.5px solid transparent; border-bottom: none; | |
| 1835 | + border-radius: 10px 10px 0 0; | |
| 1836 | +} | |
| 1837 | +.adm-tabs a:hover { color: var(--ink); border-color: var(--ink-3); } | |
| 1838 | +.adm-tabs a.on { background: var(--ink); color: var(--lime); border-color: var(--ink); } | |
| 1839 | + | |
| 1840 | +.adm-badge { | |
| 1841 | + display: inline-flex; align-items: center; gap: 5px; | |
| 1842 | + font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; | |
| 1843 | + text-transform: uppercase; letter-spacing: 0.05em; | |
| 1844 | + padding: 2px 8px; border-radius: 999px; | |
| 1845 | + border: 1px solid var(--ink-3); color: var(--ink-2); background: var(--surface); | |
| 1846 | +} | |
| 1847 | +.adm-badge.adm-ok { border-color: var(--accent); color: var(--accent-deep); background: var(--accent-soft); } | |
| 1848 | +.adm-badge.adm-err { border-color: #b3261e; color: #b3261e; background: #fbe4e2; } | |
| 1849 | +.adm-badge.adm-off { border-color: var(--ink-3); color: var(--ink-3); } | |
| 1850 | + | |
| 1851 | +.adm-sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); } | |
| 1852 | +.adm-msg { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1853 | +.adm-table { min-width: 900px; } | |
| 1854 | + | |
| 1855 | +.adm-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 14px; } | |
| 1856 | +.adm-card { | |
| 1857 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 1858 | + border-radius: var(--r-card); padding: 14px 16px; | |
| 1859 | + box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.1); | |
| 1860 | +} | |
| 1861 | +.adm-card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 4px; } | |
| 1862 | +.adm-card p { margin: 8px 0 0; font-size: 13px; color: var(--ink-2); } | |
| 1863 | + | |
| 1864 | +.adm-controls { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 16px; align-items: center; } | |
| 1865 | +.adm-controls input, .adm-controls select { | |
| 1866 | + padding: 8px 10px; font: inherit; font-size: 13.5px; | |
| 1867 | + border: 1.5px solid var(--ink); border-radius: 8px; background: var(--paper); | |
| 1868 | +} | |
| 1869 | +.adm-controls input:first-child { flex: 1; min-width: 200px; } | |
| 1870 | +.adm-status-select { | |
| 1871 | + padding: 5px 6px; font: inherit; font-size: 12.5px; | |
| 1872 | + border: 1.5px solid var(--ink-3); border-radius: 7px; background: var(--paper); | |
| 1873 | +} | |
| 1874 | +.adm-inspect { padding: 5px 10px; font-size: 12.5px; } | |
added
frontend/src/vite-env.d.ts
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +/// <reference types="vite/client" /> | |
added
frontend/tsconfig.json
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ES2020", | |
| 4 | + "useDefineForClassFields": true, | |
| 5 | + "lib": ["ES2020", "DOM", "DOM.Iterable"], | |
| 6 | + "module": "ESNext", | |
| 7 | + "skipLibCheck": true, | |
| 8 | + "moduleResolution": "bundler", | |
| 9 | + "allowImportingTsExtensions": true, | |
| 10 | + "resolveJsonModule": true, | |
| 11 | + "isolatedModules": true, | |
| 12 | + "noEmit": true, | |
| 13 | + "jsx": "react-jsx", | |
| 14 | + "strict": true, | |
| 15 | + "noUnusedLocals": false, | |
| 16 | + "noUnusedParameters": false, | |
| 17 | + "noFallthroughCasesInSwitch": true | |
| 18 | + }, | |
| 19 | + "include": ["src"] | |
| 20 | +} | |
added
frontend/vite.config.ts
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// vite.config.ts : Vite configuration (dev API proxy, build to dist/) | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | +import { defineConfig } from "vite"; | |
| 7 | +import react from "@vitejs/plugin-react"; | |
| 8 | + | |
| 9 | +export default defineConfig({ | |
| 10 | + plugins: [react()], | |
| 11 | + // @groupe-ka/ka-maps is a file: dependency (../vendor/ka-maps) without its | |
| 12 | + // own node_modules — keep the symlink so its imports (mapbox-gl, react) | |
| 13 | + // resolve from this project's node_modules. | |
| 14 | + resolve: { preserveSymlinks: true }, | |
| 15 | + server: { | |
| 16 | + proxy: { "/api": "http://localhost:8090" }, | |
| 17 | + }, | |
| 18 | + build: { outDir: "dist", chunkSizeWarningLimit: 1200 }, | |
| 19 | +}); | |
added
homeka/__init__.py
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 2 | +# US counterpart of Immo-Ka: same architecture (connectors → normalization → | |
| 3 | +# property matching → dedup → canonical listings → API/frontend), RESO Data | |
| 4 | +# Dictionary aligned, PROPERTY separated from LISTING. | |
added
homeka/brokerages.py
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# brokerages.py : brokerage registry — seed loading + scoring refresh. | |
| 5 | +# The seed (data/brokerages_seed.json) is the initial base of 500+ US | |
| 6 | +# brokerages worth pursuing for direct feeds; the discovery pipeline | |
| 7 | +# (discovery.py) then inspects, classifies and scores them. | |
| 8 | +# ----------------------------------------------------------------------------- | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import json | |
| 12 | +import time | |
| 13 | +from pathlib import Path | |
| 14 | + | |
| 15 | +from . import db | |
| 16 | +from .discovery import priority | |
| 17 | + | |
| 18 | +SEED_PATH = Path(__file__).resolve().parent.parent / "data" / "brokerages_seed.json" | |
| 19 | + | |
| 20 | + | |
| 21 | +def load_seed(path: Path | str | None = None) -> dict: | |
| 22 | + """Upsert the seed file into the brokerages table (idempotent, keyed on | |
| 23 | + website). Never overwrites discovery results or partnership statuses.""" | |
| 24 | + p = Path(path) if path else SEED_PATH | |
| 25 | + data = json.loads(p.read_text(encoding="utf-8")) | |
| 26 | + rows = data["brokerages"] if isinstance(data, dict) else data | |
| 27 | + con = db.connect() | |
| 28 | + now = time.time() | |
| 29 | + added = updated = 0 | |
| 30 | + for b in rows: | |
| 31 | + website = (b.get("website") or "").strip().rstrip("/") | |
| 32 | + if not website or not b.get("name"): | |
| 33 | + continue | |
| 34 | + states = b.get("states") or [] | |
| 35 | + prio = priority(b.get("estimated_listings"), b.get("estimated_agents"), | |
| 36 | + 30, len(states)) # 30 = unknown feed prob. before inspection | |
| 37 | + existing = con.execute("SELECT id FROM brokerages WHERE website=?", | |
| 38 | + (website,)).fetchone() | |
| 39 | + if existing is None: | |
| 40 | + con.execute( | |
| 41 | + """INSERT INTO brokerages (name, website, states, cities, | |
| 42 | + estimated_agents, estimated_listings, mls_affiliations, | |
| 43 | + possible_feed_type, priority_score, partnership_status, | |
| 44 | + created, updated) | |
| 45 | + VALUES (?,?,?,?,?,?,?,?,?,'prospect',?,?)""", | |
| 46 | + (b["name"], website, | |
| 47 | + json.dumps(states, ensure_ascii=False), | |
| 48 | + json.dumps(b.get("cities") or [], ensure_ascii=False), | |
| 49 | + b.get("estimated_agents"), b.get("estimated_listings"), | |
| 50 | + json.dumps(b.get("mls_affiliations") or [], ensure_ascii=False), | |
| 51 | + b.get("possible_feed_type") or "crawl", prio, now, now)) | |
| 52 | + added += 1 | |
| 53 | + else: | |
| 54 | + con.execute( | |
| 55 | + """UPDATE brokerages SET name=?, | |
| 56 | + states=CASE WHEN states='[]' THEN ? ELSE states END, | |
| 57 | + cities=CASE WHEN cities='[]' THEN ? ELSE cities END, | |
| 58 | + estimated_agents=COALESCE(estimated_agents, ?), | |
| 59 | + estimated_listings=COALESCE(estimated_listings, ?), | |
| 60 | + updated=? WHERE id=?""", | |
| 61 | + (b["name"], json.dumps(states, ensure_ascii=False), | |
| 62 | + json.dumps(b.get("cities") or [], ensure_ascii=False), | |
| 63 | + b.get("estimated_agents"), b.get("estimated_listings"), | |
| 64 | + now, existing["id"])) | |
| 65 | + updated += 1 | |
| 66 | + con.commit() | |
| 67 | + n = con.execute("SELECT COUNT(*) c FROM brokerages").fetchone()["c"] | |
| 68 | + con.close() | |
| 69 | + return {"added": added, "updated": updated, "total": n} | |
| 70 | + | |
| 71 | + | |
| 72 | +def rescore(con=None) -> int: | |
| 73 | + """Recompute priority scores from current inspection data.""" | |
| 74 | + own = con is None | |
| 75 | + con = con or db.connect() | |
| 76 | + n = 0 | |
| 77 | + for r in con.execute("SELECT id, estimated_listings, estimated_agents," | |
| 78 | + " feed_probability_score, states FROM brokerages"): | |
| 79 | + states = json.loads(r["states"] or "[]") | |
| 80 | + prio = priority(r["estimated_listings"], r["estimated_agents"], | |
| 81 | + r["feed_probability_score"] or 30, len(states)) | |
| 82 | + con.execute("UPDATE brokerages SET priority_score=? WHERE id=?", | |
| 83 | + (prio, r["id"])) | |
| 84 | + n += 1 | |
| 85 | + con.commit() | |
| 86 | + if own: | |
| 87 | + con.close() | |
| 88 | + return n | |
added
homeka/connectors/__init__.py
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/__init__.py : AUTO-DISCOVERING connector registry (recursive). | |
| 5 | +# | |
| 6 | +# Same principle as immo-ka, extended with connector FAMILIES: | |
| 7 | +# - FAMILIES: {"reso": RESOConnector, "xml": XMLFeedConnector, ...} | |
| 8 | +# generic config-driven classes (class attribute `family`), instantiated | |
| 9 | +# per source from the `sources` table — a new source of a known family | |
| 10 | +# is a DB row, zero code. | |
| 11 | +# - CUSTOM: {"acme_realty": AcmeRealtyConnector, ...} | |
| 12 | +# one-site classes (class attribute `source_id`), like immo-ka. | |
| 13 | +# Every module under this package (subpackages included) is imported; a broken | |
| 14 | +# connector never blocks the others. | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import importlib | |
| 19 | +import pkgutil | |
| 20 | +import sys | |
| 21 | + | |
| 22 | +from .base import BaseConnector | |
| 23 | + | |
| 24 | +FAMILIES: dict[str, type[BaseConnector]] = {} | |
| 25 | +CUSTOM: dict[str, type[BaseConnector]] = {} | |
| 26 | + | |
| 27 | + | |
| 28 | +def _register(module) -> None: | |
| 29 | + for obj in vars(module).values(): | |
| 30 | + if not (isinstance(obj, type) and issubclass(obj, BaseConnector) | |
| 31 | + and obj is not BaseConnector): | |
| 32 | + continue | |
| 33 | + fam = getattr(obj, "family", "") | |
| 34 | + sid = getattr(obj, "source_id", "") | |
| 35 | + if fam and obj.__dict__.get("family"): | |
| 36 | + FAMILIES[fam] = obj | |
| 37 | + if sid and obj.__dict__.get("source_id"): | |
| 38 | + CUSTOM[sid] = obj | |
| 39 | + | |
| 40 | + | |
| 41 | +def _walk(package_name: str, path) -> None: | |
| 42 | + for mod in pkgutil.iter_modules(path): | |
| 43 | + if mod.name in ("base", "__init__"): | |
| 44 | + continue | |
| 45 | + full = f"{package_name}.{mod.name}" | |
| 46 | + try: | |
| 47 | + module = importlib.import_module(full) | |
| 48 | + except Exception as exc: # a broken connector never blocks the others | |
| 49 | + print(f"[home-ka] connector '{full}' skipped: {exc}", file=sys.stderr) | |
| 50 | + continue | |
| 51 | + _register(module) | |
| 52 | + if mod.ispkg: | |
| 53 | + _walk(full, module.__path__) | |
| 54 | + | |
| 55 | + | |
| 56 | +_walk(__name__, __path__) | |
| 57 | + | |
| 58 | + | |
| 59 | +def build(source_id: str, connector_type: str, | |
| 60 | + config: dict | None = None) -> BaseConnector | None: | |
| 61 | + """Instantiate the connector for a `sources` row (or a custom class).""" | |
| 62 | + if source_id in CUSTOM: | |
| 63 | + return CUSTOM[source_id]() | |
| 64 | + cls = FAMILIES.get(connector_type) | |
| 65 | + if cls is None: | |
| 66 | + return None | |
| 67 | + return cls(source_id, config or {}) | |
added
homeka/connectors/_maputil.py
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/_maputil.py : shared helpers for config-driven field mapping. | |
| 5 | +# | |
| 6 | +# Family connectors (json, xml, csv, sftp, reso) turn a raw record into a | |
| 7 | +# Listing through a declarative mapping stored in the source config: | |
| 8 | +# "mapping": {"external_id": "ListingId", "list_price": "price.amount", | |
| 9 | +# "images": "photos[].url", "city": "address.city", ...} | |
| 10 | +# Paths are dot-separated; "[]" fans out over a list. Unmapped raw fields are | |
| 11 | +# preserved in Listing.details for the RESO passthrough in schema.finalize(). | |
| 12 | +# ----------------------------------------------------------------------------- | |
| 13 | +from __future__ import annotations | |
| 14 | + | |
| 15 | +from ..schema import Listing | |
| 16 | + | |
| 17 | +__all__ = ["dig", "apply_mapping"] | |
| 18 | + | |
| 19 | +_LIST_FIELDS = {"images", "features"} | |
| 20 | + | |
| 21 | + | |
| 22 | +def dig(record, path: str): | |
| 23 | + """Resolve a dot path in a nested dict/list structure. | |
| 24 | + | |
| 25 | + "photos[].url" → [p["url"] for p in record["photos"]]; | |
| 26 | + "address.city" → record["address"]["city"]. Returns None when absent. | |
| 27 | + """ | |
| 28 | + cur = record | |
| 29 | + for part in str(path).split("."): | |
| 30 | + if cur is None: | |
| 31 | + return None | |
| 32 | + fan = part.endswith("[]") | |
| 33 | + key = part[:-2] if fan else part | |
| 34 | + if key: | |
| 35 | + if isinstance(cur, dict): | |
| 36 | + cur = cur.get(key) | |
| 37 | + elif isinstance(cur, list): | |
| 38 | + cur = [c.get(key) if isinstance(c, dict) else None for c in cur] | |
| 39 | + else: | |
| 40 | + return None | |
| 41 | + if fan and not isinstance(cur, list): | |
| 42 | + cur = [cur] if cur is not None else [] | |
| 43 | + return cur | |
| 44 | + | |
| 45 | + | |
| 46 | +def apply_mapping(source_id: str, record: dict, mapping: dict, | |
| 47 | + static: dict | None = None) -> Listing | None: | |
| 48 | + """Build a Listing from a raw record and a declarative mapping.""" | |
| 49 | + kw: dict = {"source": source_id, "external_id": "", "url": ""} | |
| 50 | + for field, path in (mapping or {}).items(): | |
| 51 | + v = dig(record, path) | |
| 52 | + if v is None: | |
| 53 | + continue | |
| 54 | + if field in _LIST_FIELDS: | |
| 55 | + v = [x for x in (v if isinstance(v, list) else [v]) if x] | |
| 56 | + kw[field] = v | |
| 57 | + for field, v in (static or {}).items(): | |
| 58 | + kw.setdefault(field, v) | |
| 59 | + if not kw.get("external_id"): | |
| 60 | + return None | |
| 61 | + kw["external_id"] = str(kw["external_id"]) | |
| 62 | + kw.setdefault("details", {}) | |
| 63 | + # keep flat raw scalars for the RESO passthrough / debugging | |
| 64 | + if isinstance(record, dict): | |
| 65 | + for k, v in record.items(): | |
| 66 | + if isinstance(v, (str, int, float, bool)) and k not in kw["details"]: | |
| 67 | + kw["details"][k] = v | |
| 68 | + allowed = set(Listing.__dataclass_fields__) | |
| 69 | + return Listing(**{k: v for k, v in kw.items() if k in allowed}) | |
added
homeka/connectors/_resilient.py
+318 −0
@@ -0,0 +1,318 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Groupe KA — connecteurs : chaîne de fetch anti-bot RÉSILIENTE (commune) | |
| 3 | +# Auteur : Simon-Pierre Boucher <contact@spboucher.ai> | |
| 4 | +# Fichier : connectors/_resilient.py | |
| 5 | +# ----------------------------------------------------------------------------- | |
| 6 | +# But : rendre les connecteurs durables dans le temps. Quand un site jusque-là | |
| 7 | +# ouvert déploie un anti-bot (Cloudflare / Akamai / Incapsula / PerimeterX) ou | |
| 8 | +# renvoie 403/429/503, la requête directe N'ÉCHOUE PLUS silencieusement : elle | |
| 9 | +# ESCALADE automatiquement à travers une chaîne de secours : | |
| 10 | +# | |
| 11 | +# 1. Direct — la session du connecteur (curl_cffi impersonate si | |
| 12 | +# dispo, sinon requests) : rapide et gratuit. | |
| 13 | +# 2. Oxylabs (résid.) — proxy résidentiel Canada (-cc-CA) : IP propre. | |
| 14 | +# 3. Scrapfly (ASP) — bypass anti-bot géré + rendu JS optionnel. | |
| 15 | +# 4. Bright Data — Web Unlocker : déblocage premium, dernier recours. | |
| 16 | +# | |
| 17 | +# Le premier backend qui renvoie un 200 non vide gagne. Si TOUS échouent, on | |
| 18 | +# renvoie la dernière réponse (avec son code d'erreur) pour que le connecteur | |
| 19 | +# journalise l'échec comme avant — aucun changement de comportement en cas | |
| 20 | +# d'échec total, aucun blocage silencieux. | |
| 21 | +# | |
| 22 | +# Conception : | |
| 23 | +# - Aucun effet de bord à l'import ; toute brique non configurée est sautée. | |
| 24 | +# - Les clés sont lues de os.environ, avec repli sur le .env de l'app puis | |
| 25 | +# ~/.claude/.env, et acceptent les deux noms Scrapfly (SCRAPFLY_KEY / | |
| 26 | +# SCRAPFLY_API_KEY). => aucune modif de .env nécessaire. | |
| 27 | +# - `_ResilientResponse` imite requests.Response (.text/.content/.status_code/ | |
| 28 | +# .url/.headers/.json()/.ok/.raise_for_status()) : les connecteurs existants | |
| 29 | +# continuent de fonctionner sans modification. | |
| 30 | +# - Coupe-circuit par hôte : après plusieurs escalades totalement infructueuses | |
| 31 | +# sur un même hôte, on saute l'escalade payante pendant un temps de repos | |
| 32 | +# (évite de brûler du quota Scrapfly/Bright Data sur une source morte). | |
| 33 | +# ============================================================================= | |
| 34 | +from __future__ import annotations | |
| 35 | + | |
| 36 | +import json as _json | |
| 37 | +import os | |
| 38 | +import time | |
| 39 | +from pathlib import Path | |
| 40 | +from urllib.parse import quote, urlsplit | |
| 41 | + | |
| 42 | +import requests | |
| 43 | + | |
| 44 | +# -- curl_cffi est OPTIONNEL (meilleur fingerprint TLS s'il est présent) ------ | |
| 45 | +try: # pragma: no cover | |
| 46 | + from curl_cffi import requests as _cffi # type: ignore | |
| 47 | + _HAS_CFFI = True | |
| 48 | +except Exception: # noqa: BLE001 | |
| 49 | + _cffi = None | |
| 50 | + _HAS_CFFI = False | |
| 51 | + | |
| 52 | +# Codes HTTP typiques d'un blocage anti-bot (≠ 401/404/410/500 « métier » : | |
| 53 | +# 401 = auth manquante, 403/429 = bot bloqué, 5xx CF = challenge/edge). | |
| 54 | +BLOCK_STATUS = {403, 429, 503, 520, 521, 522, 523, 524, 526, 1020} | |
| 55 | + | |
| 56 | +# Marqueurs de page-challenge (Cloudflare/Akamai/Incapsula/PerimeterX/DataDome). | |
| 57 | +_CHALLENGE_MARKERS = ( | |
| 58 | + "just a moment", "cf-browser-verification", "cf-challenge", | |
| 59 | + "attention required", "access denied", "request unsuccessful", | |
| 60 | + "px-captcha", "perimeterx", "incapsula", "_incapsula_", "datadome", | |
| 61 | + "captcha-delivery", "please enable javascript and cookies", | |
| 62 | + "checking your browser", "ddos protection by", | |
| 63 | +) | |
| 64 | + | |
| 65 | +_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " | |
| 66 | + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") | |
| 67 | + | |
| 68 | +# Coupe-circuit en mémoire : hôte -> (timestamp_jusquà, échecs_consécutifs) | |
| 69 | +_COOLDOWN: dict[str, tuple[float, int]] = {} | |
| 70 | +_COOLDOWN_HITS = 3 # nb d'échecs totaux avant repos | |
| 71 | +_COOLDOWN_SECONDS = 900.0 # 15 min de repos pour un hôte « mort » | |
| 72 | + | |
| 73 | +# -- chargement paresseux des secrets ---------------------------------------- | |
| 74 | +_ENV_CACHE: dict[str, str] | None = None | |
| 75 | + | |
| 76 | + | |
| 77 | +def _load_env_files() -> dict[str, str]: | |
| 78 | + """Parse les .env candidats une seule fois (repli si os.environ vide).""" | |
| 79 | + global _ENV_CACHE | |
| 80 | + if _ENV_CACHE is not None: | |
| 81 | + return _ENV_CACHE | |
| 82 | + out: dict[str, str] = {} | |
| 83 | + candidates = [] | |
| 84 | + # .env de l'app (remonte quelques niveaux depuis ce module) | |
| 85 | + here = Path(__file__).resolve() | |
| 86 | + for up in range(2, 6): | |
| 87 | + try: | |
| 88 | + candidates.append(here.parents[up] / ".env") | |
| 89 | + except IndexError: | |
| 90 | + break | |
| 91 | + candidates.append(Path.home() / ".claude" / ".env") | |
| 92 | + for path in candidates: | |
| 93 | + try: | |
| 94 | + if not path.is_file(): | |
| 95 | + continue | |
| 96 | + for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): | |
| 97 | + line = line.strip() | |
| 98 | + if not line or line.startswith("#") or "=" not in line: | |
| 99 | + continue | |
| 100 | + k, _, v = line.partition("=") | |
| 101 | + k, v = k.strip(), v.strip().strip('"').strip("'") | |
| 102 | + # ne pas écraser une valeur déjà trouvée (priorité app > global) | |
| 103 | + if k and k not in out: | |
| 104 | + out[k] = v | |
| 105 | + except Exception: # noqa: BLE001 | |
| 106 | + continue | |
| 107 | + _ENV_CACHE = out | |
| 108 | + return out | |
| 109 | + | |
| 110 | + | |
| 111 | +def _secret(*names: str) -> str | None: | |
| 112 | + """Cherche une clé dans os.environ puis dans les .env (par ordre de noms).""" | |
| 113 | + for n in names: | |
| 114 | + v = os.environ.get(n) | |
| 115 | + if v: | |
| 116 | + return v | |
| 117 | + env = _load_env_files() | |
| 118 | + for n in names: | |
| 119 | + v = env.get(n) | |
| 120 | + if v: | |
| 121 | + return v | |
| 122 | + return None | |
| 123 | + | |
| 124 | + | |
| 125 | +# -- réponse compatible requests.Response ------------------------------------ | |
| 126 | +class _ResilientResponse: | |
| 127 | + """Imite le minimum utile d'une requests.Response pour les connecteurs.""" | |
| 128 | + | |
| 129 | + def __init__(self, url: str, status_code: int, text: str, | |
| 130 | + headers: dict | None = None, via: str = "direct") -> None: | |
| 131 | + self.url = url | |
| 132 | + self.status_code = int(status_code or 0) | |
| 133 | + self._text = text or "" | |
| 134 | + self.headers = headers or {} | |
| 135 | + self.encoding = "utf-8" | |
| 136 | + self.via = via # backend gagnant (diagnostic) | |
| 137 | + | |
| 138 | + @property | |
| 139 | + def text(self) -> str: | |
| 140 | + return self._text | |
| 141 | + | |
| 142 | + @property | |
| 143 | + def content(self) -> bytes: | |
| 144 | + return self._text.encode("utf-8", errors="ignore") | |
| 145 | + | |
| 146 | + @property | |
| 147 | + def ok(self) -> bool: | |
| 148 | + return 200 <= self.status_code < 400 | |
| 149 | + | |
| 150 | + def json(self, **kw): | |
| 151 | + return _json.loads(self._text) | |
| 152 | + | |
| 153 | + def raise_for_status(self): | |
| 154 | + if 400 <= self.status_code < 600: | |
| 155 | + raise requests.HTTPError( | |
| 156 | + f"{self.status_code} via {self.via} pour {self.url}", | |
| 157 | + response=self) # type: ignore[arg-type] | |
| 158 | + return None | |
| 159 | + | |
| 160 | + def __repr__(self) -> str: # pragma: no cover | |
| 161 | + return f"<_ResilientResponse [{self.status_code}] via {self.via}>" | |
| 162 | + | |
| 163 | + | |
| 164 | +# -- détection de blocage ----------------------------------------------------- | |
| 165 | +def is_blocked(resp) -> bool: | |
| 166 | + """True si la réponse ressemble à un blocage anti-bot (≠ erreur métier).""" | |
| 167 | + if resp is None: | |
| 168 | + return True | |
| 169 | + code = getattr(resp, "status_code", 0) or 0 | |
| 170 | + if code in BLOCK_STATUS: | |
| 171 | + return True | |
| 172 | + # 200 mais page-challenge servie | |
| 173 | + if code == 200: | |
| 174 | + try: | |
| 175 | + body = (resp.text or "")[:4000].lower() | |
| 176 | + except Exception: # noqa: BLE001 | |
| 177 | + return False | |
| 178 | + server = str(resp.headers.get("Server", "")).lower() if getattr(resp, "headers", None) else "" | |
| 179 | + if any(m in body for m in _CHALLENGE_MARKERS): | |
| 180 | + return True | |
| 181 | + if "cloudflare" in server and ("captcha" in body or "challenge" in body): | |
| 182 | + return True | |
| 183 | + return False | |
| 184 | + | |
| 185 | + | |
| 186 | +def _host(url: str) -> str: | |
| 187 | + try: | |
| 188 | + return urlsplit(url).netloc.lower() | |
| 189 | + except Exception: # noqa: BLE001 | |
| 190 | + return url | |
| 191 | + | |
| 192 | + | |
| 193 | +def _cooling(host: str) -> bool: | |
| 194 | + until, _ = _COOLDOWN.get(host, (0.0, 0)) | |
| 195 | + return time.time() < until | |
| 196 | + | |
| 197 | + | |
| 198 | +def _note_failure(host: str) -> None: | |
| 199 | + until, hits = _COOLDOWN.get(host, (0.0, 0)) | |
| 200 | + hits += 1 | |
| 201 | + if hits >= _COOLDOWN_HITS: | |
| 202 | + _COOLDOWN[host] = (time.time() + _COOLDOWN_SECONDS, 0) | |
| 203 | + else: | |
| 204 | + _COOLDOWN[host] = (until, hits) | |
| 205 | + | |
| 206 | + | |
| 207 | +def _note_success(host: str) -> None: | |
| 208 | + _COOLDOWN.pop(host, None) | |
| 209 | + | |
| 210 | + | |
| 211 | +# -- backends d'escalade ------------------------------------------------------ | |
| 212 | +def _try_oxylabs(url: str, timeout: int, country: str, | |
| 213 | + headers: dict | None) -> _ResilientResponse | None: | |
| 214 | + endpoint = _secret("OXYLABS_PROXY") # pr.oxylabs.io:7777 | |
| 215 | + user = _secret("OXYLABS_PROXY_USER") # customer-... (sans -cc-XX) | |
| 216 | + pwd = _secret("OXYLABS_PROXY_PASS") | |
| 217 | + if not (endpoint and user and pwd): | |
| 218 | + return None | |
| 219 | + cc = (country or "us").upper() | |
| 220 | + puser = f"{user}-cc-{cc}" | |
| 221 | + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" | |
| 222 | + proxies = {"http": proxy, "https": proxy} | |
| 223 | + hdrs = {"User-Agent": _UA} | |
| 224 | + if headers: | |
| 225 | + hdrs.update(headers) | |
| 226 | + try: | |
| 227 | + r = requests.get(url, proxies=proxies, headers=hdrs, timeout=timeout, | |
| 228 | + verify=False) # noqa: S501 (proxy MITM du CA Oxylabs) | |
| 229 | + return _ResilientResponse(url, r.status_code, r.text, | |
| 230 | + dict(r.headers), via="oxylabs") | |
| 231 | + except Exception: # noqa: BLE001 | |
| 232 | + return None | |
| 233 | + | |
| 234 | + | |
| 235 | +def _try_scrapfly(url: str, timeout: int, country: str, render_js: bool, | |
| 236 | + headers: dict | None) -> _ResilientResponse | None: | |
| 237 | + key = _secret("SCRAPFLY_KEY", "SCRAPFLY_API_KEY") | |
| 238 | + if not key: | |
| 239 | + return None | |
| 240 | + params = {"key": key, "url": url, "country": country or "us", | |
| 241 | + "asp": "true", "proxy_pool": "public_residential_pool"} | |
| 242 | + if render_js: | |
| 243 | + params["render_js"] = "true" | |
| 244 | + if headers: | |
| 245 | + for k, v in headers.items(): | |
| 246 | + params[f"headers[{k}]"] = v | |
| 247 | + try: | |
| 248 | + r = requests.get("https://api.scrapfly.io/scrape", params=params, | |
| 249 | + timeout=max(timeout, 180)) | |
| 250 | + result = (r.json() or {}).get("result") or {} | |
| 251 | + return _ResilientResponse( | |
| 252 | + url, result.get("status_code") or 0, result.get("content") or "", | |
| 253 | + (result.get("response_headers") or {}), via="scrapfly") | |
| 254 | + except Exception: # noqa: BLE001 | |
| 255 | + return None | |
| 256 | + | |
| 257 | + | |
| 258 | +def _try_brightdata(url: str, timeout: int, | |
| 259 | + headers: dict | None) -> _ResilientResponse | None: | |
| 260 | + key = _secret("BRIGHTDATA_API_KEY") | |
| 261 | + zone = _secret("BRIGHTDATA_ZONE") or "web_unlocker1" | |
| 262 | + if not key: | |
| 263 | + return None | |
| 264 | + try: | |
| 265 | + r = requests.post( | |
| 266 | + "https://api.brightdata.com/request", | |
| 267 | + headers={"Authorization": f"Bearer {key}", | |
| 268 | + "Content-Type": "application/json"}, | |
| 269 | + json={"zone": zone, "url": url, "format": "raw"}, | |
| 270 | + timeout=max(timeout, 120)) | |
| 271 | + return _ResilientResponse(url, r.status_code, r.text, | |
| 272 | + dict(r.headers), via="brightdata") | |
| 273 | + except Exception: # noqa: BLE001 | |
| 274 | + return None | |
| 275 | + | |
| 276 | + | |
| 277 | +# -- API publique ------------------------------------------------------------- | |
| 278 | +def escalate(url: str, *, timeout: int = 30, country: str = "us", | |
| 279 | + render_js: bool = False, headers: dict | None = None, | |
| 280 | + original=None): | |
| 281 | + """Tente la chaîne de secours et renvoie la meilleure réponse. | |
| 282 | + | |
| 283 | + Renvoie un `_ResilientResponse` 200 dès qu'un backend réussit ; sinon la | |
| 284 | + dernière réponse tentée (ou `original`) pour préserver le comportement | |
| 285 | + d'échec du connecteur. Respecte le coupe-circuit par hôte. | |
| 286 | + """ | |
| 287 | + host = _host(url) | |
| 288 | + if _cooling(host): | |
| 289 | + return original # source au repos : on ne brûle pas de quota payant | |
| 290 | + | |
| 291 | + last = original | |
| 292 | + for backend in ( | |
| 293 | + lambda: _try_oxylabs(url, timeout, country, headers), | |
| 294 | + lambda: _try_scrapfly(url, timeout, country, render_js, headers), | |
| 295 | + lambda: _try_brightdata(url, timeout, headers), | |
| 296 | + ): | |
| 297 | + resp = backend() | |
| 298 | + if resp is None: | |
| 299 | + continue | |
| 300 | + last = resp | |
| 301 | + if resp.status_code == 200 and resp.text and not is_blocked(resp): | |
| 302 | + _note_success(host) | |
| 303 | + return resp | |
| 304 | + time.sleep(0.4) | |
| 305 | + | |
| 306 | + _note_failure(host) | |
| 307 | + return last if last is not None else original | |
| 308 | + | |
| 309 | + | |
| 310 | +def escalate_if_blocked(resp, url: str, *, timeout: int = 30, | |
| 311 | + country: str = "us", render_js: bool = False, | |
| 312 | + headers: dict | None = None): | |
| 313 | + """Renvoie `resp` s'il est bon ; sinon lance l'escalade anti-bot.""" | |
| 314 | + if not is_blocked(resp): | |
| 315 | + return resp | |
| 316 | + better = escalate(url, timeout=timeout, country=country, | |
| 317 | + render_js=render_js, headers=headers, original=resp) | |
| 318 | + return better if better is not None else resp | |
added
homeka/connectors/base.py
+247 −0
@@ -0,0 +1,247 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/base.py : connector base class + fetch backends. | |
| 5 | +# | |
| 6 | +# Same philosophy as immo-ka: SOURCE → connector → raw ingestion → | |
| 7 | +# normalization → property matching → deduplication → canonical listing → | |
| 8 | +# search index → Home-Ka. One connector adapts ONE source; the pipeline | |
| 9 | +# (ingest.py) handles the diff with the database. | |
| 10 | +# | |
| 11 | +# Two kinds of connectors: | |
| 12 | +# - FAMILY connectors (reso/, rets/, xml/, json/, csv/, sftp/, public_data/): | |
| 13 | +# generic, config-driven — one class serves many sources. Instantiated | |
| 14 | +# with (source_id, config) from a row of the `sources` table. They carry | |
| 15 | +# a class attribute `family` (e.g. "reso"). | |
| 16 | +# - CUSTOM connectors (custom_broker/*.py): one class per site, class | |
| 17 | +# attribute `source_id` set, zero-arg constructible — exactly like | |
| 18 | +# immo-ka's per-agency connectors. | |
| 19 | +# | |
| 20 | +# Data-sourcing policy (Home-Ka): favor the LOWEST-LEVEL source available — | |
| 21 | +# direct feeds (RESO Web API, RETS, XML/JSON/CSV drops) over site crawling. | |
| 22 | +# Crawling is a discovery/bootstrap tool, not the foundation; the proxy | |
| 23 | +# backends (Firecrawl/Scrapfly) exist as a LAST RESORT only. | |
| 24 | +# ----------------------------------------------------------------------------- | |
| 25 | +from __future__ import annotations | |
| 26 | + | |
| 27 | +import json | |
| 28 | +import os | |
| 29 | +import time | |
| 30 | + | |
| 31 | +import requests | |
| 32 | + | |
| 33 | +from ..schema import Listing | |
| 34 | + | |
| 35 | +USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " | |
| 36 | + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 " | |
| 37 | + "HomeKaBot/1.0 (+https://www.home-ka.com/bot; contact@spboucher.ai)") | |
| 38 | + | |
| 39 | +FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape" | |
| 40 | +SCRAPFLY_API = "https://api.scrapfly.io/scrape" | |
| 41 | + | |
| 42 | + | |
| 43 | +def expand_env(config: dict) -> dict: | |
| 44 | + """Resolve '$ENV:NAME' placeholders in a source config — credentials live | |
| 45 | + in the environment (.env), never in the database.""" | |
| 46 | + out = {} | |
| 47 | + for k, v in (config or {}).items(): | |
| 48 | + if isinstance(v, str) and v.startswith("$ENV:"): | |
| 49 | + out[k] = os.environ.get(v[5:], "") | |
| 50 | + elif isinstance(v, dict): | |
| 51 | + out[k] = expand_env(v) | |
| 52 | + else: | |
| 53 | + out[k] = v | |
| 54 | + return out | |
| 55 | + | |
| 56 | + | |
| 57 | +class BaseConnector: | |
| 58 | + """A connector = one adapter for one listing source. | |
| 59 | + | |
| 60 | + Family subclasses: set `family`, accept (source_id, config) and implement | |
| 61 | + `fetch()`. Custom subclasses: set `source_id` and implement `fetch()`. | |
| 62 | + `fetch()` returns the complete list of listings currently visible at the | |
| 63 | + source; the pipeline handles additions/changes/removals. | |
| 64 | + """ | |
| 65 | + | |
| 66 | + family: str = "" # set on generic, config-driven families | |
| 67 | + source_id: str = "" # set on custom one-site connectors | |
| 68 | + request_delay: float = 0.6 # politeness between requests | |
| 69 | + timeout: int = 30 | |
| 70 | + use_detail_cache: bool = True # DB cache of detail pages | |
| 71 | + | |
| 72 | + def __init__(self, source_id: str | None = None, | |
| 73 | + config: dict | None = None) -> None: | |
| 74 | + if source_id: | |
| 75 | + self.source_id = source_id | |
| 76 | + self.config = expand_env(config or {}) | |
| 77 | + if self.config.get("request_delay"): | |
| 78 | + self.request_delay = float(self.config["request_delay"]) | |
| 79 | + self.session = requests.Session() | |
| 80 | + self.session.headers["User-Agent"] = USER_AGENT | |
| 81 | + self._last_request = 0.0 | |
| 82 | + self._detail_con = None | |
| 83 | + | |
| 84 | + # -- backends ------------------------------------------------------------- | |
| 85 | + def _throttle(self) -> None: | |
| 86 | + wait = self.request_delay - (time.time() - self._last_request) | |
| 87 | + if wait > 0: | |
| 88 | + time.sleep(wait) | |
| 89 | + | |
| 90 | + def get(self, url: str, **kw) -> requests.Response: | |
| 91 | + """Polite direct GET.""" | |
| 92 | + self._throttle() | |
| 93 | + resp = self.session.get(url, timeout=self.timeout, **kw) | |
| 94 | + self._last_request = time.time() | |
| 95 | + resp.raise_for_status() | |
| 96 | + return resp | |
| 97 | + | |
| 98 | + def post(self, url: str, **kw) -> requests.Response: | |
| 99 | + """Polite direct POST (internal search APIs).""" | |
| 100 | + self._throttle() | |
| 101 | + resp = self.session.post(url, timeout=self.timeout, **kw) | |
| 102 | + self._last_request = time.time() | |
| 103 | + resp.raise_for_status() | |
| 104 | + return resp | |
| 105 | + | |
| 106 | + def get_rendered(self, url: str, wait_for: int = 0, | |
| 107 | + proxy: str | None = None) -> str: | |
| 108 | + """LAST RESORT — JS-rendered HTML via Firecrawl (FIRECRAWL_API_KEY).""" | |
| 109 | + key = os.environ.get("FIRECRAWL_API_KEY") | |
| 110 | + if not key: | |
| 111 | + raise RuntimeError("FIRECRAWL_API_KEY missing (see .env)") | |
| 112 | + payload: dict = {"url": url, "formats": ["html"], "timeout": 90000} | |
| 113 | + if wait_for: | |
| 114 | + payload["waitFor"] = wait_for | |
| 115 | + if proxy: | |
| 116 | + payload["proxy"] = proxy | |
| 117 | + resp = requests.post(FIRECRAWL_API, json=payload, | |
| 118 | + headers={"Authorization": f"Bearer {key}"}, | |
| 119 | + timeout=150) | |
| 120 | + resp.raise_for_status() | |
| 121 | + return (resp.json().get("data") or {}).get("html", "") | |
| 122 | + | |
| 123 | + def scrapfly(self, url: str, render_js: bool = True, asp: bool = True, | |
| 124 | + rendering_wait: int = 0, country: str = "us", | |
| 125 | + wait_for_selector: str | None = None, | |
| 126 | + js_scenario: list | str | None = None, | |
| 127 | + proxy_pool: str | None = None, headers: dict | None = None, | |
| 128 | + method: str = "GET", body: str | None = None) -> dict: | |
| 129 | + """LAST RESORT — full Scrapfly call, returns the `result` dict. | |
| 130 | + Home-Ka policy: only for sources with no lower-level path; convert | |
| 131 | + every interesting source to a direct feed instead.""" | |
| 132 | + import base64 | |
| 133 | + key = os.environ.get("SCRAPFLY_KEY") | |
| 134 | + if not key: | |
| 135 | + raise RuntimeError("SCRAPFLY_KEY missing (see .env)") | |
| 136 | + params: dict = {"key": key, "url": url, "country": country} | |
| 137 | + if asp: | |
| 138 | + params["asp"] = "true" | |
| 139 | + if render_js: | |
| 140 | + params["render_js"] = "true" | |
| 141 | + if rendering_wait: | |
| 142 | + params["rendering_wait"] = rendering_wait | |
| 143 | + if wait_for_selector: | |
| 144 | + params["wait_for_selector"] = wait_for_selector | |
| 145 | + if proxy_pool: | |
| 146 | + params["proxy_pool"] = proxy_pool | |
| 147 | + if js_scenario is not None: | |
| 148 | + js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario) | |
| 149 | + params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode() | |
| 150 | + if headers: | |
| 151 | + for k, v in headers.items(): | |
| 152 | + params[f"headers[{k}]"] = v | |
| 153 | + self._throttle() | |
| 154 | + if method.upper() == "POST": | |
| 155 | + resp = requests.post(SCRAPFLY_API, params=params, | |
| 156 | + data=(body or ""), timeout=180) | |
| 157 | + else: | |
| 158 | + resp = requests.get(SCRAPFLY_API, params=params, timeout=180) | |
| 159 | + self._last_request = time.time() | |
| 160 | + try: | |
| 161 | + return resp.json().get("result") or {} | |
| 162 | + except ValueError: | |
| 163 | + return {} | |
| 164 | + | |
| 165 | + def detail(self, external_id: str, key: str, fetch_fn) -> dict: | |
| 166 | + """Detail-page payload with cache: `fetch_fn` only runs when the | |
| 167 | + listing is new or its list-content hash changed.""" | |
| 168 | + if not self.use_detail_cache: | |
| 169 | + return fetch_fn() or {} | |
| 170 | + from .. import db | |
| 171 | + if self._detail_con is None: | |
| 172 | + self._detail_con = db.connect() | |
| 173 | + cached = db.get_cached_detail(self._detail_con, self.source_id, | |
| 174 | + str(external_id), key) | |
| 175 | + if cached is not None: | |
| 176 | + return cached | |
| 177 | + payload = fetch_fn() or {} | |
| 178 | + db.put_cached_detail(self._detail_con, self.source_id, | |
| 179 | + str(external_id), key, payload) | |
| 180 | + return payload | |
| 181 | + | |
| 182 | + # -- contract -------------------------------------------------------------- | |
| 183 | + def fetch(self) -> list[Listing]: | |
| 184 | + raise NotImplementedError | |
| 185 | + | |
| 186 | + | |
| 187 | +# ============================================================================= | |
| 188 | +# Anti-bot resilience (Groupe KA) — auto-escalation of get() without touching | |
| 189 | +# connector bodies: an anti-bot block (403/429/503/challenge) or a network cut | |
| 190 | +# triggers the fallback chain (Oxylabs residential -> Scrapfly ASP -> Bright | |
| 191 | +# Data). See connectors/_resilient.py. Idempotent (_KA_RESILIENT_WRAPPED). | |
| 192 | +# Direct fetch stays the default — proxies only fire when blocked. | |
| 193 | +# ============================================================================= | |
| 194 | +if not getattr(BaseConnector, "_KA_RESILIENT_WRAPPED", False): | |
| 195 | + import requests as _ka_requests # noqa: E402 | |
| 196 | + from . import _resilient as _kar # noqa: E402 | |
| 197 | + | |
| 198 | + _ka_orig_get = BaseConnector.get | |
| 199 | + | |
| 200 | + def _ka_full_url(url, kw): | |
| 201 | + try: | |
| 202 | + return _ka_requests.Request("GET", url, | |
| 203 | + params=kw.get("params")).prepare().url | |
| 204 | + except Exception: # noqa: BLE001 | |
| 205 | + return url | |
| 206 | + | |
| 207 | + def _ka_resilient_get(self, url, **kw): | |
| 208 | + timeout = getattr(self, "timeout", 30) | |
| 209 | + headers = kw.get("headers") | |
| 210 | + try: | |
| 211 | + return _ka_orig_get(self, url, **kw) | |
| 212 | + except _ka_requests.HTTPError as exc: | |
| 213 | + r = getattr(exc, "response", None) | |
| 214 | + if r is not None and _kar.is_blocked(r): | |
| 215 | + target = getattr(r, "url", None) or _ka_full_url(url, kw) | |
| 216 | + better = _kar.escalate_if_blocked( | |
| 217 | + r, target, timeout=timeout, headers=headers) | |
| 218 | + if better is not None and getattr(better, "status_code", 0) == 200: | |
| 219 | + return better | |
| 220 | + raise | |
| 221 | + except (_ka_requests.ConnectionError, _ka_requests.Timeout): | |
| 222 | + better = _kar.escalate(_ka_full_url(url, kw), | |
| 223 | + timeout=timeout, headers=headers) | |
| 224 | + if better is not None and getattr(better, "status_code", 0) == 200: | |
| 225 | + return better | |
| 226 | + raise | |
| 227 | + | |
| 228 | + def _ka_get_resilient(self, url, *, render_js=False, country="us", **kw): | |
| 229 | + """Explicit anti-bot fetch: forces the fallback chain when needed.""" | |
| 230 | + timeout = getattr(self, "timeout", 30) | |
| 231 | + headers = kw.get("headers") | |
| 232 | + try: | |
| 233 | + resp = _ka_orig_get(self, url, **kw) | |
| 234 | + except _ka_requests.HTTPError as exc: | |
| 235 | + resp = getattr(exc, "response", None) | |
| 236 | + except (_ka_requests.ConnectionError, _ka_requests.Timeout): | |
| 237 | + resp = None | |
| 238 | + target = _ka_full_url(url, kw) | |
| 239 | + if resp is not None and getattr(resp, "url", None): | |
| 240 | + target = resp.url | |
| 241 | + return _kar.escalate_if_blocked(resp, target, timeout=timeout, | |
| 242 | + country=country, render_js=render_js, | |
| 243 | + headers=headers) | |
| 244 | + | |
| 245 | + BaseConnector.get = _ka_resilient_get | |
| 246 | + BaseConnector.get_resilient = _ka_get_resilient | |
| 247 | + BaseConnector._KA_RESILIENT_WRAPPED = True | |
added
homeka/connectors/csv/__init__.py
+0 −0
added
homeka/connectors/csv/feed.py
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/csv/feed.py : CSV feed connector — brokerage exports, scheduled | |
| 5 | +# drops (also consumed by the sftp family after download). | |
| 6 | +# | |
| 7 | +# {"id": "acme_csv", "connector_type": "csv", "config": { | |
| 8 | +# "url": "https://feeds.acme.com/export.csv", # or "path": local file | |
| 9 | +# "delimiter": ",", "encoding": "utf-8-sig", | |
| 10 | +# "mapping": {"external_id": "MLS #", "list_price": "Price", | |
| 11 | +# "street_address": "Address", "city": "City", ...}, | |
| 12 | +# "images_split": "|", # column with delimiter-joined URLs | |
| 13 | +# "static": {"state": "FL"}}} | |
| 14 | +# ----------------------------------------------------------------------------- | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import csv | |
| 18 | +import io | |
| 19 | + | |
| 20 | +from ..base import BaseConnector | |
| 21 | +from .._maputil import apply_mapping | |
| 22 | +from ...schema import Listing | |
| 23 | + | |
| 24 | + | |
| 25 | +class CSVFeedConnector(BaseConnector): | |
| 26 | + family = "csv" | |
| 27 | + request_delay = 0.2 | |
| 28 | + | |
| 29 | + def fetch(self) -> list[Listing]: | |
| 30 | + cfg = self.config | |
| 31 | + if cfg.get("path"): | |
| 32 | + text = open(cfg["path"], encoding=cfg.get("encoding", "utf-8-sig"), | |
| 33 | + newline="").read() | |
| 34 | + else: | |
| 35 | + resp = self.get(cfg["url"]) | |
| 36 | + resp.encoding = cfg.get("encoding", "utf-8-sig") | |
| 37 | + text = resp.text | |
| 38 | + return parse_csv(self.source_id, text, cfg) | |
| 39 | + | |
| 40 | + | |
| 41 | +def parse_csv(source_id: str, text: str, cfg: dict) -> list[Listing]: | |
| 42 | + """Shared with the sftp family.""" | |
| 43 | + mapping = cfg.get("mapping") or {} | |
| 44 | + static = cfg.get("static") or {} | |
| 45 | + split = cfg.get("images_split", "|") | |
| 46 | + out: list[Listing] = [] | |
| 47 | + reader = csv.DictReader(io.StringIO(text), | |
| 48 | + delimiter=cfg.get("delimiter", ",")) | |
| 49 | + for rec in reader: | |
| 50 | + lst = apply_mapping(source_id, rec, mapping, static) | |
| 51 | + if lst is None: | |
| 52 | + continue | |
| 53 | + if lst.images and len(lst.images) == 1 and split in (lst.images[0] or ""): | |
| 54 | + lst.images = [u.strip() for u in lst.images[0].split(split) if u.strip()] | |
| 55 | + out.append(lst) | |
| 56 | + return out | |
added
homeka/connectors/custom_broker/__init__.py
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +# Custom one-site connectors (one class per brokerage), auto-discovered by | |
| 2 | +# connectors/__init__.py through the class attribute `source_id` — exactly | |
| 3 | +# like immo-ka's per-agency connectors. Add a module here when a brokerage | |
| 4 | +# needs bespoke parsing that the config-driven families can't express. | |
added
homeka/connectors/json/__init__.py
+0 −0
added
homeka/connectors/json/feed.py
+76 −0
@@ -0,0 +1,76 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/json/feed.py : generic JSON API connector — partner APIs, | |
| 5 | +# brokerage internal search APIs, WordPress REST (wp-json) listing CPTs. | |
| 6 | +# Config-driven; pagination by page parameter or by next-link path. | |
| 7 | +# | |
| 8 | +# {"id": "acme_api", "connector_type": "json", "config": { | |
| 9 | +# "url": "https://www.acmerealty.com/api/listings?page={page}", | |
| 10 | +# "method": "GET", "headers": {}, "body": null, | |
| 11 | +# "items_path": "data.results", # dot path to the record list | |
| 12 | +# "next_path": "data.next", # OR page-based: start_page/max_pages | |
| 13 | +# "start_page": 1, "max_pages": 50, "stop_when_empty": true, | |
| 14 | +# "mapping": {"external_id": "id", "url": "permalink", | |
| 15 | +# "list_price": "price", "images": "photos[].url", ...}, | |
| 16 | +# "static": {"state": "CO"}}} | |
| 17 | +# ----------------------------------------------------------------------------- | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +from ..base import BaseConnector | |
| 21 | +from .._maputil import apply_mapping, dig | |
| 22 | +from ...schema import Listing | |
| 23 | + | |
| 24 | + | |
| 25 | +class JSONFeedConnector(BaseConnector): | |
| 26 | + family = "json" | |
| 27 | + request_delay = 0.5 | |
| 28 | + | |
| 29 | + def fetch(self) -> list[Listing]: | |
| 30 | + cfg = self.config | |
| 31 | + mapping = cfg.get("mapping") or {} | |
| 32 | + static = cfg.get("static") or {} | |
| 33 | + items_path = cfg.get("items_path", "") | |
| 34 | + headers = cfg.get("headers") or {} | |
| 35 | + out: list[Listing] = [] | |
| 36 | + seen: set[str] = set() | |
| 37 | + | |
| 38 | + url_tpl = cfg.get("url", "") | |
| 39 | + page = int(cfg.get("start_page", 1)) | |
| 40 | + max_pages = int(cfg.get("max_pages", 100)) | |
| 41 | + next_url: str | None = None | |
| 42 | + | |
| 43 | + for _ in range(max_pages): | |
| 44 | + url = next_url or url_tpl.format(page=page) | |
| 45 | + if cfg.get("method", "GET").upper() == "POST": | |
| 46 | + body = cfg.get("body") | |
| 47 | + if isinstance(body, dict): | |
| 48 | + body = {k: (str(v).format(page=page) if isinstance(v, str) else v) | |
| 49 | + for k, v in body.items()} | |
| 50 | + resp = self.post(url, json=body, headers=headers) | |
| 51 | + else: | |
| 52 | + resp = self.get(url, headers=headers) | |
| 53 | + data = resp.json() | |
| 54 | + items = dig(data, items_path) if items_path else data | |
| 55 | + if not isinstance(items, list): | |
| 56 | + items = [items] if isinstance(items, dict) else [] | |
| 57 | + new = 0 | |
| 58 | + for rec in items: | |
| 59 | + if not isinstance(rec, dict): | |
| 60 | + continue | |
| 61 | + lst = apply_mapping(self.source_id, rec, mapping, static) | |
| 62 | + if lst is not None and lst.uid not in seen: | |
| 63 | + seen.add(lst.uid) | |
| 64 | + out.append(lst) | |
| 65 | + new += 1 | |
| 66 | + if cfg.get("next_path"): | |
| 67 | + next_url = dig(data, cfg["next_path"]) | |
| 68 | + if not next_url: | |
| 69 | + break | |
| 70 | + else: | |
| 71 | + page += 1 | |
| 72 | + if cfg.get("stop_when_empty", True) and new == 0: | |
| 73 | + break | |
| 74 | + if "{page}" not in url_tpl: | |
| 75 | + break | |
| 76 | + return out | |
added
homeka/connectors/json/jsonld_site.py
+226 −0
@@ -0,0 +1,226 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/json/jsonld_site.py : schema.org JSON-LD site connector. | |
| 5 | +# | |
| 6 | +# Many brokerage sites (Real Estate Webmasters and other server-rendered | |
| 7 | +# platforms) publish each listing as JSON-LD (`Product` + `RealEstateListing` | |
| 8 | +# / `SingleFamilyResidence` nodes) — clean, stable data with full address, | |
| 9 | +# geo, price and photos, no proxy needed. This is the SECONDARY crawler path | |
| 10 | +# (authority 3): use it to bootstrap coverage while converting the brokerage | |
| 11 | +# to a direct feed. Ported from immo-ka connectors/jsonld.py. | |
| 12 | +# | |
| 13 | +# {"id": "michael_saunders", "connector_type": "jsonld", "config": { | |
| 14 | +# "base_url": "https://www.michaelsaunders.com", | |
| 15 | +# "url_include": "/listing/", | |
| 16 | +# "external_id_regex": "/listing/([A-Z0-9-]+)/", # from the URL | |
| 17 | +# "max_pages": 400, "static": {"state": "FL"}}} | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import hashlib | |
| 22 | +import json | |
| 23 | +import re | |
| 24 | +import time | |
| 25 | + | |
| 26 | +from ..base import BaseConnector | |
| 27 | +from ...normalize import parse_price | |
| 28 | +from ...schema import Listing | |
| 29 | + | |
| 30 | +_LD_RE = re.compile(r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', | |
| 31 | + re.S | re.I) | |
| 32 | +_LOC_RE = re.compile(r"<loc>\s*(.*?)\s*</loc>", re.S | re.I) | |
| 33 | + | |
| 34 | +_LISTING_TYPES = { | |
| 35 | + "realestatelisting", "singlefamilyresidence", "house", "residence", | |
| 36 | + "apartment", "product", "accommodation", "condominium", "townhouse", | |
| 37 | +} | |
| 38 | + | |
| 39 | +# URL slug fallback: ".../7039-s-straight-avenue-homosassa-fl-34446/" | |
| 40 | +_SLUG_ADDR_RE = re.compile(r"/([\w-]+)-([a-z]{2})-(\d{5})/?$", re.I) | |
| 41 | + | |
| 42 | + | |
| 43 | +def iter_ld(html: str): | |
| 44 | + """Iterate JSON-LD objects of a page (each block, flattened from @graph).""" | |
| 45 | + for block in _LD_RE.findall(html): | |
| 46 | + block = block.strip() | |
| 47 | + if not block: | |
| 48 | + continue | |
| 49 | + try: | |
| 50 | + data = json.loads(block) | |
| 51 | + except ValueError: | |
| 52 | + continue | |
| 53 | + nodes = data.get("@graph", [data]) if isinstance(data, dict) else data | |
| 54 | + for node in (nodes if isinstance(nodes, list) else [nodes]): | |
| 55 | + if isinstance(node, dict): | |
| 56 | + yield node | |
| 57 | + | |
| 58 | + | |
| 59 | +def _qv(value): | |
| 60 | + """Unwrap schema.org QuantitativeValue ({'value': 2, ...} → 2).""" | |
| 61 | + if isinstance(value, dict): | |
| 62 | + return value.get("value") | |
| 63 | + return value | |
| 64 | + | |
| 65 | + | |
| 66 | +class JSONLDSiteConnector(BaseConnector): | |
| 67 | + family = "jsonld" | |
| 68 | + request_delay = 0.8 | |
| 69 | + | |
| 70 | + # -- URL discovery ----------------------------------------------------- | |
| 71 | + def _listing_urls(self) -> list[str]: | |
| 72 | + cfg = self.config | |
| 73 | + include = re.compile(cfg.get("url_include", "/listing/|/property/")) | |
| 74 | + urls: list[str] = [] | |
| 75 | + seen: set[str] = set() | |
| 76 | + | |
| 77 | + def add(u: str) -> None: | |
| 78 | + if u not in seen and include.search(u): | |
| 79 | + seen.add(u) | |
| 80 | + urls.append(u) | |
| 81 | + | |
| 82 | + sitemaps = [cfg["sitemap_url"]] if cfg.get("sitemap_url") else [] | |
| 83 | + if not sitemaps and cfg.get("base_url"): | |
| 84 | + sitemaps = [cfg["base_url"].rstrip("/") + "/sitemap.xml"] | |
| 85 | + depth = 0 | |
| 86 | + max_pages = int(cfg.get("max_pages", 400)) | |
| 87 | + while sitemaps and depth < 3 and len(urls) < max_pages: | |
| 88 | + nxt: list[str] = [] | |
| 89 | + for sm in sitemaps[:30]: | |
| 90 | + if len(urls) >= max_pages: | |
| 91 | + break | |
| 92 | + try: | |
| 93 | + body = self.get(sm).text | |
| 94 | + except Exception: | |
| 95 | + continue | |
| 96 | + for loc in _LOC_RE.findall(body): | |
| 97 | + if loc.endswith(".xml"): | |
| 98 | + nxt.append(loc) | |
| 99 | + else: | |
| 100 | + add(loc) | |
| 101 | + sitemaps = nxt | |
| 102 | + depth += 1 | |
| 103 | + for lp in cfg.get("list_urls", []): | |
| 104 | + try: | |
| 105 | + body = self.get(lp).text | |
| 106 | + except Exception: | |
| 107 | + continue | |
| 108 | + base = cfg.get("base_url", "").rstrip("/") | |
| 109 | + for href in re.findall(r'href=["\']([^"\']+)["\']', body): | |
| 110 | + u = href if href.startswith("http") else base + href | |
| 111 | + add(u.split("#")[0]) | |
| 112 | + return urls[:max_pages] | |
| 113 | + | |
| 114 | + # -- fetch -------------------------------------------------------------- | |
| 115 | + def fetch(self) -> list[Listing]: | |
| 116 | + static = self.config.get("static") or {} | |
| 117 | + ext_re = re.compile(self.config["external_id_regex"]) \ | |
| 118 | + if self.config.get("external_id_regex") else None | |
| 119 | + # weekly cache bucket: each detail page is re-fetched at most once a | |
| 120 | + # week (price/status refresh) and never re-fetched inside a cycle | |
| 121 | + week = int(time.time() // (7 * 86400)) | |
| 122 | + out: list[Listing] = [] | |
| 123 | + for url in self._listing_urls(): | |
| 124 | + ext = "" | |
| 125 | + if ext_re: | |
| 126 | + m = ext_re.search(url) | |
| 127 | + if m: | |
| 128 | + ext = m.group(1) | |
| 129 | + if not ext: | |
| 130 | + ext = hashlib.sha1(url.encode()).hexdigest()[:16] | |
| 131 | + | |
| 132 | + def _fetch(url=url): | |
| 133 | + try: | |
| 134 | + return {"ld": list(iter_ld(self.get(url).text))} | |
| 135 | + except Exception: | |
| 136 | + return {} | |
| 137 | + | |
| 138 | + payload = self.detail(ext, f"w{week}:{url}", _fetch) | |
| 139 | + lst = self._from_nodes(payload.get("ld") or [], url, ext, static) | |
| 140 | + if lst is not None: | |
| 141 | + out.append(lst) | |
| 142 | + return out | |
| 143 | + | |
| 144 | + # -- mapping -------------------------------------------------------------- | |
| 145 | + def _from_nodes(self, nodes: list[dict], url: str, ext: str, | |
| 146 | + static: dict) -> Listing | None: | |
| 147 | + """Merge every listing-typed JSON-LD node of the page (REW splits the | |
| 148 | + data across Product + SingleFamilyResidence).""" | |
| 149 | + picked = [n for n in nodes | |
| 150 | + if str(n.get("@type") or "").lower() in _LISTING_TYPES] | |
| 151 | + if not picked: | |
| 152 | + return None | |
| 153 | + merged: dict = {} | |
| 154 | + addr: dict = {} | |
| 155 | + geo: dict = {} | |
| 156 | + offer: dict = {} | |
| 157 | + subtype = "" | |
| 158 | + for n in picked: | |
| 159 | + t = str(n.get("@type") or "").lower() | |
| 160 | + if t not in ("product",) and not subtype: | |
| 161 | + subtype = str(n.get("@type") or "") | |
| 162 | + for k, v in n.items(): | |
| 163 | + if v in (None, "", [], {}): | |
| 164 | + continue | |
| 165 | + if k == "address" and isinstance(v, dict): | |
| 166 | + addr = {**v, **addr} | |
| 167 | + elif k == "geo" and isinstance(v, dict): | |
| 168 | + geo = {**v, **geo} | |
| 169 | + elif k == "offers": | |
| 170 | + o = v[0] if isinstance(v, list) and v else v | |
| 171 | + if isinstance(o, dict): | |
| 172 | + offer = {**o, **offer} | |
| 173 | + else: | |
| 174 | + merged.setdefault(k, v) | |
| 175 | + if isinstance(merged.get("address"), str): | |
| 176 | + addr.setdefault("streetAddress", merged["address"]) | |
| 177 | + | |
| 178 | + imgs = merged.get("image") or merged.get("photo") or [] | |
| 179 | + if isinstance(imgs, str): | |
| 180 | + imgs = [imgs] | |
| 181 | + imgs = [i.get("url") if isinstance(i, dict) else i for i in imgs] | |
| 182 | + | |
| 183 | + price = parse_price(_qv(offer.get("price"))) | |
| 184 | + availability = str(offer.get("availability") or "") | |
| 185 | + status = "active" | |
| 186 | + if "soldout" in availability.lower().replace("_", ""): | |
| 187 | + status = "sold" | |
| 188 | + | |
| 189 | + street = str(addr.get("streetAddress") or "") | |
| 190 | + city = str(addr.get("addressLocality") or "") | |
| 191 | + state = str(addr.get("addressRegion") or "") | |
| 192 | + zip_code = str(addr.get("postalCode") or "") | |
| 193 | + if not street: # slug fallback: .../123-main-street-austin-tx-78701/ | |
| 194 | + m = _SLUG_ADDR_RE.search(url) | |
| 195 | + if m: | |
| 196 | + state = state or m.group(2).upper() | |
| 197 | + zip_code = zip_code or m.group(3) | |
| 198 | + | |
| 199 | + beds = _qv(merged.get("numberOfBedrooms") or merged.get("numberOfRooms")) | |
| 200 | + baths = _qv(merged.get("numberOfBathroomsTotal") | |
| 201 | + or merged.get("numberOfFullBathrooms")) | |
| 202 | + floor = _qv(merged.get("floorSize")) | |
| 203 | + | |
| 204 | + kw = dict( | |
| 205 | + source=self.source_id, external_id=ext, url=url, | |
| 206 | + title=str(merged.get("name") or ""), | |
| 207 | + street_address=street, city=city, state=state, zip_code=zip_code, | |
| 208 | + list_price=price, status=status, | |
| 209 | + mls_id=ext if not ext.startswith(("http", "sha")) and len(ext) <= 20 else "", | |
| 210 | + property_type=subtype, | |
| 211 | + property_subtype=subtype, | |
| 212 | + bedrooms=beds, bathrooms=baths, | |
| 213 | + living_area_sqft=floor, | |
| 214 | + year_built=_qv(merged.get("yearBuilt")), | |
| 215 | + description=str(merged.get("description") or ""), | |
| 216 | + images=[i for i in imgs if isinstance(i, str)], | |
| 217 | + lat=_qv(geo.get("latitude")), lng=_qv(geo.get("longitude")), | |
| 218 | + details={"jsonld_types": [str(n.get("@type")) for n in picked], | |
| 219 | + "availability": availability}, | |
| 220 | + ) | |
| 221 | + for k, v in static.items(): | |
| 222 | + if not kw.get(k): | |
| 223 | + kw[k] = v | |
| 224 | + if not kw["street_address"] and not kw["title"]: | |
| 225 | + return None | |
| 226 | + return Listing(**kw) | |
added
homeka/connectors/public_data/__init__.py
+0 −0
added
homeka/connectors/public_data/arcgis.py
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/public_data/arcgis.py : county assessor / GIS parcel connector. | |
| 5 | +# | |
| 6 | +# US counties publish parcel/assessor layers on ArcGIS FeatureServers (open | |
| 7 | +# data portals) — the lowest-level public source there is. This connector | |
| 8 | +# feeds the PROPERTIES table (physical assets: APN, address, year built, | |
| 9 | +# areas, assessed values), NOT the listings table: it enriches property | |
| 10 | +# matching and keeps properties alive after listings go off-market. | |
| 11 | +# | |
| 12 | +# {"id": "county_travis_tx", "connector_type": "arcgis", "config": { | |
| 13 | +# "layer_url": "https://services.arcgis.com/.../FeatureServer/0", | |
| 14 | +# "where": "1=1", "out_fields": "*", "page_size": 2000, | |
| 15 | +# "max_records": 200000, | |
| 16 | +# "mapping": {"apn": "PROP_ID", "street_address": "SITUS_ADDR", | |
| 17 | +# "city": "SITUS_CITY", "zip_code": "SITUS_ZIP", | |
| 18 | +# "year_built": "YEAR_BUILT", "living_area_sqft": "LIVING_AREA", | |
| 19 | +# "assessed_value": "ASSESSED_VAL"}, | |
| 20 | +# "static": {"state": "TX", "county": "Travis"}}} | |
| 21 | +# ----------------------------------------------------------------------------- | |
| 22 | +from __future__ import annotations | |
| 23 | + | |
| 24 | +import json | |
| 25 | +import time | |
| 26 | + | |
| 27 | +from ..base import BaseConnector | |
| 28 | +from .._maputil import dig | |
| 29 | +from ...schema import Listing | |
| 30 | + | |
| 31 | +# properties-table columns a mapping can target; everything else goes to details | |
| 32 | +_PROP_FIELDS = {"apn", "street_address", "unit", "city", "state", "zip_code", | |
| 33 | + "county", "property_type", "year_built", "living_area_sqft", | |
| 34 | + "lot_size_sqft", "lat", "lng"} | |
| 35 | + | |
| 36 | + | |
| 37 | +class ArcGISParcelConnector(BaseConnector): | |
| 38 | + family = "arcgis" | |
| 39 | + request_delay = 0.4 | |
| 40 | + is_public_records = True # ingest.py routes fetch_records() → properties | |
| 41 | + | |
| 42 | + def fetch(self) -> list[Listing]: | |
| 43 | + return [] # public records never create listings | |
| 44 | + | |
| 45 | + def fetch_records(self) -> list[dict]: | |
| 46 | + """Yield property records {column: value, 'details': {...}}.""" | |
| 47 | + cfg = self.config | |
| 48 | + layer = (cfg.get("layer_url") or "").rstrip("/") | |
| 49 | + if not layer: | |
| 50 | + raise RuntimeError(f"{self.source_id}: layer_url missing") | |
| 51 | + mapping = cfg.get("mapping") or {} | |
| 52 | + static = cfg.get("static") or {} | |
| 53 | + page = int(cfg.get("page_size", 2000)) | |
| 54 | + max_records = int(cfg.get("max_records", 200_000)) | |
| 55 | + offset = 0 | |
| 56 | + out: list[dict] = [] | |
| 57 | + while offset < max_records: | |
| 58 | + params = { | |
| 59 | + "f": "json", "where": cfg.get("where", "1=1"), | |
| 60 | + "outFields": cfg.get("out_fields", "*"), | |
| 61 | + "resultOffset": offset, "resultRecordCount": page, | |
| 62 | + "returnGeometry": "true", "outSR": 4326, | |
| 63 | + } | |
| 64 | + data = self.get(f"{layer}/query", params=params).json() | |
| 65 | + feats = data.get("features") or [] | |
| 66 | + if not feats: | |
| 67 | + break | |
| 68 | + for f in feats: | |
| 69 | + attrs = f.get("attributes") or {} | |
| 70 | + rec: dict = {"details": {}} | |
| 71 | + for field, path in mapping.items(): | |
| 72 | + v = dig(attrs, path) | |
| 73 | + if v in (None, ""): | |
| 74 | + continue | |
| 75 | + if field in _PROP_FIELDS: | |
| 76 | + rec[field] = v | |
| 77 | + else: | |
| 78 | + rec["details"][field] = v | |
| 79 | + for field, v in static.items(): | |
| 80 | + rec.setdefault(field, v) | |
| 81 | + geom = f.get("geometry") or {} | |
| 82 | + if "lat" not in rec and geom.get("y") is not None: | |
| 83 | + rec["lat"], rec["lng"] = geom.get("y"), geom.get("x") | |
| 84 | + if rec.get("apn") or rec.get("street_address"): | |
| 85 | + out.append(rec) | |
| 86 | + if not data.get("exceededTransferLimit") and len(feats) < page: | |
| 87 | + break | |
| 88 | + offset += len(feats) | |
| 89 | + return out | |
| 90 | + | |
| 91 | + | |
| 92 | +def sync_records(con, source_id: str, records: list[dict]) -> dict: | |
| 93 | + """Upsert public-records rows into `properties` (match APN, then address).""" | |
| 94 | + from ... import propertymatch | |
| 95 | + now = time.time() | |
| 96 | + added = updated = 0 | |
| 97 | + for rec in records: | |
| 98 | + state = str(rec.get("state") or "") | |
| 99 | + apn = str(rec.get("apn") or "").replace(" ", "") | |
| 100 | + row = None | |
| 101 | + if apn and state: | |
| 102 | + row = con.execute("SELECT id, details FROM properties" | |
| 103 | + " WHERE apn=? AND state=?", (apn, state)).fetchone() | |
| 104 | + key = propertymatch.addr_key(str(rec.get("street_address") or ""), | |
| 105 | + str(rec.get("unit") or ""), | |
| 106 | + str(rec.get("city") or ""), state, | |
| 107 | + str(rec.get("zip_code") or "")) | |
| 108 | + if row is None and key: | |
| 109 | + row = con.execute("SELECT id, details FROM properties" | |
| 110 | + " WHERE addr_key=?", (key,)).fetchone() | |
| 111 | + details = rec.get("details") or {} | |
| 112 | + if row is None: | |
| 113 | + if not key and not apn: | |
| 114 | + continue | |
| 115 | + con.execute( | |
| 116 | + """INSERT OR IGNORE INTO properties (addr_key, apn, | |
| 117 | + street_address, unit, city, state, zip_code, county, | |
| 118 | + property_type, year_built, living_area_sqft, lot_size_sqft, | |
| 119 | + lat, lng, details, first_seen, last_seen) | |
| 120 | + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", | |
| 121 | + (key, apn or None, rec.get("street_address"), rec.get("unit"), | |
| 122 | + rec.get("city"), state, rec.get("zip_code"), rec.get("county"), | |
| 123 | + rec.get("property_type"), rec.get("year_built"), | |
| 124 | + rec.get("living_area_sqft"), rec.get("lot_size_sqft"), | |
| 125 | + rec.get("lat"), rec.get("lng"), | |
| 126 | + json.dumps(details, ensure_ascii=False, default=str), now, now)) | |
| 127 | + added += 1 | |
| 128 | + else: | |
| 129 | + old = json.loads(row["details"] or "{}") | |
| 130 | + old.update(details) | |
| 131 | + con.execute( | |
| 132 | + """UPDATE properties SET | |
| 133 | + apn=COALESCE(NULLIF(apn,''), ?), | |
| 134 | + year_built=COALESCE(year_built, ?), | |
| 135 | + living_area_sqft=COALESCE(living_area_sqft, ?), | |
| 136 | + lot_size_sqft=COALESCE(lot_size_sqft, ?), | |
| 137 | + lat=COALESCE(lat, ?), lng=COALESCE(lng, ?), | |
| 138 | + details=?, last_seen=? WHERE id=?""", | |
| 139 | + (apn or None, rec.get("year_built"), | |
| 140 | + rec.get("living_area_sqft"), rec.get("lot_size_sqft"), | |
| 141 | + rec.get("lat"), rec.get("lng"), | |
| 142 | + json.dumps(old, ensure_ascii=False, default=str), now, | |
| 143 | + row["id"])) | |
| 144 | + updated += 1 | |
| 145 | + con.commit() | |
| 146 | + return {"source": source_id, "found": len(records), | |
| 147 | + "added": added, "updated": updated, "removed": 0, | |
| 148 | + "kind": "public-records"} | |
added
homeka/connectors/reso/__init__.py
+0 −0
added
homeka/connectors/reso/webapi.py
+158 −0
@@ -0,0 +1,158 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/reso/webapi.py : RESO Web API (OData) connector — the PREFERRED | |
| 5 | +# feed type. Works with MLS Grid, Trestle (CoreLogic), Bridge Interactive, | |
| 6 | +# Spark/FBS, RMLS direct RESO endpoints, and any Data Dictionary compliant | |
| 7 | +# server. One `sources` row per feed: | |
| 8 | +# | |
| 9 | +# {"id": "mlsgrid_actris", "connector_type": "reso", "config": { | |
| 10 | +# "base_url": "https://api.mlsgrid.com/v2", | |
| 11 | +# "token": "$ENV:MLSGRID_TOKEN", # or OAuth2 below | |
| 12 | +# "token_url": "", "client_id": "", "client_secret": "", | |
| 13 | +# "resource": "Property", | |
| 14 | +# "filter": "StandardStatus eq 'Active' and PropertyType eq 'Residential'", | |
| 15 | +# "expand": "Media", | |
| 16 | +# "page_size": 1000, "max_records": 50000, | |
| 17 | +# "originating_system": "ACTRIS"}} | |
| 18 | +# | |
| 19 | +# Field mapping is RESO Data Dictionary — schema.Listing understands the | |
| 20 | +# standard names natively (details passthrough), with the explicit mapping | |
| 21 | +# below for the core columns. | |
| 22 | +# ----------------------------------------------------------------------------- | |
| 23 | +from __future__ import annotations | |
| 24 | + | |
| 25 | +import time | |
| 26 | + | |
| 27 | +import requests | |
| 28 | + | |
| 29 | +from ..base import BaseConnector | |
| 30 | +from ...schema import Listing | |
| 31 | + | |
| 32 | + | |
| 33 | +class RESOWebAPIConnector(BaseConnector): | |
| 34 | + family = "reso" | |
| 35 | + request_delay = 0.3 | |
| 36 | + | |
| 37 | + # -- auth ------------------------------------------------------------- | |
| 38 | + def _token(self) -> str: | |
| 39 | + cfg = self.config | |
| 40 | + if cfg.get("token"): | |
| 41 | + return cfg["token"] | |
| 42 | + if cfg.get("token_url"): | |
| 43 | + resp = requests.post(cfg["token_url"], data={ | |
| 44 | + "grant_type": "client_credentials", | |
| 45 | + "client_id": cfg.get("client_id", ""), | |
| 46 | + "client_secret": cfg.get("client_secret", ""), | |
| 47 | + "scope": cfg.get("scope", "api"), | |
| 48 | + }, timeout=30) | |
| 49 | + resp.raise_for_status() | |
| 50 | + return resp.json()["access_token"] | |
| 51 | + raise RuntimeError(f"{self.source_id}: no RESO token/credentials configured") | |
| 52 | + | |
| 53 | + # -- fetch ------------------------------------------------------------ | |
| 54 | + def fetch(self) -> list[Listing]: | |
| 55 | + cfg = self.config | |
| 56 | + base = (cfg.get("base_url") or "").rstrip("/") | |
| 57 | + if not base: | |
| 58 | + raise RuntimeError(f"{self.source_id}: base_url missing") | |
| 59 | + resource = cfg.get("resource", "Property") | |
| 60 | + page_size = int(cfg.get("page_size", 1000)) | |
| 61 | + max_records = int(cfg.get("max_records", 100_000)) | |
| 62 | + params = {"$top": page_size} | |
| 63 | + if cfg.get("filter"): | |
| 64 | + params["$filter"] = cfg["filter"] | |
| 65 | + if cfg.get("select"): | |
| 66 | + params["$select"] = cfg["select"] | |
| 67 | + if cfg.get("expand"): | |
| 68 | + params["$expand"] = cfg["expand"] | |
| 69 | + self.session.headers["Authorization"] = f"Bearer {self._token()}" | |
| 70 | + self.session.headers["Accept"] = "application/json" | |
| 71 | + | |
| 72 | + out: list[Listing] = [] | |
| 73 | + url: str | None = f"{base}/{resource}" | |
| 74 | + first = True | |
| 75 | + while url and len(out) < max_records: | |
| 76 | + resp = self.get(url, params=params if first else None) | |
| 77 | + first = False | |
| 78 | + data = resp.json() | |
| 79 | + for rec in data.get("value", []): | |
| 80 | + lst = self._to_listing(rec) | |
| 81 | + if lst is not None: | |
| 82 | + out.append(lst) | |
| 83 | + url = data.get("@odata.nextLink") | |
| 84 | + return out | |
| 85 | + | |
| 86 | + def _to_listing(self, rec: dict) -> Listing | None: | |
| 87 | + key = rec.get("ListingKey") or rec.get("ListingId") | |
| 88 | + if not key: | |
| 89 | + return None | |
| 90 | + media = rec.get("Media") or [] | |
| 91 | + images = [m.get("MediaURL") for m in media | |
| 92 | + if isinstance(m, dict) and m.get("MediaURL") | |
| 93 | + and (m.get("MediaCategory") in (None, "Photo"))] | |
| 94 | + images.sort(key=lambda u: u or "") | |
| 95 | + if media and all(isinstance(m, dict) for m in media): | |
| 96 | + ordered = sorted(media, key=lambda m: m.get("Order") or 0) | |
| 97 | + images = [m.get("MediaURL") for m in ordered if m.get("MediaURL")] | |
| 98 | + details = {k: v for k, v in rec.items() | |
| 99 | + if isinstance(v, (str, int, float, bool))} | |
| 100 | + street = rec.get("UnparsedAddress") or " ".join( | |
| 101 | + str(rec.get(k) or "") for k in | |
| 102 | + ("StreetNumber", "StreetDirPrefix", "StreetName", "StreetSuffix") | |
| 103 | + ).strip() | |
| 104 | + return Listing( | |
| 105 | + source=self.source_id, | |
| 106 | + external_id=str(key), | |
| 107 | + url=self.config.get("listing_url_template", "").format(**rec) | |
| 108 | + if self.config.get("listing_url_template") else "", | |
| 109 | + street_address=street, | |
| 110 | + unit=str(rec.get("UnitNumber") or ""), | |
| 111 | + city=str(rec.get("City") or ""), | |
| 112 | + state=str(rec.get("StateOrProvince") or ""), | |
| 113 | + zip_code=str(rec.get("PostalCode") or ""), | |
| 114 | + county=str(rec.get("CountyOrParish") or ""), | |
| 115 | + property_type=str(rec.get("PropertySubType") | |
| 116 | + or rec.get("PropertyType") or ""), | |
| 117 | + property_subtype=str(rec.get("PropertySubType") or ""), | |
| 118 | + list_price=rec.get("ListPrice"), | |
| 119 | + bedrooms=rec.get("BedroomsTotal"), | |
| 120 | + bathrooms_full=rec.get("BathroomsFull"), | |
| 121 | + bathrooms_half=rec.get("BathroomsHalf"), | |
| 122 | + bathrooms=rec.get("BathroomsTotalInteger"), | |
| 123 | + living_area_sqft=rec.get("LivingArea"), | |
| 124 | + lot_size_sqft=rec.get("LotSizeSquareFeet"), | |
| 125 | + year_built=rec.get("YearBuilt"), | |
| 126 | + apn=str(rec.get("ParcelNumber") or ""), | |
| 127 | + mls_id=str(rec.get("ListingId") or ""), | |
| 128 | + mls_name=str(rec.get("OriginatingSystemName") | |
| 129 | + or self.config.get("originating_system") or ""), | |
| 130 | + status=str(rec.get("StandardStatus") or "Active"), | |
| 131 | + listed_at=str(rec.get("ListingContractDate") or ""), | |
| 132 | + brokerage_name=str(rec.get("ListOfficeName") or ""), | |
| 133 | + office_name=str(rec.get("ListOfficeName") or ""), | |
| 134 | + agent_name=str(rec.get("ListAgentFullName") or ""), | |
| 135 | + agent_phone=str(rec.get("ListAgentPreferredPhone") | |
| 136 | + or rec.get("ListAgentDirectPhone") or ""), | |
| 137 | + description=str(rec.get("PublicRemarks") or ""), | |
| 138 | + details=details, | |
| 139 | + images=images, | |
| 140 | + lat=rec.get("Latitude"), | |
| 141 | + lng=rec.get("Longitude"), | |
| 142 | + ) | |
| 143 | + | |
| 144 | + | |
| 145 | +def probe(base_url: str, token: str = "") -> dict: | |
| 146 | + """Quick health probe of a RESO endpoint ($metadata reachability) — used | |
| 147 | + by the admin and the brokerage discovery pipeline.""" | |
| 148 | + headers = {"Accept": "application/json"} | |
| 149 | + if token: | |
| 150 | + headers["Authorization"] = f"Bearer {token}" | |
| 151 | + t0 = time.time() | |
| 152 | + try: | |
| 153 | + resp = requests.get(base_url.rstrip("/") + "/$metadata", | |
| 154 | + headers=headers, timeout=20) | |
| 155 | + return {"ok": resp.status_code < 500, "status": resp.status_code, | |
| 156 | + "seconds": round(time.time() - t0, 2)} | |
| 157 | + except requests.RequestException as exc: | |
| 158 | + return {"ok": False, "error": str(exc)} | |
added
homeka/connectors/rets/__init__.py
+0 −0
added
homeka/connectors/rets/client.py
+128 −0
@@ -0,0 +1,128 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/rets/client.py : legacy RETS connector (login + DMQL2 search, | |
| 5 | +# COMPACT-DECODED). Many small MLSs still hand out RETS credentials faster | |
| 6 | +# than RESO Web API access — this keeps those feeds first-class. | |
| 7 | +# | |
| 8 | +# {"id": "rets_example", "connector_type": "rets", "config": { | |
| 9 | +# "login_url": "https://rets.example-mls.com/rets/login.ashx", | |
| 10 | +# "username": "$ENV:RETS_EXAMPLE_USER", "password": "$ENV:RETS_EXAMPLE_PASS", | |
| 11 | +# "search_url": "", # discovered from login when empty | |
| 12 | +# "resource": "Property", "class": "Residential", | |
| 13 | +# "query": "(Status=|A)", "select": "", | |
| 14 | +# "limit": 5000, "mapping": { ... Listing field -> RETS system name ... }, | |
| 15 | +# "user_agent": "HomeKa/1.0"}} | |
| 16 | +# ----------------------------------------------------------------------------- | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import re | |
| 20 | +import xml.etree.ElementTree as ET | |
| 21 | + | |
| 22 | +import requests | |
| 23 | +from requests.auth import HTTPBasicAuth, HTTPDigestAuth | |
| 24 | + | |
| 25 | +from ..base import BaseConnector | |
| 26 | +from .._maputil import apply_mapping | |
| 27 | +from ...schema import Listing | |
| 28 | + | |
| 29 | +_CAP_RE = re.compile(r"<RETS-RESPONSE>(.*?)</RETS-RESPONSE>", re.S | re.I) | |
| 30 | + | |
| 31 | + | |
| 32 | +class RETSConnector(BaseConnector): | |
| 33 | + family = "rets" | |
| 34 | + request_delay = 1.0 | |
| 35 | + | |
| 36 | + def _login(self) -> str: | |
| 37 | + """RETS Login transaction — returns the Search capability URL.""" | |
| 38 | + cfg = self.config | |
| 39 | + if cfg.get("user_agent"): | |
| 40 | + self.session.headers["User-Agent"] = cfg["user_agent"] | |
| 41 | + self.session.headers["RETS-Version"] = cfg.get("rets_version", "RETS/1.7.2") | |
| 42 | + auth_cls = HTTPDigestAuth if cfg.get("auth", "digest") == "digest" else HTTPBasicAuth | |
| 43 | + self.session.auth = auth_cls(cfg.get("username", ""), cfg.get("password", "")) | |
| 44 | + resp = self.get(cfg["login_url"]) | |
| 45 | + if cfg.get("search_url"): | |
| 46 | + return cfg["search_url"] | |
| 47 | + m = _CAP_RE.search(resp.text) | |
| 48 | + caps = {} | |
| 49 | + if m: | |
| 50 | + for line in m.group(1).splitlines(): | |
| 51 | + if "=" in line: | |
| 52 | + k, _, v = line.partition("=") | |
| 53 | + caps[k.strip().lower()] = v.strip() | |
| 54 | + search = caps.get("search") | |
| 55 | + if not search: | |
| 56 | + raise RuntimeError(f"{self.source_id}: no Search capability in RETS login") | |
| 57 | + if search.startswith("/"): | |
| 58 | + from urllib.parse import urlsplit | |
| 59 | + u = urlsplit(cfg["login_url"]) | |
| 60 | + search = f"{u.scheme}://{u.netloc}{search}" | |
| 61 | + return search | |
| 62 | + | |
| 63 | + def fetch(self) -> list[Listing]: | |
| 64 | + cfg = self.config | |
| 65 | + search_url = self._login() | |
| 66 | + params = { | |
| 67 | + "SearchType": cfg.get("resource", "Property"), | |
| 68 | + "Class": cfg.get("class", "Residential"), | |
| 69 | + "Query": cfg.get("query", "(Status=|A)"), | |
| 70 | + "QueryType": "DMQL2", | |
| 71 | + "Format": "COMPACT-DECODED", | |
| 72 | + "Limit": str(cfg.get("limit", 5000)), | |
| 73 | + "StandardNames": cfg.get("standard_names", "0"), | |
| 74 | + } | |
| 75 | + if cfg.get("select"): | |
| 76 | + params["Select"] = cfg["select"] | |
| 77 | + resp = self.get(search_url, params=params) | |
| 78 | + return self._parse_compact(resp.text) | |
| 79 | + | |
| 80 | + def _parse_compact(self, body: str) -> list[Listing]: | |
| 81 | + """Parse a COMPACT-DECODED RETS payload into Listings via the mapping.""" | |
| 82 | + try: | |
| 83 | + root = ET.fromstring(body) | |
| 84 | + except ET.ParseError as exc: | |
| 85 | + raise RuntimeError(f"{self.source_id}: invalid RETS XML: {exc}") | |
| 86 | + delim = "\t" | |
| 87 | + d = root.find("DELIMITER") | |
| 88 | + if d is not None and d.get("value"): | |
| 89 | + delim = chr(int(d.get("value"))) | |
| 90 | + cols_el = root.find("COLUMNS") | |
| 91 | + if cols_el is None or not cols_el.text: | |
| 92 | + return [] | |
| 93 | + cols = cols_el.text.strip(delim).split(delim) | |
| 94 | + mapping = self.config.get("mapping") or _DEFAULT_RETS_MAPPING | |
| 95 | + static = self.config.get("static") or {} | |
| 96 | + out: list[Listing] = [] | |
| 97 | + for row in root.findall("DATA"): | |
| 98 | + vals = (row.text or "").strip("\n").strip(delim).split(delim) | |
| 99 | + rec = {c: v for c, v in zip(cols, vals)} | |
| 100 | + lst = apply_mapping(self.source_id, rec, mapping, static) | |
| 101 | + if lst is not None: | |
| 102 | + out.append(lst) | |
| 103 | + return out | |
| 104 | + | |
| 105 | + | |
| 106 | +# RETS StandardNames (StandardNames=1) default mapping; system-name feeds | |
| 107 | +# override it entirely in the source config. | |
| 108 | +_DEFAULT_RETS_MAPPING = { | |
| 109 | + "external_id": "ListingID", | |
| 110 | + "mls_id": "ListingID", | |
| 111 | + "list_price": "ListPrice", | |
| 112 | + "street_address": "StreetAddress", | |
| 113 | + "city": "City", | |
| 114 | + "state": "StateOrProvince", | |
| 115 | + "zip_code": "PostalCode", | |
| 116 | + "county": "County", | |
| 117 | + "property_type": "PropertyType", | |
| 118 | + "bedrooms": "Beds", | |
| 119 | + "bathrooms": "Baths", | |
| 120 | + "living_area_sqft": "LivingArea", | |
| 121 | + "year_built": "YearBuilt", | |
| 122 | + "status": "Status", | |
| 123 | + "agent_name": "ListAgentName", | |
| 124 | + "brokerage_name": "ListOfficeName", | |
| 125 | + "description": "PublicRemarks", | |
| 126 | + "lat": "Latitude", | |
| 127 | + "lng": "Longitude", | |
| 128 | +} | |
added
homeka/connectors/sftp/__init__.py
+0 −0
added
homeka/connectors/sftp/drop.py
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/sftp/drop.py : SFTP drop connector — brokerages/MLS vendors that | |
| 5 | +# push periodic CSV/XML exports to an SFTP folder. Downloads the newest | |
| 6 | +# matching file then dispatches to the csv/xml parser. | |
| 7 | +# | |
| 8 | +# {"id": "acme_sftp", "connector_type": "sftp", "config": { | |
| 9 | +# "host": "sftp.acmefeeds.com", "port": 22, | |
| 10 | +# "username": "$ENV:ACME_SFTP_USER", "password": "$ENV:ACME_SFTP_PASS", | |
| 11 | +# "key_path": "", # or private key file | |
| 12 | +# "remote_dir": "/exports", "pattern": "listings_*.csv", | |
| 13 | +# "format": "csv", # csv | xml | |
| 14 | +# ... then the csv/xml family options (mapping, static, ...)}} | |
| 15 | +# | |
| 16 | +# Requires `paramiko` (in requirements.txt). | |
| 17 | +# ----------------------------------------------------------------------------- | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import fnmatch | |
| 21 | +import io | |
| 22 | + | |
| 23 | +from ..base import BaseConnector | |
| 24 | +from ...schema import Listing | |
| 25 | + | |
| 26 | + | |
| 27 | +class SFTPDropConnector(BaseConnector): | |
| 28 | + family = "sftp" | |
| 29 | + | |
| 30 | + def _download(self) -> bytes: | |
| 31 | + try: | |
| 32 | + import paramiko | |
| 33 | + except ImportError: | |
| 34 | + raise RuntimeError("paramiko missing — pip install paramiko") | |
| 35 | + cfg = self.config | |
| 36 | + client = paramiko.SSHClient() | |
| 37 | + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) | |
| 38 | + kw: dict = {"username": cfg.get("username", "")} | |
| 39 | + if cfg.get("key_path"): | |
| 40 | + kw["key_filename"] = cfg["key_path"] | |
| 41 | + else: | |
| 42 | + kw["password"] = cfg.get("password", "") | |
| 43 | + client.connect(cfg["host"], port=int(cfg.get("port", 22)), | |
| 44 | + timeout=30, **kw) | |
| 45 | + try: | |
| 46 | + sftp = client.open_sftp() | |
| 47 | + remote_dir = cfg.get("remote_dir", ".") | |
| 48 | + pattern = cfg.get("pattern", "*") | |
| 49 | + names = [n for n in sftp.listdir(remote_dir) | |
| 50 | + if fnmatch.fnmatch(n, pattern)] | |
| 51 | + if not names: | |
| 52 | + raise RuntimeError(f"{self.source_id}: no file matching " | |
| 53 | + f"{pattern} in {remote_dir}") | |
| 54 | + # newest file by mtime | |
| 55 | + newest = max(names, key=lambda n: sftp.stat(f"{remote_dir}/{n}").st_mtime) | |
| 56 | + buf = io.BytesIO() | |
| 57 | + sftp.getfo(f"{remote_dir}/{newest}", buf) | |
| 58 | + return buf.getvalue() | |
| 59 | + finally: | |
| 60 | + client.close() | |
| 61 | + | |
| 62 | + def fetch(self) -> list[Listing]: | |
| 63 | + data = self._download() | |
| 64 | + fmt = self.config.get("format", "csv") | |
| 65 | + if fmt == "csv": | |
| 66 | + from ..csv.feed import parse_csv | |
| 67 | + return parse_csv(self.source_id, | |
| 68 | + data.decode(self.config.get("encoding", "utf-8-sig"), | |
| 69 | + errors="replace"), | |
| 70 | + self.config) | |
| 71 | + if fmt == "xml": | |
| 72 | + from ..xml.feed import XMLFeedConnector | |
| 73 | + conn = XMLFeedConnector(self.source_id, | |
| 74 | + {**self.config, "path": None}) | |
| 75 | + import xml.etree.ElementTree as ET | |
| 76 | + root = ET.fromstring(data) | |
| 77 | + item_tag = self.config.get("item_tag", "listing") | |
| 78 | + mapping = self.config.get("mapping") or {} | |
| 79 | + static = self.config.get("static") or {} | |
| 80 | + out = [] | |
| 81 | + for el in root.iter(): | |
| 82 | + if el.tag.rsplit("}", 1)[-1] != item_tag: | |
| 83 | + continue | |
| 84 | + lst = conn._to_listing(el, mapping, static) | |
| 85 | + if lst is not None: | |
| 86 | + out.append(lst) | |
| 87 | + return out | |
| 88 | + raise RuntimeError(f"{self.source_id}: unknown sftp format {fmt!r}") | |
added
homeka/connectors/xml/__init__.py
+0 −0
added
homeka/connectors/xml/feed.py
+74 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Home-Ka — US real-estate aggregator (Groupe KA) | |
| 3 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/xml/feed.py : generic XML feed connector (brokerage exports, | |
| 5 | +# franchise syndication feeds, legacy XML drops). Config-driven: | |
| 6 | +# | |
| 7 | +# {"id": "acme_xml", "connector_type": "xml", "config": { | |
| 8 | +# "url": "https://feeds.acmerealty.com/listings.xml", | |
| 9 | +# "item_tag": "listing", # repeated element | |
| 10 | +# "mapping": {"external_id": "mlsNumber", "list_price": "price", | |
| 11 | +# "street_address": "address/street", "city": "address/city", | |
| 12 | +# "state": "address/state", "zip_code": "address/zip", | |
| 13 | +# "images": "photos/photo", ...}, # ElementTree paths | |
| 14 | +# "static": {"state": "TX"}, | |
| 15 | +# "headers": {}, "auth_user": "", "auth_pass": ""}} | |
| 16 | +# ----------------------------------------------------------------------------- | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import xml.etree.ElementTree as ET | |
| 20 | + | |
| 21 | +from ..base import BaseConnector | |
| 22 | +from ...schema import Listing | |
| 23 | + | |
| 24 | +_LIST_FIELDS = {"images", "features"} | |
| 25 | + | |
| 26 | + | |
| 27 | +def _strip_ns(tag: str) -> str: | |
| 28 | + return tag.rsplit("}", 1)[-1] | |
| 29 | + | |
| 30 | + | |
| 31 | +class XMLFeedConnector(BaseConnector): | |
| 32 | + family = "xml" | |
| 33 | + request_delay = 0.2 | |
| 34 | + | |
| 35 | + def fetch(self) -> list[Listing]: | |
| 36 | + cfg = self.config | |
| 37 | + kw = {} | |
| 38 | + if cfg.get("headers"): | |
| 39 | + kw["headers"] = cfg["headers"] | |
| 40 | + if cfg.get("auth_user"): | |
| 41 | + kw["auth"] = (cfg["auth_user"], cfg.get("auth_pass", "")) | |
| 42 | + if cfg.get("path"): | |
| 43 | + body = open(cfg["path"], "rb").read() | |
| 44 | + else: | |
| 45 | + body = self.get(cfg["url"], **kw).content | |
| 46 | + root = ET.fromstring(body) | |
| 47 | + item_tag = cfg.get("item_tag", "listing") | |
| 48 | + mapping = cfg.get("mapping") or {} | |
| 49 | + static = cfg.get("static") or {} | |
| 50 | + out: list[Listing] = [] | |
| 51 | + for el in root.iter(): | |
| 52 | + if _strip_ns(el.tag) != item_tag: | |
| 53 | + continue | |
| 54 | + lst = self._to_listing(el, mapping, static) | |
| 55 | + if lst is not None: | |
| 56 | + out.append(lst) | |
| 57 | + return out | |
| 58 | + | |
| 59 | + def _find(self, el: ET.Element, path: str): | |
| 60 | + """Namespace-tolerant lookup ('address/city' or '@attr' or 'tag@attr').""" | |
| 61 | + if path.startswith("@"): | |
| 62 | + return [el.get(path[1:])] | |
| 63 | + attr = None | |
| 64 | + if "@" in path: | |
| 65 | + path, _, attr = path.partition("@") | |
| 66 | + parts = path.split("/") | |
| 67 | + nodes = [el] | |
| 68 | + for p in parts: | |
| 69 | + nxt = [] | |
| 70 | + for n in nodes: | |
| 71 | + nxt.extend(c for c in n if _strip_ns(c.tag) == p) | |
| 72 | + nodes = nxt | |
| 73 | + if attr: | |
| 74 | + return [n.get(attr) for n in nodes] | |
Diff truncated — file too large.