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.
- package/package.json +3 -1
- package/src/app/api/cafes/route.ts +16 -0
- package/src/app/api/gyms/route.ts +7 -21
- package/src/app/api/liquidity/route.ts +35 -0
- package/src/app/favorites/page.tsx +339 -103
- package/src/app/globals.css +18 -5
- package/src/app/listing/[id]/page.tsx +113 -39
- package/src/components/ReformScenario.tsx +195 -40
- package/src/components/YieldBadge.tsx +6 -10
- package/src/lib/db.ts +8 -0
- package/src/lib/gyms.ts +5 -89
- package/src/lib/pois.ts +188 -0
- package/src/lib/store.ts +18 -0
- package/src/lib/yieldColor.ts +11 -0
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, ...
|
|
373
|
+
setQueue((prev) => [...prev, ...fresh]);
|
|
284
374
|
}
|
|
285
|
-
|
|
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
|
-
|
|
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
|
|
389
|
+
const { added, skipped } = enqueueUrls(text);
|
|
298
390
|
setSyncMsg(
|
|
299
|
-
|
|
300
|
-
? `${
|
|
301
|
-
:
|
|
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
|
-
|
|
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 (
|
|
334
|
-
if (
|
|
335
|
-
else
|
|
336
|
-
|
|
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
|
-
|
|
468
|
+
opts.setLoading(true);
|
|
340
469
|
try {
|
|
341
|
-
const res = await fetch(
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
setGymsVisible(true);
|
|
542
|
+
heatLayerRef.current = layer;
|
|
543
|
+
setHeatKind(kind);
|
|
368
544
|
} catch {
|
|
369
|
-
|
|
545
|
+
setHeatKind(null);
|
|
370
546
|
} finally {
|
|
371
|
-
|
|
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={() =>
|
|
439
|
-
className="
|
|
440
|
-
title="
|
|
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
|
|
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
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
:
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
);
|
package/src/app/globals.css
CHANGED
|
@@ -159,17 +159,30 @@ button[class*="bg-blue-5"],
|
|
|
159
159
|
border-color: rgba(255, 255, 255, 0.4);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
/*
|
|
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
|
-
|
|
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.
|
|
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
|
}
|