brickwise 0.1.0 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/fonts/Stack_Sans_Notch/OFL.txt +93 -0
- package/public/fonts/Stack_Sans_Notch/README.txt +68 -0
- package/public/fonts/Stack_Sans_Notch/StackSansNotch-VariableFont_wght.ttf +0 -0
- package/public/fonts/Stack_Sans_Notch/static/StackSansNotch-Bold.ttf +0 -0
- package/public/fonts/Stack_Sans_Notch/static/StackSansNotch-ExtraLight.ttf +0 -0
- package/public/fonts/Stack_Sans_Notch/static/StackSansNotch-Light.ttf +0 -0
- package/public/fonts/Stack_Sans_Notch/static/StackSansNotch-Medium.ttf +0 -0
- package/public/fonts/Stack_Sans_Notch/static/StackSansNotch-Regular.ttf +0 -0
- package/public/fonts/Stack_Sans_Notch/static/StackSansNotch-SemiBold.ttf +0 -0
- package/src/app/favorites/page.tsx +175 -105
- package/src/app/globals.css +123 -26
- package/src/app/layout.tsx +10 -4
- package/src/app/listing/[id]/page.tsx +18 -3
- package/src/app/page.tsx +6 -6
- package/src/app/search/page.tsx +3 -85
- package/src/components/Nav.tsx +5 -6
- package/src/components/PsqftChart.tsx +86 -23
- package/src/components/ReformScenario.tsx +123 -0
- package/src/lib/db.ts +9 -2
- package/src/lib/store.ts +8 -1
- package/src/lib/types.ts +8 -0
- package/src/lib/yield.ts +26 -15
- package/src/app/api/search/route.ts +0 -26
- package/src/components/FiltersForm.tsx +0 -240
- package/src/components/ListingCard.tsx +0 -145
- package/src/lib/dedupe.ts +0 -34
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { fmtAED, fmtPct } from "@/lib/format";
|
|
5
|
+
import { AnalysisRow } from "@/lib/types";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_AED_PER_SQFT = 300;
|
|
8
|
+
const SIZE_TOLERANCE = 0.2;
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
listingId: string;
|
|
12
|
+
price: number;
|
|
13
|
+
sizeSqft: number | null;
|
|
14
|
+
analysis: AnalysisRow;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Buy → light reform → rent higher. Within one building + size band the location
|
|
19
|
+
* is fixed, so the comparable-rent spread (P25→P75) approximates what the market
|
|
20
|
+
* pays for interior condition; renting at ~P75 is the realistic post-reform rent.
|
|
21
|
+
*/
|
|
22
|
+
export default function ReformScenario({ listingId, price, sizeSqft, analysis }: Props) {
|
|
23
|
+
const [cost, setCost] = useState(() => {
|
|
24
|
+
const fallback = sizeSqft ? Math.round(sizeSqft * DEFAULT_AED_PER_SQFT) : 100000;
|
|
25
|
+
if (typeof window === "undefined") return fallback;
|
|
26
|
+
const saved = Number(localStorage.getItem(`bw-reform-${listingId}`));
|
|
27
|
+
return Number.isFinite(saved) && saved > 0 ? saved : fallback;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const { rent_p25, rent_p75, median_rent, rent_n, asking_yield } = analysis;
|
|
31
|
+
if (rent_p25 == null || rent_p75 == null || median_rent == null || rent_n < 3) return null;
|
|
32
|
+
|
|
33
|
+
const setAndSave = (v: number) => {
|
|
34
|
+
setCost(v);
|
|
35
|
+
if (Number.isFinite(v) && v >= 0) localStorage.setItem(`bw-reform-${listingId}`, String(v));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const postReformYield = rent_p75 / (price + cost);
|
|
39
|
+
const uplift = rent_p75 - median_rent;
|
|
40
|
+
const paybackYears = uplift > 0 ? cost / uplift : null;
|
|
41
|
+
const spread = (rent_p75 - rent_p25) / median_rent;
|
|
42
|
+
|
|
43
|
+
const spreadReading =
|
|
44
|
+
spread >= 0.15
|
|
45
|
+
? { txt: "El interior mueve mucho la renta en este edificio: hay margen real para la reforma.", cls: "text-emerald-700 dark:text-emerald-400" }
|
|
46
|
+
: spread >= 0.08
|
|
47
|
+
? { txt: "Margen moderado: la reforma ayuda, pero no esperes milagros en este edificio.", cls: "text-amber-700 dark:text-amber-400" }
|
|
48
|
+
: { txt: "El edificio capa la renta: los comparables se alquilan casi igual estén como estén. La reforma difícilmente se paga aquí.", cls: "text-red-700 dark:text-red-400" };
|
|
49
|
+
|
|
50
|
+
const band = sizeSqft
|
|
51
|
+
? `${Math.round(sizeSqft * (1 - SIZE_TOLERANCE))}–${Math.round(sizeSqft * (1 + SIZE_TOLERANCE))} sqft`
|
|
52
|
+
: null;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
|
56
|
+
<h3 className="text-sm font-bold">
|
|
57
|
+
Escenario de reforma{" "}
|
|
58
|
+
<span className="font-normal text-neutral-500">
|
|
59
|
+
({rent_n} alquileres comparables{band ? ` · ${band}` : ""})
|
|
60
|
+
</span>
|
|
61
|
+
</h3>
|
|
62
|
+
|
|
63
|
+
<div className="mt-3 grid gap-4 lg:grid-cols-[1fr_auto_1fr]">
|
|
64
|
+
{/* Rent range of comparables */}
|
|
65
|
+
<div>
|
|
66
|
+
<div className="mb-1 flex justify-between text-[11px] text-neutral-500">
|
|
67
|
+
<span>P25 (sin reformar)</span>
|
|
68
|
+
<span>mediana</span>
|
|
69
|
+
<span>P75 (reformado)</span>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="relative h-2 rounded-full bg-gradient-to-r from-red-300 via-amber-300 to-emerald-400 dark:from-red-900 dark:via-amber-700 dark:to-emerald-600">
|
|
72
|
+
{rent_p75 > rent_p25 && (
|
|
73
|
+
<span
|
|
74
|
+
className="absolute -top-1 h-4 w-1 rounded bg-neutral-900 dark:bg-white"
|
|
75
|
+
style={{ left: `${Math.min(99, Math.max(1, ((median_rent - rent_p25) / (rent_p75 - rent_p25)) * 100))}%` }}
|
|
76
|
+
title={`mediana ${fmtAED(median_rent)}`}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
<div className="mt-1 flex justify-between text-xs font-semibold">
|
|
81
|
+
<span>{fmtAED(rent_p25)}</span>
|
|
82
|
+
<span className="text-neutral-500">{fmtAED(median_rent)}</span>
|
|
83
|
+
<span>{fmtAED(rent_p75)}</span>
|
|
84
|
+
</div>
|
|
85
|
+
<p className={`mt-2 text-xs ${spreadReading.cls}`}>
|
|
86
|
+
Dispersión {fmtPct(spread, 0)} de la mediana — {spreadReading.txt}
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div className="hidden w-px bg-neutral-200 lg:block dark:bg-neutral-800" />
|
|
91
|
+
|
|
92
|
+
{/* Calculator */}
|
|
93
|
+
<div className="text-sm">
|
|
94
|
+
<label className="text-xs font-semibold text-neutral-600 dark:text-neutral-400">
|
|
95
|
+
Coste de la reforma (AED)
|
|
96
|
+
{sizeSqft ? <span className="ml-1 font-normal text-neutral-400">(defecto {DEFAULT_AED_PER_SQFT}/sqft)</span> : null}
|
|
97
|
+
</label>
|
|
98
|
+
<input
|
|
99
|
+
type="number"
|
|
100
|
+
min={0}
|
|
101
|
+
step={5000}
|
|
102
|
+
suppressHydrationWarning
|
|
103
|
+
value={cost}
|
|
104
|
+
onChange={(e) => setAndSave(Number(e.target.value))}
|
|
105
|
+
className="mt-1 w-40 rounded-lg border border-neutral-300 bg-white px-2.5 py-1.5 text-sm outline-none focus:border-blue-500 dark:border-neutral-700 dark:bg-neutral-950"
|
|
106
|
+
/>
|
|
107
|
+
<div className="mt-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
|
|
108
|
+
<span className="text-neutral-500">Yield actual (mediana / precio)</span>
|
|
109
|
+
<b>{fmtPct(asking_yield)}</b>
|
|
110
|
+
<span className="text-neutral-500">Yield post-reforma (P75 / precio+reforma)</span>
|
|
111
|
+
<b className={postReformYield > (asking_yield ?? 0) ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
|
|
112
|
+
{fmtPct(postReformYield)}
|
|
113
|
+
</b>
|
|
114
|
+
<span className="text-neutral-500">Renta extra al año (P75 − mediana)</span>
|
|
115
|
+
<b>{fmtAED(uplift)}</b>
|
|
116
|
+
<span className="text-neutral-500">Payback de la reforma</span>
|
|
117
|
+
<b>{paybackYears != null ? `${paybackYears.toFixed(1)} años` : "—"}</b>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
package/src/lib/db.ts
CHANGED
|
@@ -134,8 +134,15 @@ export function getDb(): Database.Database {
|
|
|
134
134
|
|
|
135
135
|
function migrate(db: Database.Database) {
|
|
136
136
|
const cols = (db.prepare(`PRAGMA table_info(analyses)`).all() as { name: string }[]).map((c) => c.name);
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
const add = (name: string) => {
|
|
138
|
+
if (!cols.includes(name)) db.exec(`ALTER TABLE analyses ADD COLUMN ${name} REAL`);
|
|
139
|
+
};
|
|
140
|
+
add("median_sale_price");
|
|
141
|
+
add("asking_yield");
|
|
142
|
+
add("rent_p25");
|
|
143
|
+
add("rent_p75");
|
|
144
|
+
add("sale_p25");
|
|
145
|
+
add("sale_p75");
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
export function now(): string {
|
package/src/lib/store.ts
CHANGED
|
@@ -112,8 +112,9 @@ export function saveAnalysis(listingId: string, y: YieldResult, gym: { gym_name:
|
|
|
112
112
|
.prepare(
|
|
113
113
|
`INSERT OR REPLACE INTO analyses
|
|
114
114
|
(listing_id, gross_yield, asking_yield, median_rent, median_sale_price,
|
|
115
|
+
rent_p25, rent_p75, sale_p25, sale_p75,
|
|
115
116
|
rent_n, buy_n, gym_distance_m, gym_name, computed_at)
|
|
116
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
117
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
117
118
|
)
|
|
118
119
|
.run(
|
|
119
120
|
listingId,
|
|
@@ -121,6 +122,10 @@ export function saveAnalysis(listingId: string, y: YieldResult, gym: { gym_name:
|
|
|
121
122
|
y.asking_yield,
|
|
122
123
|
y.median_rent,
|
|
123
124
|
y.median_sale_price,
|
|
125
|
+
y.rent_p25,
|
|
126
|
+
y.rent_p75,
|
|
127
|
+
y.sale_p25,
|
|
128
|
+
y.sale_p75,
|
|
124
129
|
y.rent_n,
|
|
125
130
|
y.buy_n,
|
|
126
131
|
gym.distance_m,
|
|
@@ -160,6 +165,7 @@ export function listFavorites(): FavoriteListing[] {
|
|
|
160
165
|
return getDb()
|
|
161
166
|
.prepare(
|
|
162
167
|
`SELECT l.*, a.gross_yield, a.asking_yield, a.median_rent, a.median_sale_price,
|
|
168
|
+
a.rent_p25, a.rent_p75, a.sale_p25, a.sale_p75,
|
|
163
169
|
a.rent_n, a.buy_n, a.gym_distance_m, a.gym_name, a.computed_at,
|
|
164
170
|
f.notes, f.added_at
|
|
165
171
|
FROM favorites f
|
|
@@ -174,6 +180,7 @@ export function listAnalyzed(): AnalyzedListing[] {
|
|
|
174
180
|
return getDb()
|
|
175
181
|
.prepare(
|
|
176
182
|
`SELECT l.*, a.gross_yield, a.asking_yield, a.median_rent, a.median_sale_price,
|
|
183
|
+
a.rent_p25, a.rent_p75, a.sale_p25, a.sale_p75,
|
|
177
184
|
a.rent_n, a.buy_n, a.gym_distance_m, a.gym_name, a.computed_at
|
|
178
185
|
FROM listings l JOIN analyses a ON a.listing_id = l.id
|
|
179
186
|
ORDER BY a.gross_yield DESC NULLS LAST`
|
package/src/lib/types.ts
CHANGED
|
@@ -105,6 +105,10 @@ export interface AnalysisRow {
|
|
|
105
105
|
asking_yield: number | null;
|
|
106
106
|
median_rent: number | null;
|
|
107
107
|
median_sale_price: number | null;
|
|
108
|
+
rent_p25: number | null;
|
|
109
|
+
rent_p75: number | null;
|
|
110
|
+
sale_p25: number | null;
|
|
111
|
+
sale_p75: number | null;
|
|
108
112
|
rent_n: number;
|
|
109
113
|
buy_n: number;
|
|
110
114
|
gym_distance_m: number | null;
|
|
@@ -117,6 +121,10 @@ export interface YieldResult {
|
|
|
117
121
|
asking_yield: number | null;
|
|
118
122
|
median_rent: number | null;
|
|
119
123
|
median_sale_price: number | null;
|
|
124
|
+
rent_p25: number | null;
|
|
125
|
+
rent_p75: number | null;
|
|
126
|
+
sale_p25: number | null;
|
|
127
|
+
sale_p75: number | null;
|
|
120
128
|
rent_n: number;
|
|
121
129
|
buy_n: number;
|
|
122
130
|
}
|
package/src/lib/yield.ts
CHANGED
|
@@ -5,16 +5,21 @@ const WINDOW_MS = 24 * 30.44 * 24 * 3600 * 1000; // ~24 months
|
|
|
5
5
|
const MIN_SAMPLES = 3;
|
|
6
6
|
const SIZE_TOLERANCE = 0.2;
|
|
7
7
|
|
|
8
|
-
function
|
|
8
|
+
function percentile(xs: number[], p: number): number | null {
|
|
9
9
|
if (xs.length === 0) return null;
|
|
10
10
|
const s = [...xs].sort((a, b) => a - b);
|
|
11
|
-
const
|
|
12
|
-
|
|
11
|
+
const idx = (s.length - 1) * p;
|
|
12
|
+
const lo = Math.floor(idx);
|
|
13
|
+
const hi = Math.ceil(idx);
|
|
14
|
+
return lo === hi ? s[lo] : s[lo] + (s[hi] - s[lo]) * (idx - lo);
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
const median = (xs: number[]) => percentile(xs, 0.5);
|
|
18
|
+
|
|
15
19
|
/**
|
|
16
|
-
* Comparable units
|
|
17
|
-
*
|
|
20
|
+
* Comparable units: SIMILAR SIZE (±20%) is the primary criterion, refined by same
|
|
21
|
+
* bedroom count when that still leaves a usable sample. Bedrooms-only is a last
|
|
22
|
+
* resort for listings with no size. Never falls back to "every transaction in the
|
|
18
23
|
* building" — mixing unit types is what produces bogus yields.
|
|
19
24
|
*/
|
|
20
25
|
function pickSample(txs: TxRow[], bedrooms: number | null, sizeSqft: number | null): TxRow[] {
|
|
@@ -24,16 +29,14 @@ function pickSample(txs: TxRow[], bedrooms: number | null, sizeSqft: number | nu
|
|
|
24
29
|
const sameSize = (t: TxRow) =>
|
|
25
30
|
sizeSqft != null && t.size_sqft != null && Math.abs(t.size_sqft - sizeSqft) / sizeSqft <= SIZE_TOLERANCE;
|
|
26
31
|
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
-
const byBdr = recent.filter(sameBdr);
|
|
30
|
-
if (byBdr.length >= MIN_SAMPLES) return byBdr;
|
|
32
|
+
const bySizeAndBdr = recent.filter((t) => sameSize(t) && sameBdr(t));
|
|
33
|
+
if (bySizeAndBdr.length >= MIN_SAMPLES) return bySizeAndBdr;
|
|
31
34
|
const bySize = recent.filter(sameSize);
|
|
32
35
|
if (bySize.length >= MIN_SAMPLES) return bySize;
|
|
33
|
-
// Below MIN_SAMPLES everywhere:
|
|
34
|
-
if (
|
|
35
|
-
if (
|
|
36
|
-
return
|
|
36
|
+
// Below MIN_SAMPLES everywhere: best comparable set we have, even if tiny.
|
|
37
|
+
if (bySizeAndBdr.length) return bySizeAndBdr;
|
|
38
|
+
if (bySize.length) return bySize;
|
|
39
|
+
return recent.filter(sameBdr); // only reachable when the listing has no size
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/**
|
|
@@ -52,14 +55,22 @@ export function computeYield(
|
|
|
52
55
|
const buySample = pickSample(buyTxs, bedrooms, sizeSqft);
|
|
53
56
|
const rentSample = pickSample(rentTxs, bedrooms, sizeSqft);
|
|
54
57
|
|
|
55
|
-
const
|
|
56
|
-
const
|
|
58
|
+
const rents = rentSample.map((t) => t.amount);
|
|
59
|
+
const sales = buySample.map((t) => t.amount);
|
|
60
|
+
const medianRent = median(rents);
|
|
61
|
+
const medianSalePrice = median(sales);
|
|
57
62
|
|
|
58
63
|
return {
|
|
59
64
|
gross_yield: medianRent != null && medianSalePrice ? medianRent / medianSalePrice : null,
|
|
60
65
|
asking_yield: medianRent != null && price > 0 ? medianRent / price : null,
|
|
61
66
|
median_rent: medianRent,
|
|
62
67
|
median_sale_price: medianSalePrice,
|
|
68
|
+
// Spread of comparable rents/sales: within one building + size band the location
|
|
69
|
+
// is fixed, so the P25→P75 range mostly reflects interior condition/furnishing.
|
|
70
|
+
rent_p25: percentile(rents, 0.25),
|
|
71
|
+
rent_p75: percentile(rents, 0.75),
|
|
72
|
+
sale_p25: percentile(sales, 0.25),
|
|
73
|
+
sale_p75: percentile(sales, 0.75),
|
|
63
74
|
rent_n: rentSample.length,
|
|
64
75
|
buy_n: buySample.length,
|
|
65
76
|
};
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import { scrapeSearch } from "@/lib/pf/search";
|
|
3
|
-
import { getAnalysis, upsertListing } from "@/lib/store";
|
|
4
|
-
import { dedupeListings } from "@/lib/dedupe";
|
|
5
|
-
import { SearchFilters } from "@/lib/types";
|
|
6
|
-
|
|
7
|
-
export async function POST(req: NextRequest) {
|
|
8
|
-
try {
|
|
9
|
-
const filters = (await req.json()) as SearchFilters;
|
|
10
|
-
const { properties, totalCount } = await scrapeSearch(filters);
|
|
11
|
-
const rows = properties.map((p) => upsertListing(p));
|
|
12
|
-
// Collapse the same physical unit listed by several agents.
|
|
13
|
-
const { unique, removed } = dedupeListings(rows);
|
|
14
|
-
const results = unique.map((row) => ({
|
|
15
|
-
...row,
|
|
16
|
-
images: JSON.parse(row.images_json),
|
|
17
|
-
amenities: JSON.parse(row.amenities_json),
|
|
18
|
-
// Cached analysis only — nothing is scraped here; the user analyzes on demand.
|
|
19
|
-
analysis: getAnalysis(row.id),
|
|
20
|
-
}));
|
|
21
|
-
return NextResponse.json({ totalCount, count: results.length, duplicatesRemoved: removed, results });
|
|
22
|
-
} catch (e) {
|
|
23
|
-
console.error("search failed:", e);
|
|
24
|
-
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
25
|
-
}
|
|
26
|
-
}
|
|
@@ -1,240 +0,0 @@
|
|
|
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
|
-
}
|