feat: implement complete backend with Bun + Hono + SQLite

- 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
This commit is contained in:
TenerifeProp Dev
2026-04-04 22:16:06 +01:00
parent d7a04e8114
commit c1867fe074
13 changed files with 1691 additions and 22 deletions

9
.env.example Normal file
View File

@@ -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

39
Dockerfile Normal file
View File

@@ -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"]

132
README.md Normal file
View File

@@ -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

151
bun.lock Normal file
View File

@@ -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=="],
}
}

BIN
data/tenerifeprop.db Normal file

Binary file not shown.

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
volumes:
- ./data:/app/data
- ./public:/app/public:ro
environment:
- NODE_ENV=production
- PORT=8080
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/settings"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s

390
docs/TZ.md Normal file
View File

@@ -0,0 +1,390 @@
# Техническое Задание (ТЗ)
# TenerifeProp - Агентство недвижимости на Тенерифе
## 1. Введение
### 1.1 Назначение документа
Данный документ содержит полное техническое задание на разработку веб-приложения для агентства недвижимости TenerifeProp, расположенного на острове Тенерифе (Канарские острова, Испания).
### 1.2 Область применения
Веб-приложение предназначено для:
- Публичного показа объектов недвижимости (участки, дома, квартиры)
- Сбора заявок от потенциальных клиентов
- Управления контентом и объектами через административную панель
- Работы на испанском и русском языках
### 1.3 Термины и определения
| Термин | Определение |
|--------|-------------|
| Property | Объект недвижимости (участок, дом, квартира) |
| Lead | Потенциальный клиент, оставивший заявку |
| Agricultural land | Сельскохозяйственный участок (terreno agrícola) |
| Urban land | Городской участок (terreno urbano) |
| Ruins | Руины, здания под восстановление |
---
## 2. Сущности данных
### 2.1 Property (Недвижимость)
#### Атрибуты
| Поле | Тип | Обязательно | Описание |
|------|-----|--------------|----------|
| id | TEXT (UUID) | Да | Уникальный идентификатор |
| slug | TEXT | Да | URL-friendly идентификатор |
| reference | TEXT | Да | Человекочитаемый ID (TP-001) |
| type | ENUM | Да | agricultural, urban, house, apartment, ruins |
| status | ENUM | Да | active, reserved, sold, inactive |
| land_type | ENUM | Да | agricultural, urban, rustic, buildable |
| title_es | TEXT | Да | Название на испанском |
| title_ru | TEXT | Да | Название на русском |
| description_es | TEXT | Да | Описание на испанском |
| description_ru | TEXT | Да | Описание на русском |
| short_description_es | TEXT | Нет | Краткое описание ES |
| short_description_ru | TEXT | Нет | Краткое описание RU |
| address | TEXT | Да | Адрес |
| city | TEXT | Да | Город |
| province | TEXT | Да | Провинция |
| postal_code | TEXT | Да | Почтовый индекс |
| zone | TEXT | Нет | Район/зона |
| lat | REAL | Да | Широта |
| lng | REAL | Да | Долгота |
| area | INTEGER | Да | Площадь (m²) |
| price | INTEGER | Да | Цена (EUR) |
| price_per_m2 | INTEGER | Нет | Цена за m² |
| bedrooms | INTEGER | Нет | Спальни (для домов) |
| bathrooms | INTEGER | Нет | Ванные (для домов) |
| water | TEXT | Да | water status: available, unavailable, planned |
| electricity | TEXT | Да | electricity status |
| phone | TEXT | Да | phone status |
| drainage | TEXT | Да | drainage status |
| road | TEXT | Да | road type: asphalt, dirt, planned |
| gas | TEXT | Да | gas status |
| orientation | TEXT | Да | north, south, east, west |
| views_sea | BOOLEAN | Да | Вид на море |
| views_mountain | BOOLEAN | Да | Вид на горы |
| views_valley | BOOLEAN | Да | Вид на долину |
| topography | TEXT | Да | flat, slope, terraced |
| has_ruins | BOOLEAN | Да | Есть руины |
| has_license | BOOLEAN | Да | Есть лицензия |
| is_buildable | BOOLEAN | Да | Можно строить |
| max_floors | INTEGER | Нет | Макс. этажей |
| buildability_ratio | REAL | Нет | Коэф. застройки |
| images | TEXT (JSON) | Да | Массив URL изображений |
| videos | TEXT (JSON) | Нет | Массив URL видео |
| badges | TEXT (JSON) | Нет | Метки: new, exclusive, featured |
| meta_title_es | TEXT | Нет | SEO title ES |
| meta_title_ru | TEXT | Нет | SEO title RU |
| meta_description_es | TEXT | Нет | SEO description ES |
| meta_description_ru | TEXT | Нет | SEO description RU |
| views_count | INTEGER | Да | Счётчик просмотров |
| favorite_count | INTEGER | Да | Счётчик избранного |
| inquiry_count | INTEGER | Да | Счётчик заявок |
| is_featured | BOOLEAN | Да | Рекомендуемый |
| is_exclusive | BOOLEAN | Да | Эксклюзивный |
| agent_id | TEXT | Да | ID агента |
| created_at | TEXT (ISO) | Да | Дата создания |
| updated_at | TEXT (ISO) | Да | Дата обновления |
| published_at | TEXT (ISO) | Нет | Дата публикации |
#### Индексы
- `idx_property_slug` ON slug
- `idx_property_type` ON type
- `idx_property_status` ON status
- `idx_property_city` ON city
- `idx_property_price` ON price
### 2.2 Lead (Заявка)
| Поле | Тип | Обязательно | Описание |
|------|-----|--------------|----------|
| id | TEXT (UUID) | Да | Уникальный идентификатор |
| name | TEXT | Да | Имя клиента |
| email | TEXT | Да | Email |
| phone | TEXT | Да | Телефон |
| message | TEXT | Нет | Сообщение |
| property_id | TEXT | Нет | ID интересующего объекта |
| property_ids | TEXT (JSON) | Нет | Массив ID объектов |
| budget_min | INTEGER | Нет | Мин. бюджет |
| budget_max | INTEGER | Нет | Макс. бюджет |
| currency | TEXT | Да | EUR, USD, RUB |
| language | TEXT | Да | Предпочитаемый язык |
| source | TEXT | Да | whatsapp, webform, phone, email |
| utm_source | TEXT | Нет | UTM Source |
| utm_medium | TEXT | Нет | UTM Medium |
| utm_campaign | TEXT | Нет | UTM Campaign |
| status | TEXT | Да | new, contacted, qualified, negotiating, closed, lost |
| priority | TEXT | Да | low, medium, high, urgent |
| notes | TEXT | Нет | Заметки |
| assigned_to | TEXT | Нет | ID агента |
| created_at | TEXT (ISO) | Да | Дата создания |
| updated_at | TEXT (ISO) | Да | Дата обновления |
### 2.3 User (Пользователь)
| Поле | Тип | Обязательно | Описание |
|------|-----|--------------|----------|
| id | TEXT (UUID) | Да | Уникальный идентификатор |
| email | TEXT | Да | Email (уникальный) |
| password_hash | TEXT | Да | Хеш пароля |
| name | TEXT | Да | Имя |
| role | TEXT | Да | admin, agent, editor |
| avatar | TEXT | Нет | URL аватара |
| phone | TEXT | Нет | Телефон |
| language | TEXT | Да | es, ru |
| is_active | BOOLEAN | Да | Активен |
| last_login_at | TEXT (ISO) | Нет | Последний вход |
| created_at | TEXT (ISO) | Да | Дата создания |
| updated_at | TEXT (ISO) | Да | Дата обновления |
### 2.4 Testimonial (Отзыв)
| Поле | Тип | Обязательно | Описание |
|------|-----|--------------|----------|
| id | TEXT (UUID) | Да | Уникальный идентификатор |
| name | TEXT | Да | Имя клиента |
| avatar | TEXT | Нет | URL аватара |
| location | TEXT | Да | Местоположение |
| rating | INTEGER | Да | Оценка 1-5 |
| text_es | TEXT | Да | Текст ES |
| text_ru | TEXT | Да | Текст RU |
| property_id | TEXT | Нет | ID объекта |
| is_approved | BOOLEAN | Да | Одобрен |
| is_featured | BOOLEAN | Да | Рекомендуемый |
| created_at | TEXT (ISO) | Да | Дата создания |
### 2.5 FAQ (Вопрос-ответ)
| Поле | Тип | Обязательно | Описание |
|------|-----|--------------|----------|
| id | TEXT (UUID) | Да | Уникальный идентификатор |
| question_es | TEXT | Да | Вопрос ES |
| question_ru | TEXT | Да | Вопрос RU |
| answer_es | TEXT | Да | Ответ ES |
| answer_ru | TEXT | Да | Ответ RU |
| category | TEXT | Да | Категория |
| order_num | INTEGER | Да | Порядок |
| is_active | BOOLEAN | Да | Активен |
### 2.6 Service (Услуга)
| Поле | Тип | Обязательно | Описание |
|------|-----|--------------|----------|
| id | TEXT (UUID) | Да | Уникальный идентификатор |
| icon | TEXT | Да | Имя иконки |
| title_es | TEXT | Да | Название ES |
| title_ru | TEXT | Да | Название RU |
| description_es | TEXT | Да | Описание ES |
| description_ru | TEXT | Да | Описание RU |
| order_num | INTEGER | Да | Порядок |
| is_active | BOOLEAN | Да | Активна |
### 2.7 Settings (Настройки)
| Поле | Тип | Описание |
|------|-----|----------|
| key | TEXT | Ключ настройки |
| value | TEXT | Значение (JSON) |
**Системные настройки:**
- `site_name` - Название сайта
- `phone` - Телефон
- `whatsapp` - WhatsApp номер
- `email` - Email
- `address` - Адрес (JSON)
- `social` - Социальные сети (JSON)
- `default_map_center` - Центр карты по умолчанию (JSON)
- `default_map_zoom` - Масштаб карты
### 2.8 Analytics Event (Событие аналитики)
| Поле | Тип | Описание |
|------|-----|----------|
| id | TEXT (UUID) | Уникальный идентификатор |
| type | TEXT | Тип события |
| property_id | TEXT | ID объекта (если применимо) |
| session_id | TEXT | ID сессии |
| ip_address | TEXT | IP адрес |
| user_agent | TEXT | User Agent |
| metadata | TEXT (JSON) | Метаданные |
| created_at | TEXT (ISO) | Дата создания |
---
## 3. API Endpoints
### 3.1 Недвижимость
```
GET /api/properties # Список объектов (?type=&status=&city=&minPrice=&maxPrice=&minArea=&maxArea=&page=&limit=)
GET /api/properties/:slug # Детали по slug
GET /api/properties/featured # Рекомендуемые
POST /api/properties # Создать (admin)
PUT /api/properties/:id # Обновить (admin)
DELETE /api/properties/:id # Удалить (admin)
GET /api/properties/search # Поиск (?q=)
```
### 3.2 Заявки
```
GET /api/leads # Список заявок (admin)
GET /api/leads/:id # Детали заявки (admin)
POST /api/leads # Создать заявку (public)
PUT /api/leads/:id # Обновить (admin)
PUT /api/leads/:id/status # Изменить статус (admin)
DELETE /api/leads/:id # Удалить (admin)
```
### 3.3 Контент
```
GET /api/testimonials # Список отзывов
POST /api/testimonials # Создать (admin)
GET /api/faq # Список FAQ
GET /api/services # Список услуг
GET /api/settings # Настройки сайта
```
### 3.4 Аналитика
```
POST /api/analytics/event # Записать событие
GET /api/analytics/stats # Статистика (admin)
```
### 3.5 Авторизация
```
POST /api/auth/login # Вход
POST /api/auth/logout # Выход
GET /api/auth/me # Текущий пользователь
```
---
## 4. Страницы Frontend
### 4.1 Публичные страницы
1. **Landing Page** (`/`)
- Hero секция с CTA
- Преимущества
- Каталог с фильтрами
- Интерактивная карта
- Статистика
- Услуги
- Отзывы
- FAQ
- Контактная форма
- Footer
2. **Страница объекта** (`/property/:slug`)
- Галерея изображений
- Характеристики
- Коммуникации
- Документы
- Карта
- Калькулятор
- Форма запроса
- Похожие объекты
3. **Страница 404** (`/404`)
### 4.2 Админ-панель (`/admin`)
1. **Dashboard**
- Статистика
- Графики
- Последние заявки
2. **Управление объектами**
- Таблица с фильтрами
- CRUD операции
3. **Управление заявками**
- Таблица
- Детали
- Изменение статуса
4. **Управление контентом**
- Отзывы
- FAQ
- Услуги
5. **Настройки**
- Контакты
- Социальные сети
- SEO
---
## 5. Технологии
### Frontend
- HTML5 + CSS3
- Bootstrap 5.3
- Bootstrap Icons
- Leaflet (карты)
- Chart.js (графики)
- DataTables (таблицы)
- AOS (анимации)
- Lightbox2 (галерея)
### Backend
- Bun runtime
- Hono framework
- SQLite база данных
- Zod валидация
### Deployment
- Docker контейнер
- Порт 8080
---
## 6. Требования к проекту
### 6.1 Функциональные требования
1. **Мультиязычность**
- Испанский (ES) - основной
- Русский (RU)
- Переключение без перезагрузки
2. **Каталог недвижимости**
- Фильтрация по типу, цене, площади
- Фильтрация по коммуникациям
- Поиск по названию/городу
- Карта с маркерами объектов
3. **Заявки**
- Форма на странице объекта
- Форма в контактах
- WhatsApp интеграция
- Email уведомления
4. **Админ-панель**
- Авторизация
- CRUD для объектов
- Управление заявками
- Статистика и аналитика
### 6.2 Нефункциональные требования
1. **Производительность**
- Время загрузки страницы < 3 сек
- Время ответа API < 500 мс
2. **Безопасность**
- Хеширование паролей (bcrypt)
- Защита от CSRF
- Валидация входных данных
3. **Доступность**
- Responsive дизайн
- Поддержка мобильных устройств
- SEO оптимизация

