brickwise 0.1.7 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import "leaflet/dist/leaflet.css";
6
6
  import type { LayerGroup, Map as LeafletMap, Marker } from "leaflet";
7
7
  import YieldBadge from "@/components/YieldBadge";
8
8
  import { fmtAED, fmtDist, fmtSqft } from "@/lib/format";
9
+ import { yieldColor } from "@/lib/yieldColor";
9
10
 
10
11
  interface FavRow {
11
12
  id: string;
@@ -29,36 +30,25 @@ interface FavRow {
29
30
  gym_distance_m: number | null;
30
31
  gym_name: string | null;
31
32
  price_per_sqft: number | null;
33
+ description: string | null;
32
34
  notes: string;
33
35
  }
34
36
 
37
+ const MORE_SVG = (
38
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
39
+ <circle cx="11" cy="11" r="7" />
40
+ <path d="M11 8v6M8 11h6M20 20l-3.5-3.5" />
41
+ </svg>
42
+ );
43
+
35
44
  const GYM_SVG = `<svg viewBox="0 0 24 24" width="14" height="14" fill="#fff" xmlns="http://www.w3.org/2000/svg"><path d="M2 10h2v4H2zM5 8h2v8H5zM17 8h2v8h-2zM20 10h2v4h-2zM8 11h8v2H8z"/></svg>`;
45
+ const CAFE_SVG = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4 8h12v5a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8z"/><path d="M16 9h2a2 2 0 0 1 0 4h-2"/><path d="M6 3v2M10 3v2M14 3v2"/></svg>`;
36
46
 
37
47
  function matchesBedFilter(r: { bedrooms: number | null }, bedFilter: number[]): boolean {
38
48
  return bedFilter.length === 0 || (r.bedrooms != null && bedFilter.includes(r.bedrooms));
39
49
  }
40
50
 
41
- /**
42
- * Relative color scale: red (lowest yield in the set) → turquoise (highest).
43
- * Absolute thresholds made every Dubai listing green because yields are all high;
44
- * scaling to the actual min/max makes the differences readable on the map.
45
- */
46
- function yieldColor(y: number | null, min: number, max: number): string {
47
- if (y == null) return "#8a8a8a";
48
- const t = max > min ? Math.min(1, Math.max(0, (y - min) / (max - min))) : 0.5;
49
- const hue = t * 174; // 0° red → 174° turquoise
50
- return `hsl(${Math.round(hue)}, 66%, 44%)`;
51
- }
52
-
53
- /** Min/max of the located favorites' yields, for the relative color scale. */
54
- function yieldRange(rows: FavRow[] | null): { min: number; max: number } {
55
- const ys = (rows ?? [])
56
- .filter((r) => r.lat != null && r.lon != null && r.gross_yield != null)
57
- .map((r) => r.gross_yield as number);
58
- return ys.length ? { min: Math.min(...ys), max: Math.max(...ys) } : { min: 0, max: 1 };
59
- }
60
-
61
- function popupHtml(r: FavRow, min: number, max: number): string {
51
+ function popupHtml(r: FavRow): string {
62
52
  const img = r.images?.[0]?.medium || r.images?.[0]?.small;
63
53
  const yieldTxt = r.gross_yield != null ? (r.gross_yield * 100).toFixed(1) + " %" : "sin datos";
64
54
  return `
@@ -67,7 +57,7 @@ function popupHtml(r: FavRow, min: number, max: number): string {
67
57
  <div style="font-weight:600;margin-top:6px;line-height:1.3">${r.title}</div>
68
58
  <div style="color:#666;font-size:12px;margin-top:2px">${r.tower_name ?? ""}</div>
69
59
  <div style="margin-top:4px;font-size:14px">
70
- Rentabilidad: <span style="color:${yieldColor(r.gross_yield, min, max)};font-weight:700">${yieldTxt}</span>
60
+ Rentabilidad: <span style="color:${yieldColor(r.gross_yield)};font-weight:700">${yieldTxt}</span>
71
61
  </div>
72
62
  <div style="margin-top:6px;display:flex;gap:10px;font-size:12px">
73
63
  <a href="/listing/${r.id}">Ver análisis</a>
@@ -87,6 +77,59 @@ const SAVED_URL = "https://www.propertyfinder.ae/en/user/saved-properties";
87
77
  // strips javascript: hrefs.
88
78
  const BOOKMARKLET = String.raw`javascript:(function(){var u=new Set();document.querySelectorAll('a[href]').forEach(function(a){var h=(a.href||'').split('?')[0];if(/\/plp\/.*-\d+\.html$/.test(h))u.add(h)});try{var n=document.getElementById('__NEXT_DATA__');if(n){(JSON.stringify(JSON.parse(n.textContent)).match(/https?:\/\/[^"']*\/plp\/[^"']*-\d+\.html/g)||[]).forEach(function(x){u.add(x.split('?')[0])})}}catch(e){}var l=[...u];if(!l.length){alert('No encontre pisos guardados aqui. Abre tu pagina de Guardados de Property Finder con la sesion iniciada.');return}var t=l.join('\n');if(navigator.clipboard){navigator.clipboard.writeText(t).then(function(){alert(l.length+' piso(s) copiados. Abre Brickwise y pulsa "Pegar de Property Finder".')},function(){prompt('Copia estos enlaces y pegalos en Brickwise:',t)})}else{prompt('Copia estos enlaces y pegalos en Brickwise:',t)}})();`;
89
79
 
80
+ // leaflet.heat's radius is in screen pixels, so a fixed value shrinks in real-world
81
+ // terms as you zoom in. Anchor the radius to a reference zoom and scale by 2^(Δzoom)
82
+ // (each zoom level doubles pixels-per-meter) so the geographic influence stays constant.
83
+ const HEAT_REF_ZOOM = 12;
84
+ const HEAT_REF_RADIUS = 40;
85
+ const HEAT_REF_BLUR = 22;
86
+ function heatRadiusForZoom(zoom: number): { radius: number; blur: number } {
87
+ const scale = Math.pow(2, zoom - HEAT_REF_ZOOM);
88
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
89
+ return {
90
+ radius: clamp(HEAT_REF_RADIUS * scale, 6, 900),
91
+ blur: clamp(HEAT_REF_BLUR * scale, 4, 500),
92
+ };
93
+ }
94
+
95
+ function LayerToggle({
96
+ label,
97
+ color,
98
+ loading,
99
+ visible,
100
+ count,
101
+ onToggle,
102
+ }: {
103
+ label: string;
104
+ color: string;
105
+ loading: boolean;
106
+ visible: boolean;
107
+ count: number | null;
108
+ onToggle: () => void;
109
+ }) {
110
+ return (
111
+ <button
112
+ type="button"
113
+ onClick={onToggle}
114
+ disabled={loading}
115
+ className="btn-font flex w-full items-center gap-2 rounded px-2 py-1.5 text-[11px] hover:bg-neutral-100 disabled:opacity-60 dark:hover:bg-neutral-800"
116
+ >
117
+ <span
118
+ className="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border"
119
+ style={{ background: visible ? color : "transparent", borderColor: color, opacity: 0.85 }}
120
+ >
121
+ {visible && (
122
+ <svg viewBox="0 0 24 24" width="9" height="9" fill="none" stroke="#fff" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
123
+ <path d="M5 12l5 5L20 6" />
124
+ </svg>
125
+ )}
126
+ </span>
127
+ <span className="flex-1 text-left">{label}</span>
128
+ <span className="text-neutral-500">{loading ? "Buscando…" : count != null ? count : ""}</span>
129
+ </button>
130
+ );
131
+ }
132
+
90
133
  export default function FavoritesMapPage() {
91
134
  const [rows, setRows] = useState<FavRow[] | null>(null);
92
135
  const [hoveredId, setHoveredId] = useState<string | null>(null);
@@ -95,6 +138,7 @@ export default function FavoritesMapPage() {
95
138
  const [input, setInput] = useState("");
96
139
  const [queue, setQueue] = useState<string[]>([]);
97
140
  const [errors, setErrors] = useState<string[]>([]);
141
+ const [skippedMsg, setSkippedMsg] = useState<string | null>(null);
98
142
  const [sidebarW, setSidebarW] = useState(() => {
99
143
  if (typeof window === "undefined") return 440;
100
144
  const saved = Number(localStorage.getItem("bw-sidebar-w"));
@@ -104,13 +148,23 @@ export default function FavoritesMapPage() {
104
148
  const [gymsVisible, setGymsVisible] = useState(false);
105
149
  const [gymsLoading, setGymsLoading] = useState(false);
106
150
  const [gymCount, setGymCount] = useState<number | null>(null);
151
+ const [cafesVisible, setCafesVisible] = useState(false);
152
+ const [cafesLoading, setCafesLoading] = useState(false);
153
+ const [cafeCount, setCafeCount] = useState<number | null>(null);
154
+ const [heatKind, setHeatKind] = useState<"rent" | "buy" | null>(null);
155
+ const [heatLoading, setHeatLoading] = useState<"rent" | "buy" | null>(null);
156
+ const [heatMax, setHeatMax] = useState<number | null>(null);
157
+ const [showLayers, setShowLayers] = useState(false);
107
158
  const [sortBy, setSortBy] = useState<"yield" | "price">("yield");
108
159
  const [bedFilter, setBedFilter] = useState<number[]>([]);
160
+ const [expandedId, setExpandedId] = useState<string | null>(null);
109
161
 
110
162
  const mapRef = useRef<LeafletMap | null>(null);
111
163
  const leafletRef = useRef<typeof import("leaflet") | null>(null);
112
164
  const markersRef = useRef<Record<string, Marker>>({});
113
165
  const gymLayerRef = useRef<LayerGroup | null>(null);
166
+ const cafeLayerRef = useRef<LayerGroup | null>(null);
167
+ const heatLayerRef = useRef<import("leaflet").Layer | null>(null);
114
168
  const containerRef = useRef<HTMLDivElement | null>(null);
115
169
  const didFitRef = useRef(false);
116
170
  const processing = useRef(false);
@@ -170,6 +224,16 @@ export default function FavoritesMapPage() {
170
224
  };
171
225
  el.addEventListener("wheel", onWheel, { passive: false });
172
226
  map.on("remove", () => el.removeEventListener("wheel", onWheel));
227
+
228
+ // Keep the heat influence constant in real-world size across zoom levels:
229
+ // rescale the pixel radius whenever the zoom settles.
230
+ map.on("zoomend", () => {
231
+ const layer = heatLayerRef.current as unknown as {
232
+ setOptions?: (o: Record<string, unknown>) => void;
233
+ } | null;
234
+ if (!layer?.setOptions) return;
235
+ layer.setOptions(heatRadiusForZoom(map.getZoom()));
236
+ });
173
237
  let tries = 0;
174
238
  const timer = setInterval(() => {
175
239
  tries++;
@@ -193,6 +257,7 @@ export default function FavoritesMapPage() {
193
257
  mapRef.current = null;
194
258
  markersRef.current = {};
195
259
  gymLayerRef.current = null;
260
+ cafeLayerRef.current = null;
196
261
  didFitRef.current = false;
197
262
  setMapReady(false);
198
263
  };
@@ -205,20 +270,19 @@ export default function FavoritesMapPage() {
205
270
  if (!L || !map || !mapReady || !rows) return;
206
271
  Object.values(markersRef.current).forEach((m) => m.remove());
207
272
  markersRef.current = {};
208
- const { min, max } = yieldRange(rows);
209
273
  const bounds: [number, number][] = [];
210
274
  for (const r of rows) {
211
275
  if (r.lat == null || r.lon == null) continue;
212
276
  const y = r.gross_yield != null ? (r.gross_yield * 100).toFixed(1) + "%" : "—";
213
277
  const icon = L.divIcon({
214
278
  className: "",
215
- html: `<div class="bw-pin" style="--pin-color:${yieldColor(r.gross_yield, min, max)}"><div class="bw-pin-label">${y}</div><div class="bw-pin-tail"></div></div>`,
279
+ html: `<div class="bw-pin" style="--pin-color:${yieldColor(r.gross_yield)}"><div class="bw-pin-label">${y}</div><div class="bw-pin-tail"></div></div>`,
216
280
  iconSize: [0, 0],
217
281
  iconAnchor: [0, 0],
218
282
  });
219
283
  const marker = L.marker([r.lat, r.lon], { icon })
220
284
  .addTo(map)
221
- .bindPopup(popupHtml(r, min, max), { maxWidth: 260, offset: [0, -34] });
285
+ .bindPopup(popupHtml(r), { maxWidth: 260, offset: [0, -34] });
222
286
  marker.on("mouseover", () => {
223
287
  setHoveredId(r.id);
224
288
  document.getElementById(`fav-${r.id}`)?.scrollIntoView({ block: "nearest", behavior: "smooth" });
@@ -273,32 +337,62 @@ export default function FavoritesMapPage() {
273
337
  });
274
338
  }, [queue, load]);
275
339
 
276
- const enqueueUrls = (raw: string): number => {
340
+ // Same id extraction the server does (parseListingUrl in the API route).
341
+ const listingIdOf = (u: string): string | null => {
342
+ try {
343
+ const p = new URL(u).pathname;
344
+ const m = p.match(/-(\d+)\.html$/) || p.match(/\/(\d+)(?:\/)?$/);
345
+ return m ? m[1] : null;
346
+ } catch {
347
+ return null;
348
+ }
349
+ };
350
+
351
+ const enqueueUrls = (raw: string): { added: number; skipped: number } => {
277
352
  const urls = raw
278
353
  .split(/[\n\s,]+/)
279
354
  .map((s) => s.trim())
280
355
  .filter((s) => s.startsWith("http") && /propertyfinder\.ae/.test(s));
281
- if (urls.length) {
356
+ // Discard the ones already saved (and de-dupe the paste itself) so only new
357
+ // listings get analysed — no wasted scrape/round-trip on ones we already have.
358
+ const have = new Set((rows ?? []).map((r) => r.id));
359
+ const seen = new Set<string>();
360
+ const fresh: string[] = [];
361
+ let skipped = 0;
362
+ for (const u of urls) {
363
+ const id = listingIdOf(u);
364
+ if (id && (have.has(id) || seen.has(id))) {
365
+ skipped++;
366
+ continue;
367
+ }
368
+ if (id) seen.add(id);
369
+ fresh.push(u);
370
+ }
371
+ if (fresh.length) {
282
372
  setErrors([]);
283
- setQueue((prev) => [...prev, ...urls]);
373
+ setQueue((prev) => [...prev, ...fresh]);
284
374
  }
285
- return urls.length;
375
+ setSkippedMsg(skipped > 0 ? `${skipped} ya guardado${skipped === 1 ? "" : "s"}, descartado${skipped === 1 ? "" : "s"}.` : null);
376
+ return { added: fresh.length, skipped };
286
377
  };
287
378
 
288
379
  const submit = (e: React.FormEvent) => {
289
380
  e.preventDefault();
290
- if (enqueueUrls(input) > 0) setInput("");
381
+ const { added, skipped } = enqueueUrls(input);
382
+ if (added > 0 || skipped > 0) setInput("");
291
383
  };
292
384
 
293
385
  const pasteFromPf = async () => {
294
386
  setSyncMsg(null);
295
387
  try {
296
388
  const text = await navigator.clipboard.readText();
297
- const n = enqueueUrls(text);
389
+ const { added, skipped } = enqueueUrls(text);
298
390
  setSyncMsg(
299
- n > 0
300
- ? `${n} piso${n === 1 ? "" : "s"} en cola — analizando y añadiendo…`
301
- : "El portapapeles no tenía enlaces de Property Finder. Ejecuta el marcador en tu página de Guardados primero."
391
+ added > 0
392
+ ? `${added} piso${added === 1 ? "" : "s"} nuevo${added === 1 ? "" : "s"} en cola — analizando y añadiendo…${skipped > 0 ? ` (${skipped} ya guardado${skipped === 1 ? "" : "s"})` : ""}`
393
+ : skipped > 0
394
+ ? `Los ${skipped} piso${skipped === 1 ? "" : "s"} del portapapeles ya estaban guardados. Nada nuevo que analizar.`
395
+ : "El portapapeles no tenía enlaces de Property Finder. Ejecuta el marcador en tu página de Guardados primero."
302
396
  );
303
397
  } catch {
304
398
  setSyncMsg("No pude leer el portapapeles. Pega los enlaces manualmente en el cuadro de arriba.");
@@ -326,49 +420,131 @@ export default function FavoritesMapPage() {
326
420
  }
327
421
  };
328
422
 
329
- const toggleGyms = async () => {
423
+ // Build a Leaflet layer of POI markers (gyms / cafés) with a Google Maps popup.
424
+ const buildPoiLayer = (
425
+ L: typeof import("leaflet"),
426
+ items: { name: string; lat: number; lon: number }[],
427
+ cssClass: string,
428
+ svg: string
429
+ ) => {
430
+ const layer = L.layerGroup();
431
+ for (const p of items) {
432
+ const icon = L.divIcon({ className: "", html: `<div class="${cssClass}">${svg}</div>`, iconSize: [0, 0], iconAnchor: [0, 0] });
433
+ const safeName = p.name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
434
+ // Business name in the query, exact spot in the /@lat,lon path (not the query text):
435
+ // a named POI opens its own Maps place card; the coordinates never clutter the search
436
+ // box and only bias the map to Dubai so the right branch/spot is what's shown.
437
+ const mapsUrl = `https://www.google.com/maps/search/${encodeURIComponent(p.name)}/@${p.lat},${p.lon},17z`;
438
+ L.marker([p.lat, p.lon], { icon })
439
+ .bindPopup(
440
+ `<b>${safeName}</b><br><a href="${mapsUrl}" target="_blank" rel="noopener noreferrer" style="font-size:12px">Ver en Google Maps ↗</a>`,
441
+ { offset: [0, -16] }
442
+ )
443
+ .addTo(layer);
444
+ }
445
+ return layer;
446
+ };
447
+
448
+ const togglePoi = async (opts: {
449
+ api: string;
450
+ dataKey: string;
451
+ cssClass: string;
452
+ svg: string;
453
+ layerRef: React.MutableRefObject<LayerGroup | null>;
454
+ visible: boolean;
455
+ setVisible: (v: boolean) => void;
456
+ setLoading: (v: boolean) => void;
457
+ setCount: (v: number | null) => void;
458
+ }) => {
330
459
  const map = mapRef.current;
331
460
  const L = leafletRef.current;
332
461
  if (!map || !L) return;
333
- if (gymLayerRef.current) {
334
- if (gymsVisible) map.removeLayer(gymLayerRef.current);
335
- else gymLayerRef.current.addTo(map);
336
- setGymsVisible(!gymsVisible);
462
+ if (opts.layerRef.current) {
463
+ if (opts.visible) map.removeLayer(opts.layerRef.current);
464
+ else opts.layerRef.current.addTo(map);
465
+ opts.setVisible(!opts.visible);
337
466
  return;
338
467
  }
339
- setGymsLoading(true);
468
+ opts.setLoading(true);
340
469
  try {
341
- const res = await fetch("/api/gyms");
470
+ const res = await fetch(opts.api);
342
471
  const j = await res.json();
343
472
  if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
344
- const layer = L.layerGroup();
345
- for (const g of j.gyms as { name: string; lat: number; lon: number }[]) {
346
- const icon = L.divIcon({
347
- className: "",
348
- html: `<div class="bw-gym">${GYM_SVG}</div>`,
349
- iconSize: [0, 0],
350
- iconAnchor: [0, 0],
351
- });
352
- const safeName = g.name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
353
- // Link to the exact coordinates, not a text search: a name search gets biased
354
- // to the viewer's own location (e.g. Andorra), while coordinates always drop
355
- // the pin on this gym's spot in Dubai.
356
- const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${g.lat}%2C${g.lon}`;
357
- L.marker([g.lat, g.lon], { icon })
358
- .bindPopup(
359
- `<b>${safeName}</b><br><a href="${mapsUrl}" target="_blank" rel="noopener noreferrer" style="font-size:12px">Ver en Google Maps ↗</a>`,
360
- { offset: [0, -16] }
361
- )
362
- .addTo(layer);
363
- }
473
+ const items = j[opts.dataKey] as { name: string; lat: number; lon: number }[];
474
+ const layer = buildPoiLayer(L, items, opts.cssClass, opts.svg);
475
+ layer.addTo(map);
476
+ opts.layerRef.current = layer;
477
+ opts.setCount(items.length);
478
+ opts.setVisible(true);
479
+ } catch {
480
+ opts.setCount(null);
481
+ } finally {
482
+ opts.setLoading(false);
483
+ }
484
+ };
485
+
486
+ const toggleGyms = () =>
487
+ togglePoi({
488
+ api: "/api/gyms", dataKey: "gyms", cssClass: "bw-gym", svg: GYM_SVG,
489
+ layerRef: gymLayerRef, visible: gymsVisible, setVisible: setGymsVisible, setLoading: setGymsLoading, setCount: setGymCount,
490
+ });
491
+
492
+ const toggleCafes = () =>
493
+ togglePoi({
494
+ api: "/api/cafes", dataKey: "cafes", cssClass: "bw-cafe", svg: CAFE_SVG,
495
+ layerRef: cafeLayerRef, visible: cafesVisible, setVisible: setCafesVisible, setLoading: setCafesLoading, setCount: setCafeCount,
496
+ });
497
+
498
+ // Liquidity heatmap: DLD transactions per tower (last 12 months) as a heat surface.
499
+ // Rent and sale are separate markets, so only one kind shows at a time.
500
+ const toggleLiquidity = async (kind: "rent" | "buy") => {
501
+ const map = mapRef.current;
502
+ if (!map || !leafletRef.current) return;
503
+ // Clicking the active kind turns it off.
504
+ if (heatKind === kind) {
505
+ if (heatLayerRef.current) map.removeLayer(heatLayerRef.current);
506
+ heatLayerRef.current = null;
507
+ setHeatKind(null);
508
+ return;
509
+ }
510
+ // Switching kinds: drop the current layer first.
511
+ if (heatLayerRef.current) {
512
+ map.removeLayer(heatLayerRef.current);
513
+ heatLayerRef.current = null;
514
+ }
515
+ setHeatLoading(kind);
516
+ try {
517
+ const res = await fetch(`/api/liquidity?kind=${kind}`);
518
+ const j = await res.json();
519
+ if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
520
+ await import("leaflet.heat"); // patches L.heatLayer
521
+ const max = j.max || 1;
522
+ setHeatMax(j.max ?? null);
523
+ // Each favorite blooms proportionally to its tower's recent activity; a small
524
+ // floor keeps even low-liquidity listings visible as a faint mark.
525
+ const pts = (j.points as { lat: number; lon: number; weight: number }[]).map(
526
+ (p) => [p.lat, p.lon, Math.max(0.15, p.weight / max)] as [number, number, number]
527
+ );
528
+ type HeatFn = (latlngs: [number, number, number][], opts?: Record<string, unknown>) => import("leaflet").Layer;
529
+ const Lmod = leafletRef.current as unknown as { heatLayer?: HeatFn; default?: { heatLayer?: HeatFn } };
530
+ const heatLayer = Lmod.heatLayer ?? Lmod.default?.heatLayer;
531
+ if (!heatLayer) throw new Error("plugin de calor no disponible");
532
+ const { radius, blur } = heatRadiusForZoom(map.getZoom());
533
+ const layer = heatLayer(pts, {
534
+ radius,
535
+ blur,
536
+ max: 1,
537
+ minOpacity: 0.55,
538
+ // Yellow (low) → purple (high).
539
+ gradient: { 0.15: "#fef08a", 0.4: "#fde047", 0.65: "#d946ef", 1.0: "#7c3aed" },
540
+ });
364
541
  layer.addTo(map);
365
- gymLayerRef.current = layer;
366
- setGymCount(j.gyms.length);
367
- setGymsVisible(true);
542
+ heatLayerRef.current = layer;
543
+ setHeatKind(kind);
368
544
  } catch {
369
- setGymCount(null);
545
+ setHeatKind(null);
370
546
  } finally {
371
- setGymsLoading(false);
547
+ setHeatLoading(null);
372
548
  }
373
549
  };
374
550
 
@@ -435,11 +611,24 @@ export default function FavoritesMapPage() {
435
611
  <div className="min-w-0 flex-1">
436
612
  <div className="flex items-start justify-between gap-2">
437
613
  <button
438
- onClick={() => focus(r)}
439
- className="line-clamp-1 text-left font-sans text-sm font-semibold normal-case tracking-normal hover:underline"
440
- title="Centrar en el mapa"
614
+ onClick={() => setExpandedId((id) => (id === r.id ? null : r.id))}
615
+ className="flex min-w-0 items-center gap-1 text-left font-sans text-sm font-semibold normal-case tracking-normal hover:underline"
616
+ title="Ver previsualización"
441
617
  >
442
- {r.title}
618
+ <span className="truncate">{r.title}</span>
619
+ <svg
620
+ viewBox="0 0 24 24"
621
+ width="12"
622
+ height="12"
623
+ fill="none"
624
+ stroke="currentColor"
625
+ strokeWidth="2.5"
626
+ strokeLinecap="round"
627
+ strokeLinejoin="round"
628
+ className={`shrink-0 text-neutral-400 transition-transform ${expandedId === r.id ? "rotate-180" : ""}`}
629
+ >
630
+ <path d="M6 9l6 6 6-6" />
631
+ </svg>
443
632
  </button>
444
633
  <button
445
634
  onClick={() => remove(r.id)}
@@ -486,6 +675,29 @@ export default function FavoritesMapPage() {
486
675
  </span>
487
676
  </div>
488
677
 
678
+ {/* Preview desplegable al pulsar el título */}
679
+ {expandedId === r.id && (
680
+ <div className="mt-2 rounded-md border border-neutral-200 p-2 dark:border-neutral-800">
681
+ {r.images.length > 0 && (
682
+ <div className="flex gap-1.5 overflow-x-auto pb-1">
683
+ {r.images.slice(0, 10).map((img, i) => (
684
+ // eslint-disable-next-line @next/next/no-img-element
685
+ <img key={i} src={img.medium} alt="" className="h-24 w-36 shrink-0 rounded object-cover" loading="lazy" />
686
+ ))}
687
+ </div>
688
+ )}
689
+ {r.description && (
690
+ <p className="mt-1.5 line-clamp-4 whitespace-pre-line text-xs text-neutral-500">{r.description}</p>
691
+ )}
692
+ <Link
693
+ href={`/listing/${r.id}`}
694
+ className="btn-font mt-2 inline-flex items-center gap-1.5 rounded bg-blue-600 px-3 py-1.5 text-xs font-bold text-white hover:bg-blue-500"
695
+ >
696
+ {MORE_SVG} Ver más
697
+ </Link>
698
+ </div>
699
+ )}
700
+
489
701
  <textarea
490
702
  className="mt-2 h-9 w-full resize-none rounded-md border border-transparent bg-neutral-50 px-2 py-1 text-xs outline-none focus:border-neutral-300 dark:bg-neutral-950 dark:focus:border-neutral-700"
491
703
  defaultValue={r.notes}
@@ -521,32 +733,50 @@ export default function FavoritesMapPage() {
521
733
  value={input}
522
734
  onChange={(e) => setInput(e.target.value)}
523
735
  />
524
- <div className="mt-2 flex items-center gap-2">
736
+ <div className="mt-2 flex flex-col gap-1.5">
525
737
  <button
526
738
  type="submit"
527
739
  disabled={!input.trim() || busy}
528
- className="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-bold text-white hover:bg-blue-500 disabled:opacity-50"
740
+ className="w-full rounded-lg bg-blue-600 py-2 text-xs font-bold text-white hover:bg-blue-500 disabled:opacity-50"
529
741
  >
530
742
  {busy ? `Analizando ${queue.length}…` : "Añadir y analizar"}
531
743
  </button>
532
- <button
533
- type="button"
534
- onClick={toggleGyms}
535
- disabled={gymsLoading || (rows?.length ?? 0) === 0}
536
- className="rounded-lg border border-neutral-300 px-3 py-1.5 text-xs font-semibold hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
537
- >
538
- {gymsLoading
539
- ? "Buscando…"
540
- : gymCount != null
541
- ? gymsVisible
542
- ? `Ocultar gyms (${gymCount})`
543
- : `Mostrar gyms (${gymCount})`
544
- : "Gimnasios"}
545
- </button>
744
+ {skippedMsg && (
745
+ <p className="text-[11px] text-neutral-500 dark:text-neutral-400">{skippedMsg}</p>
746
+ )}
747
+ <div className="relative">
748
+ <button
749
+ type="button"
750
+ onClick={() => setShowLayers((v) => !v)}
751
+ disabled={(rows?.length ?? 0) === 0}
752
+ className="flex w-full items-center justify-center gap-1 rounded-lg border border-neutral-300 py-2 text-xs font-semibold hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
753
+ >
754
+ Capas del mapa
755
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform ${showLayers ? "rotate-180" : ""}`}>
756
+ <path d="M6 9l6 6 6-6" />
757
+ </svg>
758
+ </button>
759
+ {showLayers && (
760
+ <div className="absolute inset-x-0 top-full z-30 mt-1 rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-800 dark:bg-neutral-900">
761
+ <p className="px-2 pb-0.5 pt-1 text-[10px] uppercase tracking-wide text-neutral-400">Puntos cercanos</p>
762
+ <LayerToggle label="Gimnasios" color="#b84f30" loading={gymsLoading} visible={gymsVisible} count={gymCount} onToggle={toggleGyms} />
763
+ <LayerToggle label="Cafeterías" color="#6f4e37" loading={cafesLoading} visible={cafesVisible} count={cafeCount} onToggle={toggleCafes} />
764
+ <div className="my-1 border-t border-neutral-200 dark:border-neutral-800" />
765
+ <p className="px-2 pb-0.5 pt-0.5 text-[10px] uppercase tracking-wide text-neutral-400">Mapa de calor de liquidez (12m)</p>
766
+ <LayerToggle label="Alquiler" color="#d946ef" loading={heatLoading === "rent"} visible={heatKind === "rent"} count={null} onToggle={() => toggleLiquidity("rent")} />
767
+ <LayerToggle label="Venta" color="#7c3aed" loading={heatLoading === "buy"} visible={heatKind === "buy"} count={null} onToggle={() => toggleLiquidity("buy")} />
768
+ {heatKind && (
769
+ <p className="px-2 pb-1 pt-0.5 text-[10px] leading-tight text-neutral-500">
770
+ {heatKind === "rent" ? "Contratos de alquiler" : "Ventas"} (DLD) por edificio, últimos 12 meses. Amarillo = poco líquido, morado = mucha actividad.
771
+ </p>
772
+ )}
773
+ </div>
774
+ )}
775
+ </div>
546
776
  <button
547
777
  type="button"
548
778
  onClick={() => setShowSync((v) => !v)}
549
- className={`rounded-lg border px-3 py-1.5 text-xs font-semibold ${
779
+ className={`w-full rounded-lg border py-2 text-xs font-semibold ${
550
780
  showSync
551
781
  ? "border-blue-500 text-blue-600 dark:text-blue-400"
552
782
  : "border-neutral-300 hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800"
@@ -682,23 +912,29 @@ export default function FavoritesMapPage() {
682
912
  {/* ------------------------------------------------ map */}
683
913
  <div className="relative min-h-0 min-w-0 flex-1">
684
914
  <div ref={containerRef} className="absolute inset-0" />
685
- {(() => {
686
- const { min, max } = yieldRange(rows);
687
- if (!(max > min)) return null;
688
- return (
689
- <div className="pointer-events-none absolute bottom-4 left-3 z-[500] rounded border border-black/10 bg-white/90 px-2 py-1.5 text-[10px] text-neutral-700 shadow-lg dark:border-white/10 dark:bg-neutral-900/90 dark:text-neutral-200">
690
- <div className="btn-font mb-1 text-[9px] text-neutral-500">Rentabilidad</div>
691
- <div className="flex items-center gap-1.5">
692
- <span>{(min * 100).toFixed(1)}%</span>
693
- <span
694
- className="h-2 w-24 rounded-sm"
695
- style={{ background: `linear-gradient(90deg, hsl(0,66%,44%), hsl(87,66%,44%), hsl(174,66%,44%))` }}
696
- />
697
- <span>{(max * 100).toFixed(1)}%</span>
698
- </div>
915
+ {heatKind && (
916
+ <div className="pointer-events-none absolute bottom-4 left-3 z-[500] rounded border border-black/10 bg-white/90 px-2.5 py-2 text-neutral-700 shadow-lg dark:border-white/10 dark:bg-neutral-900/90 dark:text-neutral-200">
917
+ <div className="btn-font mb-1 text-[9px] text-neutral-500">
918
+ Liquidez {heatKind === "rent" ? "alquiler" : "venta"} · {heatKind === "rent" ? "contratos" : "ventas"}/mes
919
+ </div>
920
+ <div
921
+ className="h-2 w-52 rounded-sm"
922
+ style={{ background: "linear-gradient(90deg, #fef08a, #fde047, #d946ef, #7c3aed)" }}
923
+ />
924
+ <div className="mt-1 flex w-52 justify-between text-[10px] font-semibold">
925
+ <span>0</span>
926
+ {heatMax != null && <span>{(heatMax / 24).toFixed(1)}</span>}
927
+ {heatMax != null && <span>{(heatMax / 12).toFixed(1)}</span>}
699
928
  </div>
700
- );
701
- })()}
929
+ <div className="flex w-52 justify-between text-[9px] text-neutral-500">
930
+ <span>parado</span>
931
+ <span>activo</span>
932
+ </div>
933
+ <div className="mt-0.5 w-52 text-[9px] leading-tight text-neutral-400">
934
+ {heatKind === "rent" ? "Contratos de alquiler registrados" : "Ventas registradas"} en el DLD por edificio, últimos 12 meses.
935
+ </div>
936
+ </div>
937
+ )}
702
938
  </div>
703
939
  </div>
704
940
  );
@@ -159,17 +159,30 @@ button[class*="bg-blue-5"],
159
159
  border-color: rgba(255, 255, 255, 0.4);
160
160
  }
161
161
 
162
- /* Gym marker: brick square with a white dumbbell. */
163
- .bw-gym {
162
+ /* POI markers (gym / café): semi-transparent square with a white icon. */
163
+ .bw-gym,
164
+ .bw-cafe {
164
165
  width: 24px;
165
166
  height: 24px;
166
167
  border-radius: 2px;
167
- background: var(--accent);
168
- border: 1px solid rgba(255, 255, 255, 0.85);
168
+ border: 1px solid rgba(255, 255, 255, 0.7);
169
169
  display: flex;
170
170
  align-items: center;
171
171
  justify-content: center;
172
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
172
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
173
173
  transform: translate(-50%, -50%);
174
174
  cursor: pointer;
175
+ transition: opacity 0.12s ease;
176
+ }
177
+ .bw-gym {
178
+ background: rgba(184, 79, 48, 0.68); /* brick, transparent */
179
+ opacity: 0.9;
180
+ }
181
+ .bw-cafe {
182
+ background: rgba(111, 78, 55, 0.68); /* coffee, transparent */
183
+ opacity: 0.9;
184
+ }
185
+ .bw-gym:hover,
186
+ .bw-cafe:hover {
187
+ opacity: 1;
175
188
  }