brickwise 0.1.15 → 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/liquidity/route.ts +35 -0
- package/src/app/favorites/page.tsx +158 -26
- package/src/app/listing/[id]/page.tsx +113 -39
- package/src/lib/store.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brickwise",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"description": "App local para analizar la rentabilidad de alquiler de inmuebles de Property Finder UAE con el histórico real de transacciones DLD.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "eljommys",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"better-sqlite3": "^12.11.1",
|
|
45
45
|
"leaflet": "^1.9.4",
|
|
46
|
+
"leaflet.heat": "^0.2.0",
|
|
46
47
|
"next": "16.2.10",
|
|
47
48
|
"react": "19.2.4",
|
|
48
49
|
"react-dom": "19.2.4"
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
"@tailwindcss/postcss": "^4",
|
|
52
53
|
"@types/better-sqlite3": "^7.6.13",
|
|
53
54
|
"@types/leaflet": "^1.9.21",
|
|
55
|
+
"@types/leaflet.heat": "^0.2.5",
|
|
54
56
|
"@types/node": "^20",
|
|
55
57
|
"@types/react": "^19",
|
|
56
58
|
"@types/react-dom": "^19",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { listFavorites, txCountByTowerSince } from "@/lib/store";
|
|
3
|
+
|
|
4
|
+
const MONTHS = 12;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Liquidity of each located favorite's tower = DLD transactions registered in the
|
|
8
|
+
* last 12 months, split by kind via ?kind=buy|rent (defaults to rent). Feeds the
|
|
9
|
+
* heatmap: each favorite is a weighted point, so nearby towers blend into a
|
|
10
|
+
* "how active is this pocket" surface for that market (rentals vs sales).
|
|
11
|
+
*/
|
|
12
|
+
export async function GET(req: NextRequest) {
|
|
13
|
+
try {
|
|
14
|
+
const kindParam = req.nextUrl.searchParams.get("kind");
|
|
15
|
+
const kind: "buy" | "rent" = kindParam === "buy" ? "buy" : "rent";
|
|
16
|
+
const since = new Date();
|
|
17
|
+
since.setMonth(since.getMonth() - MONTHS);
|
|
18
|
+
const sinceISO = since.toISOString().slice(0, 10);
|
|
19
|
+
const counts = txCountByTowerSince(sinceISO, kind);
|
|
20
|
+
|
|
21
|
+
const points = listFavorites()
|
|
22
|
+
.filter((f) => f.lat != null && f.lon != null)
|
|
23
|
+
.map((f) => ({
|
|
24
|
+
id: f.id,
|
|
25
|
+
lat: f.lat as number,
|
|
26
|
+
lon: f.lon as number,
|
|
27
|
+
weight: f.tower_slug ? counts.get(f.tower_slug) ?? 0 : 0,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const max = points.reduce((m, p) => Math.max(m, p.weight), 0);
|
|
31
|
+
return NextResponse.json({ points, max, months: MONTHS, kind });
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -77,6 +77,21 @@ const SAVED_URL = "https://www.propertyfinder.ae/en/user/saved-properties";
|
|
|
77
77
|
// strips javascript: hrefs.
|
|
78
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)}})();`;
|
|
79
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
|
+
|
|
80
95
|
function LayerToggle({
|
|
81
96
|
label,
|
|
82
97
|
color,
|
|
@@ -123,6 +138,7 @@ export default function FavoritesMapPage() {
|
|
|
123
138
|
const [input, setInput] = useState("");
|
|
124
139
|
const [queue, setQueue] = useState<string[]>([]);
|
|
125
140
|
const [errors, setErrors] = useState<string[]>([]);
|
|
141
|
+
const [skippedMsg, setSkippedMsg] = useState<string | null>(null);
|
|
126
142
|
const [sidebarW, setSidebarW] = useState(() => {
|
|
127
143
|
if (typeof window === "undefined") return 440;
|
|
128
144
|
const saved = Number(localStorage.getItem("bw-sidebar-w"));
|
|
@@ -135,6 +151,9 @@ export default function FavoritesMapPage() {
|
|
|
135
151
|
const [cafesVisible, setCafesVisible] = useState(false);
|
|
136
152
|
const [cafesLoading, setCafesLoading] = useState(false);
|
|
137
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);
|
|
138
157
|
const [showLayers, setShowLayers] = useState(false);
|
|
139
158
|
const [sortBy, setSortBy] = useState<"yield" | "price">("yield");
|
|
140
159
|
const [bedFilter, setBedFilter] = useState<number[]>([]);
|
|
@@ -145,6 +164,7 @@ export default function FavoritesMapPage() {
|
|
|
145
164
|
const markersRef = useRef<Record<string, Marker>>({});
|
|
146
165
|
const gymLayerRef = useRef<LayerGroup | null>(null);
|
|
147
166
|
const cafeLayerRef = useRef<LayerGroup | null>(null);
|
|
167
|
+
const heatLayerRef = useRef<import("leaflet").Layer | null>(null);
|
|
148
168
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
149
169
|
const didFitRef = useRef(false);
|
|
150
170
|
const processing = useRef(false);
|
|
@@ -204,6 +224,16 @@ export default function FavoritesMapPage() {
|
|
|
204
224
|
};
|
|
205
225
|
el.addEventListener("wheel", onWheel, { passive: false });
|
|
206
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
|
+
});
|
|
207
237
|
let tries = 0;
|
|
208
238
|
const timer = setInterval(() => {
|
|
209
239
|
tries++;
|
|
@@ -307,32 +337,62 @@ export default function FavoritesMapPage() {
|
|
|
307
337
|
});
|
|
308
338
|
}, [queue, load]);
|
|
309
339
|
|
|
310
|
-
|
|
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 } => {
|
|
311
352
|
const urls = raw
|
|
312
353
|
.split(/[\n\s,]+/)
|
|
313
354
|
.map((s) => s.trim())
|
|
314
355
|
.filter((s) => s.startsWith("http") && /propertyfinder\.ae/.test(s));
|
|
315
|
-
|
|
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) {
|
|
316
372
|
setErrors([]);
|
|
317
|
-
setQueue((prev) => [...prev, ...
|
|
373
|
+
setQueue((prev) => [...prev, ...fresh]);
|
|
318
374
|
}
|
|
319
|
-
|
|
375
|
+
setSkippedMsg(skipped > 0 ? `${skipped} ya guardado${skipped === 1 ? "" : "s"}, descartado${skipped === 1 ? "" : "s"}.` : null);
|
|
376
|
+
return { added: fresh.length, skipped };
|
|
320
377
|
};
|
|
321
378
|
|
|
322
379
|
const submit = (e: React.FormEvent) => {
|
|
323
380
|
e.preventDefault();
|
|
324
|
-
|
|
381
|
+
const { added, skipped } = enqueueUrls(input);
|
|
382
|
+
if (added > 0 || skipped > 0) setInput("");
|
|
325
383
|
};
|
|
326
384
|
|
|
327
385
|
const pasteFromPf = async () => {
|
|
328
386
|
setSyncMsg(null);
|
|
329
387
|
try {
|
|
330
388
|
const text = await navigator.clipboard.readText();
|
|
331
|
-
const
|
|
389
|
+
const { added, skipped } = enqueueUrls(text);
|
|
332
390
|
setSyncMsg(
|
|
333
|
-
|
|
334
|
-
? `${
|
|
335
|
-
:
|
|
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."
|
|
336
396
|
);
|
|
337
397
|
} catch {
|
|
338
398
|
setSyncMsg("No pude leer el portapapeles. Pega los enlaces manualmente en el cuadro de arriba.");
|
|
@@ -435,6 +495,59 @@ export default function FavoritesMapPage() {
|
|
|
435
495
|
layerRef: cafeLayerRef, visible: cafesVisible, setVisible: setCafesVisible, setLoading: setCafesLoading, setCount: setCafeCount,
|
|
436
496
|
});
|
|
437
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
|
+
});
|
|
541
|
+
layer.addTo(map);
|
|
542
|
+
heatLayerRef.current = layer;
|
|
543
|
+
setHeatKind(kind);
|
|
544
|
+
} catch {
|
|
545
|
+
setHeatKind(null);
|
|
546
|
+
} finally {
|
|
547
|
+
setHeatLoading(null);
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
438
551
|
// --- Resizable divider ---------------------------------------------------
|
|
439
552
|
const startDrag = (e: React.MouseEvent) => {
|
|
440
553
|
e.preventDefault();
|
|
@@ -628,6 +741,9 @@ export default function FavoritesMapPage() {
|
|
|
628
741
|
>
|
|
629
742
|
{busy ? `Analizando ${queue.length}…` : "Añadir y analizar"}
|
|
630
743
|
</button>
|
|
744
|
+
{skippedMsg && (
|
|
745
|
+
<p className="text-[11px] text-neutral-500 dark:text-neutral-400">{skippedMsg}</p>
|
|
746
|
+
)}
|
|
631
747
|
<div className="relative">
|
|
632
748
|
<button
|
|
633
749
|
type="button"
|
|
@@ -635,15 +751,25 @@ export default function FavoritesMapPage() {
|
|
|
635
751
|
disabled={(rows?.length ?? 0) === 0}
|
|
636
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"
|
|
637
753
|
>
|
|
638
|
-
|
|
754
|
+
Capas del mapa
|
|
639
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" : ""}`}>
|
|
640
756
|
<path d="M6 9l6 6 6-6" />
|
|
641
757
|
</svg>
|
|
642
758
|
</button>
|
|
643
759
|
{showLayers && (
|
|
644
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>
|
|
645
762
|
<LayerToggle label="Gimnasios" color="#b84f30" loading={gymsLoading} visible={gymsVisible} count={gymCount} onToggle={toggleGyms} />
|
|
646
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
|
+
)}
|
|
647
773
|
</div>
|
|
648
774
|
)}
|
|
649
775
|
</div>
|
|
@@ -786,23 +912,29 @@ export default function FavoritesMapPage() {
|
|
|
786
912
|
{/* ------------------------------------------------ map */}
|
|
787
913
|
<div className="relative min-h-0 min-w-0 flex-1">
|
|
788
914
|
<div ref={containerRef} className="absolute inset-0" />
|
|
789
|
-
|
|
790
|
-
<div className="
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
<
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
<
|
|
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>}
|
|
928
|
+
</div>
|
|
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>
|
|
804
936
|
</div>
|
|
805
|
-
|
|
937
|
+
)}
|
|
806
938
|
</div>
|
|
807
939
|
</div>
|
|
808
940
|
);
|
|
@@ -79,10 +79,6 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
|
|
|
79
79
|
<h1 className="text-2xl font-bold">{listing.title}</h1>
|
|
80
80
|
<YieldBadge value={analysis.gross_yield} n={analysis.rent_n} />
|
|
81
81
|
</div>
|
|
82
|
-
<p className="mt-1 text-sm text-neutral-500">
|
|
83
|
-
{listing.tower_name} · {listing.bedrooms === 0 ? "Studio" : `${listing.bedrooms ?? "?"} hab`} ·{" "}
|
|
84
|
-
{listing.bathrooms ?? "?"} baños · {fmtSqft(listing.size_sqft)} · {listing.property_type}
|
|
85
|
-
</p>
|
|
86
82
|
<div className="mt-1 flex items-center gap-4">
|
|
87
83
|
<a
|
|
88
84
|
href={listing.url}
|
|
@@ -143,41 +139,68 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
|
|
|
143
139
|
</div>
|
|
144
140
|
)}
|
|
145
141
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
142
|
+
{/* -------- Datos básicos (raw property facts) ------------------------ */}
|
|
143
|
+
<section>
|
|
144
|
+
<h2 className="mb-2 text-sm font-bold uppercase tracking-wide text-neutral-500">Datos básicos</h2>
|
|
145
|
+
<div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
|
146
|
+
<p className="flex items-baseline gap-2">
|
|
147
|
+
<span className="text-3xl font-bold" style={{ color: "var(--accent)" }}>{fmtAED(listing.price)}</span>
|
|
148
|
+
{listing.size_sqft ? (
|
|
149
|
+
<span className="text-sm text-neutral-500">· {Math.round(listing.price / listing.size_sqft)} AED/sqft</span>
|
|
150
|
+
) : null}
|
|
151
|
+
</p>
|
|
152
|
+
<div className="mt-3 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3 lg:grid-cols-6">
|
|
153
|
+
<Fact label="Superficie" value={fmtSqft(listing.size_sqft)} />
|
|
154
|
+
<Fact label="Habitaciones" value={listing.bedrooms === 0 ? "Studio" : listing.bedrooms != null ? String(listing.bedrooms) : "—"} />
|
|
155
|
+
<Fact label="Baños" value={listing.bathrooms != null ? String(listing.bathrooms) : "—"} />
|
|
156
|
+
<Fact label="Tipo" value={listing.property_type || "—"} />
|
|
157
|
+
<Fact label="AED/sqft" value={listing.size_sqft ? `${Math.round(listing.price / listing.size_sqft)}` : "—"} />
|
|
158
|
+
<Fact label="Edificio" value={listing.tower_name || "—"} />
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</section>
|
|
162
|
+
|
|
163
|
+
{/* -------- Análisis (computed metrics) ------------------------------- */}
|
|
164
|
+
<section>
|
|
165
|
+
<h2 className="mb-2 flex items-center text-sm font-bold uppercase tracking-wide text-neutral-500">
|
|
166
|
+
Análisis
|
|
167
|
+
<InfoList items={ANALYSIS_HELP} />
|
|
168
|
+
</h2>
|
|
169
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
170
|
+
<Stat
|
|
171
|
+
label="Rentabilidad anual de alquiler"
|
|
172
|
+
value={fmtPct(analysis.gross_yield)}
|
|
173
|
+
sub="renta anual mediana ÷ precio de venta mediana (unidades comparables del edificio)"
|
|
174
|
+
accent={analysis.gross_yield != null && analysis.gross_yield >= 0.06 ? "good" : undefined}
|
|
175
|
+
/>
|
|
176
|
+
<Stat
|
|
177
|
+
label="Se alquila por (año, mediana)"
|
|
178
|
+
value={fmtAED(analysis.median_rent)}
|
|
179
|
+
sub={`${analysis.rent_n} alquiler${analysis.rent_n === 1 ? "" : "es"} de tamaño similar (±20%, 24 meses)${
|
|
180
|
+
analysis.rent_p25 != null && analysis.rent_p75 != null
|
|
181
|
+
? ` · rango ${fmtAED(analysis.rent_p25)}–${fmtAED(analysis.rent_p75)}`
|
|
182
|
+
: ""
|
|
183
|
+
}`}
|
|
184
|
+
/>
|
|
185
|
+
<Stat
|
|
186
|
+
label="Se vende por (mediana)"
|
|
187
|
+
value={fmtAED(analysis.median_sale_price)}
|
|
188
|
+
sub={`${analysis.buy_n} venta${analysis.buy_n === 1 ? "" : "s"} de tamaño similar (±20%, 24 meses)${
|
|
189
|
+
analysis.sale_p25 != null ? ` · objetivo compra p/ reformar ≈ ${fmtAED(analysis.sale_p25)} (P25)` : ""
|
|
190
|
+
}`}
|
|
191
|
+
/>
|
|
192
|
+
<Stat
|
|
193
|
+
label="Rentabilidad sobre el precio del anuncio"
|
|
194
|
+
value={fmtPct(analysis.asking_yield)}
|
|
195
|
+
sub={`si lo compras por ${fmtAED(listing.price)}`}
|
|
196
|
+
/>
|
|
197
|
+
<Stat
|
|
198
|
+
label="Gimnasio más cercano (OSM)"
|
|
199
|
+
value={fmtDist(analysis.gym_distance_m)}
|
|
200
|
+
sub={analysis.gym_name ?? undefined}
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
</section>
|
|
181
204
|
|
|
182
205
|
{listing.amenities?.length > 0 && (
|
|
183
206
|
<div className="flex flex-wrap gap-1.5">
|
|
@@ -228,6 +251,57 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
|
|
|
228
251
|
);
|
|
229
252
|
}
|
|
230
253
|
|
|
254
|
+
const ANALYSIS_HELP: { term: string; desc: string }[] = [
|
|
255
|
+
{
|
|
256
|
+
term: "Rentabilidad anual de alquiler",
|
|
257
|
+
desc: "Renta anual mediana ÷ precio de venta mediana de unidades comparables (mismo edificio, tamaño ±20%, últimos 24 meses). Es la rentabilidad bruta que da el mercado, independiente del precio de este anuncio.",
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
term: "Se alquila por (año, mediana)",
|
|
261
|
+
desc: "Mediana de los contratos de alquiler anuales de unidades de tamaño similar (±20%) del edificio, últimos 24 meses. El rango es P25–P75: lo que pagan los pisos peor y mejor acondicionados.",
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
term: "Se vende por (mediana)",
|
|
265
|
+
desc: "Mediana de las ventas registradas en el DLD de tamaño similar (±20%), últimos 24 meses. El P25 es el tramo bajo del mercado ≈ precio objetivo de compra para reformar.",
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
term: "Rentabilidad sobre el precio del anuncio",
|
|
269
|
+
desc: "Renta anual mediana ÷ el precio que pide ESTE anuncio. Indica si este piso, a su precio, rinde por encima o por debajo de la mediana del edificio.",
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
term: "Gimnasio más cercano",
|
|
273
|
+
desc: "Distancia en línea recta al gimnasio más próximo según OpenStreetMap.",
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
function Fact({ label, value }: { label: string; value: string }) {
|
|
278
|
+
return (
|
|
279
|
+
<div>
|
|
280
|
+
<div className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">{label}</div>
|
|
281
|
+
<div className="mt-0.5 text-sm font-medium">{value}</div>
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function InfoList({ items }: { items: { term: string; desc: string }[] }) {
|
|
287
|
+
return (
|
|
288
|
+
<span className="group relative ml-2 inline-flex align-middle normal-case">
|
|
289
|
+
<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">
|
|
290
|
+
?
|
|
291
|
+
</span>
|
|
292
|
+
<span className="pointer-events-none absolute left-0 top-6 z-30 w-80 rounded-md border border-neutral-200 bg-white p-3 text-left opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-neutral-700 dark:bg-neutral-800">
|
|
293
|
+
<span className="mb-1 block text-[11px] font-bold uppercase tracking-wide text-neutral-500">Cómo leer cada campo</span>
|
|
294
|
+
{items.map((it) => (
|
|
295
|
+
<span key={it.term} className="mb-2 block last:mb-0">
|
|
296
|
+
<span className="block text-[11px] font-semibold text-neutral-700 dark:text-neutral-200">{it.term}</span>
|
|
297
|
+
<span className="block text-[11px] font-normal leading-snug text-neutral-500 dark:text-neutral-400">{it.desc}</span>
|
|
298
|
+
</span>
|
|
299
|
+
))}
|
|
300
|
+
</span>
|
|
301
|
+
</span>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
231
305
|
function Stat({ label, value, sub, accent }: { label: string; value: string; sub?: string; accent?: "good" | "bad" }) {
|
|
232
306
|
return (
|
|
233
307
|
<div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
package/src/lib/store.ts
CHANGED
|
@@ -176,6 +176,24 @@ export function listFavorites(): FavoriteListing[] {
|
|
|
176
176
|
.all() as FavoriteListing[];
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Liquidity proxy: number of DLD transactions per tower registered on/after
|
|
181
|
+
* `sinceISO`, optionally restricted to one kind ('buy' | 'rent'). Raw totals are
|
|
182
|
+
* capped at ~100/kind by scraping, so recent activity — not the lifetime count —
|
|
183
|
+
* is what separates a liquid tower from a stale one. Returned as tower_slug → count.
|
|
184
|
+
*/
|
|
185
|
+
export function txCountByTowerSince(sinceISO: string, kind?: "buy" | "rent"): Map<string, number> {
|
|
186
|
+
const db = getDb();
|
|
187
|
+
const rows = (
|
|
188
|
+
kind
|
|
189
|
+
? db.prepare(`SELECT tower_slug, COUNT(*) n FROM transactions WHERE date >= ? AND kind = ? GROUP BY tower_slug`).all(sinceISO, kind)
|
|
190
|
+
: db.prepare(`SELECT tower_slug, COUNT(*) n FROM transactions WHERE date >= ? GROUP BY tower_slug`).all(sinceISO)
|
|
191
|
+
) as { tower_slug: string; n: number }[];
|
|
192
|
+
const m = new Map<string, number>();
|
|
193
|
+
for (const r of rows) m.set(r.tower_slug, r.n);
|
|
194
|
+
return m;
|
|
195
|
+
}
|
|
196
|
+
|
|
179
197
|
export function listAnalyzed(): AnalyzedListing[] {
|
|
180
198
|
return getDb()
|
|
181
199
|
.prepare(
|