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
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import Link from "next/link";
|
|
5
|
-
import YieldBadge from "./YieldBadge";
|
|
6
|
-
import { fmtAED, fmtDist, fmtSqft } from "@/lib/format";
|
|
7
|
-
import { AnalysisRow } from "@/lib/types";
|
|
8
|
-
|
|
9
|
-
export interface CardListing {
|
|
10
|
-
id: string;
|
|
11
|
-
url: string;
|
|
12
|
-
title: string;
|
|
13
|
-
price: number;
|
|
14
|
-
size_sqft: number | null;
|
|
15
|
-
bedrooms: number | null;
|
|
16
|
-
bathrooms: number | null;
|
|
17
|
-
property_type: string;
|
|
18
|
-
tower_name: string | null;
|
|
19
|
-
images: { small: string; medium: string }[];
|
|
20
|
-
amenities: string[];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export default function ListingCard({
|
|
24
|
-
listing,
|
|
25
|
-
analysis: initialAnalysis,
|
|
26
|
-
onAnalyzed,
|
|
27
|
-
}: {
|
|
28
|
-
listing: CardListing;
|
|
29
|
-
analysis?: AnalysisRow | null;
|
|
30
|
-
onAnalyzed?: (id: string, a: AnalysisRow) => void;
|
|
31
|
-
}) {
|
|
32
|
-
const [analysis, setAnalysis] = useState<AnalysisRow | null>(initialAnalysis ?? null);
|
|
33
|
-
const [loading, setLoading] = useState(false);
|
|
34
|
-
const [imgIdx, setImgIdx] = useState(0);
|
|
35
|
-
const images = listing.images || [];
|
|
36
|
-
|
|
37
|
-
const analyze = () => {
|
|
38
|
-
if (loading || analysis) return;
|
|
39
|
-
setLoading(true);
|
|
40
|
-
fetch(`/api/listing/${listing.id}?url=${encodeURIComponent(listing.url)}`)
|
|
41
|
-
.then(async (r) => {
|
|
42
|
-
const j = await r.json();
|
|
43
|
-
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
|
|
44
|
-
setAnalysis(j.analysis);
|
|
45
|
-
onAnalyzed?.(listing.id, j.analysis);
|
|
46
|
-
})
|
|
47
|
-
.catch(() => {})
|
|
48
|
-
.finally(() => setLoading(false));
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<div className="group flex flex-col overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm transition hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900">
|
|
53
|
-
<div className="relative aspect-[3/2] bg-neutral-100 dark:bg-neutral-800">
|
|
54
|
-
{images.length > 0 && (
|
|
55
|
-
// eslint-disable-next-line @next/next/no-img-element
|
|
56
|
-
<img
|
|
57
|
-
src={images[imgIdx]?.medium || images[imgIdx]?.small}
|
|
58
|
-
alt={listing.title}
|
|
59
|
-
className="h-full w-full object-cover"
|
|
60
|
-
loading="lazy"
|
|
61
|
-
/>
|
|
62
|
-
)}
|
|
63
|
-
{images.length > 1 && (
|
|
64
|
-
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between p-2 opacity-0 transition group-hover:opacity-100">
|
|
65
|
-
<button
|
|
66
|
-
onClick={() => setImgIdx((imgIdx - 1 + images.length) % images.length)}
|
|
67
|
-
className="rounded-full bg-black/50 px-2.5 py-1 text-sm text-white"
|
|
68
|
-
>
|
|
69
|
-
‹
|
|
70
|
-
</button>
|
|
71
|
-
<span className="rounded-full bg-black/50 px-2 py-0.5 text-xs text-white">
|
|
72
|
-
{imgIdx + 1}/{images.length}
|
|
73
|
-
</span>
|
|
74
|
-
<button
|
|
75
|
-
onClick={() => setImgIdx((imgIdx + 1) % images.length)}
|
|
76
|
-
className="rounded-full bg-black/50 px-2.5 py-1 text-sm text-white"
|
|
77
|
-
>
|
|
78
|
-
›
|
|
79
|
-
</button>
|
|
80
|
-
</div>
|
|
81
|
-
)}
|
|
82
|
-
<div className="absolute left-2 top-2">
|
|
83
|
-
{loading ? (
|
|
84
|
-
<span className="inline-flex items-center rounded-full bg-black/60 px-2 py-0.5 text-xs font-medium text-white">
|
|
85
|
-
analizando…
|
|
86
|
-
</span>
|
|
87
|
-
) : analysis ? (
|
|
88
|
-
<YieldBadge value={analysis.gross_yield} n={analysis.rent_n} />
|
|
89
|
-
) : null}
|
|
90
|
-
</div>
|
|
91
|
-
</div>
|
|
92
|
-
|
|
93
|
-
<div className="flex flex-1 flex-col gap-1.5 p-3">
|
|
94
|
-
<div className="flex items-baseline justify-between gap-2">
|
|
95
|
-
<span className="text-lg font-bold">{fmtAED(listing.price)}</span>
|
|
96
|
-
<span className="shrink-0 text-xs text-neutral-500">{listing.property_type}</span>
|
|
97
|
-
</div>
|
|
98
|
-
<Link href={`/listing/${listing.id}`} className="line-clamp-2 text-sm font-medium hover:underline">
|
|
99
|
-
{listing.title}
|
|
100
|
-
</Link>
|
|
101
|
-
<div className="text-xs text-neutral-500">
|
|
102
|
-
{listing.bedrooms === 0 ? "Studio" : `${listing.bedrooms ?? "?"} hab`} · {listing.bathrooms ?? "?"} baños ·{" "}
|
|
103
|
-
{fmtSqft(listing.size_sqft)}
|
|
104
|
-
</div>
|
|
105
|
-
{listing.tower_name && (
|
|
106
|
-
<div className="truncate text-xs text-neutral-500">📍 {listing.tower_name}</div>
|
|
107
|
-
)}
|
|
108
|
-
{analysis && (
|
|
109
|
-
<div className="mt-1 grid grid-cols-2 gap-x-3 gap-y-0.5 text-xs text-neutral-600 dark:text-neutral-400">
|
|
110
|
-
<span>Alquiler/año: {fmtAED(analysis.median_rent)}</span>
|
|
111
|
-
<span>🏋️ {fmtDist(analysis.gym_distance_m)}</span>
|
|
112
|
-
<span>Venta med.: {fmtAED(analysis.median_sale_price)}</span>
|
|
113
|
-
<span>s/ anuncio: {analysis.asking_yield != null ? (analysis.asking_yield * 100).toFixed(1) + " %" : "—"}</span>
|
|
114
|
-
</div>
|
|
115
|
-
)}
|
|
116
|
-
<div className="mt-auto flex items-center gap-3 pt-2">
|
|
117
|
-
{analysis ? (
|
|
118
|
-
<Link
|
|
119
|
-
href={`/listing/${listing.id}`}
|
|
120
|
-
className="rounded-lg bg-neutral-900 px-3 py-1.5 text-xs font-semibold text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
|
|
121
|
-
>
|
|
122
|
-
Ver análisis
|
|
123
|
-
</Link>
|
|
124
|
-
) : (
|
|
125
|
-
<button
|
|
126
|
-
onClick={analyze}
|
|
127
|
-
disabled={loading}
|
|
128
|
-
className="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-500 disabled:opacity-60"
|
|
129
|
-
>
|
|
130
|
-
{loading ? "Analizando…" : "⚡ Analizar"}
|
|
131
|
-
</button>
|
|
132
|
-
)}
|
|
133
|
-
<a
|
|
134
|
-
href={listing.url}
|
|
135
|
-
target="_blank"
|
|
136
|
-
rel="noopener noreferrer"
|
|
137
|
-
className="text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
|
|
138
|
-
>
|
|
139
|
-
Ver en Property Finder ↗
|
|
140
|
-
</a>
|
|
141
|
-
</div>
|
|
142
|
-
</div>
|
|
143
|
-
</div>
|
|
144
|
-
);
|
|
145
|
-
}
|
package/src/lib/dedupe.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
interface UnitLike {
|
|
2
|
-
id: string;
|
|
3
|
-
tower_slug: string | null;
|
|
4
|
-
bedrooms: number | null;
|
|
5
|
-
size_sqft: number | null;
|
|
6
|
-
price: number;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The same physical apartment is often listed by several agents under different
|
|
11
|
-
* Property Finder ids. Those share the building, bedroom count, size and asking
|
|
12
|
-
* price (agents copy the same DLD figures), so collapse them to one card.
|
|
13
|
-
*
|
|
14
|
-
* When any of those fields is missing we cannot be confident two rows are the
|
|
15
|
-
* same unit, so we key on the id and never merge.
|
|
16
|
-
*/
|
|
17
|
-
function unitSignature(r: UnitLike): string {
|
|
18
|
-
if (!r.tower_slug || r.size_sqft == null || !r.price) return `id:${r.id}`;
|
|
19
|
-
const bd = r.bedrooms == null ? "?" : r.bedrooms;
|
|
20
|
-
return `${r.tower_slug}|${bd}|${Math.round(r.size_sqft)}|${r.price}`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Keep the first occurrence of each physical unit (newest first with ob=mr). */
|
|
24
|
-
export function dedupeListings<T extends UnitLike>(rows: T[]): { unique: T[]; removed: number } {
|
|
25
|
-
const seen = new Set<string>();
|
|
26
|
-
const unique: T[] = [];
|
|
27
|
-
for (const r of rows) {
|
|
28
|
-
const sig = unitSignature(r);
|
|
29
|
-
if (seen.has(sig)) continue;
|
|
30
|
-
seen.add(sig);
|
|
31
|
-
unique.push(r);
|
|
32
|
-
}
|
|
33
|
-
return { unique, removed: rows.length - unique.length };
|
|
34
|
-
}
|