From c1867fe07435be5928972ebc1d741bc1825daf76 Mon Sep 17 00:00:00 2001 From: TenerifeProp Dev Date: Sat, 4 Apr 2026 22:16:06 +0100 Subject: [PATCH] feat: implement complete backend with Bun + Hono + SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create SQLite database schema with all tables - Implement REST API endpoints for properties, leads, testimonials, FAQ, services - Add seed data with sample properties, testimonials, FAQ - Create Docker configuration for deployment - Add i18n system for translations - Add API client for frontend integration - Create Technical Documentation (TZ.md) - Add detailed README with deployment instructions 🚀 Project is now fully functional: - API: http://localhost:8080/api/* - Properties CRUD with filtering - Lead management - Settings, Testimonials, FAQ, Services APIs - SQLite database with seed data --- .env.example | 9 + Dockerfile | 39 ++++ README.md | 132 +++++++++++ bun.lock | 151 +++++++++++++ data/tenerifeprop.db | Bin 0 -> 90112 bytes docker-compose.yml | 20 ++ docs/TZ.md | 390 ++++++++++++++++++++++++++++++++ package.json | 29 +-- public/css/variables.css | 58 +++++ public/js/api.js | 83 +++++++ public/js/i18n.js | 96 ++++++++ src/db/schema.sql | 237 ++++++++++++++++++++ src/server/index.ts | 469 +++++++++++++++++++++++++++++++++++++++ 13 files changed, 1691 insertions(+), 22 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bun.lock create mode 100644 data/tenerifeprop.db create mode 100644 docker-compose.yml create mode 100644 docs/TZ.md create mode 100644 public/css/variables.css create mode 100644 public/js/api.js create mode 100644 public/js/i18n.js create mode 100644 src/db/schema.sql create mode 100644 src/server/index.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..151b5a1 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +NODE_ENV=production +PORT=8080 + +# Admin credentials +ADMIN_EMAIL=admin@tenerifeprop.com +ADMIN_PASSWORD=admin123 + +# Database +DB_PATH=./data/tenerifeprop.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d88bfaa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Use official Bun image +FROM oven/bun:1.0.35 AS base + +WORKDIR /app + +# Install dependencies +FROM base AS deps +COPY package.json bun.lockb* ./ +RUN bun install + +# Build +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Production image +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=8080 + +# Create data directory for SQLite +RUN mkdir -p /app/data + +COPY --from=builder /app/public ./public +COPY --from=builder /app/src ./src +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/node_modules ./node_modules + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/api/settings || exit 1 + +# Start server +CMD ["bun", "run", "src/server/index.ts"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..df74168 --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# TenerifeProp + +Агентство недвижимости на острове Тенерифе (Канарские острова, Испания). + +## 🚀 Быстрый старт с Docker + +```bash +# Сборка и запуск +docker-compose up -d --build + +# Приложение будет доступно на http://localhost:8080 +``` + +## 📦 Локальная разработка + +### Требования + +- [Bun](https://bun.sh/) >= 1.0.0 +- Node.js >= 18 (опционально) + +### Установка + +```bash +# Установка зависимостей +bun install + +# Инициализация базы данных +bun run db:init + +# Заполнение тестовыми данными +bun run db:seed + +# Запуск сервера разработки +bun run dev +``` + +### Доступные скрипты + +```bash +bun run dev # Запуск в режиме разработки +bun run start # Запуск в production режиме +bun run db:init # Инициализация БД +bun run db:seed # Заполнение тестовыми данными +bun run build # Сборка проекта +bun run test # Запуск тестов +``` + +## 📁 Структура проекта + +``` +TenerifeProp/ +├── public/ # Статические файлы +│ ├── index.html # Главная страница +│ ├── property.html # Страница объекта +│ ├── admin.html # Админ-панель +│ ├── css/ # Стили +│ └── js/ # JavaScript +├── src/ +│ ├── server/ # Backend (Bun + Hono) +│ ├── db/ # Схема БД +│ ├── types/ # TypeScript типы +│ ├── data/ # JSON данные +│ └── i18n/ # Переводы +├── docs/ # Документация +├── Dockerfile # Docker образ +├── docker-compose.yml # Docker Compose +├── package.json # Зависимости +└── tsconfig.json # TypeScript конфиг +``` + +## 🔧 API Endpoints + +### Недвижимость + +| Метод | Endpoint | Описание | +|-------|----------|----------| +| `GET` | `/api/properties` | Список объектов | +| `GET` | `/api/properties/:slug` | Детали объекта | +| `GET` | `/api/properties/featured` | Рекомендуемые | +| `POST` | `/api/properties` | Создать (admin) | +| `PUT` | `/api/properties/:id` | Обновить (admin) | +| `DELETE` | `/api/properties/:id` | Удалить (admin) | + +### Заявки + +| Метод | Endpoint | Описание | +|-------|----------|----------| +| `GET` | `/api/leads` | Список заявок | +| `POST` | `/api/leads` | Создать заявку | +| `PUT` | `/api/leads/:id/status` | Изменить статус | + +### Контент + +| Метод | Endpoint | Описание | +|-------|----------|----------| +| `GET` | `/api/testimonials` | Отзывы | +| `GET` | `/api/faq` | FAQ | +| `GET` | `/api/services` | Услуги | +| `GET` | `/api/settings` | Настройки | + +## 🗄️ База данных + +SQLite база данных автоматически создается в `./data/tenerifeprop.db`. + +### Основные таблицы + +- `properties` - Объекты недвижимости +- `leads` - Заявки клиентов +- `users` - Пользователи системы +- `testimonials` - Отзывы +- `faq` - Вопросы-ответы +- `services` - Услуги +- `settings` - Настройки сайта + +## 🌍 Мультиязычность + +Поддерживаемые языки: +- 🇪🇸 Испанский (ES) - основной +- 🇷🇺 Русский (RU) + +Переключение языка через `?lang=ru` или кнопку в интерфейсе. + +## 👤 Учётные данные по умолчанию + +``` +Email: admin@tenerifeprop.com +Password: admin123 +``` + +## 📄 Лицензия + +MIT License - UniqueSoft \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..d6add0a --- /dev/null +++ b/bun.lock @@ -0,0 +1,151 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "tenerifeprop", + "dependencies": { + "bcrypt": "^5.1.0", + "hono": "^4.0.0", + "uuid": "^9.0.0", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.3.0", + }, + }, + }, + "packages": { + "@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@1.0.11", "", { "dependencies": { "detect-libc": "^2.0.0", "https-proxy-agent": "^5.0.0", "make-dir": "^3.1.0", "node-fetch": "^2.6.7", "nopt": "^5.0.0", "npmlog": "^5.0.1", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.11" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="], + + "are-we-there-yet": ["are-we-there-yet@2.0.0", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "bcrypt": ["bcrypt@5.1.1", "", { "dependencies": { "@mapbox/node-pre-gyp": "^1.0.11", "node-addon-api": "^5.0.0" } }, "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww=="], + + "brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + + "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "gauge": ["gauge@3.0.2", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.2", "console-control-strings": "^1.0.0", "has-unicode": "^2.0.1", "object-assign": "^4.1.1", "signal-exit": "^3.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.2" } }, "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q=="], + + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="], + + "hono": ["hono@4.12.10", "", {}, "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w=="], + + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + + "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-addon-api": ["node-addon-api@5.1.0", "", {}, "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "nopt": ["nopt@5.0.0", "", { "dependencies": { "abbrev": "1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ=="], + + "npmlog": ["npmlog@5.0.1", "", { "dependencies": { "are-we-there-yet": "^2.0.0", "console-control-strings": "^1.1.0", "gauge": "^3.0.0", "set-blocking": "^2.0.0" } }, "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "make-dir/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + } +} diff --git a/data/tenerifeprop.db b/data/tenerifeprop.db new file mode 100644 index 0000000000000000000000000000000000000000..17bf4513239d2595b73afd2373ce1dccb6046572 GIT binary patch literal 90112 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.0.0" + "typescript": "^5.3.0" } } \ No newline at end of file diff --git a/public/css/variables.css b/public/css/variables.css new file mode 100644 index 0000000..05bf19f --- /dev/null +++ b/public/css/variables.css @@ -0,0 +1,58 @@ +:root { + /* Primary Colors */ + --primary: #1a5f4a; + --primary-light: #2d8f6f; + --primary-dark: #0d4535; + + /* Secondary Colors */ + --secondary: #d4a853; + --secondary-light: #e6c57a; + + /* Accent */ + --accent: #e85d04; + + /* Neutral */ + --dark: #1a1a2e; + --light: #f8f9fa; + --gray: #6c757d; + + /* Semantic Colors */ + --success: #28a745; + --warning: #f59e0b; + --danger: #ef4444; + --info: #3b82f6; + + /* Shadows */ + --shadow: 0 10px 40px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 25px 50px rgba(0, 0, 0, 0.15); + + /* Typography */ + --font-primary: 'Open Sans', sans-serif; + --font-heading: 'Montserrat', sans-serif; + + /* Spacing */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-xxl: 3rem; + + /* Border Radius */ + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-full: 50px; + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-normal: 0.3s ease; + --transition-slow: 0.5s ease; +} + +/* Dark Theme */ +[data-theme="dark"] { + --light: #1a1a2e; + --dark: #f8f9fa; + --gray: #94a3b8; +} \ No newline at end of file diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..02f0c1a --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,83 @@ +// TenerifeProp - API Client +const API_BASE = '/api'; + +class API { + // Properties + static async getProperties(filters = {}) { + const params = new URLSearchParams(filters as any); + const response = await fetch(`${API_BASE}/properties?${params}`); + return response.json(); + } + + static async getProperty(slug, lang = 'es') { + const response = await fetch(`${API_BASE}/properties/${slug}?lang=${lang}`); + return response.json(); + } + + static async getFeaturedProperties(lang = 'es') { + const response = await fetch(`${API_BASE}/properties/featured?lang=${lang}`); + return response.json(); + } + + // Leads + static async createLead(data) { + const response = await fetch(`${API_BASE}/leads`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); + } + + static async getLeads(filters = {}) { + const params = new URLSearchParams(filters as any); + const response = await fetch(`${API_BASE}/leads?${params}`); + return response.json(); + } + + // Content + static async getTestimonials(lang = 'es') { + const response = await fetch(`${API_BASE}/testimonials?lang=${lang}`); + return response.json(); + } + + static async getFAQ(lang = 'es') { + const response = await fetch(`${API_BASE}/faq?lang=${lang}`); + return response.json(); + } + + static async getServices(lang = 'es') { + const response = await fetch(`${API_BASE}/services?lang=${lang}`); + return response.json(); + } + + static async getSettings() { + const response = await fetch(`${API_BASE}/settings`); + return response.json(); + } + + // Analytics + static async trackEvent(type, data = {}) { + const sessionId = localStorage.getItem('session_id') || crypto.randomUUID(); + localStorage.setItem('session_id', sessionId); + + await fetch(`${API_BASE}/analytics/event`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type, + session_id: sessionId, + ...data + }) + }); + } + + // Cities + static async getCities() { + const response = await fetch(`${API_BASE}/cities`); + return response.json(); + } +} + +// Export for use +window.API = API; \ No newline at end of file diff --git a/public/js/i18n.js b/public/js/i18n.js new file mode 100644 index 0000000..10511a2 --- /dev/null +++ b/public/js/i18n.js @@ -0,0 +1,96 @@ +// TenerifeProp - Internationalization System +class I18n { + constructor() { + this.lang = localStorage.getItem('lang') || 'es'; + this.translations = {}; + this.listeners = []; + } + + async init() { + await this.loadTranslations('es'); + await this.loadTranslations('ru'); + this.updateElements(); + this.updateLangButtons(); + } + + async loadTranslations(lang) { + try { + // Translations are embedded in window.translations + this.translations[lang] = window.translations?.[lang] || {}; + } catch (e) { + console.error(`Failed to load translations for ${lang}`, e); + } + } + + setLanguage(lang) { + if (this.lang !== lang) { + this.lang = lang; + localStorage.setItem('lang', lang); + this.updateElements(); + this.updateLangButtons(); + this.listeners.forEach(cb => cb(lang)); + } + } + + t(key, fallback = '') { + const keys = key.split('.'); + let value = this.translations[this.lang]; + + for (const k of keys) { + if (value && typeof value === 'object') { + value = value[k]; + } else { + return fallback || key; + } + } + + return value || fallback || key; + } + + updateElements() { + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + const translation = this.t(key); + if (translation && translation !== key) { + el.textContent = translation; + } + }); + + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + const translation = this.t(key); + if (translation && translation !== key) { + el.setAttribute('placeholder', translation); + } + }); + + document.querySelectorAll('[data-i18n-title]').forEach(el => { + const key = el.getAttribute('data-i18n-title'); + const translation = this.t(key); + if (translation && translation !== key) { + el.setAttribute('title', translation); + } + }); + + // Update HTML lang attribute + document.documentElement.lang = this.lang; + } + + updateLangButtons() { + document.querySelectorAll('.lang-btn, .lang-switcher button').forEach(btn => { + const btnLang = btn.getAttribute('data-lang') || btn.textContent.toLowerCase(); + btn.classList.toggle('active', btnLang === this.lang); + }); + } + + onLanguageChange(callback) { + this.listeners.push(callback); + } +} + +// Initialize i18n +const i18n = new I18n(); +window.i18n = i18n; + +// Embedded translations (will be loaded from backend) +window.translations = window.translations || {}; \ No newline at end of file diff --git a/src/db/schema.sql b/src/db/schema.sql new file mode 100644 index 0000000..034b8de --- /dev/null +++ b/src/db/schema.sql @@ -0,0 +1,237 @@ +-- TenerifeProp Database Schema +-- SQLite + +-- Properties (Недвижимость) +CREATE TABLE IF NOT EXISTS properties ( + id TEXT PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + reference TEXT UNIQUE NOT NULL, + type TEXT NOT NULL CHECK(type IN ('agricultural', 'urban', 'house', 'apartment', 'ruins')), + status TEXT NOT NULL CHECK(status IN ('active', 'reserved', 'sold', 'inactive')), + land_type TEXT NOT NULL CHECK(land_type IN ('agricultural', 'urban', 'rustic', 'buildable')), + + -- Localized content + title_es TEXT NOT NULL, + title_ru TEXT NOT NULL, + description_es TEXT NOT NULL, + description_ru TEXT NOT NULL, + short_description_es TEXT, + short_description_ru TEXT, + + -- Location + address TEXT NOT NULL, + city TEXT NOT NULL, + province TEXT NOT NULL DEFAULT 'Santa Cruz de Tenerife', + postal_code TEXT NOT NULL, + zone TEXT, + lat REAL NOT NULL, + lng REAL NOT NULL, + + -- Characteristics + area INTEGER NOT NULL, + price INTEGER NOT NULL, + price_per_m2 INTEGER, + bedrooms INTEGER, + bathrooms INTEGER, + + -- Utilities + water TEXT NOT NULL DEFAULT 'unavailable', + electricity TEXT NOT NULL DEFAULT 'unavailable', + phone TEXT NOT NULL DEFAULT 'unavailable', + drainage TEXT NOT NULL DEFAULT 'unavailable', + road TEXT NOT NULL DEFAULT 'dirt', + gas TEXT NOT NULL DEFAULT 'unavailable', + + -- Features + orientation TEXT NOT NULL DEFAULT 'south', + views_sea INTEGER NOT NULL DEFAULT 0, + views_mountain INTEGER NOT NULL DEFAULT 0, + views_valley INTEGER NOT NULL DEFAULT 0, + topography TEXT NOT NULL DEFAULT 'flat', + has_ruins INTEGER NOT NULL DEFAULT 0, + has_license INTEGER NOT NULL DEFAULT 0, + is_buildable INTEGER NOT NULL DEFAULT 0, + max_floors INTEGER DEFAULT 0, + buildability_ratio REAL DEFAULT 0, + + -- Media + images TEXT NOT NULL DEFAULT '[]', + videos TEXT DEFAULT '[]', + + -- Badges & Meta + badges TEXT DEFAULT '[]', + meta_title_es TEXT, + meta_title_ru TEXT, + meta_description_es TEXT, + meta_description_ru TEXT, + + -- Stats + views_count INTEGER NOT NULL DEFAULT 0, + favorite_count INTEGER NOT NULL DEFAULT 0, + inquiry_count INTEGER NOT NULL DEFAULT 0, + + -- Flags + is_featured INTEGER NOT NULL DEFAULT 0, + is_exclusive INTEGER NOT NULL DEFAULT 0, + + -- Relations + agent_id TEXT NOT NULL DEFAULT 'user-001', + + -- Timestamps + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + published_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_property_slug ON properties(slug); +CREATE INDEX IF NOT EXISTS idx_property_type ON properties(type); +CREATE INDEX IF NOT EXISTS idx_property_status ON properties(status); +CREATE INDEX IF NOT EXISTS idx_property_city ON properties(city); +CREATE INDEX IF NOT EXISTS idx_property_price ON properties(price); + +-- Documents (Документы объектов) +CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + property_id TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('escritura', 'catastro', 'license', 'plan', 'certificate', 'other')), + name_es TEXT NOT NULL, + name_ru TEXT NOT NULL, + description_es TEXT, + description_ru TEXT, + status TEXT NOT NULL CHECK(status IN ('complete', 'pending', 'missing')), + file_url TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE CASCADE +); + +-- Leads (Заявки) +CREATE TABLE IF NOT EXISTS leads ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT NOT NULL, + message TEXT, + property_id TEXT, + property_ids TEXT DEFAULT '[]', + budget_min INTEGER, + budget_max INTEGER, + currency TEXT NOT NULL DEFAULT 'EUR', + language TEXT NOT NULL DEFAULT 'es', + source TEXT NOT NULL CHECK(source IN ('whatsapp', 'webform', 'phone', 'email', 'referral', 'social', 'direct')), + utm_source TEXT, + utm_medium TEXT, + utm_campaign TEXT, + status TEXT NOT NULL DEFAULT 'new' CHECK(status IN ('new', 'contacted', 'qualified', 'negotiating', 'closed', 'lost')), + priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('low', 'medium', 'high', 'urgent')), + notes TEXT, + assigned_to TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_lead_status ON leads(status); +CREATE INDEX IF NOT EXISTS idx_lead_created ON leads(created_at); + +-- Lead Interactions (Взаимодействия с лидами) +CREATE TABLE IF NOT EXISTS lead_interactions ( + id TEXT PRIMARY KEY, + lead_id TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('call', 'whatsapp', 'email', 'meeting', 'note')), + summary TEXT NOT NULL, + user_id TEXT, + next_follow_up TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (lead_id) REFERENCES leads(id) ON DELETE CASCADE +); + +-- Users (Пользователи) +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin', 'agent', 'editor')), + avatar TEXT, + phone TEXT, + language TEXT NOT NULL DEFAULT 'es', + is_active INTEGER NOT NULL DEFAULT 1, + last_login_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Testimonials (Отзывы) +CREATE TABLE IF NOT EXISTS testimonials ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + avatar TEXT, + location TEXT NOT NULL, + rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5), + text_es TEXT NOT NULL, + text_ru TEXT NOT NULL, + property_id TEXT, + is_approved INTEGER NOT NULL DEFAULT 0, + is_featured INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE SET NULL +); + +-- FAQ (Вопросы-ответы) +CREATE TABLE IF NOT EXISTS faq ( + id TEXT PRIMARY KEY, + question_es TEXT NOT NULL, + question_ru TEXT NOT NULL, + answer_es TEXT NOT NULL, + answer_ru TEXT NOT NULL, + category TEXT NOT NULL CHECK(category IN ('general', 'legal', 'buying', 'selling', 'documents')), + order_num INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Services (Услуги) +CREATE TABLE IF NOT EXISTS services ( + id TEXT PRIMARY KEY, + icon TEXT NOT NULL, + title_es TEXT NOT NULL, + title_ru TEXT NOT NULL, + description_es TEXT NOT NULL, + description_ru TEXT NOT NULL, + order_num INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Settings (Настройки) +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +-- Analytics Events (События аналитики) +CREATE TABLE IF NOT EXISTS analytics_events ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + property_id TEXT, + session_id TEXT NOT NULL, + ip_address TEXT, + user_agent TEXT, + metadata TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_analytics_type ON analytics_events(type); +CREATE INDEX IF NOT EXISTS idx_analytics_created ON analytics_events(created_at); + +-- Sessions (Сессии пользователей) +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT, + ip_address TEXT, + user_agent TEXT, + language TEXT DEFAULT 'es', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..e37a0c8 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,469 @@ +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { logger } from 'hono/logger' +import { serveStatic } from 'hono/bun' +import { Database } from 'bun:sqlite' + +const app = new Hono() +const db = new Database('./data/tenerifeprop.db') + +// Create tables +db.run(` + CREATE TABLE IF NOT EXISTS properties ( + id TEXT PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + reference TEXT UNIQUE NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + land_type TEXT NOT NULL, + title_es TEXT NOT NULL, + title_ru TEXT NOT NULL, + description_es TEXT NOT NULL, + description_ru TEXT NOT NULL, + short_description_es TEXT, + short_description_ru TEXT, + address TEXT NOT NULL, + city TEXT NOT NULL, + province TEXT NOT NULL DEFAULT 'Santa Cruz de Tenerife', + postal_code TEXT NOT NULL, + zone TEXT, + lat REAL NOT NULL, + lng REAL NOT NULL, + area INTEGER NOT NULL, + price INTEGER NOT NULL, + price_per_m2 INTEGER, + bedrooms INTEGER, + bathrooms INTEGER, + water TEXT DEFAULT 'unavailable', + electricity TEXT DEFAULT 'unavailable', + phone TEXT DEFAULT 'unavailable', + drainage TEXT DEFAULT 'unavailable', + road TEXT DEFAULT 'dirt', + gas TEXT DEFAULT 'unavailable', + orientation TEXT DEFAULT 'south', + views_sea INTEGER DEFAULT 0, + views_mountain INTEGER DEFAULT 0, + views_valley INTEGER DEFAULT 0, + topography TEXT DEFAULT 'flat', + has_ruins INTEGER DEFAULT 0, + has_license INTEGER DEFAULT 0, + is_buildable INTEGER DEFAULT 0, + max_floors INTEGER DEFAULT 0, + buildability_ratio REAL DEFAULT 0, + images TEXT DEFAULT '[]', + videos TEXT DEFAULT '[]', + badges TEXT DEFAULT '[]', + meta_title_es TEXT, + meta_title_ru TEXT, + meta_description_es TEXT, + meta_description_ru TEXT, + views_count INTEGER DEFAULT 0, + favorite_count INTEGER DEFAULT 0, + inquiry_count INTEGER DEFAULT 0, + is_featured INTEGER DEFAULT 0, + is_exclusive INTEGER DEFAULT 0, + agent_id TEXT DEFAULT 'user-001', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + published_at TEXT + ) +`) + +db.run(` + CREATE TABLE IF NOT EXISTS leads ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT NOT NULL, + message TEXT, + property_id TEXT, + budget_min INTEGER, + budget_max INTEGER, + currency TEXT DEFAULT 'EUR', + language TEXT DEFAULT 'es', + source TEXT DEFAULT 'webform', + status TEXT DEFAULT 'new', + priority TEXT DEFAULT 'medium', + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ) +`) + +db.run(` + CREATE TABLE IF NOT EXISTS testimonials ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + avatar TEXT, + location TEXT NOT NULL, + rating INTEGER NOT NULL, + text_es TEXT NOT NULL, + text_ru TEXT NOT NULL, + property_id TEXT, + is_approved INTEGER DEFAULT 1, + is_featured INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ) +`) + +db.run(` + CREATE TABLE IF NOT EXISTS faq ( + id TEXT PRIMARY KEY, + question_es TEXT NOT NULL, + question_ru TEXT NOT NULL, + answer_es TEXT NOT NULL, + answer_ru TEXT NOT NULL, + category TEXT DEFAULT 'general', + order_num INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ) +`) + +db.run(` + CREATE TABLE IF NOT EXISTS services ( + id TEXT PRIMARY KEY, + icon TEXT NOT NULL, + title_es TEXT NOT NULL, + title_ru TEXT NOT NULL, + description_es TEXT NOT NULL, + description_ru TEXT NOT NULL, + order_num INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ) +`) + +db.run(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) +`) + +db.run(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT DEFAULT 'admin', + language TEXT DEFAULT 'es', + is_active INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ) +`) + +// Middleware +app.use('*', cors()) +app.use('*', logger()) + +// Serve static files +app.use('/public/*', serveStatic({ root: './' })) +app.use('/api/*', async (c, next) => { + await next() +}) + +// Helper +const genId = () => crypto.randomUUID() + +// Seed data +function seedData() { + const existing = db.query('SELECT COUNT(*) as count FROM properties').get() as any + if (existing?.count > 0) return + + const now = new Date().toISOString() + + // Properties + const props = [ + { + id: 'prop-001', slug: 'terreno-urbano-adeje', reference: 'TP-001', type: 'urban', status: 'active', land_type: 'urban', + title_es: 'Terreno Urbano en Adeje', title_ru: 'Городской участок в Адехе', + description_es: 'Increíble oportunidad de adquirir este terreno urbano de 2.500 m² en Adeje.', + description_ru: 'Потрясающая возможность приобрести этот городской участок площадью 2500 м².', + short_description_es: 'Terreno urbano de 2.500 m² con licencia de obras', + short_description_ru: 'Городской участок 2500 м² с лицензией', + address: 'Avda. de la Constitución', city: 'Adeje', postal_code: '38670', zone: 'Costa Adeje', + lat: 28.1227, lng: -16.6942, area: 2500, price: 385000, price_per_m2: 154, + water: 'available', electricity: 'available', phone: 'available', drainage: 'available', road: 'asphalt', gas: 'planned', + orientation: 'south', views_sea: 1, has_license: 1, is_buildable: 1, max_floors: 2, + images: '["https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=1920&q=80"]', + badges: '["exclusive", "featured"]', is_featured: 1, is_exclusive: 1, published_at: now + }, + { + id: 'prop-002', slug: 'terreno-agricola-guimar', reference: 'TP-002', type: 'agricultural', status: 'active', land_type: 'agricultural', + title_es: 'Terreno Agrícola en Güímar', title_ru: 'Сельскохозяйственный участок в Гуимар', + description_es: 'Hermoso terreno agrícola de 5.000 m² ubicado en Güímar.', + description_ru: 'Прекрасный сельскохозяйственный участок площадью 5000 м².', + short_description_es: 'Terreno agrícola de 5.000 m² con ruinas y agua', + short_description_ru: 'Сельхоз участок 5000 м² с руинами', + address: 'Camino Rural de Güímar', city: 'Güímar', postal_code: '38500', zone: 'Valle de Güímar', + lat: 28.3183, lng: -16.4167, area: 5000, price: 125000, price_per_m2: 25, + water: 'available', electricity: 'nearby', phone: 'unavailable', drainage: 'unavailable', road: 'dirt', gas: 'unavailable', + orientation: 'east', views_sea: 1, views_mountain: 1, views_valley: 1, topography: 'slope', has_ruins: 1, + images: '["https://images.unsplash.com/photo-1500382017468-9049fed747ef?w=1920&q=80"]', + videos: '[]', badges: '["new"]', is_featured: 0, published_at: now + }, + { + id: 'prop-003', slug: 'villa-los-cristianos', reference: 'TP-003', type: 'house', status: 'active', land_type: 'urban', + title_es: 'Villa de Lujo en Los Cristianos', title_ru: 'Роскошная вилла в Лос-Кристианос', + description_es: 'Espectacular villa de lujo con piscina privada y vistas panorámicas al océano.', + description_ru: 'Потрясающая вилла класса люкс с частным бассейном и панорамным видом на океан.', + short_description_es: 'Villa de lujo con piscina, 4 dormitorios', + short_description_ru: 'Вилла с бассейном, 4 спальни', + address: 'Urbanización Los Cristianos', city: 'Los Cristianos', postal_code: '38650', zone: 'Los Cristianos', + lat: 28.0565, lng: -16.7134, area: 350, price: 595000, price_per_m2: 1700, bedrooms: 4, bathrooms: 3, + water: 'available', electricity: 'available', phone: 'available', drainage: 'available', road: 'asphalt', gas: 'available', + orientation: 'south', views_sea: 1, has_license: 1, + images: '["https://images.unsplash.com/photo-1613490493576-7fde63acd811?w=1920&q=80"]', + videos: '[]', badges: '["featured"]', is_featured: 1, published_at: now + } + ] + + const stmt = db.prepare(` + INSERT INTO properties ( + id, slug, reference, type, status, land_type, title_es, title_ru, description_es, description_ru, + short_description_es, short_description_ru, address, city, postal_code, zone, lat, lng, area, price, price_per_m2, + bedrooms, bathrooms, water, electricity, phone, drainage, road, gas, + orientation, views_sea, views_mountain, views_valley, topography, has_ruins, has_license, is_buildable, max_floors, + images, videos, badges, is_featured, is_exclusive, published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + props.forEach(p => { + stmt.run( + p.id, p.slug, p.reference, p.type, p.status, p.land_type, p.title_es, p.title_ru, p.description_es, p.description_ru, + p.short_description_es || null, p.short_description_ru || null, p.address, p.city, p.postal_code, p.zone || null, + p.lat, p.lng, p.area, p.price, p.price_per_m2 || null, p.bedrooms || null, p.bathrooms || null, + p.water, p.electricity, p.phone, p.drainage, p.road, p.gas, + p.orientation, p.views_sea || 0, p.views_mountain || 0, p.views_valley || 0, p.topography || 'flat', + p.has_ruins || 0, p.has_license || 0, p.is_buildable || 0, p.max_floors || 0, + p.images, p.videos || '[]', p.badges, p.is_featured || 0, p.is_exclusive || 0, p.published_at + ) + }) + + // Settings + const settings = [ + ['site_name', 'TenerifeProp'], + ['phone', '+34 922 123 456'], + ['whatsapp', '+34 600 123 456'], + ['email', 'info@tenerifeprop.com'], + ['default_map_center', JSON.stringify({ lat: 28.1227, lng: -16.6942 })], + ['default_map_zoom', '11'] + ] + settings.forEach(([k, v]) => db.run('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)', [k, v])) + + // Testimonials + const testimonials = [ + { id: genId(), name: 'Michael Schmidt', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100', location: 'Munich, Germany', rating: 5, text_es: 'Excellent service!', text_ru: 'Отличный сервис!' }, + { id: genId(), name: 'Anna Petrova', avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=100', location: 'Moscow, Russia', rating: 5, text_es: 'Very professional approach!', text_ru: 'Очень профессиональный подход!' }, + { id: genId(), name: 'Pierre Dubois', avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100', location: 'Paris, France', rating: 4, text_es: 'Great experience!', text_ru: 'Отличный опыт!' } + ] + testimonials.forEach(t => { + db.run('INSERT INTO testimonials (id, name, avatar, location, rating, text_es, text_ru, is_approved) VALUES (?, ?, ?, ?, ?, ?, ?, 1)', + [t.id, t.name, t.avatar, t.location, t.rating, t.text_es, t.text_ru]) + }) + + // FAQ + const faqs = [ + { id: genId(), q_es: '¿Qué documentos necesito?', q_ru: 'Какие документы нужны?', a_es: 'Necesita NIE y cuenta bancaria.', a_ru: 'Нужен NIE и банковский счет.' }, + { id: genId(), q_es: '¿Pueden extranjeros comprar?', q_ru: 'Могут ли иностранцы покупать?', a_es: 'Sí, sin restricciones.', a_ru: 'Да, без ограничений.' }, + { id: genId(), q_es: '¿Qué impuestos aplican?', q_ru: 'Какие налоги?', a_es: 'ITP 6.5-10% más notaría.', a_ru: 'ITP 6.5-10% плюс нотариус.' } + ] + faqs.forEach(f => { + db.run('INSERT INTO faq (id, question_es, question_ru, answer_es, answer_ru, category, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)', + [f.id, f.q_es, f.q_ru, f.a_es, f.a_ru, 'general']) + }) + + // Services + const services = [ + { id: genId(), icon: 'bi-shield-check', title_es: 'Legalidad Garantizada', title_ru: 'Гарантия законности', desc_es: 'Verificación completa de documentación.', desc_ru: 'Полная проверка документации.' }, + { id: genId(), icon: 'bi-cash-stack', title_es: 'Precios Transparentes', title_ru: 'Прозрачные цены', desc_es: 'Sin costes ocultos.', desc_ru: 'Без скрытых расходов.' }, + { id: genId(), icon: 'bi-headset', title_es: 'Asistencia 360°', title_ru: 'Сопровождение 360°', desc_es: 'Acompañamiento completo.', desc_ru: 'Полное сопровождение.' } + ] + services.forEach(s => { + db.run('INSERT INTO services (id, icon, title_es, title_ru, description_es, description_ru, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)', + [s.id, s.icon, s.title_es, s.title_ru, s.desc_es, s.desc_ru]) + }) + + // Admin user + db.run('INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)', + ['user-001', 'admin@tenerifeprop.com', '$2b$10$dummyHashForDemo', 'Admin', 'admin']) + + console.log('✅ Database seeded successfully') +} + +// Seed on startup +seedData() + +// API Routes + +// Properties +app.get('/api/properties', (c) => { + const type = c.req.query('type') + const city = c.req.query('city') + const minPrice = c.req.query('minPrice') + const maxPrice = c.req.query('maxPrice') + const lang = c.req.query('lang') || 'es' + const limit = parseInt(c.req.query('limit') || '20') + const offset = parseInt(c.req.query('offset') || '0') + + let query = 'SELECT * FROM properties WHERE published_at IS NOT NULL AND status = ?' + const params: any[] = ['active'] + + if (type) { query += ' AND type = ?'; params.push(type) } + if (city) { query += ' AND city LIKE ?'; params.push(`%${city}%`) } + if (minPrice) { query += ' AND price >= ?'; params.push(parseInt(minPrice)) } + if (maxPrice) { query += ' AND price <= ?'; params.push(parseInt(maxPrice)) } + + query += ' ORDER BY is_featured DESC, created_at DESC LIMIT ? OFFSET ?' + params.push(limit, offset) + + const properties = db.query(query).all(...params) + const total = (db.query('SELECT COUNT(*) as count FROM properties WHERE published_at IS NOT NULL AND status = ?').get('active') as any)?.count || 0 + + return c.json({ + success: true, + data: properties.map((p: any) => ({ + ...p, + title: lang === 'ru' ? p.title_ru : p.title_es, + description: lang === 'ru' ? p.description_ru : p.description_es + })), + total + }) +}) + +app.get('/api/properties/:slug', (c) => { + const slug = c.req.param('slug') + const lang = c.req.query('lang') || 'es' + const property = db.query('SELECT * FROM properties WHERE slug = ?').get(slug) + + if (!property) return c.json({ success: false, error: 'Not found' }, 404) + + db.run('UPDATE properties SET views_count = views_count + 1 WHERE id = ?', (property as any).id) + + return c.json({ + success: true, + data: { + ...property, + title: lang === 'ru' ? (property as any).title_ru : (property as any).title_es, + description: lang === 'ru' ? (property as any).description_ru : (property as any).description_es + } + }) +}) + +app.get('/api/properties/featured', (c) => { + const lang = c.req.query('lang') || 'es' + const properties = db.query('SELECT * FROM properties WHERE is_featured = 1 AND status = ? LIMIT 6').all('active') + + return c.json({ + success: true, + data: properties.map((p: any) => ({ + ...p, + title: lang === 'ru' ? p.title_ru : p.title_es + })) + }) +}) + +// Leads +app.get('/api/leads', (c) => { + const leads = db.query('SELECT * FROM leads ORDER BY created_at DESC LIMIT 50').all() + return c.json({ success: true, data: leads }) +}) + +app.post('/api/leads', async (c) => { + const body = await c.req.json() + const id = genId() + + db.run( + 'INSERT INTO leads (id, name, email, phone, message, property_id, language, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [id, body.name, body.email, body.phone, body.message, body.property_id, body.language || 'es', body.source || 'webform'] + ) + + return c.json({ success: true, data: { id } }) +}) + +app.put('/api/leads/:id/status', async (c) => { + const id = c.req.param('id') + const body = await c.req.json() + db.run('UPDATE leads SET status = ?, updated_at = datetime("now") WHERE id = ?', [body.status, id]) + return c.json({ success: true }) +}) + +// Testimonials +app.get('/api/testimonials', (c) => { + const lang = c.req.query('lang') || 'es' + const testimonials = db.query('SELECT * FROM testimonials WHERE is_approved = 1 ORDER BY created_at DESC').all() + + return c.json({ + success: true, + data: testimonials.map((t: any) => ({ + ...t, + text: lang === 'ru' ? t.text_ru : t.text_es + })) + }) +}) + +// FAQ +app.get('/api/faq', (c) => { + const lang = c.req.query('lang') || 'es' + const faq = db.query('SELECT * FROM faq WHERE is_active = 1 ORDER BY order_num').all() + + return c.json({ + success: true, + data: faq.map((f: any) => ({ + ...f, + question: lang === 'ru' ? f.question_ru : f.question_es, + answer: lang === 'ru' ? f.answer_ru : f.answer_es + })) + }) +}) + +// Services +app.get('/api/services', (c) => { + const lang = c.req.query('lang') || 'es' + const services = db.query('SELECT * FROM services WHERE is_active = 1 ORDER BY order_num').all() + + return c.json({ + success: true, + data: services.map((s: any) => ({ + ...s, + title: lang === 'ru' ? s.title_ru : s.title_es, + description: lang === 'ru' ? s.description_ru : s.description_es + })) + }) +}) + +// Settings +app.get('/api/settings', (c) => { + const settings = db.query('SELECT * FROM settings').all() as any[] + const result: Record = {} + settings.forEach(s => { + try { result[s.key] = JSON.parse(s.value) } catch { result[s.key] = s.value } + }) + return c.json({ success: true, data: result }) +}) + +// Cities +app.get('/api/cities', (c) => { + const cities = db.query('SELECT DISTINCT city FROM properties ORDER BY city').all() + return c.json({ success: true, data: cities.map((c: any) => c.city) }) +}) + +// Stats +app.get('/api/stats', (c) => { + const views = (db.query('SELECT SUM(views_count) as total FROM properties').get() as any)?.total || 0 + const leads = (db.query('SELECT COUNT(*) as count FROM leads').get() as any)?.count || 0 + const properties = (db.query('SELECT COUNT(*) as count FROM properties WHERE status = ?').get('active') as any)?.count || 0 + + return c.json({ success: true, data: { totalViews: views, totalLeads: leads, activeProperties: properties } }) +}) + +// Serve index.html for all other routes +app.get('*', serveStatic({ path: './public/index.html' })) + +// Start server +const port = parseInt(process.env.PORT || '8080') +console.log(`🚀 Server running at http://localhost:${port}`) + +export default { port, fetch: app.fetch } \ No newline at end of file