brickwise 0.1.1 → 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 +12 -5
- 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
package/src/app/globals.css
CHANGED
|
@@ -1,20 +1,64 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
|
|
3
|
+
/* ---- Local font (public/fonts) — titles only ------------------------ */
|
|
4
|
+
|
|
5
|
+
@font-face {
|
|
6
|
+
font-family: "Stack Sans Notch";
|
|
7
|
+
src: url("/fonts/Stack_Sans_Notch/StackSansNotch-VariableFont_wght.ttf") format("truetype");
|
|
8
|
+
font-weight: 200 700;
|
|
9
|
+
font-display: swap;
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
:root {
|
|
4
|
-
--background: #
|
|
5
|
-
--foreground: #
|
|
13
|
+
--background: #f5f5f5;
|
|
14
|
+
--foreground: #202020;
|
|
15
|
+
--font-title: "Stack Sans Notch", system-ui, sans-serif;
|
|
16
|
+
--font-button: var(--font-space-mono), ui-monospace, monospace;
|
|
17
|
+
--accent: #b84f30; /* ladrillo */
|
|
18
|
+
--accent-ink: #1a1208; /* texto casi negro sobre acento */
|
|
6
19
|
}
|
|
7
20
|
|
|
8
21
|
@theme inline {
|
|
9
22
|
--color-background: var(--background);
|
|
10
23
|
--color-foreground: var(--foreground);
|
|
11
|
-
--font-sans: var(--font-
|
|
24
|
+
--font-sans: var(--font-montserrat);
|
|
12
25
|
--font-mono: var(--font-geist-mono);
|
|
13
26
|
}
|
|
14
27
|
|
|
28
|
+
@theme {
|
|
29
|
+
/* Neutral dark grays anchored to the #2c2c2c (44,44,44) background */
|
|
30
|
+
--color-neutral-950: #242424; /* deep insets / header */
|
|
31
|
+
--color-neutral-900: #2c2c2c; /* panels & cards == background */
|
|
32
|
+
--color-neutral-800: #383838; /* borders, dividers, hover (subtle) */
|
|
33
|
+
--color-neutral-700: #4a4a4a;
|
|
34
|
+
|
|
35
|
+
/* Brick accent — remaps every blue-* usage in the app to color ladrillo */
|
|
36
|
+
--color-blue-50: #faeee8;
|
|
37
|
+
--color-blue-100: #f5ddd2;
|
|
38
|
+
--color-blue-200: #eecebf;
|
|
39
|
+
--color-blue-300: #e0a98e;
|
|
40
|
+
--color-blue-400: #d97f5a;
|
|
41
|
+
--color-blue-500: #cd6240;
|
|
42
|
+
--color-blue-600: #b84f30;
|
|
43
|
+
--color-blue-700: #9a4126;
|
|
44
|
+
--color-blue-800: #7c3520;
|
|
45
|
+
--color-blue-900: #5a2718;
|
|
46
|
+
--color-blue-950: #38180e;
|
|
47
|
+
|
|
48
|
+
/* Minimalist: mostly-sharp corners */
|
|
49
|
+
--radius-xs: 0px;
|
|
50
|
+
--radius-sm: 0px;
|
|
51
|
+
--radius-md: 0px;
|
|
52
|
+
--radius-lg: 2px;
|
|
53
|
+
--radius-xl: 2px;
|
|
54
|
+
--radius-2xl: 2px;
|
|
55
|
+
--radius-3xl: 2px;
|
|
56
|
+
--radius-4xl: 2px;
|
|
57
|
+
}
|
|
58
|
+
|
|
15
59
|
@media (prefers-color-scheme: dark) {
|
|
16
60
|
:root {
|
|
17
|
-
--background: #
|
|
61
|
+
--background: #2c2c2c; /* gris muy oscuro (44,44,44) */
|
|
18
62
|
--foreground: #ededed;
|
|
19
63
|
}
|
|
20
64
|
}
|
|
@@ -22,12 +66,59 @@
|
|
|
22
66
|
body {
|
|
23
67
|
background: var(--background);
|
|
24
68
|
color: var(--foreground);
|
|
25
|
-
font-family: var(--font-
|
|
69
|
+
font-family: var(--font-montserrat), system-ui, sans-serif;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ---- Type roles (base layer, so per-element utilities can override) --- */
|
|
73
|
+
|
|
74
|
+
@layer base {
|
|
75
|
+
/* Titles (logo, headings) → Stack Sans Notch */
|
|
76
|
+
h1,
|
|
77
|
+
h2,
|
|
78
|
+
h3,
|
|
79
|
+
.font-brand {
|
|
80
|
+
font-family: var(--font-title);
|
|
81
|
+
letter-spacing: 0.01em;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* Logo in uppercase */
|
|
85
|
+
.font-brand {
|
|
86
|
+
text-transform: uppercase;
|
|
87
|
+
letter-spacing: 0.04em;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Button text → Space Mono, uppercase, flat */
|
|
91
|
+
button,
|
|
92
|
+
.btn-font {
|
|
93
|
+
font-family: var(--font-button);
|
|
94
|
+
text-transform: uppercase;
|
|
95
|
+
letter-spacing: 0.03em;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Accent-background buttons → near-black ink (higher specificity than text-*) */
|
|
100
|
+
button[class*="bg-blue-6"],
|
|
101
|
+
button[class*="bg-blue-5"],
|
|
102
|
+
.btn-font[class*="bg-blue-6"],
|
|
103
|
+
.btn-font[class*="bg-blue-5"] {
|
|
104
|
+
color: var(--accent-ink);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ---- Minimalist: no hard/offset shadows ------------------------------ */
|
|
108
|
+
|
|
109
|
+
.shadow-sm,
|
|
110
|
+
.shadow-md {
|
|
111
|
+
box-shadow: none;
|
|
112
|
+
}
|
|
113
|
+
.shadow-lg {
|
|
114
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
|
|
115
|
+
}
|
|
116
|
+
.rounded-full {
|
|
117
|
+
border-radius: 2px;
|
|
26
118
|
}
|
|
27
119
|
|
|
28
120
|
/* ---- Map markers ---------------------------------------------------- */
|
|
29
121
|
|
|
30
|
-
/* Property pin: pill with the yield + a small tail; the tail tip sits on the latlng. */
|
|
31
122
|
.bw-pin {
|
|
32
123
|
display: flex;
|
|
33
124
|
flex-direction: column;
|
|
@@ -38,41 +129,47 @@ body {
|
|
|
38
129
|
.bw-pin-label {
|
|
39
130
|
background: var(--pin-color, #737373);
|
|
40
131
|
color: #fff;
|
|
41
|
-
font: 700 11px/1 var(--font-
|
|
42
|
-
padding:
|
|
43
|
-
border-radius:
|
|
44
|
-
border:
|
|
45
|
-
box-shadow: 0
|
|
132
|
+
font: 700 11px/1 var(--font-button);
|
|
133
|
+
padding: 4px 8px;
|
|
134
|
+
border-radius: 2px;
|
|
135
|
+
border: 1px solid rgba(255, 255, 255, 0.85);
|
|
136
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
|
46
137
|
white-space: nowrap;
|
|
47
138
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
48
139
|
}
|
|
49
140
|
.bw-pin-tail {
|
|
50
141
|
width: 0;
|
|
51
142
|
height: 0;
|
|
52
|
-
border-left:
|
|
53
|
-
border-right:
|
|
54
|
-
border-top:
|
|
143
|
+
border-left: 5px solid transparent;
|
|
144
|
+
border-right: 5px solid transparent;
|
|
145
|
+
border-top: 6px solid rgba(255, 255, 255, 0.85);
|
|
55
146
|
margin-top: -1px;
|
|
56
|
-
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3));
|
|
57
147
|
}
|
|
58
148
|
.bw-pin--hl .bw-pin-label {
|
|
59
|
-
transform: scale(1.
|
|
60
|
-
box-shadow:
|
|
61
|
-
|
|
62
|
-
|
|
149
|
+
transform: scale(1.2);
|
|
150
|
+
box-shadow: 0 0 0 3px rgba(184, 79, 48, 0.6);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* Filtered-out markers: darker + transparent */
|
|
154
|
+
.bw-pin--dim {
|
|
155
|
+
opacity: 0.4;
|
|
156
|
+
}
|
|
157
|
+
.bw-pin--dim .bw-pin-label {
|
|
158
|
+
filter: grayscale(0.75) brightness(0.5);
|
|
159
|
+
border-color: rgba(255, 255, 255, 0.4);
|
|
63
160
|
}
|
|
64
161
|
|
|
65
|
-
/* Gym marker:
|
|
162
|
+
/* Gym marker: brick square with a white dumbbell. */
|
|
66
163
|
.bw-gym {
|
|
67
|
-
width:
|
|
68
|
-
height:
|
|
69
|
-
border-radius:
|
|
70
|
-
background:
|
|
71
|
-
border:
|
|
164
|
+
width: 24px;
|
|
165
|
+
height: 24px;
|
|
166
|
+
border-radius: 2px;
|
|
167
|
+
background: var(--accent);
|
|
168
|
+
border: 1px solid rgba(255, 255, 255, 0.85);
|
|
72
169
|
display: flex;
|
|
73
170
|
align-items: center;
|
|
74
171
|
justify-content: center;
|
|
75
|
-
box-shadow: 0
|
|
172
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
|
76
173
|
transform: translate(-50%, -50%);
|
|
77
174
|
cursor: pointer;
|
|
78
175
|
}
|
package/src/app/layout.tsx
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
|
-
import {
|
|
2
|
+
import { Geist_Mono, Montserrat, Space_Mono } from "next/font/google";
|
|
3
3
|
import ChromeHeader from "@/components/ChromeHeader";
|
|
4
4
|
import "./globals.css";
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
variable: "--font-
|
|
6
|
+
const montserrat = Montserrat({
|
|
7
|
+
variable: "--font-montserrat",
|
|
8
|
+
subsets: ["latin"],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const spaceMono = Space_Mono({
|
|
12
|
+
variable: "--font-space-mono",
|
|
13
|
+
weight: ["400", "700"],
|
|
8
14
|
subsets: ["latin"],
|
|
9
15
|
});
|
|
10
16
|
|
|
@@ -26,7 +32,7 @@ export default function RootLayout({
|
|
|
26
32
|
return (
|
|
27
33
|
<html
|
|
28
34
|
lang="es"
|
|
29
|
-
className={`${
|
|
35
|
+
className={`${montserrat.variable} ${spaceMono.variable} ${geistMono.variable} h-full antialiased`}
|
|
30
36
|
>
|
|
31
37
|
<body className="flex min-h-svh flex-col bg-neutral-50 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100">
|
|
32
38
|
<ChromeHeader />
|
|
@@ -4,6 +4,7 @@ import { use, useEffect, useState } from "react";
|
|
|
4
4
|
import YieldBadge from "@/components/YieldBadge";
|
|
5
5
|
import TransactionsTable from "@/components/TransactionsTable";
|
|
6
6
|
import PsqftChart from "@/components/PsqftChart";
|
|
7
|
+
import ReformScenario from "@/components/ReformScenario";
|
|
7
8
|
import { fmtAED, fmtDist, fmtPct, fmtSqft } from "@/lib/format";
|
|
8
9
|
import { AnalysisRow } from "@/lib/types";
|
|
9
10
|
import { TxRow } from "@/lib/pf/transactions";
|
|
@@ -96,7 +97,7 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
|
|
|
96
97
|
disabled={faved}
|
|
97
98
|
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
|
>
|
|
99
|
-
{faved ? "
|
|
100
|
+
{faved ? "Guardado" : "Guardar en favoritos"}
|
|
100
101
|
</button>
|
|
101
102
|
</div>
|
|
102
103
|
</div>
|
|
@@ -152,12 +153,18 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
|
|
|
152
153
|
<Stat
|
|
153
154
|
label="Se alquila por (año, mediana)"
|
|
154
155
|
value={fmtAED(analysis.median_rent)}
|
|
155
|
-
sub={`${analysis.rent_n}
|
|
156
|
+
sub={`${analysis.rent_n} alquiler${analysis.rent_n === 1 ? "" : "es"} de tamaño similar (±20%, 24 meses)${
|
|
157
|
+
analysis.rent_p25 != null && analysis.rent_p75 != null
|
|
158
|
+
? ` · rango ${fmtAED(analysis.rent_p25)}–${fmtAED(analysis.rent_p75)}`
|
|
159
|
+
: ""
|
|
160
|
+
}`}
|
|
156
161
|
/>
|
|
157
162
|
<Stat
|
|
158
163
|
label="Se vende por (mediana)"
|
|
159
164
|
value={fmtAED(analysis.median_sale_price)}
|
|
160
|
-
sub={`${analysis.buy_n} venta${analysis.buy_n === 1 ? "" : "s"}
|
|
165
|
+
sub={`${analysis.buy_n} venta${analysis.buy_n === 1 ? "" : "s"} de tamaño similar (±20%, 24 meses)${
|
|
166
|
+
analysis.sale_p25 != null ? ` · objetivo compra p/ reformar ≈ ${fmtAED(analysis.sale_p25)} (P25)` : ""
|
|
167
|
+
}`}
|
|
161
168
|
/>
|
|
162
169
|
<Stat
|
|
163
170
|
label="Rentabilidad sobre el precio del anuncio"
|
|
@@ -182,11 +189,19 @@ export default function ListingPage({ params }: { params: Promise<{ id: string }
|
|
|
182
189
|
</div>
|
|
183
190
|
)}
|
|
184
191
|
|
|
192
|
+
<ReformScenario
|
|
193
|
+
listingId={listing.id}
|
|
194
|
+
price={listing.price}
|
|
195
|
+
sizeSqft={listing.size_sqft}
|
|
196
|
+
analysis={analysis}
|
|
197
|
+
/>
|
|
198
|
+
|
|
185
199
|
{transactions && (transactions.buy.length > 0 || transactions.rent.length > 0) && (
|
|
186
200
|
<PsqftChart
|
|
187
201
|
buy={transactions.buy}
|
|
188
202
|
rent={transactions.rent}
|
|
189
203
|
listingBedrooms={listing.bedrooms}
|
|
204
|
+
listingSizeSqft={listing.size_sqft}
|
|
190
205
|
listingPsqft={listing.size_sqft ? listing.price / listing.size_sqft : null}
|
|
191
206
|
/>
|
|
192
207
|
)}
|
package/src/app/page.tsx
CHANGED
|
@@ -70,10 +70,10 @@ export default function Dashboard() {
|
|
|
70
70
|
<div className="flex items-center justify-between">
|
|
71
71
|
<h1 className="text-2xl font-bold">Inmuebles analizados</h1>
|
|
72
72
|
<Link
|
|
73
|
-
href="/
|
|
74
|
-
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-bold text-white hover:bg-blue-500"
|
|
73
|
+
href="/favorites"
|
|
74
|
+
className="btn-font rounded-lg bg-blue-600 px-4 py-2 text-sm font-bold text-white hover:bg-blue-500"
|
|
75
75
|
>
|
|
76
|
-
+
|
|
76
|
+
+ Añadir inmuebles
|
|
77
77
|
</Link>
|
|
78
78
|
</div>
|
|
79
79
|
|
|
@@ -81,9 +81,9 @@ export default function Dashboard() {
|
|
|
81
81
|
<p className="animate-pulse text-neutral-500">Cargando…</p>
|
|
82
82
|
) : sorted.length === 0 ? (
|
|
83
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.
|
|
85
|
-
<Link href="/
|
|
86
|
-
|
|
84
|
+
Aún no hay inmuebles analizados. Añade enlaces de Property Finder en{" "}
|
|
85
|
+
<Link href="/favorites" className="font-semibold text-blue-600 hover:underline dark:text-blue-400">
|
|
86
|
+
Favoritos + Mapa
|
|
87
87
|
</Link>
|
|
88
88
|
.
|
|
89
89
|
</div>
|
package/src/app/search/page.tsx
CHANGED
|
@@ -1,87 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
);
|
|
3
|
+
export default function SearchRemoved() {
|
|
4
|
+
redirect("/favorites");
|
|
87
5
|
}
|
package/src/components/Nav.tsx
CHANGED
|
@@ -4,17 +4,16 @@ import Link from "next/link";
|
|
|
4
4
|
import { usePathname } from "next/navigation";
|
|
5
5
|
|
|
6
6
|
const ITEMS = [
|
|
7
|
-
{ href: "/
|
|
8
|
-
{ href: "/
|
|
9
|
-
{ href: "/", label: "📊 Analizados" },
|
|
7
|
+
{ href: "/favorites", label: "Favoritos + Mapa" },
|
|
8
|
+
{ href: "/", label: "Analizados" },
|
|
10
9
|
];
|
|
11
10
|
|
|
12
11
|
export default function Nav() {
|
|
13
12
|
const pathname = usePathname();
|
|
14
13
|
return (
|
|
15
14
|
<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
|
-
|
|
15
|
+
<Link href="/" className="font-brand mr-4 flex items-center gap-2 text-lg font-bold tracking-tight">
|
|
16
|
+
Brickwise
|
|
18
17
|
</Link>
|
|
19
18
|
{ITEMS.map((it) => {
|
|
20
19
|
const active = it.href === "/" ? pathname === "/" : pathname.startsWith(it.href);
|
|
@@ -22,7 +21,7 @@ export default function Nav() {
|
|
|
22
21
|
<Link
|
|
23
22
|
key={it.href}
|
|
24
23
|
href={it.href}
|
|
25
|
-
className={`rounded-full px-3 py-1.5 text-sm font-medium transition ${
|
|
24
|
+
className={`btn-font rounded-full px-3 py-1.5 text-sm font-medium transition ${
|
|
26
25
|
active
|
|
27
26
|
? "bg-neutral-900 text-white dark:bg-white dark:text-neutral-900"
|
|
28
27
|
: "text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:hover:text-white"
|
|
@@ -7,9 +7,12 @@ interface Props {
|
|
|
7
7
|
buy: TxRow[];
|
|
8
8
|
rent: TxRow[];
|
|
9
9
|
listingBedrooms: number | null;
|
|
10
|
+
listingSizeSqft: number | null;
|
|
10
11
|
listingPsqft: number | null; // asking price / size
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
const SIZE_TOLERANCE = 0.2;
|
|
15
|
+
|
|
13
16
|
interface Pt {
|
|
14
17
|
t: number; // date ms
|
|
15
18
|
y: number; // AED/sqft
|
|
@@ -25,7 +28,7 @@ const M = { top: 16, right: 16, bottom: 34, left: 52 };
|
|
|
25
28
|
const IW = W - M.left - M.right;
|
|
26
29
|
const IH = H - M.top - M.bottom;
|
|
27
30
|
|
|
28
|
-
const ACCENT = "#
|
|
31
|
+
const ACCENT = "#b84f30"; // comparable (similar size) — brick accent
|
|
29
32
|
const MUTED = "#9ca3af"; // other units
|
|
30
33
|
const REF = "#059669"; // this listing
|
|
31
34
|
const TREND = "#7c3aed"; // tendency line
|
|
@@ -48,7 +51,7 @@ const fmtMonth = (ms: number) =>
|
|
|
48
51
|
const fmtDate = (ms: number) => new Date(ms).toLocaleDateString("es-ES");
|
|
49
52
|
const fmtAED = (n: number) => new Intl.NumberFormat("en-AE", { maximumFractionDigits: 0 }).format(n);
|
|
50
53
|
|
|
51
|
-
export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }: Props) {
|
|
54
|
+
export default function PsqftChart({ buy, rent, listingBedrooms, listingSizeSqft, listingPsqft }: Props) {
|
|
52
55
|
const [mode, setMode] = useState<"buy" | "rent">("buy");
|
|
53
56
|
const [hover, setHover] = useState<{ p: Pt; x: number; y: number } | null>(null);
|
|
54
57
|
|
|
@@ -64,11 +67,15 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
|
|
|
64
67
|
bedrooms: r.bedrooms,
|
|
65
68
|
amount: r.amount,
|
|
66
69
|
size: r.size_sqft,
|
|
67
|
-
|
|
70
|
+
// Same criterion as the yield sample: similar size first, bedrooms fallback.
|
|
71
|
+
comparable:
|
|
72
|
+
listingSizeSqft != null
|
|
73
|
+
? Math.abs((r.size_sqft as number) - listingSizeSqft) / listingSizeSqft <= SIZE_TOLERANCE
|
|
74
|
+
: listingBedrooms != null && r.bedrooms === listingBedrooms,
|
|
68
75
|
}))
|
|
69
76
|
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.y))
|
|
70
77
|
.sort((a, b) => a.t - b.t),
|
|
71
|
-
[rows, listingBedrooms]
|
|
78
|
+
[rows, listingBedrooms, listingSizeSqft]
|
|
72
79
|
);
|
|
73
80
|
|
|
74
81
|
const scales = useMemo(() => {
|
|
@@ -271,7 +278,7 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
|
|
|
271
278
|
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-neutral-500">
|
|
272
279
|
<span className="flex items-center gap-1.5">
|
|
273
280
|
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: ACCENT }} />
|
|
274
|
-
Mismo nº de habitaciones ({compCount})
|
|
281
|
+
{listingSizeSqft != null ? "Tamaño similar (±20%)" : "Mismo nº de habitaciones"} ({compCount})
|
|
275
282
|
</span>
|
|
276
283
|
<span className="flex items-center gap-1.5">
|
|
277
284
|
<span className="inline-block h-2.5 w-2.5 rounded-full border-[1.5px]" style={{ borderColor: MUTED }} />
|
|
@@ -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`
|