brickwise 0.1.5 → 0.1.15
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 +1 -1
- package/src/app/api/cafes/route.ts +16 -0
- package/src/app/api/gyms/route.ts +7 -21
- package/src/app/favorites/page.tsx +198 -62
- package/src/app/globals.css +24 -11
- 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/yieldColor.ts +11 -0
package/package.json
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { poisForPoints } from "@/lib/pois";
|
|
3
|
+
import { listFavorites } from "@/lib/store";
|
|
4
|
+
|
|
5
|
+
/** All cafés near any located favorite, in a single Overpass query. */
|
|
6
|
+
export async function GET() {
|
|
7
|
+
try {
|
|
8
|
+
const points = listFavorites()
|
|
9
|
+
.filter((f) => f.lat != null && f.lon != null)
|
|
10
|
+
.map((f) => ({ lat: f.lat!, lon: f.lon! }));
|
|
11
|
+
const { pois, failed } = await poisForPoints(points, "cafe");
|
|
12
|
+
return NextResponse.json({ cafes: pois, favoritesQueried: points.length, failed });
|
|
13
|
+
} catch (e) {
|
|
14
|
+
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
2
|
+
import { poisForPoints } from "@/lib/pois";
|
|
3
3
|
import { listFavorites } from "@/lib/store";
|
|
4
4
|
|
|
5
|
-
/** All gyms
|
|
5
|
+
/** All gyms near any located favorite, in a single Overpass query. */
|
|
6
6
|
export async function GET() {
|
|
7
7
|
try {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (gyms == null) {
|
|
14
|
-
failed++;
|
|
15
|
-
continue;
|
|
16
|
-
}
|
|
17
|
-
for (const g of gyms) {
|
|
18
|
-
const key = `${g.lat.toFixed(5)},${g.lon.toFixed(5)}`;
|
|
19
|
-
if (!seen.has(key)) seen.set(key, { name: g.name, lat: g.lat, lon: g.lon });
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return NextResponse.json({
|
|
23
|
-
gyms: [...seen.values()],
|
|
24
|
-
favoritesQueried: favorites.length,
|
|
25
|
-
failed,
|
|
26
|
-
});
|
|
8
|
+
const points = listFavorites()
|
|
9
|
+
.filter((f) => f.lat != null && f.lon != null)
|
|
10
|
+
.map((f) => ({ lat: f.lat!, lon: f.lon! }));
|
|
11
|
+
const { pois, failed } = await poisForPoints(points, "gym");
|
|
12
|
+
return NextResponse.json({ gyms: pois, favoritesQueried: points.length, failed });
|
|
27
13
|
} catch (e) {
|
|
28
14
|
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
29
15
|
}
|
|
@@ -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,23 +30,24 @@ 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
|
-
function yieldColor(y: number | null): string {
|
|
42
|
-
if (y == null) return "#737373";
|
|
43
|
-
if (y >= 0.07) return "#059669";
|
|
44
|
-
if (y >= 0.055) return "#65a30d";
|
|
45
|
-
if (y >= 0.04) return "#d97706";
|
|
46
|
-
return "#dc2626";
|
|
47
|
-
}
|
|
48
|
-
|
|
49
51
|
function popupHtml(r: FavRow): string {
|
|
50
52
|
const img = r.images?.[0]?.medium || r.images?.[0]?.small;
|
|
51
53
|
const yieldTxt = r.gross_yield != null ? (r.gross_yield * 100).toFixed(1) + " %" : "sin datos";
|
|
@@ -75,6 +77,44 @@ const SAVED_URL = "https://www.propertyfinder.ae/en/user/saved-properties";
|
|
|
75
77
|
// strips javascript: hrefs.
|
|
76
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)}})();`;
|
|
77
79
|
|
|
80
|
+
function LayerToggle({
|
|
81
|
+
label,
|
|
82
|
+
color,
|
|
83
|
+
loading,
|
|
84
|
+
visible,
|
|
85
|
+
count,
|
|
86
|
+
onToggle,
|
|
87
|
+
}: {
|
|
88
|
+
label: string;
|
|
89
|
+
color: string;
|
|
90
|
+
loading: boolean;
|
|
91
|
+
visible: boolean;
|
|
92
|
+
count: number | null;
|
|
93
|
+
onToggle: () => void;
|
|
94
|
+
}) {
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
onClick={onToggle}
|
|
99
|
+
disabled={loading}
|
|
100
|
+
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"
|
|
101
|
+
>
|
|
102
|
+
<span
|
|
103
|
+
className="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border"
|
|
104
|
+
style={{ background: visible ? color : "transparent", borderColor: color, opacity: 0.85 }}
|
|
105
|
+
>
|
|
106
|
+
{visible && (
|
|
107
|
+
<svg viewBox="0 0 24 24" width="9" height="9" fill="none" stroke="#fff" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
|
|
108
|
+
<path d="M5 12l5 5L20 6" />
|
|
109
|
+
</svg>
|
|
110
|
+
)}
|
|
111
|
+
</span>
|
|
112
|
+
<span className="flex-1 text-left">{label}</span>
|
|
113
|
+
<span className="text-neutral-500">{loading ? "Buscando…" : count != null ? count : ""}</span>
|
|
114
|
+
</button>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
78
118
|
export default function FavoritesMapPage() {
|
|
79
119
|
const [rows, setRows] = useState<FavRow[] | null>(null);
|
|
80
120
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
@@ -92,13 +132,19 @@ export default function FavoritesMapPage() {
|
|
|
92
132
|
const [gymsVisible, setGymsVisible] = useState(false);
|
|
93
133
|
const [gymsLoading, setGymsLoading] = useState(false);
|
|
94
134
|
const [gymCount, setGymCount] = useState<number | null>(null);
|
|
135
|
+
const [cafesVisible, setCafesVisible] = useState(false);
|
|
136
|
+
const [cafesLoading, setCafesLoading] = useState(false);
|
|
137
|
+
const [cafeCount, setCafeCount] = useState<number | null>(null);
|
|
138
|
+
const [showLayers, setShowLayers] = useState(false);
|
|
95
139
|
const [sortBy, setSortBy] = useState<"yield" | "price">("yield");
|
|
96
140
|
const [bedFilter, setBedFilter] = useState<number[]>([]);
|
|
141
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
97
142
|
|
|
98
143
|
const mapRef = useRef<LeafletMap | null>(null);
|
|
99
144
|
const leafletRef = useRef<typeof import("leaflet") | null>(null);
|
|
100
145
|
const markersRef = useRef<Record<string, Marker>>({});
|
|
101
146
|
const gymLayerRef = useRef<LayerGroup | null>(null);
|
|
147
|
+
const cafeLayerRef = useRef<LayerGroup | null>(null);
|
|
102
148
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
103
149
|
const didFitRef = useRef(false);
|
|
104
150
|
const processing = useRef(false);
|
|
@@ -181,6 +227,7 @@ export default function FavoritesMapPage() {
|
|
|
181
227
|
mapRef.current = null;
|
|
182
228
|
markersRef.current = {};
|
|
183
229
|
gymLayerRef.current = null;
|
|
230
|
+
cafeLayerRef.current = null;
|
|
184
231
|
didFitRef.current = false;
|
|
185
232
|
setMapReady(false);
|
|
186
233
|
};
|
|
@@ -313,52 +360,81 @@ export default function FavoritesMapPage() {
|
|
|
313
360
|
}
|
|
314
361
|
};
|
|
315
362
|
|
|
316
|
-
|
|
363
|
+
// Build a Leaflet layer of POI markers (gyms / cafés) with a Google Maps popup.
|
|
364
|
+
const buildPoiLayer = (
|
|
365
|
+
L: typeof import("leaflet"),
|
|
366
|
+
items: { name: string; lat: number; lon: number }[],
|
|
367
|
+
cssClass: string,
|
|
368
|
+
svg: string
|
|
369
|
+
) => {
|
|
370
|
+
const layer = L.layerGroup();
|
|
371
|
+
for (const p of items) {
|
|
372
|
+
const icon = L.divIcon({ className: "", html: `<div class="${cssClass}">${svg}</div>`, iconSize: [0, 0], iconAnchor: [0, 0] });
|
|
373
|
+
const safeName = p.name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
374
|
+
// Business name in the query, exact spot in the /@lat,lon path (not the query text):
|
|
375
|
+
// a named POI opens its own Maps place card; the coordinates never clutter the search
|
|
376
|
+
// box and only bias the map to Dubai so the right branch/spot is what's shown.
|
|
377
|
+
const mapsUrl = `https://www.google.com/maps/search/${encodeURIComponent(p.name)}/@${p.lat},${p.lon},17z`;
|
|
378
|
+
L.marker([p.lat, p.lon], { icon })
|
|
379
|
+
.bindPopup(
|
|
380
|
+
`<b>${safeName}</b><br><a href="${mapsUrl}" target="_blank" rel="noopener noreferrer" style="font-size:12px">Ver en Google Maps ↗</a>`,
|
|
381
|
+
{ offset: [0, -16] }
|
|
382
|
+
)
|
|
383
|
+
.addTo(layer);
|
|
384
|
+
}
|
|
385
|
+
return layer;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const togglePoi = async (opts: {
|
|
389
|
+
api: string;
|
|
390
|
+
dataKey: string;
|
|
391
|
+
cssClass: string;
|
|
392
|
+
svg: string;
|
|
393
|
+
layerRef: React.MutableRefObject<LayerGroup | null>;
|
|
394
|
+
visible: boolean;
|
|
395
|
+
setVisible: (v: boolean) => void;
|
|
396
|
+
setLoading: (v: boolean) => void;
|
|
397
|
+
setCount: (v: number | null) => void;
|
|
398
|
+
}) => {
|
|
317
399
|
const map = mapRef.current;
|
|
318
400
|
const L = leafletRef.current;
|
|
319
401
|
if (!map || !L) return;
|
|
320
|
-
if (
|
|
321
|
-
if (
|
|
322
|
-
else
|
|
323
|
-
|
|
402
|
+
if (opts.layerRef.current) {
|
|
403
|
+
if (opts.visible) map.removeLayer(opts.layerRef.current);
|
|
404
|
+
else opts.layerRef.current.addTo(map);
|
|
405
|
+
opts.setVisible(!opts.visible);
|
|
324
406
|
return;
|
|
325
407
|
}
|
|
326
|
-
|
|
408
|
+
opts.setLoading(true);
|
|
327
409
|
try {
|
|
328
|
-
const res = await fetch(
|
|
410
|
+
const res = await fetch(opts.api);
|
|
329
411
|
const j = await res.json();
|
|
330
412
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
const icon = L.divIcon({
|
|
334
|
-
className: "",
|
|
335
|
-
html: `<div class="bw-gym">${GYM_SVG}</div>`,
|
|
336
|
-
iconSize: [0, 0],
|
|
337
|
-
iconAnchor: [0, 0],
|
|
338
|
-
});
|
|
339
|
-
const safeName = g.name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
340
|
-
// Link to the exact coordinates, not a text search: a name search gets biased
|
|
341
|
-
// to the viewer's own location (e.g. Andorra), while coordinates always drop
|
|
342
|
-
// the pin on this gym's spot in Dubai.
|
|
343
|
-
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${g.lat}%2C${g.lon}`;
|
|
344
|
-
L.marker([g.lat, g.lon], { icon })
|
|
345
|
-
.bindPopup(
|
|
346
|
-
`<b>${safeName}</b><br><a href="${mapsUrl}" target="_blank" rel="noopener noreferrer" style="font-size:12px">Ver en Google Maps ↗</a>`,
|
|
347
|
-
{ offset: [0, -16] }
|
|
348
|
-
)
|
|
349
|
-
.addTo(layer);
|
|
350
|
-
}
|
|
413
|
+
const items = j[opts.dataKey] as { name: string; lat: number; lon: number }[];
|
|
414
|
+
const layer = buildPoiLayer(L, items, opts.cssClass, opts.svg);
|
|
351
415
|
layer.addTo(map);
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
416
|
+
opts.layerRef.current = layer;
|
|
417
|
+
opts.setCount(items.length);
|
|
418
|
+
opts.setVisible(true);
|
|
355
419
|
} catch {
|
|
356
|
-
|
|
420
|
+
opts.setCount(null);
|
|
357
421
|
} finally {
|
|
358
|
-
|
|
422
|
+
opts.setLoading(false);
|
|
359
423
|
}
|
|
360
424
|
};
|
|
361
425
|
|
|
426
|
+
const toggleGyms = () =>
|
|
427
|
+
togglePoi({
|
|
428
|
+
api: "/api/gyms", dataKey: "gyms", cssClass: "bw-gym", svg: GYM_SVG,
|
|
429
|
+
layerRef: gymLayerRef, visible: gymsVisible, setVisible: setGymsVisible, setLoading: setGymsLoading, setCount: setGymCount,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const toggleCafes = () =>
|
|
433
|
+
togglePoi({
|
|
434
|
+
api: "/api/cafes", dataKey: "cafes", cssClass: "bw-cafe", svg: CAFE_SVG,
|
|
435
|
+
layerRef: cafeLayerRef, visible: cafesVisible, setVisible: setCafesVisible, setLoading: setCafesLoading, setCount: setCafeCount,
|
|
436
|
+
});
|
|
437
|
+
|
|
362
438
|
// --- Resizable divider ---------------------------------------------------
|
|
363
439
|
const startDrag = (e: React.MouseEvent) => {
|
|
364
440
|
e.preventDefault();
|
|
@@ -422,11 +498,24 @@ export default function FavoritesMapPage() {
|
|
|
422
498
|
<div className="min-w-0 flex-1">
|
|
423
499
|
<div className="flex items-start justify-between gap-2">
|
|
424
500
|
<button
|
|
425
|
-
onClick={() =>
|
|
426
|
-
className="
|
|
427
|
-
title="
|
|
501
|
+
onClick={() => setExpandedId((id) => (id === r.id ? null : r.id))}
|
|
502
|
+
className="flex min-w-0 items-center gap-1 text-left font-sans text-sm font-semibold normal-case tracking-normal hover:underline"
|
|
503
|
+
title="Ver previsualización"
|
|
428
504
|
>
|
|
429
|
-
{r.title}
|
|
505
|
+
<span className="truncate">{r.title}</span>
|
|
506
|
+
<svg
|
|
507
|
+
viewBox="0 0 24 24"
|
|
508
|
+
width="12"
|
|
509
|
+
height="12"
|
|
510
|
+
fill="none"
|
|
511
|
+
stroke="currentColor"
|
|
512
|
+
strokeWidth="2.5"
|
|
513
|
+
strokeLinecap="round"
|
|
514
|
+
strokeLinejoin="round"
|
|
515
|
+
className={`shrink-0 text-neutral-400 transition-transform ${expandedId === r.id ? "rotate-180" : ""}`}
|
|
516
|
+
>
|
|
517
|
+
<path d="M6 9l6 6 6-6" />
|
|
518
|
+
</svg>
|
|
430
519
|
</button>
|
|
431
520
|
<button
|
|
432
521
|
onClick={() => remove(r.id)}
|
|
@@ -473,6 +562,29 @@ export default function FavoritesMapPage() {
|
|
|
473
562
|
</span>
|
|
474
563
|
</div>
|
|
475
564
|
|
|
565
|
+
{/* Preview desplegable al pulsar el título */}
|
|
566
|
+
{expandedId === r.id && (
|
|
567
|
+
<div className="mt-2 rounded-md border border-neutral-200 p-2 dark:border-neutral-800">
|
|
568
|
+
{r.images.length > 0 && (
|
|
569
|
+
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
|
570
|
+
{r.images.slice(0, 10).map((img, i) => (
|
|
571
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
572
|
+
<img key={i} src={img.medium} alt="" className="h-24 w-36 shrink-0 rounded object-cover" loading="lazy" />
|
|
573
|
+
))}
|
|
574
|
+
</div>
|
|
575
|
+
)}
|
|
576
|
+
{r.description && (
|
|
577
|
+
<p className="mt-1.5 line-clamp-4 whitespace-pre-line text-xs text-neutral-500">{r.description}</p>
|
|
578
|
+
)}
|
|
579
|
+
<Link
|
|
580
|
+
href={`/listing/${r.id}`}
|
|
581
|
+
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"
|
|
582
|
+
>
|
|
583
|
+
{MORE_SVG} Ver más
|
|
584
|
+
</Link>
|
|
585
|
+
</div>
|
|
586
|
+
)}
|
|
587
|
+
|
|
476
588
|
<textarea
|
|
477
589
|
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"
|
|
478
590
|
defaultValue={r.notes}
|
|
@@ -508,32 +620,37 @@ export default function FavoritesMapPage() {
|
|
|
508
620
|
value={input}
|
|
509
621
|
onChange={(e) => setInput(e.target.value)}
|
|
510
622
|
/>
|
|
511
|
-
<div className="mt-2 flex
|
|
623
|
+
<div className="mt-2 flex flex-col gap-1.5">
|
|
512
624
|
<button
|
|
513
625
|
type="submit"
|
|
514
626
|
disabled={!input.trim() || busy}
|
|
515
|
-
className="rounded-lg bg-blue-600
|
|
627
|
+
className="w-full rounded-lg bg-blue-600 py-2 text-xs font-bold text-white hover:bg-blue-500 disabled:opacity-50"
|
|
516
628
|
>
|
|
517
629
|
{busy ? `Analizando ${queue.length}…` : "Añadir y analizar"}
|
|
518
630
|
</button>
|
|
519
|
-
<
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
631
|
+
<div className="relative">
|
|
632
|
+
<button
|
|
633
|
+
type="button"
|
|
634
|
+
onClick={() => setShowLayers((v) => !v)}
|
|
635
|
+
disabled={(rows?.length ?? 0) === 0}
|
|
636
|
+
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"
|
|
637
|
+
>
|
|
638
|
+
Puntos cercanos
|
|
639
|
+
<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" : ""}`}>
|
|
640
|
+
<path d="M6 9l6 6 6-6" />
|
|
641
|
+
</svg>
|
|
642
|
+
</button>
|
|
643
|
+
{showLayers && (
|
|
644
|
+
<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">
|
|
645
|
+
<LayerToggle label="Gimnasios" color="#b84f30" loading={gymsLoading} visible={gymsVisible} count={gymCount} onToggle={toggleGyms} />
|
|
646
|
+
<LayerToggle label="Cafeterías" color="#6f4e37" loading={cafesLoading} visible={cafesVisible} count={cafeCount} onToggle={toggleCafes} />
|
|
647
|
+
</div>
|
|
648
|
+
)}
|
|
649
|
+
</div>
|
|
533
650
|
<button
|
|
534
651
|
type="button"
|
|
535
652
|
onClick={() => setShowSync((v) => !v)}
|
|
536
|
-
className={`rounded-lg border
|
|
653
|
+
className={`w-full rounded-lg border py-2 text-xs font-semibold ${
|
|
537
654
|
showSync
|
|
538
655
|
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
|
539
656
|
: "border-neutral-300 hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
|
@@ -667,7 +784,26 @@ export default function FavoritesMapPage() {
|
|
|
667
784
|
/>
|
|
668
785
|
|
|
669
786
|
{/* ------------------------------------------------ map */}
|
|
670
|
-
<div
|
|
787
|
+
<div className="relative min-h-0 min-w-0 flex-1">
|
|
788
|
+
<div ref={containerRef} className="absolute inset-0" />
|
|
789
|
+
<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">
|
|
790
|
+
<div className="btn-font mb-1 text-[9px] text-neutral-500">Rentabilidad anual</div>
|
|
791
|
+
<div
|
|
792
|
+
className="h-2 w-52 rounded-sm"
|
|
793
|
+
style={{ background: "linear-gradient(90deg, hsl(0,66%,44%), hsl(87,66%,44%), hsl(174,66%,44%))" }}
|
|
794
|
+
/>
|
|
795
|
+
<div className="mt-1 flex w-52 justify-between text-[10px] font-semibold">
|
|
796
|
+
<span>Mediocre</span>
|
|
797
|
+
<span>Bueno</span>
|
|
798
|
+
<span>Espectacular</span>
|
|
799
|
+
</div>
|
|
800
|
+
<div className="flex w-52 justify-between text-[9px] text-neutral-500">
|
|
801
|
+
<span>≤6%</span>
|
|
802
|
+
<span>8–9%</span>
|
|
803
|
+
<span>10%+</span>
|
|
804
|
+
</div>
|
|
805
|
+
</div>
|
|
806
|
+
</div>
|
|
671
807
|
</div>
|
|
672
808
|
);
|
|
673
809
|
}
|
package/src/app/globals.css
CHANGED
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
@theme {
|
|
29
|
-
/* Neutral dark grays anchored to the #
|
|
30
|
-
--color-neutral-950: #
|
|
31
|
-
--color-neutral-900: #
|
|
32
|
-
--color-neutral-800: #
|
|
33
|
-
--color-neutral-700: #
|
|
29
|
+
/* Neutral dark grays anchored to the #181818 background */
|
|
30
|
+
--color-neutral-950: #121212; /* deep insets / header */
|
|
31
|
+
--color-neutral-900: #181818; /* panels & cards == background */
|
|
32
|
+
--color-neutral-800: #2a2a2a; /* borders, dividers, hover (subtle) */
|
|
33
|
+
--color-neutral-700: #3a3a3a;
|
|
34
34
|
|
|
35
35
|
/* Brick accent — remaps every blue-* usage in the app to color ladrillo */
|
|
36
36
|
--color-blue-50: #faeee8;
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
|
|
59
59
|
@media (prefers-color-scheme: dark) {
|
|
60
60
|
:root {
|
|
61
|
-
--background: #
|
|
61
|
+
--background: #181818;
|
|
62
62
|
--foreground: #ededed;
|
|
63
63
|
}
|
|
64
64
|
}
|
|
@@ -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
|
}
|
|
@@ -4,7 +4,6 @@ import { useState } from "react";
|
|
|
4
4
|
import { fmtAED, fmtPct } from "@/lib/format";
|
|
5
5
|
import { AnalysisRow } from "@/lib/types";
|
|
6
6
|
|
|
7
|
-
const DEFAULT_AED_PER_SQFT = 300;
|
|
8
7
|
const SIZE_TOLERANCE = 0.2;
|
|
9
8
|
|
|
10
9
|
interface Props {
|
|
@@ -15,30 +14,101 @@ interface Props {
|
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* Itemized reform model. The rent uplift you can claim depends on the SCOPE of
|
|
18
|
+
* the reform (which items you do), not on the money you type in: each item
|
|
19
|
+
* captures a share ("weight") of the median→P75 spread. Costs only affect the
|
|
20
|
+
* payback. This prevents "cheap reform + full P75 rent" from inflating yields.
|
|
21
|
+
*
|
|
22
|
+
* Default costs are rough Dubai light-reform figures per sqft; edit them freely.
|
|
21
23
|
*/
|
|
24
|
+
const ITEMS = {
|
|
25
|
+
mobiliario: { label: "Mobiliario", defaults: { comprado: 45, medida: 100 }, weights: { comprado: 0.4, medida: 0.5 } },
|
|
26
|
+
suelo: { label: "Suelo", perSqft: 55, weight: 0.25 },
|
|
27
|
+
paredes: { label: "Paredes (pintura y arreglos)", perSqft: 12, weight: 0.2 },
|
|
28
|
+
techos: { label: "Techos (falso techo e iluminación)", perSqft: 20, weight: 0.1 },
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
type FurnitureType = "comprado" | "medida";
|
|
32
|
+
|
|
33
|
+
interface ReformState {
|
|
34
|
+
mobiliario: { on: boolean; tipo: FurnitureType; cost: number };
|
|
35
|
+
suelo: { on: boolean; cost: number };
|
|
36
|
+
paredes: { on: boolean; cost: number };
|
|
37
|
+
techos: { on: boolean; cost: number };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function defaultState(sizeSqft: number | null): ReformState {
|
|
41
|
+
const s = sizeSqft ?? 500;
|
|
42
|
+
return {
|
|
43
|
+
mobiliario: { on: true, tipo: "comprado", cost: Math.round(s * ITEMS.mobiliario.defaults.comprado) },
|
|
44
|
+
suelo: { on: true, cost: Math.round(s * ITEMS.suelo.perSqft) },
|
|
45
|
+
paredes: { on: true, cost: Math.round(s * ITEMS.paredes.perSqft) },
|
|
46
|
+
techos: { on: false, cost: Math.round(s * ITEMS.techos.perSqft) },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const REFORM_HELP =
|
|
51
|
+
"Comparamos los alquileres de pisos de TAMAÑO SIMILAR (±20%) del mismo edificio (24 meses). " +
|
|
52
|
+
"P25 = piso sin reformar; P75 = bien reformado; mediana = el típico. La reforma solo puede capturar " +
|
|
53
|
+
"el margen mediana→P75, y CADA PARTIDA captura una parte: mobiliario 40% (50% si es a medida), " +
|
|
54
|
+
"suelo 25%, paredes 20%, techos 10%. Marcar o desmarcar partidas cambia la renta esperada; " +
|
|
55
|
+
"el coste que escribas solo cambia el payback — así una reforma barata no infla la rentabilidad.";
|
|
56
|
+
|
|
57
|
+
/** Small "?" badge with a hover tooltip. */
|
|
58
|
+
function InfoTip({ text }: { text: string }) {
|
|
59
|
+
return (
|
|
60
|
+
<span className="group relative ml-1 inline-flex align-middle normal-case">
|
|
61
|
+
<span className="flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-neutral-400 text-[10px] font-bold text-neutral-500 dark:border-neutral-500">
|
|
62
|
+
?
|
|
63
|
+
</span>
|
|
64
|
+
<span className="pointer-events-none absolute left-0 top-5 z-30 w-72 rounded-md border border-neutral-200 bg-white p-2.5 text-[11px] font-normal leading-snug text-neutral-600 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
|
|
65
|
+
{text}
|
|
66
|
+
</span>
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
22
71
|
export default function ReformScenario({ listingId, price, sizeSqft, analysis }: Props) {
|
|
23
|
-
const [
|
|
24
|
-
const fallback =
|
|
72
|
+
const [state, setState] = useState<ReformState>(() => {
|
|
73
|
+
const fallback = defaultState(sizeSqft);
|
|
25
74
|
if (typeof window === "undefined") return fallback;
|
|
26
|
-
|
|
27
|
-
|
|
75
|
+
try {
|
|
76
|
+
const saved = JSON.parse(localStorage.getItem(`bw-reform-${listingId}`) ?? "");
|
|
77
|
+
// Older versions stored a plain number — ignore those.
|
|
78
|
+
if (saved && typeof saved === "object" && saved.mobiliario) return saved as ReformState;
|
|
79
|
+
} catch {}
|
|
80
|
+
return fallback;
|
|
28
81
|
});
|
|
29
82
|
|
|
30
83
|
const { rent_p25, rent_p75, median_rent, rent_n, asking_yield } = analysis;
|
|
31
84
|
if (rent_p25 == null || rent_p75 == null || median_rent == null || rent_n < 3) return null;
|
|
32
85
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
86
|
+
const update = (next: ReformState) => {
|
|
87
|
+
setState(next);
|
|
88
|
+
localStorage.setItem(`bw-reform-${listingId}`, JSON.stringify(next));
|
|
36
89
|
};
|
|
37
90
|
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
91
|
+
const uplift = rent_p75 - median_rent; // full spread captured by a complete reform
|
|
92
|
+
const weightOf = {
|
|
93
|
+
mobiliario: state.mobiliario.on ? ITEMS.mobiliario.weights[state.mobiliario.tipo] : 0,
|
|
94
|
+
suelo: state.suelo.on ? ITEMS.suelo.weight : 0,
|
|
95
|
+
paredes: state.paredes.on ? ITEMS.paredes.weight : 0,
|
|
96
|
+
techos: state.techos.on ? ITEMS.techos.weight : 0,
|
|
97
|
+
};
|
|
98
|
+
const scope = Math.min(1, weightOf.mobiliario + weightOf.suelo + weightOf.paredes + weightOf.techos);
|
|
99
|
+
const totalCost =
|
|
100
|
+
(state.mobiliario.on ? state.mobiliario.cost : 0) +
|
|
101
|
+
(state.suelo.on ? state.suelo.cost : 0) +
|
|
102
|
+
(state.paredes.on ? state.paredes.cost : 0) +
|
|
103
|
+
(state.techos.on ? state.techos.cost : 0);
|
|
104
|
+
|
|
105
|
+
const expectedRent = median_rent + uplift * scope;
|
|
106
|
+
const capturedUplift = expectedRent - median_rent;
|
|
107
|
+
const postReformYield = expectedRent / (price + totalCost);
|
|
108
|
+
const paybackYears = capturedUplift > 0 && totalCost > 0 ? totalCost / capturedUplift : null;
|
|
41
109
|
const spread = (rent_p75 - rent_p25) / median_rent;
|
|
110
|
+
const medianPct =
|
|
111
|
+
rent_p75 > rent_p25 ? Math.min(96, Math.max(4, ((median_rent - rent_p25) / (rent_p75 - rent_p25)) * 100)) : 50;
|
|
42
112
|
|
|
43
113
|
const spreadReading =
|
|
44
114
|
spread >= 0.15
|
|
@@ -51,16 +121,48 @@ export default function ReformScenario({ listingId, price, sizeSqft, analysis }:
|
|
|
51
121
|
? `${Math.round(sizeSqft * (1 - SIZE_TOLERANCE))}–${Math.round(sizeSqft * (1 + SIZE_TOLERANCE))} sqft`
|
|
52
122
|
: null;
|
|
53
123
|
|
|
124
|
+
const rowCls = "flex items-center gap-2 py-1";
|
|
125
|
+
const inputCls =
|
|
126
|
+
"w-24 rounded border border-neutral-300 bg-white px-1.5 py-0.5 text-right text-xs outline-none focus:border-blue-500 disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-950";
|
|
127
|
+
|
|
128
|
+
const itemRow = (
|
|
129
|
+
key: "suelo" | "paredes" | "techos",
|
|
130
|
+
label: string,
|
|
131
|
+
weight: number
|
|
132
|
+
) => (
|
|
133
|
+
<label className={rowCls}>
|
|
134
|
+
<input
|
|
135
|
+
type="checkbox"
|
|
136
|
+
checked={state[key].on}
|
|
137
|
+
onChange={(e) => update({ ...state, [key]: { ...state[key], on: e.target.checked } })}
|
|
138
|
+
className="h-3.5 w-3.5 accent-blue-600"
|
|
139
|
+
/>
|
|
140
|
+
<span className="flex-1 text-xs">{label}</span>
|
|
141
|
+
<span className="text-[10px] text-neutral-400">+{Math.round(weight * 100)}%</span>
|
|
142
|
+
<input
|
|
143
|
+
type="number"
|
|
144
|
+
min={0}
|
|
145
|
+
step={1000}
|
|
146
|
+
disabled={!state[key].on}
|
|
147
|
+
value={state[key].cost}
|
|
148
|
+
suppressHydrationWarning
|
|
149
|
+
onChange={(e) => update({ ...state, [key]: { ...state[key], cost: Number(e.target.value) } })}
|
|
150
|
+
className={inputCls}
|
|
151
|
+
/>
|
|
152
|
+
</label>
|
|
153
|
+
);
|
|
154
|
+
|
|
54
155
|
return (
|
|
55
156
|
<div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
|
56
157
|
<h3 className="text-sm font-bold">
|
|
57
|
-
Escenario de reforma
|
|
158
|
+
Escenario de reforma
|
|
159
|
+
<InfoTip text={REFORM_HELP} />{" "}
|
|
58
160
|
<span className="font-normal text-neutral-500">
|
|
59
161
|
({rent_n} alquileres comparables{band ? ` · ${band}` : ""})
|
|
60
162
|
</span>
|
|
61
163
|
</h3>
|
|
62
164
|
|
|
63
|
-
<div className="mt-3 grid gap-4 lg:grid-cols-[
|
|
165
|
+
<div className="mt-3 grid gap-4 lg:grid-cols-[1fr_auto_1.15fr]">
|
|
64
166
|
{/* Rent range of comparables */}
|
|
65
167
|
<div>
|
|
66
168
|
<div className="mb-1 flex justify-between text-[11px] text-neutral-500">
|
|
@@ -68,20 +170,32 @@ export default function ReformScenario({ listingId, price, sizeSqft, analysis }:
|
|
|
68
170
|
<span>mediana</span>
|
|
69
171
|
<span>P75 (reformado)</span>
|
|
70
172
|
</div>
|
|
71
|
-
<div className="relative h-2 rounded-full bg-
|
|
173
|
+
<div className="relative h-2.5 rounded-full bg-neutral-200 dark:bg-neutral-800">
|
|
72
174
|
{rent_p75 > rent_p25 && (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
175
|
+
<>
|
|
176
|
+
{/* upside captured by the CURRENT scope */}
|
|
177
|
+
<div
|
|
178
|
+
className="absolute inset-y-0 rounded-r-full bg-emerald-400 dark:bg-emerald-600"
|
|
179
|
+
style={{ left: `${medianPct}%`, width: `${(100 - medianPct) * scope}%` }}
|
|
180
|
+
title={`Margen capturado por esta reforma (${Math.round(scope * 100)}%)`}
|
|
181
|
+
/>
|
|
182
|
+
<span
|
|
183
|
+
className="absolute -top-[3px] h-[17px] w-[3px] -translate-x-1/2 rounded bg-neutral-900 dark:bg-white"
|
|
184
|
+
style={{ left: `${medianPct}%` }}
|
|
185
|
+
title={`mediana ${fmtAED(median_rent)}`}
|
|
186
|
+
/>
|
|
187
|
+
</>
|
|
78
188
|
)}
|
|
79
189
|
</div>
|
|
80
|
-
<div className="mt-1 flex justify-between text-xs font-semibold">
|
|
190
|
+
<div className="mt-1.5 flex justify-between text-xs font-semibold">
|
|
81
191
|
<span>{fmtAED(rent_p25)}</span>
|
|
82
192
|
<span className="text-neutral-500">{fmtAED(median_rent)}</span>
|
|
83
193
|
<span>{fmtAED(rent_p75)}</span>
|
|
84
194
|
</div>
|
|
195
|
+
<div className="mt-1 flex items-center gap-1.5 text-[10px] text-neutral-500">
|
|
196
|
+
<span className="inline-block h-2 w-3 rounded-sm bg-emerald-400 dark:bg-emerald-600" />
|
|
197
|
+
margen capturado por esta reforma ({Math.round(scope * 100)}% del total)
|
|
198
|
+
</div>
|
|
85
199
|
<p className={`mt-2 text-xs ${spreadReading.cls}`}>
|
|
86
200
|
Dispersión {fmtPct(spread, 0)} de la mediana — {spreadReading.txt}
|
|
87
201
|
</p>
|
|
@@ -89,30 +203,71 @@ export default function ReformScenario({ listingId, price, sizeSqft, analysis }:
|
|
|
89
203
|
|
|
90
204
|
<div className="hidden w-px bg-neutral-200 lg:block dark:bg-neutral-800" />
|
|
91
205
|
|
|
92
|
-
{/*
|
|
206
|
+
{/* Itemized reform */}
|
|
93
207
|
<div className="text-sm">
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
208
|
+
<div className="mb-1 flex items-center justify-between">
|
|
209
|
+
<span className="text-xs font-semibold text-neutral-600 dark:text-neutral-400">Partidas de la reforma</span>
|
|
210
|
+
<span className="text-[10px] text-neutral-400">% del margen · coste AED</span>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{/* Mobiliario: checkbox + type selector */}
|
|
214
|
+
<label className={rowCls}>
|
|
215
|
+
<input
|
|
216
|
+
type="checkbox"
|
|
217
|
+
checked={state.mobiliario.on}
|
|
218
|
+
onChange={(e) => update({ ...state, mobiliario: { ...state.mobiliario, on: e.target.checked } })}
|
|
219
|
+
className="h-3.5 w-3.5 accent-blue-600"
|
|
220
|
+
/>
|
|
221
|
+
<span className="text-xs">Mobiliario</span>
|
|
222
|
+
<select
|
|
223
|
+
disabled={!state.mobiliario.on}
|
|
224
|
+
value={state.mobiliario.tipo}
|
|
225
|
+
onChange={(e) => {
|
|
226
|
+
const tipo = e.target.value as FurnitureType;
|
|
227
|
+
const cost = sizeSqft ? Math.round(sizeSqft * ITEMS.mobiliario.defaults[tipo]) : state.mobiliario.cost;
|
|
228
|
+
update({ ...state, mobiliario: { ...state.mobiliario, tipo, cost } });
|
|
229
|
+
}}
|
|
230
|
+
className="rounded border border-neutral-300 bg-white px-1 py-0.5 text-[11px] disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-950"
|
|
231
|
+
>
|
|
232
|
+
<option value="comprado">comprado</option>
|
|
233
|
+
<option value="medida">a medida</option>
|
|
234
|
+
</select>
|
|
235
|
+
<span className="flex-1" />
|
|
236
|
+
<span className="text-[10px] text-neutral-400">
|
|
237
|
+
+{Math.round(ITEMS.mobiliario.weights[state.mobiliario.tipo] * 100)}%
|
|
238
|
+
</span>
|
|
239
|
+
<input
|
|
240
|
+
type="number"
|
|
241
|
+
min={0}
|
|
242
|
+
step={1000}
|
|
243
|
+
disabled={!state.mobiliario.on}
|
|
244
|
+
value={state.mobiliario.cost}
|
|
245
|
+
suppressHydrationWarning
|
|
246
|
+
onChange={(e) => update({ ...state, mobiliario: { ...state.mobiliario, cost: Number(e.target.value) } })}
|
|
247
|
+
className={inputCls}
|
|
248
|
+
/>
|
|
97
249
|
</label>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
250
|
+
|
|
251
|
+
{itemRow("suelo", "Suelo", ITEMS.suelo.weight)}
|
|
252
|
+
{itemRow("paredes", "Paredes (pintura y arreglos)", ITEMS.paredes.weight)}
|
|
253
|
+
{itemRow("techos", "Techos (falso techo, iluminación)", ITEMS.techos.weight)}
|
|
254
|
+
|
|
255
|
+
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-1.5 border-t border-neutral-200 pt-2 text-xs dark:border-neutral-800">
|
|
256
|
+
<span className="text-neutral-500">Coste total</span>
|
|
257
|
+
<b>{fmtAED(totalCost)}</b>
|
|
258
|
+
<span className="text-neutral-500">Renta esperada post-reforma</span>
|
|
259
|
+
<b>
|
|
260
|
+
{fmtAED(Math.round(expectedRent))}
|
|
261
|
+
<span className="ml-1 font-normal text-neutral-400">({Math.round(scope * 100)}% del margen)</span>
|
|
262
|
+
</b>
|
|
108
263
|
<span className="text-neutral-500">Yield actual (mediana / precio)</span>
|
|
109
264
|
<b>{fmtPct(asking_yield)}</b>
|
|
110
|
-
<span className="text-neutral-500">Yield post-reforma
|
|
265
|
+
<span className="text-neutral-500">Yield post-reforma</span>
|
|
111
266
|
<b className={postReformYield > (asking_yield ?? 0) ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
|
|
112
267
|
{fmtPct(postReformYield)}
|
|
113
268
|
</b>
|
|
114
|
-
<span className="text-neutral-500">Renta extra al año
|
|
115
|
-
<b>{fmtAED(
|
|
269
|
+
<span className="text-neutral-500">Renta extra al año</span>
|
|
270
|
+
<b>{fmtAED(Math.round(capturedUplift))}</b>
|
|
116
271
|
<span className="text-neutral-500">Payback de la reforma</span>
|
|
117
272
|
<b>{paybackYears != null ? `${paybackYears.toFixed(1)} años` : "—"}</b>
|
|
118
273
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fmtPct } from "@/lib/format";
|
|
2
|
+
import { yieldColor } from "@/lib/yieldColor";
|
|
2
3
|
|
|
3
4
|
export default function YieldBadge({ value, n }: { value: number | null; n?: number }) {
|
|
4
5
|
if (value == null)
|
|
@@ -7,18 +8,13 @@ export default function YieldBadge({ value, n }: { value: number | null; n?: num
|
|
|
7
8
|
sin datos
|
|
8
9
|
</span>
|
|
9
10
|
);
|
|
10
|
-
const color =
|
|
11
|
-
value >= 0.07
|
|
12
|
-
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
|
13
|
-
: value >= 0.055
|
|
14
|
-
? "bg-lime-100 text-lime-800 dark:bg-lime-950 dark:text-lime-300"
|
|
15
|
-
: value >= 0.04
|
|
16
|
-
? "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
|
17
|
-
: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300";
|
|
18
11
|
return (
|
|
19
|
-
<span
|
|
12
|
+
<span
|
|
13
|
+
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
|
14
|
+
style={{ background: yieldColor(value) }}
|
|
15
|
+
>
|
|
20
16
|
{fmtPct(value)}
|
|
21
|
-
{n != null && <span className="font-normal opacity-
|
|
17
|
+
{n != null && <span className="font-normal opacity-80">({n} tx)</span>}
|
|
22
18
|
</span>
|
|
23
19
|
);
|
|
24
20
|
}
|
package/src/lib/db.ts
CHANGED
|
@@ -100,6 +100,14 @@ CREATE TABLE IF NOT EXISTS gyms_list_cache (
|
|
|
100
100
|
fetched_at TEXT NOT NULL
|
|
101
101
|
);
|
|
102
102
|
|
|
103
|
+
CREATE TABLE IF NOT EXISTS poi_list_cache (
|
|
104
|
+
geo_key TEXT NOT NULL,
|
|
105
|
+
kind TEXT NOT NULL,
|
|
106
|
+
json TEXT NOT NULL,
|
|
107
|
+
fetched_at TEXT NOT NULL,
|
|
108
|
+
PRIMARY KEY (geo_key, kind)
|
|
109
|
+
);
|
|
110
|
+
|
|
103
111
|
CREATE TABLE IF NOT EXISTS favorites (
|
|
104
112
|
listing_id TEXT PRIMARY KEY REFERENCES listings(id),
|
|
105
113
|
notes TEXT NOT NULL DEFAULT '',
|
package/src/lib/gyms.ts
CHANGED
|
@@ -1,104 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
import { getDb, now } from "./db";
|
|
1
|
+
import { poisNear, Poi } from "./pois";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
const OVERPASS_ENDPOINTS = [
|
|
6
|
-
"https://overpass-api.de/api/interpreter",
|
|
7
|
-
"https://overpass-api.de/api/interpreter",
|
|
8
|
-
"https://overpass.private.coffee/api/interpreter",
|
|
9
|
-
"https://overpass.kumi.systems/api/interpreter",
|
|
10
|
-
];
|
|
11
|
-
const RADIUS_M = 2000;
|
|
12
|
-
const TTL_MS = 30 * 24 * 3600 * 1000;
|
|
13
|
-
|
|
14
|
-
export interface Gym {
|
|
15
|
-
name: string;
|
|
16
|
-
lat: number;
|
|
17
|
-
lon: number;
|
|
18
|
-
distance_m: number;
|
|
19
|
-
}
|
|
3
|
+
export type Gym = Poi;
|
|
20
4
|
|
|
21
5
|
export interface GymResult {
|
|
22
6
|
gym_name: string | null;
|
|
23
7
|
distance_m: number | null;
|
|
24
8
|
}
|
|
25
9
|
|
|
26
|
-
|
|
27
|
-
const R = 6371000;
|
|
28
|
-
const toRad = (d: number) => (d * Math.PI) / 180;
|
|
29
|
-
const dLat = toRad(lat2 - lat1);
|
|
30
|
-
const dLon = toRad(lon2 - lon1);
|
|
31
|
-
const a =
|
|
32
|
-
Math.sin(dLat / 2) ** 2 +
|
|
33
|
-
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
|
34
|
-
return 2 * R * Math.asin(Math.sqrt(a));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Overpass allows very few concurrent requests per IP — serialize ours.
|
|
38
|
-
let queue: Promise<unknown> = Promise.resolve();
|
|
39
|
-
|
|
40
|
-
/** All gyms (OSM fitness centres) within 2 km, nearest first; cached by ~100 m grid cell. */
|
|
10
|
+
/** All gyms (OSM fitness centres) within 2 km, nearest first; cached. */
|
|
41
11
|
export function gymsNear(lat: number, lon: number): Promise<Gym[] | null> {
|
|
42
|
-
|
|
43
|
-
queue = task.catch(() => null);
|
|
44
|
-
return task;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** null = Overpass unavailable (not cached); [] = confirmed no gyms nearby. */
|
|
48
|
-
async function doGymsNear(lat: number, lon: number): Promise<Gym[] | null> {
|
|
49
|
-
const db = getDb();
|
|
50
|
-
const geoKey = `${lat.toFixed(3)},${lon.toFixed(3)}`;
|
|
51
|
-
const cached = db.prepare(`SELECT * FROM gyms_list_cache WHERE geo_key = ?`).get(geoKey) as any;
|
|
52
|
-
if (cached && Date.now() - Date.parse(cached.fetched_at) < TTL_MS) {
|
|
53
|
-
return JSON.parse(cached.gyms_json);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const query = `[out:json][timeout:15];(node["leisure"="fitness_centre"](around:${RADIUS_M},${lat},${lon});way["leisure"="fitness_centre"](around:${RADIUS_M},${lat},${lon}););out center 60;`;
|
|
57
|
-
for (const endpoint of OVERPASS_ENDPOINTS) {
|
|
58
|
-
try {
|
|
59
|
-
const res = await fetch(endpoint, {
|
|
60
|
-
method: "POST",
|
|
61
|
-
body: "data=" + encodeURIComponent(query),
|
|
62
|
-
headers: {
|
|
63
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
64
|
-
// overpass-api.de answers 406 to requests without a User-Agent
|
|
65
|
-
"User-Agent": "brickwise-local/0.1 (personal use)",
|
|
66
|
-
},
|
|
67
|
-
signal: AbortSignal.timeout(20000),
|
|
68
|
-
});
|
|
69
|
-
if (!res.ok) continue;
|
|
70
|
-
const j = await res.json();
|
|
71
|
-
// Overloaded instances answer 200 + empty elements + a "remark" runtime error.
|
|
72
|
-
if (j.remark && !(j.elements || []).length) continue;
|
|
73
|
-
const gyms: Gym[] = [];
|
|
74
|
-
for (const el of j.elements || []) {
|
|
75
|
-
const eLat = el.lat ?? el.center?.lat;
|
|
76
|
-
const eLon = el.lon ?? el.center?.lon;
|
|
77
|
-
if (eLat == null || eLon == null) continue;
|
|
78
|
-
gyms.push({
|
|
79
|
-
name: el.tags?.name || "Gym",
|
|
80
|
-
lat: eLat,
|
|
81
|
-
lon: eLon,
|
|
82
|
-
distance_m: Math.round(haversineM(lat, lon, eLat, eLon)),
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
gyms.sort((a, b) => a.distance_m - b.distance_m);
|
|
86
|
-
db.prepare(
|
|
87
|
-
`INSERT OR REPLACE INTO gyms_list_cache (geo_key, gyms_json, fetched_at) VALUES (?, ?, ?)`
|
|
88
|
-
).run(geoKey, JSON.stringify(gyms), now());
|
|
89
|
-
return gyms;
|
|
90
|
-
} catch {
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return null; // all endpoints down: unknown, not cached
|
|
12
|
+
return poisNear(lat, lon, "gym");
|
|
95
13
|
}
|
|
96
14
|
|
|
97
15
|
/** Nearest gym within 2 km (derived from the cached list). */
|
|
98
16
|
export async function nearestGym(lat: number, lon: number): Promise<GymResult> {
|
|
99
17
|
const gyms = await gymsNear(lat, lon);
|
|
100
18
|
const best = gyms?.[0];
|
|
101
|
-
return best
|
|
102
|
-
? { gym_name: best.name, distance_m: best.distance_m }
|
|
103
|
-
: { gym_name: null, distance_m: null };
|
|
19
|
+
return best ? { gym_name: best.name, distance_m: best.distance_m } : { gym_name: null, distance_m: null };
|
|
104
20
|
}
|
package/src/lib/pois.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import { getDb, now } from "./db";
|
|
4
|
+
|
|
5
|
+
// Overpass instances are flaky under load; try each twice, in order.
|
|
6
|
+
const OVERPASS_ENDPOINTS = [
|
|
7
|
+
"https://overpass-api.de/api/interpreter",
|
|
8
|
+
"https://overpass-api.de/api/interpreter",
|
|
9
|
+
"https://overpass.private.coffee/api/interpreter",
|
|
10
|
+
"https://overpass.kumi.systems/api/interpreter",
|
|
11
|
+
];
|
|
12
|
+
const RADIUS_M = 2000;
|
|
13
|
+
const TTL_MS = 30 * 24 * 3600 * 1000;
|
|
14
|
+
|
|
15
|
+
export type PoiKind = "gym" | "cafe";
|
|
16
|
+
|
|
17
|
+
// OSM tag filters + default label per kind.
|
|
18
|
+
const POI = {
|
|
19
|
+
gym: { tag: `["leisure"="fitness_centre"]`, label: "Gym" },
|
|
20
|
+
cafe: { tag: `["amenity"="cafe"]`, label: "Café" },
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export interface Poi {
|
|
24
|
+
name: string;
|
|
25
|
+
lat: number;
|
|
26
|
+
lon: number;
|
|
27
|
+
distance_m: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function haversineM(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
|
31
|
+
const R = 6371000;
|
|
32
|
+
const toRad = (d: number) => (d * Math.PI) / 180;
|
|
33
|
+
const dLat = toRad(lat2 - lat1);
|
|
34
|
+
const dLon = toRad(lon2 - lon1);
|
|
35
|
+
const a =
|
|
36
|
+
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
|
37
|
+
return 2 * R * Math.asin(Math.sqrt(a));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Overpass allows very few concurrent requests per IP — serialize ours.
|
|
41
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
42
|
+
|
|
43
|
+
/** All POIs of `kind` within 2 km, nearest first; cached by ~100 m grid cell. */
|
|
44
|
+
export function poisNear(lat: number, lon: number, kind: PoiKind): Promise<Poi[] | null> {
|
|
45
|
+
const task = queue.then(() => doPoisNear(lat, lon, kind));
|
|
46
|
+
queue = task.catch(() => null);
|
|
47
|
+
return task;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** null = Overpass unavailable (not cached); [] = confirmed none nearby. */
|
|
51
|
+
async function doPoisNear(lat: number, lon: number, kind: PoiKind): Promise<Poi[] | null> {
|
|
52
|
+
const db = getDb();
|
|
53
|
+
const geoKey = `${lat.toFixed(3)},${lon.toFixed(3)}`;
|
|
54
|
+
const cached = db.prepare(`SELECT * FROM poi_list_cache WHERE geo_key = ? AND kind = ?`).get(geoKey, kind) as any;
|
|
55
|
+
if (cached && Date.now() - Date.parse(cached.fetched_at) < TTL_MS) {
|
|
56
|
+
return JSON.parse(cached.json);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tag = POI[kind].tag;
|
|
60
|
+
const query = `[out:json][timeout:15];(node${tag}(around:${RADIUS_M},${lat},${lon});way${tag}(around:${RADIUS_M},${lat},${lon}););out center 80;`;
|
|
61
|
+
const j = await overpassJson(query, 12000);
|
|
62
|
+
if (!j) return null; // all endpoints down: unknown, not cached
|
|
63
|
+
const pois: Poi[] = [];
|
|
64
|
+
for (const el of j.elements || []) {
|
|
65
|
+
const eLat = el.lat ?? el.center?.lat;
|
|
66
|
+
const eLon = el.lon ?? el.center?.lon;
|
|
67
|
+
if (eLat == null || eLon == null) continue;
|
|
68
|
+
pois.push({
|
|
69
|
+
name: el.tags?.name || POI[kind].label,
|
|
70
|
+
lat: eLat,
|
|
71
|
+
lon: eLon,
|
|
72
|
+
distance_m: Math.round(haversineM(lat, lon, eLat, eLon)),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
pois.sort((a, b) => a.distance_m - b.distance_m);
|
|
76
|
+
db.prepare(`INSERT OR REPLACE INTO poi_list_cache (geo_key, kind, json, fetched_at) VALUES (?, ?, ?, ?)`).run(
|
|
77
|
+
geoKey,
|
|
78
|
+
kind,
|
|
79
|
+
JSON.stringify(pois),
|
|
80
|
+
now()
|
|
81
|
+
);
|
|
82
|
+
return pois;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** POST an Overpass query, trying each endpoint until one answers; null if all fail. */
|
|
86
|
+
async function overpassJson(query: string, timeoutMs: number): Promise<any | null> {
|
|
87
|
+
for (const endpoint of OVERPASS_ENDPOINTS) {
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(endpoint, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
body: "data=" + encodeURIComponent(query),
|
|
92
|
+
headers: {
|
|
93
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
94
|
+
"User-Agent": "brickwise-local/0.1 (personal use)",
|
|
95
|
+
},
|
|
96
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok) continue;
|
|
99
|
+
const j = await res.json();
|
|
100
|
+
if (j.remark && !(j.elements || []).length) continue;
|
|
101
|
+
return j;
|
|
102
|
+
} catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* All POIs of `kind` near ANY of the given points, in a SINGLE Overpass query
|
|
111
|
+
* (one request for the whole map, not one per property). Cached by the point set.
|
|
112
|
+
*/
|
|
113
|
+
export function poisForPoints(
|
|
114
|
+
points: { lat: number; lon: number }[],
|
|
115
|
+
kind: PoiKind
|
|
116
|
+
): Promise<{ pois: Poi[]; failed: boolean }> {
|
|
117
|
+
const task = queue.then(() => doPoisForPoints(points, kind));
|
|
118
|
+
queue = task.catch(() => ({ pois: [] as Poi[], failed: true }));
|
|
119
|
+
return task;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function doPoisForPoints(
|
|
123
|
+
points: { lat: number; lon: number }[],
|
|
124
|
+
kind: PoiKind
|
|
125
|
+
): Promise<{ pois: Poi[]; failed: boolean }> {
|
|
126
|
+
const db = getDb();
|
|
127
|
+
// One ~100 m cell per cluster of nearby favorites (key format == doPoisNear's).
|
|
128
|
+
const cellMap = new Map<string, { lat: number; lon: number }>();
|
|
129
|
+
for (const p of points) {
|
|
130
|
+
const key = `${p.lat.toFixed(3)},${p.lon.toFixed(3)}`;
|
|
131
|
+
if (!cellMap.has(key)) cellMap.set(key, { lat: p.lat, lon: p.lon });
|
|
132
|
+
}
|
|
133
|
+
if (!cellMap.size) return { pois: [], failed: false };
|
|
134
|
+
|
|
135
|
+
const dedupe = (list: Poi[]) => {
|
|
136
|
+
const m = new Map<string, Poi>();
|
|
137
|
+
for (const p of list) m.set(`${p.lat.toFixed(5)},${p.lon.toFixed(5)}`, p);
|
|
138
|
+
return [...m.values()];
|
|
139
|
+
};
|
|
140
|
+
const fresh = (row: any) => row && Date.now() - Date.parse(row.fetched_at) < TTL_MS;
|
|
141
|
+
const get = (key: string) => db.prepare(`SELECT * FROM poi_list_cache WHERE geo_key = ? AND kind = ?`).get(key, kind) as any;
|
|
142
|
+
const put = (key: string, pois: Poi[]) =>
|
|
143
|
+
db.prepare(`INSERT OR REPLACE INTO poi_list_cache (geo_key, kind, json, fetched_at) VALUES (?, ?, ?, ?)`).run(key, kind, JSON.stringify(pois), now());
|
|
144
|
+
|
|
145
|
+
const allKeys = [...cellMap.keys()].sort();
|
|
146
|
+
const multiKey = "multi:" + createHash("md5").update(allKeys.join(";")).digest("hex");
|
|
147
|
+
|
|
148
|
+
// 1) Whole-set cache.
|
|
149
|
+
const multi = get(multiKey);
|
|
150
|
+
if (fresh(multi)) return { pois: JSON.parse(multi.json), failed: false };
|
|
151
|
+
|
|
152
|
+
// 2) Reuse per-cell cache; only query Overpass for what's missing.
|
|
153
|
+
const collected: Poi[] = [];
|
|
154
|
+
const missing: { lat: number; lon: number }[] = [];
|
|
155
|
+
for (const [key, c] of cellMap) {
|
|
156
|
+
const cc = get(key);
|
|
157
|
+
if (fresh(cc)) collected.push(...(JSON.parse(cc.json) as Poi[]));
|
|
158
|
+
else missing.push(c);
|
|
159
|
+
}
|
|
160
|
+
if (missing.length === 0) {
|
|
161
|
+
const pois = dedupe(collected);
|
|
162
|
+
put(multiKey, pois);
|
|
163
|
+
return { pois, failed: false };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 3) One combined Overpass query for the missing cells.
|
|
167
|
+
const tag = POI[kind].tag;
|
|
168
|
+
const clauses = missing
|
|
169
|
+
.map((c) => `node${tag}(around:${RADIUS_M},${c.lat},${c.lon});way${tag}(around:${RADIUS_M},${c.lat},${c.lon});`)
|
|
170
|
+
.join("");
|
|
171
|
+
const query = `[out:json][timeout:90];(${clauses});out center 600;`;
|
|
172
|
+
const j = await overpassJson(query, 40000);
|
|
173
|
+
if (!j) {
|
|
174
|
+
// 4) Overpass down: return whatever we already had cached (partial).
|
|
175
|
+
const pois = dedupe(collected);
|
|
176
|
+
return { pois, failed: pois.length === 0 };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const el of j.elements || []) {
|
|
180
|
+
const eLat = el.lat ?? el.center?.lat;
|
|
181
|
+
const eLon = el.lon ?? el.center?.lon;
|
|
182
|
+
if (eLat == null || eLon == null) continue;
|
|
183
|
+
collected.push({ name: el.tags?.name || POI[kind].label, lat: eLat, lon: eLon, distance_m: 0 });
|
|
184
|
+
}
|
|
185
|
+
const pois = dedupe(collected);
|
|
186
|
+
put(multiKey, pois);
|
|
187
|
+
return { pois, failed: false };
|
|
188
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Shared yield → color scale used by both the map pins and the list badge.
|
|
2
|
+
// ≤6% red (mediocre) → 10%+ turquoise (espectacular); 8–9% around green (bueno).
|
|
3
|
+
export const YIELD_LOW = 0.06;
|
|
4
|
+
export const YIELD_HIGH = 0.1;
|
|
5
|
+
|
|
6
|
+
export function yieldColor(y: number | null): string {
|
|
7
|
+
if (y == null) return "#8a8a8a";
|
|
8
|
+
const t = Math.min(1, Math.max(0, (y - YIELD_LOW) / (YIELD_HIGH - YIELD_LOW)));
|
|
9
|
+
const hue = t * 174; // 0° red → 174° turquoise
|
|
10
|
+
return `hsl(${Math.round(hue)}, 66%, 44%)`;
|
|
11
|
+
}
|