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
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,9 +28,10 @@ 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
|
|
34
|
+
const TREND = "#7c3aed"; // tendency line
|
|
31
35
|
|
|
32
36
|
function niceTicks(min: number, max: number, count: number): number[] {
|
|
33
37
|
if (min === max) return [min];
|
|
@@ -47,7 +51,7 @@ const fmtMonth = (ms: number) =>
|
|
|
47
51
|
const fmtDate = (ms: number) => new Date(ms).toLocaleDateString("es-ES");
|
|
48
52
|
const fmtAED = (n: number) => new Intl.NumberFormat("en-AE", { maximumFractionDigits: 0 }).format(n);
|
|
49
53
|
|
|
50
|
-
export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }: Props) {
|
|
54
|
+
export default function PsqftChart({ buy, rent, listingBedrooms, listingSizeSqft, listingPsqft }: Props) {
|
|
51
55
|
const [mode, setMode] = useState<"buy" | "rent">("buy");
|
|
52
56
|
const [hover, setHover] = useState<{ p: Pt; x: number; y: number } | null>(null);
|
|
53
57
|
|
|
@@ -63,11 +67,15 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
|
|
|
63
67
|
bedrooms: r.bedrooms,
|
|
64
68
|
amount: r.amount,
|
|
65
69
|
size: r.size_sqft,
|
|
66
|
-
|
|
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,
|
|
67
75
|
}))
|
|
68
76
|
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.y))
|
|
69
77
|
.sort((a, b) => a.t - b.t),
|
|
70
|
-
[rows, listingBedrooms]
|
|
78
|
+
[rows, listingBedrooms, listingSizeSqft]
|
|
71
79
|
);
|
|
72
80
|
|
|
73
81
|
const scales = useMemo(() => {
|
|
@@ -77,18 +85,43 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
|
|
|
77
85
|
const refY = mode === "buy" ? listingPsqft ?? null : null;
|
|
78
86
|
const tMin = Math.min(...ts);
|
|
79
87
|
const tMax = Math.max(...ts);
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
|
|
89
|
+
// Least-squares trend over comparable units (fallback to all) — the tendency.
|
|
90
|
+
const comp = pts.filter((p) => p.comparable);
|
|
91
|
+
const fitSet = comp.length >= 2 ? comp : pts;
|
|
92
|
+
let trend: { y0: number; y1: number; annualPct: number | null } | null = null;
|
|
93
|
+
if (fitSet.length >= 2 && tMax > tMin) {
|
|
94
|
+
const n = fitSet.length;
|
|
95
|
+
const mt = fitSet.reduce((s, p) => s + p.t, 0) / n;
|
|
96
|
+
const my = fitSet.reduce((s, p) => s + p.y, 0) / n;
|
|
97
|
+
let num = 0;
|
|
98
|
+
let den = 0;
|
|
99
|
+
for (const p of fitSet) {
|
|
100
|
+
num += (p.t - mt) * (p.y - my);
|
|
101
|
+
den += (p.t - mt) ** 2;
|
|
102
|
+
}
|
|
103
|
+
const slope = den ? num / den : 0; // AED/sqft per ms
|
|
104
|
+
const intercept = my - slope * mt;
|
|
105
|
+
const YEAR = 365.25 * 24 * 3600 * 1000;
|
|
106
|
+
trend = {
|
|
107
|
+
y0: intercept + slope * tMin,
|
|
108
|
+
y1: intercept + slope * tMax,
|
|
109
|
+
annualPct: my ? (slope * YEAR) / my : null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const extra = [...(refY ? [refY] : []), ...(trend ? [trend.y0, trend.y1] : [])];
|
|
114
|
+
let yMin = Math.min(...ys, ...extra);
|
|
115
|
+
let yMax = Math.max(...ys, ...extra);
|
|
82
116
|
const pad = (yMax - yMin) * 0.1 || yMax * 0.1 || 1;
|
|
83
117
|
yMin = Math.max(0, yMin - pad);
|
|
84
118
|
yMax = yMax + pad;
|
|
85
119
|
const x = (t: number) => (tMax === tMin ? IW / 2 : ((t - tMin) / (tMax - tMin)) * IW);
|
|
86
120
|
const y = (v: number) => IH - ((v - yMin) / (yMax - yMin)) * IH;
|
|
87
121
|
// median of comparable set (fallback to all)
|
|
88
|
-
const comp = pts.filter((p) => p.comparable);
|
|
89
122
|
const base = (comp.length ? comp : pts).map((p) => p.y).sort((a, b) => a - b);
|
|
90
123
|
const median = base.length ? base[Math.floor(base.length / 2)] : null;
|
|
91
|
-
return { x, y, tMin, tMax, yMin, yMax, refY, median };
|
|
124
|
+
return { x, y, tMin, tMax, yMin, yMax, refY, median, trend };
|
|
92
125
|
}, [pts, mode, listingPsqft]);
|
|
93
126
|
|
|
94
127
|
const compCount = pts.filter((p) => p.comparable).length;
|
|
@@ -181,24 +214,44 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
|
|
|
181
214
|
</>
|
|
182
215
|
)}
|
|
183
216
|
|
|
184
|
-
{/*
|
|
217
|
+
{/* trend (least-squares) line */}
|
|
218
|
+
{scales.trend && (
|
|
219
|
+
<line
|
|
220
|
+
x1={scales.x(scales.tMin)}
|
|
221
|
+
x2={scales.x(scales.tMax)}
|
|
222
|
+
y1={scales.y(scales.trend.y0)}
|
|
223
|
+
y2={scales.y(scales.trend.y1)}
|
|
224
|
+
stroke={TREND}
|
|
225
|
+
strokeWidth={2.5}
|
|
226
|
+
strokeLinecap="round"
|
|
227
|
+
/>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{/* points: muted first, comparable on top; small dot + invisible hit area */}
|
|
185
231
|
{pts
|
|
186
232
|
.slice()
|
|
187
233
|
.sort((a, b) => Number(a.comparable) - Number(b.comparable))
|
|
188
234
|
.map((p, i) => (
|
|
189
|
-
<
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
235
|
+
<g key={i}>
|
|
236
|
+
<circle
|
|
237
|
+
cx={scales.x(p.t)}
|
|
238
|
+
cy={scales.y(p.y)}
|
|
239
|
+
r={p.comparable ? 3 : 2}
|
|
240
|
+
fill={p.comparable ? ACCENT : MUTED}
|
|
241
|
+
stroke="#fff"
|
|
242
|
+
strokeWidth={p.comparable ? 1 : 0.5}
|
|
243
|
+
opacity={p.comparable ? 1 : 0.7}
|
|
244
|
+
/>
|
|
245
|
+
<circle
|
|
246
|
+
cx={scales.x(p.t)}
|
|
247
|
+
cy={scales.y(p.y)}
|
|
248
|
+
r={8}
|
|
249
|
+
fill="transparent"
|
|
250
|
+
onMouseEnter={() => setHover({ p, x: scales.x(p.t), y: scales.y(p.y) })}
|
|
251
|
+
onMouseLeave={() => setHover(null)}
|
|
252
|
+
style={{ cursor: "pointer" }}
|
|
253
|
+
/>
|
|
254
|
+
</g>
|
|
202
255
|
))}
|
|
203
256
|
</g>
|
|
204
257
|
</svg>
|
|
@@ -225,7 +278,7 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
|
|
|
225
278
|
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-neutral-500">
|
|
226
279
|
<span className="flex items-center gap-1.5">
|
|
227
280
|
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: ACCENT }} />
|
|
228
|
-
Mismo nº de habitaciones ({compCount})
|
|
281
|
+
{listingSizeSqft != null ? "Tamaño similar (±20%)" : "Mismo nº de habitaciones"} ({compCount})
|
|
229
282
|
</span>
|
|
230
283
|
<span className="flex items-center gap-1.5">
|
|
231
284
|
<span className="inline-block h-2.5 w-2.5 rounded-full border-[1.5px]" style={{ borderColor: MUTED }} />
|
|
@@ -235,6 +288,16 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
|
|
|
235
288
|
<span className="inline-block h-0 w-4 border-t-[1.5px] border-dashed" style={{ borderColor: MUTED }} />
|
|
236
289
|
Mediana
|
|
237
290
|
</span>
|
|
291
|
+
{scales.trend && (
|
|
292
|
+
<span className="flex items-center gap-1.5">
|
|
293
|
+
<span className="inline-block h-0 w-4 border-t-[2.5px]" style={{ borderColor: TREND }} />
|
|
294
|
+
<span style={{ color: TREND }} className="font-semibold">
|
|
295
|
+
Tendencia
|
|
296
|
+
{scales.trend.annualPct != null &&
|
|
297
|
+
` (${scales.trend.annualPct >= 0 ? "+" : ""}${(scales.trend.annualPct * 100).toFixed(1)} %/año)`}
|
|
298
|
+
</span>
|
|
299
|
+
</span>
|
|
300
|
+
)}
|
|
238
301
|
{mode === "buy" && listingPsqft != null && (
|
|
239
302
|
<span className="flex items-center gap-1.5">
|
|
240
303
|
<span className="inline-block h-0 w-4 border-t-2" style={{ borderColor: REF }} />
|