brickwise 0.1.0
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/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/brickwise.js +100 -0
- package/eslint.config.mjs +18 -0
- package/next.config.ts +14 -0
- package/package.json +62 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/dashboard/route.ts +15 -0
- package/src/app/api/favorites/route.ts +71 -0
- package/src/app/api/gyms/route.ts +30 -0
- package/src/app/api/listing/[id]/route.ts +30 -0
- package/src/app/api/locations/route.ts +13 -0
- package/src/app/api/recompute/route.ts +28 -0
- package/src/app/api/search/route.ts +26 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/favorites/page.tsx +603 -0
- package/src/app/globals.css +78 -0
- package/src/app/layout.tsx +37 -0
- package/src/app/listing/[id]/page.tsx +230 -0
- package/src/app/map/page.tsx +5 -0
- package/src/app/page.tsx +155 -0
- package/src/app/search/page.tsx +87 -0
- package/src/components/ChromeHeader.tsx +16 -0
- package/src/components/FiltersForm.tsx +240 -0
- package/src/components/ListingCard.tsx +145 -0
- package/src/components/Nav.tsx +37 -0
- package/src/components/PsqftChart.tsx +249 -0
- package/src/components/TransactionsTable.tsx +40 -0
- package/src/components/YieldBadge.tsx +24 -0
- package/src/lib/analyze.ts +51 -0
- package/src/lib/db.ts +143 -0
- package/src/lib/dedupe.ts +34 -0
- package/src/lib/format.ts +13 -0
- package/src/lib/gyms.ts +104 -0
- package/src/lib/pf/client.ts +45 -0
- package/src/lib/pf/listing.ts +16 -0
- package/src/lib/pf/locations.ts +73 -0
- package/src/lib/pf/parse.ts +15 -0
- package/src/lib/pf/search.ts +57 -0
- package/src/lib/pf/transactions.ts +85 -0
- package/src/lib/store.ts +182 -0
- package/src/lib/types.ts +122 -0
- package/src/lib/yield.ts +80 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import "leaflet/dist/leaflet.css";
|
|
6
|
+
import type { LayerGroup, Map as LeafletMap, Marker } from "leaflet";
|
|
7
|
+
import YieldBadge from "@/components/YieldBadge";
|
|
8
|
+
import { fmtAED, fmtDist, fmtSqft } from "@/lib/format";
|
|
9
|
+
|
|
10
|
+
interface FavRow {
|
|
11
|
+
id: string;
|
|
12
|
+
url: string;
|
|
13
|
+
title: string;
|
|
14
|
+
price: number;
|
|
15
|
+
size_sqft: number | null;
|
|
16
|
+
bedrooms: number | null;
|
|
17
|
+
bathrooms: number | null;
|
|
18
|
+
property_type: string;
|
|
19
|
+
tower_name: string | null;
|
|
20
|
+
lat: number | null;
|
|
21
|
+
lon: number | null;
|
|
22
|
+
images: { small: string; medium: string }[];
|
|
23
|
+
gross_yield: number | null;
|
|
24
|
+
asking_yield: number | null;
|
|
25
|
+
median_rent: number | null;
|
|
26
|
+
median_sale_price: number | null;
|
|
27
|
+
rent_n: number | null;
|
|
28
|
+
buy_n: number | null;
|
|
29
|
+
gym_distance_m: number | null;
|
|
30
|
+
gym_name: string | null;
|
|
31
|
+
price_per_sqft: number | null;
|
|
32
|
+
notes: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
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>`;
|
|
36
|
+
|
|
37
|
+
function yieldColor(y: number | null): string {
|
|
38
|
+
if (y == null) return "#737373";
|
|
39
|
+
if (y >= 0.07) return "#059669";
|
|
40
|
+
if (y >= 0.055) return "#65a30d";
|
|
41
|
+
if (y >= 0.04) return "#d97706";
|
|
42
|
+
return "#dc2626";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function popupHtml(r: FavRow): string {
|
|
46
|
+
const img = r.images?.[0]?.medium || r.images?.[0]?.small;
|
|
47
|
+
const yieldTxt = r.gross_yield != null ? (r.gross_yield * 100).toFixed(1) + " %" : "sin datos";
|
|
48
|
+
return `
|
|
49
|
+
<div style="width:230px">
|
|
50
|
+
${img ? `<img src="${img}" style="width:100%;height:120px;object-fit:cover;border-radius:8px" alt=""/>` : ""}
|
|
51
|
+
<div style="font-weight:600;margin-top:6px;line-height:1.3">${r.title}</div>
|
|
52
|
+
<div style="color:#666;font-size:12px;margin-top:2px">${r.tower_name ?? ""}</div>
|
|
53
|
+
<div style="margin-top:4px;font-size:14px">
|
|
54
|
+
Rentabilidad: <span style="color:${yieldColor(r.gross_yield)};font-weight:700">${yieldTxt}</span>
|
|
55
|
+
</div>
|
|
56
|
+
<div style="margin-top:6px;display:flex;gap:10px;font-size:12px">
|
|
57
|
+
<a href="/listing/${r.id}">Ver análisis</a>
|
|
58
|
+
<a href="${r.url}" target="_blank" rel="noopener noreferrer">PF ↗</a>
|
|
59
|
+
</div>
|
|
60
|
+
</div>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MIN_W = 320;
|
|
64
|
+
const MAX_W = 760;
|
|
65
|
+
|
|
66
|
+
const SAVED_URL = "https://www.propertyfinder.ae/en/user/saved-properties";
|
|
67
|
+
|
|
68
|
+
// Bookmarklet: run on the Property Finder "saved properties" page (logged in) to
|
|
69
|
+
// collect every saved listing URL and copy them to the clipboard. String.raw keeps
|
|
70
|
+
// the regex backslashes intact; it is set on the link imperatively because React
|
|
71
|
+
// strips javascript: hrefs.
|
|
72
|
+
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)}})();`;
|
|
73
|
+
|
|
74
|
+
export default function FavoritesMapPage() {
|
|
75
|
+
const [rows, setRows] = useState<FavRow[] | null>(null);
|
|
76
|
+
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
77
|
+
const [showSync, setShowSync] = useState(false);
|
|
78
|
+
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
|
79
|
+
const [input, setInput] = useState("");
|
|
80
|
+
const [queue, setQueue] = useState<string[]>([]);
|
|
81
|
+
const [errors, setErrors] = useState<string[]>([]);
|
|
82
|
+
const [sidebarW, setSidebarW] = useState(() => {
|
|
83
|
+
if (typeof window === "undefined") return 440;
|
|
84
|
+
const saved = Number(localStorage.getItem("bw-sidebar-w"));
|
|
85
|
+
return saved >= MIN_W && saved <= MAX_W ? saved : 440;
|
|
86
|
+
});
|
|
87
|
+
const [mapReady, setMapReady] = useState(false);
|
|
88
|
+
const [gymsVisible, setGymsVisible] = useState(false);
|
|
89
|
+
const [gymsLoading, setGymsLoading] = useState(false);
|
|
90
|
+
const [gymCount, setGymCount] = useState<number | null>(null);
|
|
91
|
+
|
|
92
|
+
const mapRef = useRef<LeafletMap | null>(null);
|
|
93
|
+
const leafletRef = useRef<typeof import("leaflet") | null>(null);
|
|
94
|
+
const markersRef = useRef<Record<string, Marker>>({});
|
|
95
|
+
const gymLayerRef = useRef<LayerGroup | null>(null);
|
|
96
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
97
|
+
const didFitRef = useRef(false);
|
|
98
|
+
const processing = useRef(false);
|
|
99
|
+
const bookmarkletRef = useRef<HTMLAnchorElement | null>(null);
|
|
100
|
+
|
|
101
|
+
// React refuses to render javascript: hrefs, so set it via the DOM.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (showSync && bookmarkletRef.current) bookmarkletRef.current.setAttribute("href", BOOKMARKLET);
|
|
104
|
+
}, [showSync]);
|
|
105
|
+
|
|
106
|
+
const load = useCallback(
|
|
107
|
+
() =>
|
|
108
|
+
fetch("/api/favorites")
|
|
109
|
+
.then((r) => r.json())
|
|
110
|
+
.then((j) => setRows(j.results || []))
|
|
111
|
+
.catch(() => setRows([])),
|
|
112
|
+
[]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
load();
|
|
117
|
+
}, [load]);
|
|
118
|
+
|
|
119
|
+
// --- Map creation (once) -------------------------------------------------
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!containerRef.current || mapRef.current) return;
|
|
122
|
+
let cancelled = false;
|
|
123
|
+
import("leaflet").then((L) => {
|
|
124
|
+
if (cancelled || !containerRef.current || mapRef.current) return;
|
|
125
|
+
leafletRef.current = L;
|
|
126
|
+
const map = L.map(containerRef.current, {
|
|
127
|
+
center: [25.1, 55.2],
|
|
128
|
+
zoom: 11,
|
|
129
|
+
zoomControl: true,
|
|
130
|
+
scrollWheelZoom: false, // replaced by the trackpad handler below
|
|
131
|
+
zoomSnap: 0, // allow fractional zoom for smooth pinch
|
|
132
|
+
});
|
|
133
|
+
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
134
|
+
maxZoom: 19,
|
|
135
|
+
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
136
|
+
}).addTo(map);
|
|
137
|
+
mapRef.current = map;
|
|
138
|
+
|
|
139
|
+
// Trackpad gestures: two-finger scroll pans in 2D, pinch zooms.
|
|
140
|
+
// Browsers report a trackpad pinch as a wheel event with ctrlKey set.
|
|
141
|
+
const el = map.getContainer();
|
|
142
|
+
const onWheel = (e: WheelEvent) => {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
if (e.ctrlKey || e.metaKey) {
|
|
145
|
+
const rect = el.getBoundingClientRect();
|
|
146
|
+
const point = L.point(e.clientX - rect.left, e.clientY - rect.top);
|
|
147
|
+
map.setZoomAround(point, map.getZoom() - e.deltaY * 0.01, { animate: false });
|
|
148
|
+
} else {
|
|
149
|
+
const scale = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1;
|
|
150
|
+
map.panBy([e.deltaX * scale, e.deltaY * scale], { animate: false });
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
154
|
+
map.on("remove", () => el.removeEventListener("wheel", onWheel));
|
|
155
|
+
let tries = 0;
|
|
156
|
+
const timer = setInterval(() => {
|
|
157
|
+
tries++;
|
|
158
|
+
const w = containerRef.current?.clientWidth ?? 0;
|
|
159
|
+
if ((w > 300 && document.readyState === "complete") || tries > 30) {
|
|
160
|
+
clearInterval(timer);
|
|
161
|
+
map.invalidateSize();
|
|
162
|
+
setMapReady(true);
|
|
163
|
+
}
|
|
164
|
+
}, 100);
|
|
165
|
+
const ro = new ResizeObserver(() => map.invalidateSize());
|
|
166
|
+
ro.observe(containerRef.current);
|
|
167
|
+
map.on("remove", () => {
|
|
168
|
+
clearInterval(timer);
|
|
169
|
+
ro.disconnect();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
return () => {
|
|
173
|
+
cancelled = true;
|
|
174
|
+
mapRef.current?.remove();
|
|
175
|
+
mapRef.current = null;
|
|
176
|
+
markersRef.current = {};
|
|
177
|
+
gymLayerRef.current = null;
|
|
178
|
+
didFitRef.current = false;
|
|
179
|
+
setMapReady(false);
|
|
180
|
+
};
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
// --- Property markers: kept in sync with the favorites list --------------
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const L = leafletRef.current;
|
|
186
|
+
const map = mapRef.current;
|
|
187
|
+
if (!L || !map || !mapReady || !rows) return;
|
|
188
|
+
Object.values(markersRef.current).forEach((m) => m.remove());
|
|
189
|
+
markersRef.current = {};
|
|
190
|
+
const bounds: [number, number][] = [];
|
|
191
|
+
for (const r of rows) {
|
|
192
|
+
if (r.lat == null || r.lon == null) continue;
|
|
193
|
+
const y = r.gross_yield != null ? (r.gross_yield * 100).toFixed(1) + "%" : "—";
|
|
194
|
+
const icon = L.divIcon({
|
|
195
|
+
className: "",
|
|
196
|
+
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>`,
|
|
197
|
+
iconSize: [0, 0],
|
|
198
|
+
iconAnchor: [0, 0],
|
|
199
|
+
});
|
|
200
|
+
const marker = L.marker([r.lat, r.lon], { icon })
|
|
201
|
+
.addTo(map)
|
|
202
|
+
.bindPopup(popupHtml(r), { maxWidth: 260, offset: [0, -34] });
|
|
203
|
+
marker.on("mouseover", () => {
|
|
204
|
+
setHoveredId(r.id);
|
|
205
|
+
document.getElementById(`fav-${r.id}`)?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
206
|
+
});
|
|
207
|
+
marker.on("mouseout", () => setHoveredId(null));
|
|
208
|
+
markersRef.current[r.id] = marker;
|
|
209
|
+
bounds.push([r.lat, r.lon]);
|
|
210
|
+
}
|
|
211
|
+
if (!didFitRef.current && bounds.length > 0) {
|
|
212
|
+
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
|
213
|
+
didFitRef.current = true;
|
|
214
|
+
}
|
|
215
|
+
}, [rows, mapReady]);
|
|
216
|
+
|
|
217
|
+
// --- Bidirectional hover highlight ---------------------------------------
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
for (const [id, m] of Object.entries(markersRef.current)) {
|
|
220
|
+
const pin = m.getElement()?.querySelector(".bw-pin");
|
|
221
|
+
pin?.classList.toggle("bw-pin--hl", id === hoveredId);
|
|
222
|
+
m.setZIndexOffset(id === hoveredId ? 1000 : 0);
|
|
223
|
+
}
|
|
224
|
+
}, [hoveredId]);
|
|
225
|
+
|
|
226
|
+
// --- Add-by-URL queue ----------------------------------------------------
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (processing.current || queue.length === 0) return;
|
|
229
|
+
processing.current = true;
|
|
230
|
+
fetch("/api/favorites", {
|
|
231
|
+
method: "POST",
|
|
232
|
+
headers: { "Content-Type": "application/json" },
|
|
233
|
+
body: JSON.stringify({ url: queue[0] }),
|
|
234
|
+
})
|
|
235
|
+
.then(async (r) => {
|
|
236
|
+
const j = await r.json();
|
|
237
|
+
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
|
|
238
|
+
})
|
|
239
|
+
.catch((e) => setErrors((prev) => [...prev, `${queue[0]} → ${(e as Error).message}`]))
|
|
240
|
+
.finally(() => {
|
|
241
|
+
processing.current = false;
|
|
242
|
+
setQueue((prev) => prev.slice(1));
|
|
243
|
+
load();
|
|
244
|
+
});
|
|
245
|
+
}, [queue, load]);
|
|
246
|
+
|
|
247
|
+
const enqueueUrls = (raw: string): number => {
|
|
248
|
+
const urls = raw
|
|
249
|
+
.split(/[\n\s,]+/)
|
|
250
|
+
.map((s) => s.trim())
|
|
251
|
+
.filter((s) => s.startsWith("http") && /propertyfinder\.ae/.test(s));
|
|
252
|
+
if (urls.length) {
|
|
253
|
+
setErrors([]);
|
|
254
|
+
setQueue((prev) => [...prev, ...urls]);
|
|
255
|
+
}
|
|
256
|
+
return urls.length;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const submit = (e: React.FormEvent) => {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
if (enqueueUrls(input) > 0) setInput("");
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const pasteFromPf = async () => {
|
|
265
|
+
setSyncMsg(null);
|
|
266
|
+
try {
|
|
267
|
+
const text = await navigator.clipboard.readText();
|
|
268
|
+
const n = enqueueUrls(text);
|
|
269
|
+
setSyncMsg(
|
|
270
|
+
n > 0
|
|
271
|
+
? `${n} piso${n === 1 ? "" : "s"} en cola — analizando y añadiendo…`
|
|
272
|
+
: "El portapapeles no tenía enlaces de Property Finder. Ejecuta el marcador en tu página de Guardados primero."
|
|
273
|
+
);
|
|
274
|
+
} catch {
|
|
275
|
+
setSyncMsg("No pude leer el portapapeles. Pega los enlaces manualmente en el cuadro de arriba.");
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const remove = async (id: string) => {
|
|
280
|
+
await fetch(`/api/favorites?id=${id}`, { method: "DELETE" });
|
|
281
|
+
load();
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const saveNotes = (id: string, notes: string) => {
|
|
285
|
+
fetch("/api/favorites", {
|
|
286
|
+
method: "PATCH",
|
|
287
|
+
headers: { "Content-Type": "application/json" },
|
|
288
|
+
body: JSON.stringify({ id, notes }),
|
|
289
|
+
}).catch(() => {});
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const focus = (r: FavRow) => {
|
|
293
|
+
const marker = markersRef.current[r.id];
|
|
294
|
+
if (mapRef.current && marker && r.lat != null && r.lon != null) {
|
|
295
|
+
mapRef.current.flyTo([r.lat, r.lon], Math.max(mapRef.current.getZoom(), 15), { duration: 0.6 });
|
|
296
|
+
marker.openPopup();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const toggleGyms = async () => {
|
|
301
|
+
const map = mapRef.current;
|
|
302
|
+
const L = leafletRef.current;
|
|
303
|
+
if (!map || !L) return;
|
|
304
|
+
if (gymLayerRef.current) {
|
|
305
|
+
if (gymsVisible) map.removeLayer(gymLayerRef.current);
|
|
306
|
+
else gymLayerRef.current.addTo(map);
|
|
307
|
+
setGymsVisible(!gymsVisible);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
setGymsLoading(true);
|
|
311
|
+
try {
|
|
312
|
+
const res = await fetch("/api/gyms");
|
|
313
|
+
const j = await res.json();
|
|
314
|
+
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
|
315
|
+
const layer = L.layerGroup();
|
|
316
|
+
for (const g of j.gyms as { name: string; lat: number; lon: number }[]) {
|
|
317
|
+
const icon = L.divIcon({
|
|
318
|
+
className: "",
|
|
319
|
+
html: `<div class="bw-gym">${GYM_SVG}</div>`,
|
|
320
|
+
iconSize: [0, 0],
|
|
321
|
+
iconAnchor: [0, 0],
|
|
322
|
+
});
|
|
323
|
+
const safeName = g.name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
324
|
+
// Link to the exact coordinates, not a text search: a name search gets biased
|
|
325
|
+
// to the viewer's own location (e.g. Andorra), while coordinates always drop
|
|
326
|
+
// the pin on this gym's spot in Dubai.
|
|
327
|
+
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${g.lat}%2C${g.lon}`;
|
|
328
|
+
L.marker([g.lat, g.lon], { icon })
|
|
329
|
+
.bindPopup(
|
|
330
|
+
`<b>🏋️ ${safeName}</b><br><a href="${mapsUrl}" target="_blank" rel="noopener noreferrer" style="font-size:12px">Ver en Google Maps ↗</a>`,
|
|
331
|
+
{ offset: [0, -16] }
|
|
332
|
+
)
|
|
333
|
+
.addTo(layer);
|
|
334
|
+
}
|
|
335
|
+
layer.addTo(map);
|
|
336
|
+
gymLayerRef.current = layer;
|
|
337
|
+
setGymCount(j.gyms.length);
|
|
338
|
+
setGymsVisible(true);
|
|
339
|
+
} catch {
|
|
340
|
+
setGymCount(null);
|
|
341
|
+
} finally {
|
|
342
|
+
setGymsLoading(false);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// --- Resizable divider ---------------------------------------------------
|
|
347
|
+
const startDrag = (e: React.MouseEvent) => {
|
|
348
|
+
e.preventDefault();
|
|
349
|
+
const startX = e.clientX;
|
|
350
|
+
const startW = sidebarW;
|
|
351
|
+
let w = startW;
|
|
352
|
+
const move = (ev: MouseEvent) => {
|
|
353
|
+
w = Math.min(MAX_W, Math.max(MIN_W, startW + ev.clientX - startX));
|
|
354
|
+
setSidebarW(w);
|
|
355
|
+
};
|
|
356
|
+
const up = () => {
|
|
357
|
+
window.removeEventListener("mousemove", move);
|
|
358
|
+
window.removeEventListener("mouseup", up);
|
|
359
|
+
document.body.style.cursor = "";
|
|
360
|
+
document.body.style.userSelect = "";
|
|
361
|
+
localStorage.setItem("bw-sidebar-w", String(w));
|
|
362
|
+
};
|
|
363
|
+
document.body.style.cursor = "col-resize";
|
|
364
|
+
document.body.style.userSelect = "none";
|
|
365
|
+
window.addEventListener("mousemove", move);
|
|
366
|
+
window.addEventListener("mouseup", up);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const busy = queue.length > 0;
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<div className="fixed inset-0 flex overflow-hidden">
|
|
373
|
+
{/* ------------------------------------------------ sidebar */}
|
|
374
|
+
<aside
|
|
375
|
+
style={{ width: sidebarW }}
|
|
376
|
+
suppressHydrationWarning
|
|
377
|
+
className="flex min-h-0 shrink-0 flex-col border-r border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
|
|
378
|
+
>
|
|
379
|
+
<div className="flex items-center gap-3 border-b border-neutral-200 px-3 py-2 dark:border-neutral-800">
|
|
380
|
+
<Link href="/" className="text-sm font-bold tracking-tight">
|
|
381
|
+
🧱 Brickwise
|
|
382
|
+
</Link>
|
|
383
|
+
<span className="ml-auto flex items-center gap-1 text-xs">
|
|
384
|
+
<Link href="/search" className="rounded-full px-2.5 py-1 font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
|
385
|
+
🔍 Buscar
|
|
386
|
+
</Link>
|
|
387
|
+
<Link href="/" className="rounded-full px-2.5 py-1 font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
|
388
|
+
📊 Analizados
|
|
389
|
+
</Link>
|
|
390
|
+
</span>
|
|
391
|
+
</div>
|
|
392
|
+
<div className="border-b border-neutral-200 p-3 dark:border-neutral-800">
|
|
393
|
+
<form onSubmit={submit}>
|
|
394
|
+
<textarea
|
|
395
|
+
className="h-14 w-full resize-none rounded-lg border border-neutral-300 bg-white px-2.5 py-1.5 font-mono text-[11px] outline-none focus:border-blue-500 dark:border-neutral-700 dark:bg-neutral-950"
|
|
396
|
+
placeholder="Pega enlaces de Property Finder (uno por línea) y pulsa Añadir…"
|
|
397
|
+
value={input}
|
|
398
|
+
onChange={(e) => setInput(e.target.value)}
|
|
399
|
+
/>
|
|
400
|
+
<div className="mt-2 flex items-center gap-2">
|
|
401
|
+
<button
|
|
402
|
+
type="submit"
|
|
403
|
+
disabled={!input.trim() || busy}
|
|
404
|
+
className="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-bold text-white hover:bg-blue-500 disabled:opacity-50"
|
|
405
|
+
>
|
|
406
|
+
{busy ? `Analizando ${queue.length}…` : "+ Añadir y analizar"}
|
|
407
|
+
</button>
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
onClick={toggleGyms}
|
|
411
|
+
disabled={gymsLoading || (rows?.length ?? 0) === 0}
|
|
412
|
+
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-xs font-semibold hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
|
413
|
+
>
|
|
414
|
+
{gymsLoading
|
|
415
|
+
? "Buscando…"
|
|
416
|
+
: gymCount != null
|
|
417
|
+
? gymsVisible
|
|
418
|
+
? `🏋️ Ocultar (${gymCount})`
|
|
419
|
+
: `🏋️ Mostrar (${gymCount})`
|
|
420
|
+
: "🏋️ Gimnasios"}
|
|
421
|
+
</button>
|
|
422
|
+
<button
|
|
423
|
+
type="button"
|
|
424
|
+
onClick={() => setShowSync((v) => !v)}
|
|
425
|
+
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold ${
|
|
426
|
+
showSync
|
|
427
|
+
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
|
428
|
+
: "border-neutral-300 hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
|
429
|
+
}`}
|
|
430
|
+
>
|
|
431
|
+
⟳ Sincronizar con PF
|
|
432
|
+
</button>
|
|
433
|
+
</div>
|
|
434
|
+
{errors.length > 0 && (
|
|
435
|
+
<ul className="mt-2 space-y-1 text-[11px] text-red-600 dark:text-red-400">
|
|
436
|
+
{errors.map((er, i) => (
|
|
437
|
+
<li key={i} className="break-all">
|
|
438
|
+
{er}
|
|
439
|
+
</li>
|
|
440
|
+
))}
|
|
441
|
+
</ul>
|
|
442
|
+
)}
|
|
443
|
+
</form>
|
|
444
|
+
|
|
445
|
+
{showSync && (
|
|
446
|
+
<div className="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3 text-[12px] leading-relaxed dark:border-blue-900 dark:bg-blue-950/40">
|
|
447
|
+
<p className="font-semibold">Traer tus guardados de Property Finder</p>
|
|
448
|
+
<ol className="mt-1.5 list-decimal space-y-1 pl-4">
|
|
449
|
+
<li>
|
|
450
|
+
Arrastra este botón a tu barra de marcadores (solo la primera vez):{" "}
|
|
451
|
+
<a
|
|
452
|
+
ref={bookmarkletRef}
|
|
453
|
+
onClick={(e) => e.preventDefault()}
|
|
454
|
+
className="mx-0.5 inline-block cursor-grab rounded bg-neutral-900 px-2 py-0.5 font-bold text-white dark:bg-white dark:text-neutral-900"
|
|
455
|
+
draggable
|
|
456
|
+
>
|
|
457
|
+
📌 Guardados → Brickwise
|
|
458
|
+
</a>
|
|
459
|
+
</li>
|
|
460
|
+
<li>
|
|
461
|
+
Abre tu{" "}
|
|
462
|
+
<a href={SAVED_URL} target="_blank" rel="noopener noreferrer" className="font-medium text-blue-600 underline dark:text-blue-400">
|
|
463
|
+
página de Guardados
|
|
464
|
+
</a>{" "}
|
|
465
|
+
(con tu sesión de PF) y pulsa el marcador: copia tus pisos.
|
|
466
|
+
</li>
|
|
467
|
+
<li>
|
|
468
|
+
Vuelve aquí y pulsa{" "}
|
|
469
|
+
<button
|
|
470
|
+
type="button"
|
|
471
|
+
onClick={pasteFromPf}
|
|
472
|
+
className="rounded bg-blue-600 px-2 py-0.5 font-bold text-white hover:bg-blue-500"
|
|
473
|
+
>
|
|
474
|
+
📋 Pegar de Property Finder
|
|
475
|
+
</button>
|
|
476
|
+
</li>
|
|
477
|
+
</ol>
|
|
478
|
+
{syncMsg && <p className="mt-2 font-medium text-blue-700 dark:text-blue-300">{syncMsg}</p>}
|
|
479
|
+
<p className="mt-2 text-[11px] text-neutral-500">
|
|
480
|
+
Los que ya tengas no se duplican; los nuevos se analizan uno a uno.
|
|
481
|
+
</p>
|
|
482
|
+
</div>
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
487
|
+
{rows == null ? (
|
|
488
|
+
<p className="animate-pulse p-4 text-sm text-neutral-500">Cargando…</p>
|
|
489
|
+
) : rows.length === 0 ? (
|
|
490
|
+
<p className="p-4 text-sm text-neutral-500">
|
|
491
|
+
No hay favoritos aún. Pega arriba un enlace de Property Finder o guarda inmuebles desde{" "}
|
|
492
|
+
<Link href="/search" className="font-semibold text-blue-600 hover:underline dark:text-blue-400">
|
|
493
|
+
Buscar
|
|
494
|
+
</Link>
|
|
495
|
+
.
|
|
496
|
+
</p>
|
|
497
|
+
) : (
|
|
498
|
+
rows.map((r) => (
|
|
499
|
+
<div
|
|
500
|
+
key={r.id}
|
|
501
|
+
id={`fav-${r.id}`}
|
|
502
|
+
onMouseEnter={() => setHoveredId(r.id)}
|
|
503
|
+
onMouseLeave={() => setHoveredId(null)}
|
|
504
|
+
className={`border-b border-neutral-100 p-3 transition-colors dark:border-neutral-800 ${
|
|
505
|
+
hoveredId === r.id ? "bg-blue-50 dark:bg-neutral-800" : ""
|
|
506
|
+
}`}
|
|
507
|
+
>
|
|
508
|
+
<div className="flex gap-3">
|
|
509
|
+
{r.images?.[0] && (
|
|
510
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
511
|
+
<img
|
|
512
|
+
src={r.images[0].small}
|
|
513
|
+
alt=""
|
|
514
|
+
onClick={() => focus(r)}
|
|
515
|
+
className="h-16 w-24 shrink-0 cursor-pointer rounded-lg object-cover"
|
|
516
|
+
loading="lazy"
|
|
517
|
+
/>
|
|
518
|
+
)}
|
|
519
|
+
<div className="min-w-0 flex-1">
|
|
520
|
+
<div className="flex items-start justify-between gap-2">
|
|
521
|
+
<button
|
|
522
|
+
onClick={() => focus(r)}
|
|
523
|
+
className="line-clamp-1 text-left text-sm font-semibold hover:underline"
|
|
524
|
+
title="Centrar en el mapa"
|
|
525
|
+
>
|
|
526
|
+
{r.title}
|
|
527
|
+
</button>
|
|
528
|
+
<button
|
|
529
|
+
onClick={() => remove(r.id)}
|
|
530
|
+
title="Quitar de favoritos"
|
|
531
|
+
className="shrink-0 rounded-md px-1.5 text-xs text-neutral-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
|
|
532
|
+
>
|
|
533
|
+
✕
|
|
534
|
+
</button>
|
|
535
|
+
</div>
|
|
536
|
+
<div className="truncate text-xs text-neutral-500">
|
|
537
|
+
{r.tower_name} · {r.bedrooms === 0 ? "Studio" : `${r.bedrooms ?? "?"} hab`} / {r.bathrooms ?? "?"} baños ·{" "}
|
|
538
|
+
{fmtSqft(r.size_sqft)}
|
|
539
|
+
</div>
|
|
540
|
+
<div className="mt-1 flex items-center gap-2">
|
|
541
|
+
<YieldBadge value={r.gross_yield} n={r.rent_n ?? undefined} />
|
|
542
|
+
{r.asking_yield != null && (
|
|
543
|
+
<span className="text-xs text-neutral-500">
|
|
544
|
+
s/ anuncio: {(r.asking_yield * 100).toFixed(1)} %
|
|
545
|
+
</span>
|
|
546
|
+
)}
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
|
|
551
|
+
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-0.5 text-xs text-neutral-600 dark:text-neutral-400">
|
|
552
|
+
<span>
|
|
553
|
+
Precio: <b className="text-neutral-900 dark:text-neutral-100">{fmtAED(r.price)}</b>
|
|
554
|
+
</span>
|
|
555
|
+
<span>AED/sqft: {r.price_per_sqft ? Math.round(r.price_per_sqft) : "—"}</span>
|
|
556
|
+
<span>
|
|
557
|
+
Renta mediana: {fmtAED(r.median_rent)}
|
|
558
|
+
{r.rent_n ? ` (${r.rent_n})` : ""}
|
|
559
|
+
</span>
|
|
560
|
+
<span>
|
|
561
|
+
Venta mediana: {fmtAED(r.median_sale_price)}
|
|
562
|
+
{r.buy_n ? ` (${r.buy_n})` : ""}
|
|
563
|
+
</span>
|
|
564
|
+
<span title={r.gym_name ?? undefined}>Gym: {fmtDist(r.gym_distance_m)}</span>
|
|
565
|
+
<span className="flex gap-2">
|
|
566
|
+
<Link href={`/listing/${r.id}`} className="font-medium text-blue-600 hover:underline dark:text-blue-400">
|
|
567
|
+
Ver análisis
|
|
568
|
+
</Link>
|
|
569
|
+
<a
|
|
570
|
+
href={r.url}
|
|
571
|
+
target="_blank"
|
|
572
|
+
rel="noopener noreferrer"
|
|
573
|
+
className="font-medium text-blue-600 hover:underline dark:text-blue-400"
|
|
574
|
+
>
|
|
575
|
+
PF ↗
|
|
576
|
+
</a>
|
|
577
|
+
</span>
|
|
578
|
+
</div>
|
|
579
|
+
|
|
580
|
+
<textarea
|
|
581
|
+
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"
|
|
582
|
+
defaultValue={r.notes}
|
|
583
|
+
placeholder="notas…"
|
|
584
|
+
onBlur={(e) => saveNotes(r.id, e.target.value)}
|
|
585
|
+
/>
|
|
586
|
+
</div>
|
|
587
|
+
))
|
|
588
|
+
)}
|
|
589
|
+
</div>
|
|
590
|
+
</aside>
|
|
591
|
+
|
|
592
|
+
{/* ------------------------------------------------ divider */}
|
|
593
|
+
<div
|
|
594
|
+
onMouseDown={startDrag}
|
|
595
|
+
title="Arrastra para redimensionar"
|
|
596
|
+
className="w-1.5 shrink-0 cursor-col-resize bg-neutral-200 transition-colors hover:bg-blue-400 dark:bg-neutral-800 dark:hover:bg-blue-600"
|
|
597
|
+
/>
|
|
598
|
+
|
|
599
|
+
{/* ------------------------------------------------ map */}
|
|
600
|
+
<div ref={containerRef} className="min-h-0 min-w-0 flex-1" />
|
|
601
|
+
</div>
|
|
602
|
+
);
|
|
603
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--background: #ffffff;
|
|
5
|
+
--foreground: #171717;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
@theme inline {
|
|
9
|
+
--color-background: var(--background);
|
|
10
|
+
--color-foreground: var(--foreground);
|
|
11
|
+
--font-sans: var(--font-geist-sans);
|
|
12
|
+
--font-mono: var(--font-geist-mono);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
:root {
|
|
17
|
+
--background: #0a0a0a;
|
|
18
|
+
--foreground: #ededed;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
body {
|
|
23
|
+
background: var(--background);
|
|
24
|
+
color: var(--foreground);
|
|
25
|
+
font-family: var(--font-geist-sans), system-ui, sans-serif;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* ---- Map markers ---------------------------------------------------- */
|
|
29
|
+
|
|
30
|
+
/* Property pin: pill with the yield + a small tail; the tail tip sits on the latlng. */
|
|
31
|
+
.bw-pin {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
align-items: center;
|
|
35
|
+
transform: translate(-50%, -100%);
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
}
|
|
38
|
+
.bw-pin-label {
|
|
39
|
+
background: var(--pin-color, #737373);
|
|
40
|
+
color: #fff;
|
|
41
|
+
font: 700 11px/1 var(--font-geist-sans), system-ui, sans-serif;
|
|
42
|
+
padding: 5px 9px;
|
|
43
|
+
border-radius: 999px;
|
|
44
|
+
border: 2px solid #fff;
|
|
45
|
+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
48
|
+
}
|
|
49
|
+
.bw-pin-tail {
|
|
50
|
+
width: 0;
|
|
51
|
+
height: 0;
|
|
52
|
+
border-left: 6px solid transparent;
|
|
53
|
+
border-right: 6px solid transparent;
|
|
54
|
+
border-top: 7px solid #fff;
|
|
55
|
+
margin-top: -1px;
|
|
56
|
+
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3));
|
|
57
|
+
}
|
|
58
|
+
.bw-pin--hl .bw-pin-label {
|
|
59
|
+
transform: scale(1.22);
|
|
60
|
+
box-shadow:
|
|
61
|
+
0 0 0 4px rgba(59, 130, 246, 0.5),
|
|
62
|
+
0 3px 10px rgba(0, 0, 0, 0.45);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Gym marker: blue disc with a white dumbbell. */
|
|
66
|
+
.bw-gym {
|
|
67
|
+
width: 28px;
|
|
68
|
+
height: 28px;
|
|
69
|
+
border-radius: 50%;
|
|
70
|
+
background: #1d4ed8;
|
|
71
|
+
border: 2px solid #fff;
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
justify-content: center;
|
|
75
|
+
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.35);
|
|
76
|
+
transform: translate(-50%, -50%);
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
}
|