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.
@@ -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
- if (!cols.includes("median_sale_price")) db.exec(`ALTER TABLE analyses ADD COLUMN median_sale_price REAL`);
138
- if (!cols.includes("asking_yield")) db.exec(`ALTER TABLE analyses ADD COLUMN asking_yield REAL`);
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 median(xs: number[]): number | null {
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 m = Math.floor(s.length / 2);
12
- return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
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 only: same bedrooms + similar size when possible, relaxing to
17
- * same bedrooms, then similar size. Never falls back to "every transaction in the
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 byBoth = recent.filter((t) => sameBdr(t) && sameSize(t));
28
- if (byBoth.length >= MIN_SAMPLES) return byBoth;
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: use the best comparable set we have, even if tiny.
34
- if (byBoth.length) return byBoth;
35
- if (byBdr.length) return byBdr;
36
- return bySize;
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 medianRent = median(rentSample.map((t) => t.amount));
56
- const medianSalePrice = median(buySample.map((t) => t.amount));
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
- }