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,37 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { Geist, Geist_Mono } from "next/font/google";
|
|
3
|
+
import ChromeHeader from "@/components/ChromeHeader";
|
|
4
|
+
import "./globals.css";
|
|
5
|
+
|
|
6
|
+
const geistSans = Geist({
|
|
7
|
+
variable: "--font-geist-sans",
|
|
8
|
+
subsets: ["latin"],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const geistMono = Geist_Mono({
|
|
12
|
+
variable: "--font-geist-mono",
|
|
13
|
+
subsets: ["latin"],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const metadata: Metadata = {
|
|
17
|
+
title: "Brickwise — Rentabilidad Property Finder",
|
|
18
|
+
description: "Scraper y analizador de rentabilidad de inmuebles de Property Finder UAE",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default function RootLayout({
|
|
22
|
+
children,
|
|
23
|
+
}: Readonly<{
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
}>) {
|
|
26
|
+
return (
|
|
27
|
+
<html
|
|
28
|
+
lang="es"
|
|
29
|
+
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
30
|
+
>
|
|
31
|
+
<body className="flex min-h-svh flex-col bg-neutral-50 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100">
|
|
32
|
+
<ChromeHeader />
|
|
33
|
+
<main className="flex min-h-0 flex-1 flex-col">{children}</main>
|
|
34
|
+
</body>
|
|
35
|
+
</html>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { use, useEffect, useState } from "react";
|
|
4
|
+
import YieldBadge from "@/components/YieldBadge";
|
|
5
|
+
import TransactionsTable from "@/components/TransactionsTable";
|
|
6
|
+
import PsqftChart from "@/components/PsqftChart";
|
|
7
|
+
import { fmtAED, fmtDist, fmtPct, fmtSqft } from "@/lib/format";
|
|
8
|
+
import { AnalysisRow } from "@/lib/types";
|
|
9
|
+
import { TxRow } from "@/lib/pf/transactions";
|
|
10
|
+
|
|
11
|
+
interface Detail {
|
|
12
|
+
listing: {
|
|
13
|
+
id: string;
|
|
14
|
+
url: string;
|
|
15
|
+
title: string;
|
|
16
|
+
price: number;
|
|
17
|
+
size_sqft: number | null;
|
|
18
|
+
bedrooms: number | null;
|
|
19
|
+
bathrooms: number | null;
|
|
20
|
+
property_type: string;
|
|
21
|
+
tower_name: string | null;
|
|
22
|
+
tower_slug: string | null;
|
|
23
|
+
description: string | null;
|
|
24
|
+
images: { small: string; medium: string }[];
|
|
25
|
+
amenities: string[];
|
|
26
|
+
};
|
|
27
|
+
analysis: AnalysisRow;
|
|
28
|
+
transactions: { buy: TxRow[]; rent: TxRow[] } | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function ListingPage({ params }: { params: Promise<{ id: string }> }) {
|
|
32
|
+
const { id } = use(params);
|
|
33
|
+
const [data, setData] = useState<Detail | null>(null);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const [imgIdx, setImgIdx] = useState(0);
|
|
36
|
+
const [faved, setFaved] = useState(false);
|
|
37
|
+
|
|
38
|
+
const addFavorite = async () => {
|
|
39
|
+
if (!data) return;
|
|
40
|
+
await fetch("/api/favorites", {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
body: JSON.stringify({ url: data.listing.url }),
|
|
44
|
+
});
|
|
45
|
+
setFaved(true);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
fetch(`/api/listing/${id}?tx=1`)
|
|
50
|
+
.then(async (r) => {
|
|
51
|
+
const j = await r.json();
|
|
52
|
+
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
|
|
53
|
+
setData(j);
|
|
54
|
+
})
|
|
55
|
+
.catch((e) => setError(e.message));
|
|
56
|
+
}, [id]);
|
|
57
|
+
|
|
58
|
+
if (error)
|
|
59
|
+
return (
|
|
60
|
+
<div className="mx-auto w-full max-w-7xl px-4 py-6">
|
|
61
|
+
<div className="rounded-lg border border-red-300 bg-red-50 p-4 text-red-800">Error: {error}</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
if (!data)
|
|
65
|
+
return (
|
|
66
|
+
<p className="mx-auto w-full max-w-7xl animate-pulse px-4 py-6 text-neutral-500">
|
|
67
|
+
Cargando análisis (scrapeando si es necesario)…
|
|
68
|
+
</p>
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const { listing, analysis, transactions } = data;
|
|
72
|
+
const images = listing.images || [];
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
|
76
|
+
<div>
|
|
77
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
78
|
+
<h1 className="text-2xl font-bold">{listing.title}</h1>
|
|
79
|
+
<YieldBadge value={analysis.gross_yield} n={analysis.rent_n} />
|
|
80
|
+
</div>
|
|
81
|
+
<p className="mt-1 text-sm text-neutral-500">
|
|
82
|
+
{listing.tower_name} · {listing.bedrooms === 0 ? "Studio" : `${listing.bedrooms ?? "?"} hab`} ·{" "}
|
|
83
|
+
{listing.bathrooms ?? "?"} baños · {fmtSqft(listing.size_sqft)} · {listing.property_type}
|
|
84
|
+
</p>
|
|
85
|
+
<div className="mt-1 flex items-center gap-4">
|
|
86
|
+
<a
|
|
87
|
+
href={listing.url}
|
|
88
|
+
target="_blank"
|
|
89
|
+
rel="noopener noreferrer"
|
|
90
|
+
className="text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
|
|
91
|
+
>
|
|
92
|
+
Ver anuncio en Property Finder ↗
|
|
93
|
+
</a>
|
|
94
|
+
<button
|
|
95
|
+
onClick={addFavorite}
|
|
96
|
+
disabled={faved}
|
|
97
|
+
className="rounded-lg border border-neutral-300 px-3 py-1 text-sm font-medium hover:bg-neutral-100 disabled:opacity-60 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
|
98
|
+
>
|
|
99
|
+
{faved ? "⭐ Guardado" : "☆ Guardar en favoritos"}
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{images.length > 0 && (
|
|
105
|
+
<div className="grid gap-2 lg:grid-cols-[2fr_1fr]">
|
|
106
|
+
<div className="relative overflow-hidden rounded-xl bg-neutral-100 dark:bg-neutral-800">
|
|
107
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
108
|
+
<img src={images[imgIdx]?.medium} alt={listing.title} className="aspect-[3/2] w-full object-cover" />
|
|
109
|
+
{images.length > 1 && (
|
|
110
|
+
<div className="absolute inset-x-0 bottom-2 flex items-center justify-center gap-3">
|
|
111
|
+
<button
|
|
112
|
+
onClick={() => setImgIdx((imgIdx - 1 + images.length) % images.length)}
|
|
113
|
+
className="rounded-full bg-black/50 px-3 py-1 text-white"
|
|
114
|
+
>
|
|
115
|
+
‹
|
|
116
|
+
</button>
|
|
117
|
+
<span className="rounded-full bg-black/50 px-2 py-0.5 text-xs text-white">
|
|
118
|
+
{imgIdx + 1}/{images.length}
|
|
119
|
+
</span>
|
|
120
|
+
<button
|
|
121
|
+
onClick={() => setImgIdx((imgIdx + 1) % images.length)}
|
|
122
|
+
className="rounded-full bg-black/50 px-3 py-1 text-white"
|
|
123
|
+
>
|
|
124
|
+
›
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
<div className="grid max-h-[420px] grid-cols-2 gap-2 overflow-auto">
|
|
130
|
+
{images.slice(0, 12).map((img, i) => (
|
|
131
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
132
|
+
<img
|
|
133
|
+
key={i}
|
|
134
|
+
src={img.small}
|
|
135
|
+
alt=""
|
|
136
|
+
onClick={() => setImgIdx(i)}
|
|
137
|
+
className={`aspect-[3/2] w-full cursor-pointer rounded-lg object-cover ${i === imgIdx ? "ring-2 ring-blue-500" : ""}`}
|
|
138
|
+
loading="lazy"
|
|
139
|
+
/>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
146
|
+
<Stat
|
|
147
|
+
label="Rentabilidad anual de alquiler"
|
|
148
|
+
value={fmtPct(analysis.gross_yield)}
|
|
149
|
+
sub="renta anual mediana ÷ precio de venta mediana (unidades comparables del edificio)"
|
|
150
|
+
accent={analysis.gross_yield != null && analysis.gross_yield >= 0.06 ? "good" : undefined}
|
|
151
|
+
/>
|
|
152
|
+
<Stat
|
|
153
|
+
label="Se alquila por (año, mediana)"
|
|
154
|
+
value={fmtAED(analysis.median_rent)}
|
|
155
|
+
sub={`${analysis.rent_n} contrato${analysis.rent_n === 1 ? "" : "s"} de alquiler comparables (24 meses)`}
|
|
156
|
+
/>
|
|
157
|
+
<Stat
|
|
158
|
+
label="Se vende por (mediana)"
|
|
159
|
+
value={fmtAED(analysis.median_sale_price)}
|
|
160
|
+
sub={`${analysis.buy_n} venta${analysis.buy_n === 1 ? "" : "s"} comparables (24 meses)`}
|
|
161
|
+
/>
|
|
162
|
+
<Stat
|
|
163
|
+
label="Rentabilidad sobre el precio del anuncio"
|
|
164
|
+
value={fmtPct(analysis.asking_yield)}
|
|
165
|
+
sub={`si lo compras por ${fmtAED(listing.price)}`}
|
|
166
|
+
/>
|
|
167
|
+
<Stat
|
|
168
|
+
label="Gimnasio más cercano (OSM)"
|
|
169
|
+
value={fmtDist(analysis.gym_distance_m)}
|
|
170
|
+
sub={analysis.gym_name ?? undefined}
|
|
171
|
+
/>
|
|
172
|
+
<Stat label="AED/sqft del anuncio" value={listing.size_sqft ? `${Math.round(listing.price / listing.size_sqft)} AED/sqft` : "—"} />
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{listing.amenities?.length > 0 && (
|
|
176
|
+
<div className="flex flex-wrap gap-1.5">
|
|
177
|
+
{listing.amenities.map((a) => (
|
|
178
|
+
<span key={a} className="rounded-full bg-neutral-200 px-2.5 py-0.5 text-xs dark:bg-neutral-800">
|
|
179
|
+
{a}
|
|
180
|
+
</span>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{transactions && (transactions.buy.length > 0 || transactions.rent.length > 0) && (
|
|
186
|
+
<PsqftChart
|
|
187
|
+
buy={transactions.buy}
|
|
188
|
+
rent={transactions.rent}
|
|
189
|
+
listingBedrooms={listing.bedrooms}
|
|
190
|
+
listingPsqft={listing.size_sqft ? listing.price / listing.size_sqft : null}
|
|
191
|
+
/>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
195
|
+
<TransactionsTable
|
|
196
|
+
title={`Transacciones de venta en ${listing.tower_name ?? "el edificio"}`}
|
|
197
|
+
rows={transactions?.buy ?? []}
|
|
198
|
+
kind="buy"
|
|
199
|
+
/>
|
|
200
|
+
<TransactionsTable
|
|
201
|
+
title={`Contratos de alquiler en ${listing.tower_name ?? "el edificio"}`}
|
|
202
|
+
rows={transactions?.rent ?? []}
|
|
203
|
+
kind="rent"
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{listing.description && (
|
|
208
|
+
<div className="rounded-xl border border-neutral-200 bg-white p-4 text-sm whitespace-pre-line dark:border-neutral-800 dark:bg-neutral-900">
|
|
209
|
+
{listing.description}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function Stat({ label, value, sub, accent }: { label: string; value: string; sub?: string; accent?: "good" | "bad" }) {
|
|
217
|
+
return (
|
|
218
|
+
<div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
|
219
|
+
<div className="text-xs font-semibold text-neutral-500">{label}</div>
|
|
220
|
+
<div
|
|
221
|
+
className={`mt-1 text-xl font-bold ${
|
|
222
|
+
accent === "good" ? "text-emerald-600 dark:text-emerald-400" : accent === "bad" ? "text-red-600 dark:text-red-400" : ""
|
|
223
|
+
}`}
|
|
224
|
+
>
|
|
225
|
+
{value}
|
|
226
|
+
</div>
|
|
227
|
+
{sub && <div className="mt-0.5 text-xs text-neutral-500">{sub}</div>}
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
package/src/app/page.tsx
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import YieldBadge from "@/components/YieldBadge";
|
|
6
|
+
import { fmtAED, fmtDist, fmtSqft } from "@/lib/format";
|
|
7
|
+
|
|
8
|
+
interface Row {
|
|
9
|
+
id: string;
|
|
10
|
+
url: string;
|
|
11
|
+
title: string;
|
|
12
|
+
price: number;
|
|
13
|
+
size_sqft: number | null;
|
|
14
|
+
bedrooms: number | null;
|
|
15
|
+
property_type: string;
|
|
16
|
+
tower_name: string | null;
|
|
17
|
+
images: { small: string; medium: string }[];
|
|
18
|
+
gross_yield: number | null;
|
|
19
|
+
asking_yield: number | null;
|
|
20
|
+
median_rent: number | null;
|
|
21
|
+
median_sale_price: number | null;
|
|
22
|
+
rent_n: number;
|
|
23
|
+
buy_n: number;
|
|
24
|
+
gym_distance_m: number | null;
|
|
25
|
+
computed_at: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type SortKey = "gross_yield" | "asking_yield" | "price" | "gym_distance_m";
|
|
29
|
+
|
|
30
|
+
export default function Dashboard() {
|
|
31
|
+
const [rows, setRows] = useState<Row[] | null>(null);
|
|
32
|
+
const [sortKey, setSortKey] = useState<SortKey>("gross_yield");
|
|
33
|
+
const [asc, setAsc] = useState(false);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
fetch("/api/dashboard")
|
|
37
|
+
.then((r) => r.json())
|
|
38
|
+
.then((j) => setRows(j.results || []))
|
|
39
|
+
.catch(() => setRows([]));
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const sorted = useMemo(() => {
|
|
43
|
+
if (!rows) return null;
|
|
44
|
+
return [...rows].sort((a, b) => {
|
|
45
|
+
const av = a[sortKey];
|
|
46
|
+
const bv = b[sortKey];
|
|
47
|
+
if (av == null) return 1;
|
|
48
|
+
if (bv == null) return -1;
|
|
49
|
+
return asc ? (av as number) - (bv as number) : (bv as number) - (av as number);
|
|
50
|
+
});
|
|
51
|
+
}, [rows, sortKey, asc]);
|
|
52
|
+
|
|
53
|
+
const header = (label: string, key: SortKey) => (
|
|
54
|
+
<th
|
|
55
|
+
className="cursor-pointer select-none px-3 py-2 text-left text-xs font-semibold text-neutral-500 hover:text-neutral-900 dark:hover:text-white"
|
|
56
|
+
onClick={() => {
|
|
57
|
+
if (sortKey === key) setAsc(!asc);
|
|
58
|
+
else {
|
|
59
|
+
setSortKey(key);
|
|
60
|
+
setAsc(key === "gym_distance_m");
|
|
61
|
+
}
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
{label} {sortKey === key ? (asc ? "↑" : "↓") : ""}
|
|
65
|
+
</th>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5 px-4 py-6">
|
|
70
|
+
<div className="flex items-center justify-between">
|
|
71
|
+
<h1 className="text-2xl font-bold">Inmuebles analizados</h1>
|
|
72
|
+
<Link
|
|
73
|
+
href="/search"
|
|
74
|
+
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-bold text-white hover:bg-blue-500"
|
|
75
|
+
>
|
|
76
|
+
+ Nueva búsqueda
|
|
77
|
+
</Link>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{sorted == null ? (
|
|
81
|
+
<p className="animate-pulse text-neutral-500">Cargando…</p>
|
|
82
|
+
) : sorted.length === 0 ? (
|
|
83
|
+
<div className="rounded-xl border border-dashed border-neutral-300 p-10 text-center text-neutral-500 dark:border-neutral-700">
|
|
84
|
+
Aún no hay inmuebles analizados. Empieza con una{" "}
|
|
85
|
+
<Link href="/search" className="font-semibold text-blue-600 hover:underline dark:text-blue-400">
|
|
86
|
+
búsqueda
|
|
87
|
+
</Link>
|
|
88
|
+
.
|
|
89
|
+
</div>
|
|
90
|
+
) : (
|
|
91
|
+
<div className="overflow-x-auto rounded-xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
|
|
92
|
+
<table className="w-full min-w-[900px] text-sm">
|
|
93
|
+
<thead className="border-b border-neutral-200 dark:border-neutral-800">
|
|
94
|
+
<tr>
|
|
95
|
+
<th className="px-3 py-2 text-left text-xs font-semibold text-neutral-500">Inmueble</th>
|
|
96
|
+
{header("Rentabilidad", "gross_yield")}
|
|
97
|
+
{header("s/ anuncio", "asking_yield")}
|
|
98
|
+
{header("Precio", "price")}
|
|
99
|
+
<th className="px-3 py-2 text-left text-xs font-semibold text-neutral-500">Venta mediana</th>
|
|
100
|
+
{header("Gym", "gym_distance_m")}
|
|
101
|
+
<th className="px-3 py-2 text-left text-xs font-semibold text-neutral-500">Renta mediana</th>
|
|
102
|
+
<th className="px-3 py-2" />
|
|
103
|
+
</tr>
|
|
104
|
+
</thead>
|
|
105
|
+
<tbody>
|
|
106
|
+
{sorted.map((r) => (
|
|
107
|
+
<tr key={r.id} className="border-b border-neutral-100 last:border-0 dark:border-neutral-800">
|
|
108
|
+
<td className="px-3 py-2">
|
|
109
|
+
<div className="flex items-center gap-3">
|
|
110
|
+
{r.images?.[0] && (
|
|
111
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
112
|
+
<img src={r.images[0].small} alt="" className="h-12 w-16 rounded-md object-cover" loading="lazy" />
|
|
113
|
+
)}
|
|
114
|
+
<div className="min-w-0">
|
|
115
|
+
<Link href={`/listing/${r.id}`} className="line-clamp-1 font-medium hover:underline">
|
|
116
|
+
{r.title}
|
|
117
|
+
</Link>
|
|
118
|
+
<div className="text-xs text-neutral-500">
|
|
119
|
+
{r.tower_name} · {r.bedrooms === 0 ? "Studio" : `${r.bedrooms ?? "?"} hab`} · {fmtSqft(r.size_sqft)}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</td>
|
|
124
|
+
<td className="px-3 py-2">
|
|
125
|
+
<YieldBadge value={r.gross_yield} n={r.rent_n} />
|
|
126
|
+
</td>
|
|
127
|
+
<td className="px-3 py-2 whitespace-nowrap">
|
|
128
|
+
{r.asking_yield != null ? (r.asking_yield * 100).toFixed(1) + " %" : "—"}
|
|
129
|
+
</td>
|
|
130
|
+
<td className="px-3 py-2 whitespace-nowrap font-medium">{fmtAED(r.price)}</td>
|
|
131
|
+
<td className="px-3 py-2 whitespace-nowrap">
|
|
132
|
+
{fmtAED(r.median_sale_price)}
|
|
133
|
+
{r.buy_n ? <span className="ml-1 text-xs text-neutral-500">({r.buy_n})</span> : null}
|
|
134
|
+
</td>
|
|
135
|
+
<td className="px-3 py-2 whitespace-nowrap">{fmtDist(r.gym_distance_m)}</td>
|
|
136
|
+
<td className="px-3 py-2 whitespace-nowrap">{fmtAED(r.median_rent)}</td>
|
|
137
|
+
<td className="px-3 py-2 whitespace-nowrap text-right">
|
|
138
|
+
<a
|
|
139
|
+
href={r.url}
|
|
140
|
+
target="_blank"
|
|
141
|
+
rel="noopener noreferrer"
|
|
142
|
+
className="text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
|
|
143
|
+
>
|
|
144
|
+
PF ↗
|
|
145
|
+
</a>
|
|
146
|
+
</td>
|
|
147
|
+
</tr>
|
|
148
|
+
))}
|
|
149
|
+
</tbody>
|
|
150
|
+
</table>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import FiltersForm from "@/components/FiltersForm";
|
|
5
|
+
import ListingCard, { CardListing } from "@/components/ListingCard";
|
|
6
|
+
import { AnalysisRow, SearchFilters } from "@/lib/types";
|
|
7
|
+
|
|
8
|
+
export default function SearchPage() {
|
|
9
|
+
const [results, setResults] = useState<CardListing[]>([]);
|
|
10
|
+
const [analyses, setAnalyses] = useState<Record<string, AnalysisRow>>({});
|
|
11
|
+
const [totalCount, setTotalCount] = useState<number | null>(null);
|
|
12
|
+
const [duplicatesRemoved, setDuplicatesRemoved] = useState(0);
|
|
13
|
+
const [searching, setSearching] = useState(false);
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
const [maxGymM, setMaxGymM] = useState<number | undefined>(undefined);
|
|
16
|
+
|
|
17
|
+
const search = async (f: SearchFilters) => {
|
|
18
|
+
setSearching(true);
|
|
19
|
+
setError(null);
|
|
20
|
+
setResults([]);
|
|
21
|
+
setAnalyses({});
|
|
22
|
+
setMaxGymM(f.maxGymDistanceM);
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch("/api/search", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify(f),
|
|
28
|
+
});
|
|
29
|
+
const j = await res.json();
|
|
30
|
+
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
|
31
|
+
setResults(j.results);
|
|
32
|
+
const cached: Record<string, AnalysisRow> = {};
|
|
33
|
+
for (const r of j.results) if (r.analysis) cached[r.id] = r.analysis;
|
|
34
|
+
setAnalyses(cached);
|
|
35
|
+
setTotalCount(j.totalCount);
|
|
36
|
+
setDuplicatesRemoved(j.duplicatesRemoved ?? 0);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
setError((e as Error).message);
|
|
39
|
+
} finally {
|
|
40
|
+
setSearching(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const visible = results.filter((r) => {
|
|
45
|
+
if (maxGymM == null) return true;
|
|
46
|
+
const a = analyses[r.id];
|
|
47
|
+
if (!a) return true; // keep while analysis pending
|
|
48
|
+
return a.gym_distance_m != null && a.gym_distance_m <= maxGymM;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5 px-4 py-6">
|
|
53
|
+
<h1 className="text-2xl font-bold">Buscar inmuebles</h1>
|
|
54
|
+
<FiltersForm onSearch={search} searching={searching} />
|
|
55
|
+
|
|
56
|
+
{error && (
|
|
57
|
+
<div className="rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
|
|
58
|
+
Error: {error}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
{totalCount != null && (
|
|
63
|
+
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
|
64
|
+
{visible.length} mostrados de {results.length} únicos ({totalCount.toLocaleString()} en Property Finder)
|
|
65
|
+
{duplicatesRemoved > 0 && (
|
|
66
|
+
<span className="ml-1 rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
|
|
67
|
+
{duplicatesRemoved} duplicado{duplicatesRemoved === 1 ? "" : "s"} agrupado{duplicatesRemoved === 1 ? "" : "s"}
|
|
68
|
+
</span>
|
|
69
|
+
)}
|
|
70
|
+
. Pulsa <span className="font-semibold">⚡ Analizar</span> en los inmuebles que te interesen (los ya analizados
|
|
71
|
+
muestran su rentabilidad al instante).
|
|
72
|
+
</p>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
76
|
+
{visible.map((r) => (
|
|
77
|
+
<ListingCard
|
|
78
|
+
key={r.id}
|
|
79
|
+
listing={r}
|
|
80
|
+
analysis={analyses[r.id] ?? null}
|
|
81
|
+
onAnalyzed={(id, a) => setAnalyses((prev) => ({ ...prev, [id]: a }))}
|
|
82
|
+
/>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname } from "next/navigation";
|
|
4
|
+
import Nav from "./Nav";
|
|
5
|
+
|
|
6
|
+
/** The map view is full-screen and provides its own navigation, so hide the
|
|
7
|
+
* global top bar there. */
|
|
8
|
+
export default function ChromeHeader() {
|
|
9
|
+
const pathname = usePathname();
|
|
10
|
+
if (pathname.startsWith("/favorites") || pathname.startsWith("/map")) return null;
|
|
11
|
+
return (
|
|
12
|
+
<header className="sticky top-0 z-40 border-b border-neutral-200 bg-white/85 backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/85">
|
|
13
|
+
<Nav />
|
|
14
|
+
</header>
|
|
15
|
+
);
|
|
16
|
+
}
|