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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/brickwise.js +100 -0
  4. package/eslint.config.mjs +18 -0
  5. package/next.config.ts +14 -0
  6. package/package.json +62 -0
  7. package/postcss.config.mjs +7 -0
  8. package/public/file.svg +1 -0
  9. package/public/globe.svg +1 -0
  10. package/public/next.svg +1 -0
  11. package/public/vercel.svg +1 -0
  12. package/public/window.svg +1 -0
  13. package/src/app/api/dashboard/route.ts +15 -0
  14. package/src/app/api/favorites/route.ts +71 -0
  15. package/src/app/api/gyms/route.ts +30 -0
  16. package/src/app/api/listing/[id]/route.ts +30 -0
  17. package/src/app/api/locations/route.ts +13 -0
  18. package/src/app/api/recompute/route.ts +28 -0
  19. package/src/app/api/search/route.ts +26 -0
  20. package/src/app/favicon.ico +0 -0
  21. package/src/app/favorites/page.tsx +603 -0
  22. package/src/app/globals.css +78 -0
  23. package/src/app/layout.tsx +37 -0
  24. package/src/app/listing/[id]/page.tsx +230 -0
  25. package/src/app/map/page.tsx +5 -0
  26. package/src/app/page.tsx +155 -0
  27. package/src/app/search/page.tsx +87 -0
  28. package/src/components/ChromeHeader.tsx +16 -0
  29. package/src/components/FiltersForm.tsx +240 -0
  30. package/src/components/ListingCard.tsx +145 -0
  31. package/src/components/Nav.tsx +37 -0
  32. package/src/components/PsqftChart.tsx +249 -0
  33. package/src/components/TransactionsTable.tsx +40 -0
  34. package/src/components/YieldBadge.tsx +24 -0
  35. package/src/lib/analyze.ts +51 -0
  36. package/src/lib/db.ts +143 -0
  37. package/src/lib/dedupe.ts +34 -0
  38. package/src/lib/format.ts +13 -0
  39. package/src/lib/gyms.ts +104 -0
  40. package/src/lib/pf/client.ts +45 -0
  41. package/src/lib/pf/listing.ts +16 -0
  42. package/src/lib/pf/locations.ts +73 -0
  43. package/src/lib/pf/parse.ts +15 -0
  44. package/src/lib/pf/search.ts +57 -0
  45. package/src/lib/pf/transactions.ts +85 -0
  46. package/src/lib/store.ts +182 -0
  47. package/src/lib/types.ts +122 -0
  48. package/src/lib/yield.ts +80 -0
  49. package/tsconfig.json +34 -0
