brickwise 0.1.0

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/brickwise.js +100 -0
  4. package/eslint.config.mjs +18 -0
  5. package/next.config.ts +14 -0
  6. package/package.json +62 -0
  7. package/postcss.config.mjs +7 -0
  8. package/public/file.svg +1 -0
  9. package/public/globe.svg +1 -0
  10. package/public/next.svg +1 -0
  11. package/public/vercel.svg +1 -0
  12. package/public/window.svg +1 -0
  13. package/src/app/api/dashboard/route.ts +15 -0
  14. package/src/app/api/favorites/route.ts +71 -0
  15. package/src/app/api/gyms/route.ts +30 -0
  16. package/src/app/api/listing/[id]/route.ts +30 -0
  17. package/src/app/api/locations/route.ts +13 -0
  18. package/src/app/api/recompute/route.ts +28 -0
  19. package/src/app/api/search/route.ts +26 -0
  20. package/src/app/favicon.ico +0 -0
  21. package/src/app/favorites/page.tsx +603 -0
  22. package/src/app/globals.css +78 -0
  23. package/src/app/layout.tsx +37 -0
  24. package/src/app/listing/[id]/page.tsx +230 -0
  25. package/src/app/map/page.tsx +5 -0
  26. package/src/app/page.tsx +155 -0
  27. package/src/app/search/page.tsx +87 -0
  28. package/src/components/ChromeHeader.tsx +16 -0
  29. package/src/components/FiltersForm.tsx +240 -0
  30. package/src/components/ListingCard.tsx +145 -0
  31. package/src/components/Nav.tsx +37 -0
  32. package/src/components/PsqftChart.tsx +249 -0
  33. package/src/components/TransactionsTable.tsx +40 -0
  34. package/src/components/YieldBadge.tsx +24 -0
  35. package/src/lib/analyze.ts +51 -0
  36. package/src/lib/db.ts +143 -0
  37. package/src/lib/dedupe.ts +34 -0
  38. package/src/lib/format.ts +13 -0
  39. package/src/lib/gyms.ts +104 -0
  40. package/src/lib/pf/client.ts +45 -0
  41. package/src/lib/pf/listing.ts +16 -0
  42. package/src/lib/pf/locations.ts +73 -0
  43. package/src/lib/pf/parse.ts +15 -0
  44. package/src/lib/pf/search.ts +57 -0
  45. package/src/lib/pf/transactions.ts +85 -0
  46. package/src/lib/store.ts +182 -0
  47. package/src/lib/types.ts +122 -0
  48. package/src/lib/yield.ts +80 -0
  49. package/tsconfig.json +34 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 eljommys
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # 🧱 Brickwise
2
+
3
+ App web local (Next.js) para buscar inmuebles en venta en [Property Finder UAE](https://www.propertyfinder.ae) y analizar su **rentabilidad bruta de alquiler** con el histórico real de transacciones DLD (compra y alquiler) del propio edificio.
4
+
5
+ ## Arranque en un comando
6
+
7
+ App **100% local**: el servidor y la base de datos corren en tu máquina, sin nube. Solo necesitas
8
+ tener [Node.js](https://nodejs.org) 18.18+ instalado.
9
+
10
+ ```bash
11
+ npx brickwise
12
+ ```
13
+
14
+ Eso es todo: **instala las dependencias y arranca** (la primera vez tarda ~1 min instalando),
15
+ elige un puerto libre y abre la app en tu navegador. No hay nada más que configurar.
16
+
17
+ <details>
18
+ <summary>¿Prefieres clonarlo?</summary>
19
+
20
+ ```bash
21
+ git clone https://github.com/eljommys/brickwise.git
22
+ cd brickwise
23
+ npm install
24
+ npm run dev # → http://localhost:3000
25
+ ```
26
+ </details>
27
+
28
+ ### Base de datos local
29
+
30
+ Todo (favoritos, análisis, transacciones, ubicaciones, gimnasios) se guarda en un único fichero
31
+ **SQLite** en **`~/.brickwise/brickwise.db`** (en tu carpeta de usuario). Se crea solo al primer uso
32
+ y **persiste entre reinicios y actualizaciones**. Para copia de seguridad o mover tus datos, basta con
33
+ **copiar ese fichero**. Puedes cambiar su ubicación con la variable de entorno `BRICKWISE_DB`.
34
+
35
+ ## Qué hace
36
+
37
+ - **/search** — buscador con filtros: ubicación (autocompletado local con las +16k ubicaciones de PF), precio, superficie, habitaciones, baños, tipo, amenities (gimnasio, parking, piscina…) y distancia máxima al gimnasio externo más cercano (OpenStreetMap).
38
+ - Cada resultado se analiza en segundo plano: se scrapea la ficha del anuncio y el histórico completo de transacciones de su torre (`/en/transactions/{buy|rent}/dubai/{torre}`), y se calcula:
39
+ - **Rentabilidad bruta** = mediana de rentas anuales (últimos 24 meses, mismas habitaciones) / precio del anuncio.
40
+ - **Valor de mercado estimado** = mediana AED/sqft de ventas × superficie, y % de prima/descuento del precio frente a ese valor.
41
+ - **Distancia al gimnasio más cercano** vía Overpass (OSM), con caché.
42
+ - **/listing/[id]** — ficha con galería de fotos, métricas desglosadas, tablas de transacciones de venta y alquiler del edificio y enlace al anuncio original.
43
+ - **/** — dashboard persistente con todo lo analizado, ordenable por rentabilidad, precio, prima vs mercado o distancia al gym.
44
+
45
+ ## Cómo scrapea
46
+
47
+ Property Finder es una app Next.js: cada página embebe sus datos en `<script id="__NEXT_DATA__">`. Basta un `fetch` con User-Agent de navegador (sin headless):
48
+
49
+ - Búsqueda: `/en/search?l={locId}&c=1&pf=&pt=&af=&at=&bdr[]=&am[]=&t=&page=`
50
+ - Ficha: `share_url` del anuncio → `propertyResult.property` (incluye `similar_price_transactions` como fallback).
51
+ - Transacciones: `/en/transactions/{buy|rent}/dubai/{slug}?page=` (paginado, tope 10 págs por tipo).
52
+ - Ubicaciones: `/api/pwa/location/list` (~7,5k filas) + ancestros derivados de los paths → tabla `locations`.
53
+
54
+ Scraping educado: cola global a **1 req/s**, retry con backoff, caché en SQLite (transacciones 7 días, gimnasios 30 días, análisis 24 h).
55
+
56
+ ## Estructura
57
+
58
+ ```
59
+ src/lib/pf/ cliente HTTP, parser __NEXT_DATA__, search, listing, transactions, locations
60
+ src/lib/yield.ts cálculo de rentabilidad (medianas, ventana 24 meses, muestra por habitaciones/tamaño)
61
+ src/lib/gyms.ts gimnasio más cercano vía Overpass/OSM con caché
62
+ src/lib/db.ts SQLite (better-sqlite3) + esquema
63
+ src/app/api/ /api/search, /api/listing/[id], /api/locations, /api/dashboard
64
+ src/app/ páginas: dashboard, search, listing/[id]
65
+ ```
66
+
67
+ ## Notas
68
+
69
+ - Uso personal/local. Respeta los términos de Property Finder: no elevar el rate-limit ni desplegarlo público.
70
+ - La rentabilidad es **bruta** (sin service charges, vacancy ni costes de compra). Los tamaños muestrales (`N tx`) se muestran como indicador de confianza.
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable */
3
+ "use strict";
4
+
5
+ // Zero-config launcher for Brickwise.
6
+ // Run directly (`npx github:eljommys/brickwise`) or after cloning (`node bin/brickwise.js`).
7
+ //
8
+ // npx installs the package nested under ~/.npm/_npx/<hash>/ next to its own
9
+ // lockfile, which confuses Next's workspace-root detection (breaking module
10
+ // resolution and the bundler). To avoid that entirely we copy the app into a
11
+ // clean, single-lockfile directory (~/.brickwise/app) and run it from there.
12
+ // Data lives in ~/.brickwise/brickwise.db, independent of the app copy.
13
+
14
+ const { spawnSync, spawn } = require("child_process");
15
+ const fs = require("fs");
16
+ const net = require("net");
17
+ const os = require("os");
18
+ const path = require("path");
19
+
20
+ const isWin = process.platform === "win32";
21
+ const npm = isWin ? "npm.cmd" : "npm";
22
+ const pkgDir = path.resolve(__dirname, "..");
23
+ const APP = path.join(os.homedir(), ".brickwise", "app");
24
+
25
+ function step(msg) {
26
+ process.stdout.write(`\n\x1b[1m\x1b[34mâ–¸ ${msg}\x1b[0m\n`);
27
+ }
28
+
29
+ function run(cmd, args, cwd) {
30
+ const r = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: isWin });
31
+ if (r.status !== 0) {
32
+ console.error(`\n\x1b[31m✖ Falló: ${cmd} ${args.join(" ")}\x1b[0m`);
33
+ process.exit(r.status || 1);
34
+ }
35
+ }
36
+
37
+ const EXCLUDE = /(^|[\/\\])(node_modules|\.next|\.git|brickwise\.db)/;
38
+
39
+ function syncSources() {
40
+ fs.mkdirSync(APP, { recursive: true });
41
+ fs.cpSync(pkgDir, APP, {
42
+ recursive: true,
43
+ filter: (src) => !EXCLUDE.test(path.relative(pkgDir, src)),
44
+ });
45
+ }
46
+
47
+ function findFreePort(start) {
48
+ return new Promise((resolve) => {
49
+ const srv = net.createServer();
50
+ srv.once("error", () => resolve(findFreePort(start + 1)));
51
+ srv.listen(start, () => {
52
+ const port = srv.address().port;
53
+ srv.close(() => resolve(port));
54
+ });
55
+ });
56
+ }
57
+
58
+ function openBrowser(url) {
59
+ const cmd = process.platform === "darwin" ? "open" : isWin ? "start" : "xdg-open";
60
+ try {
61
+ spawn(cmd, [url], { stdio: "ignore", detached: true, shell: isWin });
62
+ } catch {
63
+ /* the printed URL is enough */
64
+ }
65
+ }
66
+
67
+ (async () => {
68
+ // If already launched from inside the clean copy, run in place; otherwise sync.
69
+ const inPlace = path.resolve(pkgDir) === path.resolve(APP);
70
+ const workdir = inPlace ? pkgDir : APP;
71
+
72
+ if (!inPlace) {
73
+ step("Preparando la app…");
74
+ syncSources();
75
+ }
76
+
77
+ if (!fs.existsSync(path.join(workdir, "node_modules", "next"))) {
78
+ step("Instalando dependencias (solo la primera vez, ~1 min)…");
79
+ run(npm, ["install", "--include=dev", "--no-audit", "--no-fund"], workdir);
80
+ }
81
+
82
+ const port = await findFreePort(Number(process.env.PORT) || 3000);
83
+ const url = `http://localhost:${port}`;
84
+
85
+ step(`Arrancando Brickwise en ${url}`);
86
+ console.log(" (Ctrl+C para parar · tus datos se guardan en ~/.brickwise/brickwise.db)\n");
87
+
88
+ const child = spawn(npm, ["run", "dev", "--", "-p", String(port)], {
89
+ cwd: workdir,
90
+ stdio: "inherit",
91
+ shell: isWin,
92
+ });
93
+
94
+ setTimeout(() => openBrowser(url), 2500);
95
+
96
+ const stop = () => child.kill("SIGINT");
97
+ process.on("SIGINT", stop);
98
+ process.on("SIGTERM", stop);
99
+ child.on("exit", (code) => process.exit(code || 0));
100
+ })();
@@ -0,0 +1,18 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
package/next.config.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ // Pin the workspace root to the current directory. Without it, running via `npx`
4
+ // (which nests the package under ~/.npm/_npx/<hash>/ with its own lockfile) makes
5
+ // Next pick the wrong root and Turbopack fails to build /page. The launcher and the
6
+ // npm scripts always run from the app directory, so process.cwd() is the app root.
7
+ const root = process.cwd();
8
+
9
+ const nextConfig: NextConfig = {
10
+ turbopack: { root },
11
+ outputFileTracingRoot: root,
12
+ };
13
+
14
+ export default nextConfig;
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "brickwise",
3
+ "version": "0.1.0",
4
+ "description": "App local para analizar la rentabilidad de alquiler de inmuebles de Property Finder UAE con el histórico real de transacciones DLD.",
5
+ "license": "MIT",
6
+ "author": "eljommys",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/eljommys/brickwise.git"
10
+ },
11
+ "homepage": "https://github.com/eljommys/brickwise#readme",
12
+ "keywords": [
13
+ "property-finder",
14
+ "dubai",
15
+ "real-estate",
16
+ "rental-yield",
17
+ "scraper",
18
+ "nextjs"
19
+ ],
20
+ "bin": {
21
+ "brickwise": "bin/brickwise.js"
22
+ },
23
+ "files": [
24
+ "bin/",
25
+ "src/",
26
+ "public/",
27
+ "next.config.ts",
28
+ "postcss.config.mjs",
29
+ "eslint.config.mjs",
30
+ "tsconfig.json",
31
+ "package-lock.json"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18.18"
35
+ },
36
+ "scripts": {
37
+ "dev": "next dev --webpack",
38
+ "build": "next build --webpack",
39
+ "start": "next start",
40
+ "app": "next build --webpack && next start",
41
+ "lint": "eslint"
42
+ },
43
+ "dependencies": {
44
+ "better-sqlite3": "^12.11.1",
45
+ "leaflet": "^1.9.4",
46
+ "next": "16.2.10",
47
+ "react": "19.2.4",
48
+ "react-dom": "19.2.4"
49
+ },
50
+ "devDependencies": {
51
+ "@tailwindcss/postcss": "^4",
52
+ "@types/better-sqlite3": "^7.6.13",
53
+ "@types/leaflet": "^1.9.21",
54
+ "@types/node": "^20",
55
+ "@types/react": "^19",
56
+ "@types/react-dom": "^19",
57
+ "eslint": "^9",
58
+ "eslint-config-next": "16.2.10",
59
+ "tailwindcss": "^4",
60
+ "typescript": "^5"
61
+ }
62
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1 @@
1
+ <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
@@ -0,0 +1,15 @@
1
+ import { NextResponse } from "next/server";
2
+ import { listAnalyzed } from "@/lib/store";
3
+
4
+ export async function GET() {
5
+ try {
6
+ const rows = listAnalyzed().map((r) => ({
7
+ ...r,
8
+ images: JSON.parse(r.images_json),
9
+ amenities: JSON.parse(r.amenities_json),
10
+ }));
11
+ return NextResponse.json({ results: rows });
12
+ } catch (e) {
13
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
14
+ }
15
+ }
@@ -0,0 +1,71 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { analyzeListing } from "@/lib/analyze";
3
+ import { addFavorite, listFavorites, removeFavorite, setFavoriteNotes } from "@/lib/store";
4
+
5
+ function serialize(rows: ReturnType<typeof listFavorites>) {
6
+ return rows.map((r) => ({
7
+ ...r,
8
+ images: JSON.parse(r.images_json),
9
+ amenities: JSON.parse(r.amenities_json),
10
+ }));
11
+ }
12
+
13
+ export async function GET() {
14
+ try {
15
+ return NextResponse.json({ results: serialize(listFavorites()) });
16
+ } catch (e) {
17
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
18
+ }
19
+ }
20
+
21
+ /** Accepts any propertyfinder.ae listing URL and extracts the numeric listing id. */
22
+ function parseListingUrl(raw: string): { id: string; url: string } {
23
+ let url: URL;
24
+ try {
25
+ url = new URL(raw.trim());
26
+ } catch {
27
+ throw new Error("URL no válida");
28
+ }
29
+ if (!/(^|\.)propertyfinder\.ae$/.test(url.hostname)) {
30
+ throw new Error("La URL debe ser de propertyfinder.ae");
31
+ }
32
+ const m = url.pathname.match(/-(\d+)\.html$/) || url.pathname.match(/\/(\d+)(?:\/)?$/);
33
+ if (!m) throw new Error("No encuentro el id del anuncio en la URL (pega el enlace de la ficha del inmueble)");
34
+ return { id: m[1], url: url.origin + url.pathname };
35
+ }
36
+
37
+ export async function POST(req: NextRequest) {
38
+ try {
39
+ const body = await req.json();
40
+ const { id, url } = parseListingUrl(String(body.url || ""));
41
+ const { listing, analysis } = await analyzeListing(id, url);
42
+ addFavorite(listing.id);
43
+ return NextResponse.json({
44
+ listing: { ...listing, images: JSON.parse(listing.images_json), amenities: JSON.parse(listing.amenities_json) },
45
+ analysis,
46
+ });
47
+ } catch (e) {
48
+ return NextResponse.json({ error: (e as Error).message }, { status: 400 });
49
+ }
50
+ }
51
+
52
+ export async function PATCH(req: NextRequest) {
53
+ try {
54
+ const body = await req.json();
55
+ setFavoriteNotes(String(body.id), String(body.notes ?? ""));
56
+ return NextResponse.json({ ok: true });
57
+ } catch (e) {
58
+ return NextResponse.json({ error: (e as Error).message }, { status: 400 });
59
+ }
60
+ }
61
+
62
+ export async function DELETE(req: NextRequest) {
63
+ try {
64
+ const id = req.nextUrl.searchParams.get("id");
65
+ if (!id) throw new Error("id requerido");
66
+ removeFavorite(id);
67
+ return NextResponse.json({ ok: true });
68
+ } catch (e) {
69
+ return NextResponse.json({ error: (e as Error).message }, { status: 400 });
70
+ }
71
+ }
@@ -0,0 +1,30 @@
1
+ import { NextResponse } from "next/server";
2
+ import { gymsNear } from "@/lib/gyms";
3
+ import { listFavorites } from "@/lib/store";
4
+
5
+ /** All gyms within 2 km of every located favorite, deduplicated. */
6
+ export async function GET() {
7
+ try {
8
+ const favorites = listFavorites().filter((f) => f.lat != null && f.lon != null);
9
+ const seen = new Map<string, { name: string; lat: number; lon: number }>();
10
+ let failed = 0;
11
+ for (const f of favorites) {
12
+ const gyms = await gymsNear(f.lat!, f.lon!);
13
+ if (gyms == null) {
14
+ failed++;
15
+ continue;
16
+ }
17
+ for (const g of gyms) {
18
+ const key = `${g.lat.toFixed(5)},${g.lon.toFixed(5)}`;
19
+ if (!seen.has(key)) seen.set(key, { name: g.name, lat: g.lat, lon: g.lon });
20
+ }
21
+ }
22
+ return NextResponse.json({
23
+ gyms: [...seen.values()],
24
+ favoritesQueried: favorites.length,
25
+ failed,
26
+ });
27
+ } catch (e) {
28
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
29
+ }
30
+ }
@@ -0,0 +1,30 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { analyzeListing } from "@/lib/analyze";
3
+ import { getTransactions } from "@/lib/pf/transactions";
4
+
5
+ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
6
+ const { id } = await ctx.params;
7
+ const url = req.nextUrl.searchParams.get("url") || undefined;
8
+ const force = req.nextUrl.searchParams.get("force") === "1";
9
+ const withTx = req.nextUrl.searchParams.get("tx") === "1";
10
+ try {
11
+ const { listing, analysis } = await analyzeListing(id, url, force);
12
+ return NextResponse.json({
13
+ listing: {
14
+ ...listing,
15
+ images: JSON.parse(listing.images_json),
16
+ amenities: JSON.parse(listing.amenities_json),
17
+ },
18
+ analysis,
19
+ transactions:
20
+ withTx && listing.tower_slug
21
+ ? {
22
+ buy: getTransactions(listing.tower_slug, "buy"),
23
+ rent: getTransactions(listing.tower_slug, "rent"),
24
+ }
25
+ : null,
26
+ });
27
+ } catch (e) {
28
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
29
+ }
30
+ }
@@ -0,0 +1,13 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { searchLocations } from "@/lib/pf/locations";
3
+
4
+ export async function GET(req: NextRequest) {
5
+ const q = req.nextUrl.searchParams.get("q") || "";
6
+ if (q.trim().length < 2) return NextResponse.json({ locations: [] });
7
+ try {
8
+ const locations = await searchLocations(q);
9
+ return NextResponse.json({ locations });
10
+ } catch (e) {
11
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
12
+ }
13
+ }
@@ -0,0 +1,28 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getDb } from "@/lib/db";
3
+ import { getTransactions } from "@/lib/pf/transactions";
4
+ import { computeYield } from "@/lib/yield";
5
+ import { getAnalysis, saveAnalysis } from "@/lib/store";
6
+ import { ListingRow } from "@/lib/types";
7
+
8
+ /** Recompute every stored analysis from the cached transactions (no scraping). */
9
+ export async function POST() {
10
+ try {
11
+ const db = getDb();
12
+ const listings = db
13
+ .prepare(`SELECT l.* FROM listings l JOIN analyses a ON a.listing_id = l.id`)
14
+ .all() as ListingRow[];
15
+ let updated = 0;
16
+ for (const l of listings) {
17
+ const buy = l.tower_slug ? getTransactions(l.tower_slug, "buy") : [];
18
+ const rent = l.tower_slug ? getTransactions(l.tower_slug, "rent") : [];
19
+ const y = computeYield(l.price, l.size_sqft, l.bedrooms, buy, rent);
20
+ const prev = getAnalysis(l.id);
21
+ saveAnalysis(l.id, y, { gym_name: prev?.gym_name ?? null, distance_m: prev?.gym_distance_m ?? null });
22
+ updated++;
23
+ }
24
+ return NextResponse.json({ updated });
25
+ } catch (e) {
26
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
27
+ }
28
+ }
@@ -0,0 +1,26 @@
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
+ }
Binary file