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:
9
.env.example
Normal file
9
.env.example
Normal 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
39
Dockerfile
Normal 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
132
README.md
Normal 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
151
bun.lock
Normal 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
BIN
data/tenerifeprop.db
Normal file
Binary file not shown.
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal 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
390
docs/TZ.md
Normal 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 оптимизация
|
||||
29
package.json
29
package.json
@@ -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
58
public/css/variables.css
Normal 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
83
public/js/api.js
Normal 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
96
public/js/i18n.js
Normal 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
237
src/db/schema.sql
Normal 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
469
src/server/index.ts
Normal 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 }
|
||||
Reference in New Issue
Block a user