@@ -0,0 +1,240 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { SearchFilters } from "@/lib/types";
5
+
6
+ const AMENITIES = [
7
+ { value: "SY", label: "Gimnasio compartido" },
8
+ { value: "PY", label: "Gimnasio privado" },
9
+ { value: "CP", label: "Parking cubierto" },
10
+ { value: "SP", label: "Piscina compartida" },
11
+ { value: "PP", label: "Piscina privada" },
12
+ { value: "BA", label: "Balcón" },
13
+ { value: "AC", label: "A/C central" },
14
+ { value: "MR", label: "Cuarto de servicio" },
15
+ { value: "BW", label: "Armarios empotrados" },
16
+ { value: "ST", label: "Estudio/despacho" },
17
+ { value: "VW", label: "Vistas al agua" },
18
+ { value: "PA", label: "Mascotas" },
19
+ ];
20
+
21
+ const PROPERTY_TYPES = [
22
+ { value: "", label: "Cualquiera" },
23
+ { value: "1", label: "Apartamento" },
24
+ { value: "35", label: "Villa" },
25
+ { value: "22", label: "Townhouse" },
26
+ { value: "20", label: "Penthouse" },
27
+ { value: "24", label: "Dúplex" },
28
+ ];
29
+
30
+ const BEDROOM_OPTS = ["0", "1", "2", "3", "4", "5"];
31
+
32
+ interface LocOption {
33
+ id: string;
34
+ name: string;
35
+ path_name: string;
36
+ }
37
+
38
+ export default function FiltersForm({
39
+ onSearch,
40
+ searching,
41
+ }: {
42
+ onSearch: (f: SearchFilters) => void;
43
+ searching: boolean;
44
+ }) {
45
+ const [locQuery, setLocQuery] = useState("");
46
+ const [locOptions, setLocOptions] = useState<LocOption[]>([]);
47
+ const [location, setLocation] = useState<LocOption | null>(null);
48
+ const [showDropdown, setShowDropdown] = useState(false);
49
+ const [minPrice, setMinPrice] = useState("");
50
+ const [maxPrice, setMaxPrice] = useState("");
51
+ const [minArea, setMinArea] = useState("");
52
+ const [maxArea, setMaxArea] = useState("");
53
+ const [bedrooms, setBedrooms] = useState<string[]>([]);
54
+ const [propertyTypeId, setPropertyTypeId] = useState("");
55
+ const [amenities, setAmenities] = useState<string[]>([]);
56
+ const [maxGymKm, setMaxGymKm] = useState("");
57
+ const [maxPages, setMaxPages] = useState("2");
58
+ const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
59
+
60
+ useEffect(() => {
61
+ if (debounce.current) clearTimeout(debounce.current);
62
+ debounce.current = setTimeout(() => {
63
+ if (locQuery.trim().length < 2 || locQuery === location?.name) {
64
+ setLocOptions([]);
65
+ return;
66
+ }
67
+ fetch(`/api/locations?q=${encodeURIComponent(locQuery)}`)
68
+ .then((r) => r.json())
69
+ .then((j) => setLocOptions(j.locations || []))
70
+ .catch(() => {});
71
+ }, 250);
72
+ }, [locQuery, location]);
73
+
74
+ const toggle = (arr: string[], v: string, set: (x: string[]) => void) =>
75
+ set(arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]);
76
+
77
+ const submit = (e: React.FormEvent) => {
78
+ e.preventDefault();
79
+ onSearch({
80
+ locationId: location?.id,
81
+ locationLabel: location ? `${location.name} (${location.path_name})` : "Dubai",
82
+ minPrice: minPrice ? Number(minPrice) : undefined,
83
+ maxPrice: maxPrice ? Number(maxPrice) : undefined,
84
+ minArea: minArea ? Number(minArea) : undefined,
85
+ maxArea: maxArea ? Number(maxArea) : undefined,
86
+ bedrooms: bedrooms.length ? bedrooms : undefined,
87
+ propertyTypeId: propertyTypeId || undefined,
88
+ amenities: amenities.length ? amenities : undefined,
89
+ maxGymDistanceM: maxGymKm ? Number(maxGymKm) * 1000 : undefined,
90
+ maxPages: Number(maxPages) || 2,
91
+ });
92
+ };
93
+
94
+ const inputCls =
95
+ "w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-neutral-500 dark:border-neutral-700 dark:bg-neutral-900";
96
+
97
+ return (
98
+ <form onSubmit={submit} className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
99
+ <div className="grid gap-3 md:grid-cols-4">
100
+ <div className="relative md:col-span-2">
101
+ <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Ubicación</label>
102
+ <input
103
+ className={inputCls}
104
+ placeholder="Ciudad, zona o edificio (p. ej. Dubai Marina)"
105
+ value={locQuery}
106
+ onChange={(e) => {
107
+ setLocQuery(e.target.value);
108
+ setLocation(null);
109
+ setShowDropdown(true);
110
+ }}
111
+ onFocus={() => setShowDropdown(true)}
112
+ />
113
+ {showDropdown && locOptions.length > 0 && (
114
+ <ul className="absolute z-30 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900">
115
+ {locOptions.map((o) => (
116
+ <li key={o.id}>
117
+ <button
118
+ type="button"
119
+ className="w-full px-3 py-2 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800"
120
+ onClick={() => {
121
+ setLocation(o);
122
+ setLocQuery(o.name);
123
+ setShowDropdown(false);
124
+ }}
125
+ >
126
+ <span className="font-medium">{o.name}</span>
127
+ <span className="ml-2 text-xs text-neutral-500">{o.path_name}</span>
128
+ </button>
129
+ </li>
130
+ ))}
131
+ </ul>
132
+ )}
133
+ </div>
134
+
135
+ <div>
136
+ <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Tipo</label>
137
+ <select className={inputCls} value={propertyTypeId} onChange={(e) => setPropertyTypeId(e.target.value)}>
138
+ {PROPERTY_TYPES.map((t) => (
139
+ <option key={t.value} value={t.value}>
140
+ {t.label}
141
+ </option>
142
+ ))}
143
+ </select>
144
+ </div>
145
+
146
+ <div>
147
+ <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Páginas a scrapear (×25)</label>
148
+ <select className={inputCls} value={maxPages} onChange={(e) => setMaxPages(e.target.value)}>
149
+ {["1", "2", "3", "4", "5"].map((p) => (
150
+ <option key={p} value={p}>
151
+ {p}
152
+ </option>
153
+ ))}
154
+ </select>
155
+ </div>
156
+
157
+ <div>
158
+ <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Precio mín. (AED)</label>
159
+ <input className={inputCls} type="number" min="0" placeholder="500000" value={minPrice} onChange={(e) => setMinPrice(e.target.value)} />
160
+ </div>
161
+ <div>
162
+ <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Precio máx. (AED)</label>
163
+ <input className={inputCls} type="number" min="0" placeholder="2000000" value={maxPrice} onChange={(e) => setMaxPrice(e.target.value)} />
164
+ </div>
165
+ <div>
166
+ <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Superficie mín. (sqft)</label>
167
+ <input className={inputCls} type="number" min="0" placeholder="800" value={minArea} onChange={(e) => setMinArea(e.target.value)} />
168
+ </div>
169
+ <div>
170
+ <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Superficie máx. (sqft)</label>
171
+ <input className={inputCls} type="number" min="0" placeholder="1500" value={maxArea} onChange={(e) => setMaxArea(e.target.value)} />
172
+ </div>
173
+ </div>
174
+
175
+ <div className="mt-3">
176
+ <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Habitaciones</label>
177
+ <div className="flex flex-wrap gap-2">
178
+ {BEDROOM_OPTS.map((b) => (
179
+ <button
180
+ key={b}
181
+ type="button"
182
+ onClick={() => toggle(bedrooms, b, setBedrooms)}
183
+ className={`rounded-full border px-3 py-1 text-sm ${
184
+ bedrooms.includes(b)
185
+ ? "border-neutral-900 bg-neutral-900 text-white dark:border-white dark:bg-white dark:text-neutral-900"
186
+ : "border-neutral-300 dark:border-neutral-700"
187
+ }`}
188
+ >
189
+ {b === "0" ? "Studio" : b === "5" ? "5+" : b}
190
+ </button>
191
+ ))}
192
+ </div>
193
+ </div>
194
+
195
+ <div className="mt-3">
196
+ <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">Características</label>
197
+ <div className="flex flex-wrap gap-2">
198
+ {AMENITIES.map((a) => (
199
+ <button
200
+ key={a.value}
201
+ type="button"
202
+ onClick={() => toggle(amenities, a.value, setAmenities)}
203
+ className={`rounded-full border px-3 py-1 text-xs ${
204
+ amenities.includes(a.value)
205
+ ? "border-neutral-900 bg-neutral-900 text-white dark:border-white dark:bg-white dark:text-neutral-900"
206
+ : "border-neutral-300 dark:border-neutral-700"
207
+ }`}
208
+ >
209
+ {a.label}
210
+ </button>
211
+ ))}
212
+ </div>
213
+ </div>
214
+
215
+ <div className="mt-3 flex flex-wrap items-end gap-3">
216
+ <div>
217
+ <label className="mb-1 block text-xs font-semibold text-neutral-600 dark:text-neutral-400">
218
+ Dist. máx. a gimnasio externo (km, opcional)
219
+ </label>
220
+ <input
221
+ className={inputCls}
222
+ type="number"
223
+ step="0.1"
224
+ min="0"
225
+ placeholder="p. ej. 0.5"
226
+ value={maxGymKm}
227
+ onChange={(e) => setMaxGymKm(e.target.value)}
228
+ />
229
+ </div>
230
+ <button
231
+ type="submit"
232
+ disabled={searching}
233
+ className="ml-auto rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-bold text-white hover:bg-blue-500 disabled:opacity-50"
234
+ >
235
+ {searching ? "Scrapeando…" : "Buscar en Property Finder"}
236
+ </button>
237
+ </div>
238
+ </form>
239
+ );
240
+ }
@@ -0,0 +1,145 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import Link from "next/link";
5
+ import YieldBadge from "./YieldBadge";
6
+ import { fmtAED, fmtDist, fmtSqft } from "@/lib/format";
7
+ import { AnalysisRow } from "@/lib/types";
8
+
9
+ export interface CardListing {
10
+ id: string;
11
+ url: string;
12
+ title: string;
13
+ price: number;
14
+ size_sqft: number | null;
15
+ bedrooms: number | null;
16
+ bathrooms: number | null;
17
+ property_type: string;
18
+ tower_name: string | null;
19
+ images: { small: string; medium: string }[];
20
+ amenities: string[];
21
+ }
22
+
23
+ export default function ListingCard({
24
+ listing,
25
+ analysis: initialAnalysis,
26
+ onAnalyzed,
27
+ }: {
28
+ listing: CardListing;
29
+ analysis?: AnalysisRow | null;
30
+ onAnalyzed?: (id: string, a: AnalysisRow) => void;
31
+ }) {
32
+ const [analysis, setAnalysis] = useState<AnalysisRow | null>(initialAnalysis ?? null);
33
+ const [loading, setLoading] = useState(false);
34
+ const [imgIdx, setImgIdx] = useState(0);
35
+ const images = listing.images || [];
36
+
37
+ const analyze = () => {
38
+ if (loading || analysis) return;
39
+ setLoading(true);
40
+ fetch(`/api/listing/${listing.id}?url=${encodeURIComponent(listing.url)}`)
41
+ .then(async (r) => {
42
+ const j = await r.json();
43
+ if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
44
+ setAnalysis(j.analysis);
45
+ onAnalyzed?.(listing.id, j.analysis);
46
+ })
47
+ .catch(() => {})
48
+ .finally(() => setLoading(false));
49
+ };
50
+
51
+ return (
52
+ <div className="group flex flex-col overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm transition hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900">
53
+ <div className="relative aspect-[3/2] bg-neutral-100 dark:bg-neutral-800">
54
+ {images.length > 0 && (
55
+ // eslint-disable-next-line @next/next/no-img-element
56
+ <img
57
+ src={images[imgIdx]?.medium || images[imgIdx]?.small}
58
+ alt={listing.title}
59
+ className="h-full w-full object-cover"
60
+ loading="lazy"
61
+ />
62
+ )}
63
+ {images.length > 1 && (
64
+ <div className="absolute inset-x-0 bottom-0 flex items-center justify-between p-2 opacity-0 transition group-hover:opacity-100">
65
+ <button
66
+ onClick={() => setImgIdx((imgIdx - 1 + images.length) % images.length)}
67
+ className="rounded-full bg-black/50 px-2.5 py-1 text-sm text-white"
68
+ >
69
+
70
+ </button>
71
+ <span className="rounded-full bg-black/50 px-2 py-0.5 text-xs text-white">
72
+ {imgIdx + 1}/{images.length}
73
+ </span>
74
+ <button
75
+ onClick={() => setImgIdx((imgIdx + 1) % images.length)}
76
+ className="rounded-full bg-black/50 px-2.5 py-1 text-sm text-white"
77
+ >
78
+
79
+ </button>
80
+ </div>
81
+ )}
82
+ <div className="absolute left-2 top-2">
83
+ {loading ? (
84
+ <span className="inline-flex items-center rounded-full bg-black/60 px-2 py-0.5 text-xs font-medium text-white">
85
+ analizando…
86
+ </span>
87
+ ) : analysis ? (
88
+ <YieldBadge value={analysis.gross_yield} n={analysis.rent_n} />
89
+ ) : null}
90
+ </div>
91
+ </div>
92
+
93
+ <div className="flex flex-1 flex-col gap-1.5 p-3">
94
+ <div className="flex items-baseline justify-between gap-2">
95
+ <span className="text-lg font-bold">{fmtAED(listing.price)}</span>
96
+ <span className="shrink-0 text-xs text-neutral-500">{listing.property_type}</span>
97
+ </div>
98
+ <Link href={`/listing/${listing.id}`} className="line-clamp-2 text-sm font-medium hover:underline">
99
+ {listing.title}
100
+ </Link>
101
+ <div className="text-xs text-neutral-500">
102
+ {listing.bedrooms === 0 ? "Studio" : `${listing.bedrooms ?? "?"} hab`} · {listing.bathrooms ?? "?"} baños ·{" "}
103
+ {fmtSqft(listing.size_sqft)}
104
+ </div>
105
+ {listing.tower_name && (
106
+ <div className="truncate text-xs text-neutral-500">📍 {listing.tower_name}</div>
107
+ )}
108
+ {analysis && (
109
+ <div className="mt-1 grid grid-cols-2 gap-x-3 gap-y-0.5 text-xs text-neutral-600 dark:text-neutral-400">
110
+ <span>Alquiler/año: {fmtAED(analysis.median_rent)}</span>
111
+ <span>🏋️ {fmtDist(analysis.gym_distance_m)}</span>
112
+ <span>Venta med.: {fmtAED(analysis.median_sale_price)}</span>
113
+ <span>s/ anuncio: {analysis.asking_yield != null ? (analysis.asking_yield * 100).toFixed(1) + " %" : "—"}</span>
114
+ </div>
115
+ )}
116
+ <div className="mt-auto flex items-center gap-3 pt-2">
117
+ {analysis ? (
118
+ <Link
119
+ href={`/listing/${listing.id}`}
120
+ className="rounded-lg bg-neutral-900 px-3 py-1.5 text-xs font-semibold text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
121
+ >
122
+ Ver análisis
123
+ </Link>
124
+ ) : (
125
+ <button
126
+ onClick={analyze}
127
+ disabled={loading}
128
+ className="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-500 disabled:opacity-60"
129
+ >
130
+ {loading ? "Analizando…" : "⚡ Analizar"}
131
+ </button>
132
+ )}
133
+ <a
134
+ href={listing.url}
135
+ target="_blank"
136
+ rel="noopener noreferrer"
137
+ className="text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
138
+ >
139
+ Ver en Property Finder ↗
140
+ </a>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ );
145
+ }
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+
6
+ const ITEMS = [
7
+ { href: "/search", label: "🔍 Buscar" },
8
+ { href: "/favorites", label: "⭐ Favoritos + Mapa" },
9
+ { href: "/", label: "📊 Analizados" },
10
+ ];
11
+
12
+ export default function Nav() {
13
+ const pathname = usePathname();
14
+ return (
15
+ <nav className="mx-auto flex h-14 w-full max-w-[1800px] items-center gap-2 px-4">
16
+ <Link href="/" className="mr-4 flex items-center gap-2 text-lg font-bold tracking-tight">
17
+ 🧱 Brickwise
18
+ </Link>
19
+ {ITEMS.map((it) => {
20
+ const active = it.href === "/" ? pathname === "/" : pathname.startsWith(it.href);
21
+ return (
22
+ <Link
23
+ key={it.href}
24
+ href={it.href}
25
+ className={`rounded-full px-3 py-1.5 text-sm font-medium transition ${
26
+ active
27
+ ? "bg-neutral-900 text-white dark:bg-white dark:text-neutral-900"
28
+ : "text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:hover:text-white"
29
+ }`}
30
+ >
31
+ {it.label}
32
+ </Link>
33
+ );
34
+ })}
35
+ </nav>
36
+ );
37
+ }
@@ -0,0 +1,249 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { TxRow } from "@/lib/pf/transactions";
5
+
6
+ interface Props {
7
+ buy: TxRow[];
8
+ rent: TxRow[];
9
+ listingBedrooms: number | null;
10
+ listingPsqft: number | null; // asking price / size
11
+ }
12
+
13
+ interface Pt {
14
+ t: number; // date ms
15
+ y: number; // AED/sqft
16
+ bedrooms: number | null;
17
+ amount: number;
18
+ size: number | null;
19
+ comparable: boolean;
20
+ }
21
+
22
+ const W = 760;
23
+ const H = 320;
24
+ const M = { top: 16, right: 16, bottom: 34, left: 52 };
25
+ const IW = W - M.left - M.right;
26
+ const IH = H - M.top - M.bottom;
27
+
28
+ const ACCENT = "#2563eb"; // comparable (same bedrooms)
29
+ const MUTED = "#9ca3af"; // other units
30
+ const REF = "#059669"; // this listing
31
+
32
+ function niceTicks(min: number, max: number, count: number): number[] {
33
+ if (min === max) return [min];
34
+ const span = max - min;
35
+ const step0 = span / count;
36
+ const mag = Math.pow(10, Math.floor(Math.log10(step0)));
37
+ const norm = step0 / mag;
38
+ const step = (norm >= 5 ? 5 : norm >= 2 ? 2 : 1) * mag;
39
+ const start = Math.ceil(min / step) * step;
40
+ const out: number[] = [];
41
+ for (let v = start; v <= max + 1e-6; v += step) out.push(v);
42
+ return out;
43
+ }
44
+
45
+ const fmtMonth = (ms: number) =>
46
+ new Date(ms).toLocaleDateString("es-ES", { month: "short", year: "2-digit" });
47
+ const fmtDate = (ms: number) => new Date(ms).toLocaleDateString("es-ES");
48
+ const fmtAED = (n: number) => new Intl.NumberFormat("en-AE", { maximumFractionDigits: 0 }).format(n);
49
+
50
+ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }: Props) {
51
+ const [mode, setMode] = useState<"buy" | "rent">("buy");
52
+ const [hover, setHover] = useState<{ p: Pt; x: number; y: number } | null>(null);
53
+
54
+ const rows = mode === "buy" ? buy : rent;
55
+
56
+ const pts: Pt[] = useMemo(
57
+ () =>
58
+ rows
59
+ .filter((r) => r.size_sqft && r.size_sqft > 0 && r.amount > 0 && r.date)
60
+ .map((r) => ({
61
+ t: Date.parse(r.date),
62
+ y: r.amount / (r.size_sqft as number), // sale AED/sqft, or annual rent AED/sqft
63
+ bedrooms: r.bedrooms,
64
+ amount: r.amount,
65
+ size: r.size_sqft,
66
+ comparable: listingBedrooms != null && r.bedrooms === listingBedrooms,
67
+ }))
68
+ .filter((p) => Number.isFinite(p.t) && Number.isFinite(p.y))
69
+ .sort((a, b) => a.t - b.t),
70
+ [rows, listingBedrooms]
71
+ );
72
+
73
+ const scales = useMemo(() => {
74
+ if (!pts.length) return null;
75
+ const ts = pts.map((p) => p.t);
76
+ const ys = pts.map((p) => p.y);
77
+ const refY = mode === "buy" ? listingPsqft ?? null : null;
78
+ const tMin = Math.min(...ts);
79
+ const tMax = Math.max(...ts);
80
+ let yMin = Math.min(...ys, ...(refY ? [refY] : []));
81
+ let yMax = Math.max(...ys, ...(refY ? [refY] : []));
82
+ const pad = (yMax - yMin) * 0.1 || yMax * 0.1 || 1;
83
+ yMin = Math.max(0, yMin - pad);
84
+ yMax = yMax + pad;
85
+ const x = (t: number) => (tMax === tMin ? IW / 2 : ((t - tMin) / (tMax - tMin)) * IW);
86
+ const y = (v: number) => IH - ((v - yMin) / (yMax - yMin)) * IH;
87
+ // median of comparable set (fallback to all)
88
+ const comp = pts.filter((p) => p.comparable);
89
+ const base = (comp.length ? comp : pts).map((p) => p.y).sort((a, b) => a - b);
90
+ const median = base.length ? base[Math.floor(base.length / 2)] : null;
91
+ return { x, y, tMin, tMax, yMin, yMax, refY, median };
92
+ }, [pts, mode, listingPsqft]);
93
+
94
+ const compCount = pts.filter((p) => p.comparable).length;
95
+
96
+ return (
97
+ <div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
98
+ <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
99
+ <h3 className="text-sm font-bold">
100
+ AED/sqft por transacción{" "}
101
+ <span className="font-normal text-neutral-500">
102
+ ({mode === "buy" ? "ventas" : "alquileres anuales"} del edificio · {pts.length})
103
+ </span>
104
+ </h3>
105
+ <div className="flex overflow-hidden rounded-lg border border-neutral-300 text-xs dark:border-neutral-700">
106
+ {(["buy", "rent"] as const).map((m) => (
107
+ <button
108
+ key={m}
109
+ onClick={() => {
110
+ setMode(m);
111
+ setHover(null);
112
+ }}
113
+ className={`px-3 py-1 font-semibold ${
114
+ mode === m
115
+ ? "bg-neutral-900 text-white dark:bg-white dark:text-neutral-900"
116
+ : "text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
117
+ }`}
118
+ >
119
+ {m === "buy" ? "Venta" : "Alquiler"}
120
+ </button>
121
+ ))}
122
+ </div>
123
+ </div>
124
+
125
+ {!scales ? (
126
+ <p className="py-10 text-center text-sm text-neutral-500">
127
+ Sin transacciones de {mode === "buy" ? "venta" : "alquiler"} registradas.
128
+ </p>
129
+ ) : (
130
+ <div className="relative">
131
+ <svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ height: "auto" }} role="img">
132
+ <g transform={`translate(${M.left},${M.top})`}>
133
+ {/* Y grid + labels */}
134
+ {niceTicks(scales.yMin, scales.yMax, 5).map((v) => (
135
+ <g key={v}>
136
+ <line x1={0} x2={IW} y1={scales.y(v)} y2={scales.y(v)} stroke="currentColor" strokeOpacity={0.12} />
137
+ <text x={-8} y={scales.y(v)} dy="0.32em" textAnchor="end" className="fill-neutral-500 text-[10px]">
138
+ {fmtAED(v)}
139
+ </text>
140
+ </g>
141
+ ))}
142
+ {/* X labels */}
143
+ {niceTicks(scales.tMin, scales.tMax, 5).map((t) => (
144
+ <text
145
+ key={t}
146
+ x={scales.x(t)}
147
+ y={IH + 20}
148
+ textAnchor="middle"
149
+ className="fill-neutral-500 text-[10px]"
150
+ >
151
+ {fmtMonth(t)}
152
+ </text>
153
+ ))}
154
+
155
+ {/* median line */}
156
+ {scales.median != null && (
157
+ <line
158
+ x1={0}
159
+ x2={IW}
160
+ y1={scales.y(scales.median)}
161
+ y2={scales.y(scales.median)}
162
+ stroke={MUTED}
163
+ strokeDasharray="4 4"
164
+ strokeWidth={1.5}
165
+ />
166
+ )}
167
+ {/* this-listing reference line (sale only) */}
168
+ {scales.refY != null && (
169
+ <>
170
+ <line
171
+ x1={0}
172
+ x2={IW}
173
+ y1={scales.y(scales.refY)}
174
+ y2={scales.y(scales.refY)}
175
+ stroke={REF}
176
+ strokeWidth={2}
177
+ />
178
+ <text x={IW} y={scales.y(scales.refY) - 5} textAnchor="end" className="text-[10px]" fill={REF}>
179
+ este anuncio {fmtAED(scales.refY)}
180
+ </text>
181
+ </>
182
+ )}
183
+
184
+ {/* points: muted first, comparable on top */}
185
+ {pts
186
+ .slice()
187
+ .sort((a, b) => Number(a.comparable) - Number(b.comparable))
188
+ .map((p, i) => (
189
+ <circle
190
+ key={i}
191
+ cx={scales.x(p.t)}
192
+ cy={scales.y(p.y)}
193
+ r={p.comparable ? 5 : 3.5}
194
+ fill={p.comparable ? ACCENT : "none"}
195
+ stroke={p.comparable ? "#fff" : MUTED}
196
+ strokeWidth={p.comparable ? 1.5 : 1.5}
197
+ opacity={p.comparable ? 1 : 0.7}
198
+ onMouseEnter={() => setHover({ p, x: scales.x(p.t), y: scales.y(p.y) })}
199
+ onMouseLeave={() => setHover(null)}
200
+ style={{ cursor: "pointer" }}
201
+ />
202
+ ))}
203
+ </g>
204
+ </svg>
205
+
206
+ {hover && (
207
+ <div
208
+ className="pointer-events-none absolute z-10 rounded-lg border border-neutral-200 bg-white px-2.5 py-1.5 text-[11px] shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
209
+ style={{
210
+ left: `calc(${((M.left + hover.x) / W) * 100}% + 8px)`,
211
+ top: `calc(${((M.top + hover.y) / H) * 100}% - 8px)`,
212
+ transform: hover.x > IW * 0.7 ? "translateX(-100%) translateX(-16px)" : undefined,
213
+ }}
214
+ >
215
+ <div className="font-bold">{fmtAED(hover.p.y)} AED/sqft</div>
216
+ <div className="text-neutral-500">
217
+ {fmtDate(hover.p.t)} · {hover.p.bedrooms === 0 ? "Studio" : `${hover.p.bedrooms ?? "?"} hab`}
218
+ </div>
219
+ <div className="text-neutral-500">
220
+ {fmtAED(hover.p.amount)} AED · {hover.p.size ? Math.round(hover.p.size) : "—"} sqft
221
+ </div>
222
+ </div>
223
+ )}
224
+
225
+ <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-neutral-500">
226
+ <span className="flex items-center gap-1.5">
227
+ <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: ACCENT }} />
228
+ Mismo nº de habitaciones ({compCount})
229
+ </span>
230
+ <span className="flex items-center gap-1.5">
231
+ <span className="inline-block h-2.5 w-2.5 rounded-full border-[1.5px]" style={{ borderColor: MUTED }} />
232
+ Otras unidades
233
+ </span>
234
+ <span className="flex items-center gap-1.5">
235
+ <span className="inline-block h-0 w-4 border-t-[1.5px] border-dashed" style={{ borderColor: MUTED }} />
236
+ Mediana
237
+ </span>
238
+ {mode === "buy" && listingPsqft != null && (
239
+ <span className="flex items-center gap-1.5">
240
+ <span className="inline-block h-0 w-4 border-t-2" style={{ borderColor: REF }} />
241
+ Este anuncio
242
+ </span>
243
+ )}
244
+ </div>
245
+ </div>
246
+ )}
247
+ </div>
248
+ );
249
+ }