View File

@@ -5,34 +5,19 @@
"type": "module",
"scripts": {
"dev": "bun run --watch src/server/index.ts",
"build": "bun build ./public --outdir ./dist",
"start": "bun run src/server/index.ts",
"lint": "eslint src/**/*.ts",
"format": "prettier --write \"src/**/*.ts\" \"public/**/*.{html,css,js}\"",
"typecheck": "tsc --noEmit"
"db:init": "bun run src/db/init.ts",
"db:seed": "bun run src/db/seed.ts",
"build": "bun build ./src/server/index.ts --outdir ./dist --target bun",
"test": "bun test"
},
"keywords": [
"real-estate",
"tenerife",
"property",
"canary-islands",
"land"
],
"author": "UniqueSoft",
"license": "MIT",
"dependencies": {
"hono": "^4.0.0",
"zod": "^3.22.0"
"bcrypt": "^5.1.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.3.0",
"prettier": "^3.2.0",
"eslint": "^8.56.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0"
},
"engines": {
"node": ">=18.0.0"
"typescript": "^5.3.0"
}
}

58
public/css/variables.css Normal file
View File

@@ -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;
}

83
public/js/api.js Normal file
View File

@@ -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;

96
public/js/i18n.js Normal file
View File

@@ -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 || {};

237
src/db/schema.sql Normal file
View File

@@ -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
);

469
src/server/index.ts Normal file
View File

@@ -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<string, string> = {}
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 }