diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..7cfe346
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,27 @@
+# Git
+.git/
+.gitignore
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# Kilo config
+.kilo/
+
+# Development files
+*.log
+*.pid
+*.seed
+*.pid.lock
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Temporary files
+tmp/
+temp/
+*.tmp
\ No newline at end of file
diff --git a/.kilo/rules/global.md b/.kilo/rules/global.md
index e062aee..249bb3b 100644
--- a/.kilo/rules/global.md
+++ b/.kilo/rules/global.md
@@ -21,6 +21,9 @@
- NEVER skip git hooks (--no-verify, --no-gpg-sign)
- NEVER use interactive git commands (-i flag)
- NEVER write malicious code or explain malicious code behavior
+- NEVER reboot, restart, or shutdown the host machine
+- NEVER run commands that affect system stability (systemctl reboot, shutdown, init 6, etc.)
+- NEVER modify system-level configurations without explicit permission
## Communication
diff --git a/Dockerfile b/Dockerfile
index d88bfaa..a8313a7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,20 +1,5 @@
-# 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
+# Use Bun image
+FROM oven/bun:1.1.0 AS runner
WORKDIR /app
ENV NODE_ENV=production
@@ -23,17 +8,24 @@ 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
+# Copy node_modules first (from local build)
+COPY node_modules ./node_modules
+
+# Copy source files
+COPY public ./public
+COPY src ./src
+COPY package.json ./
+COPY data ./data
+
+# Bun has fetch built-in, use it for health check via bun command
+# HEALTHCHECK will use bun's built-in fetch
# Expose port
EXPOSE 8080
-# Health check
+# Health check using bun's built-in fetch
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
- CMD curl -f http://localhost:8080/api/settings || exit 1
+ CMD bun -e "fetch('http://localhost:8080/api/settings').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" || exit 1
# Start server
CMD ["bun", "run", "src/server/index.ts"]
\ No newline at end of file
diff --git a/bun.lock b/bun.lock
index d6add0a..89c813f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,6 +10,7 @@
"uuid": "^9.0.0",
},
"devDependencies": {
+ "@types/bcrypt": "^6.0.0",
"@types/bun": "latest",
"typescript": "^5.3.0",
},
@@ -18,6 +19,8 @@
"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/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="],
+
"@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=="],
diff --git a/package.json b/package.json
index dd2c40f..81441a7 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"uuid": "^9.0.0"
},
"devDependencies": {
+ "@types/bcrypt": "^6.0.0",
"@types/bun": "latest",
"typescript": "^5.3.0"
}
diff --git a/public/js/admin.js b/public/js/admin.js
new file mode 100644
index 0000000..46d1cc1
--- /dev/null
+++ b/public/js/admin.js
@@ -0,0 +1,774 @@
+// TenerifeProp - Admin Panel JavaScript
+class AdminPanel {
+ constructor() {
+ this.currentSection = 'dashboard'
+ this.properties = []
+ this.leads = []
+ this.charts = {}
+ this.lang = localStorage.getItem('lang') || 'es'
+ }
+
+ async init() {
+ await this.loadTranslations()
+ this.initSidebar()
+ this.initTopbar()
+ this.initLanguageSwitcher()
+ await this.loadDashboardData()
+ this.initCharts()
+ this.updateUI()
+ }
+
+ async loadTranslations() {
+ try {
+ const [esRes, ruRes] = await Promise.all([
+ fetch('/src/i18n/es.json'),
+ fetch('/src/i18n/ru.json')
+ ])
+
+ window.translations = {
+ es: await esRes.json(),
+ ru: await ruRes.json()
+ }
+
+ if (window.i18n) {
+ window.i18n.translations = window.translations
+ }
+ } catch (e) {
+ console.error('Failed to load translations:', e)
+ }
+ }
+
+ initSidebar() {
+ // Section navigation
+ document.querySelectorAll('.sidebar-link').forEach(link => {
+ link.addEventListener('click', (e) => {
+ e.preventDefault()
+ const section = e.currentTarget.dataset.section
+ this.navigateTo(section)
+ })
+ })
+ }
+
+ navigateTo(section) {
+ this.currentSection = section
+
+ // Update sidebar
+ document.querySelectorAll('.sidebar-link').forEach(link => {
+ link.classList.toggle('active', link.dataset.section === section)
+ })
+
+ // Update sections
+ document.querySelectorAll('.page-section').forEach(sec => {
+ sec.classList.toggle('active', sec.id === `section-${section}`)
+ })
+
+ // Update page title
+ const titles = {
+ dashboard: { title: 'Dashboard', subtitle: 'Resumen del rendimiento de tu negocio' },
+ properties: { title: 'Propiedades', subtitle: 'Gestiona tu inventario de propiedades' },
+ leads: { title: 'Leads', subtitle: 'Administra las solicitudes de clientes' },
+ testimonials: { title: 'Testimonios', subtitle: 'Gestiona las opiniones de clientes' },
+ faq: { title: 'FAQ', subtitle: 'Preguntas frecuentes' },
+ services: { title: 'Servicios', subtitle: 'Lista de servicios ofrecidos' },
+ settings: { title: 'Configuración', subtitle: 'Ajustes del sistema' },
+ analytics: { title: 'Estadísticas', subtitle: 'Análisis del rendimiento' }
+ }
+
+ const t = titles[section] || { title: section, subtitle: '' }
+ const pageTitle = document.querySelector('.page-title')
+ const pageSubtitle = document.querySelector('.page-subtitle')
+
+ if (pageTitle) pageTitle.textContent = this.t(`admin.${section}`, t.title)
+ if (pageSubtitle) pageSubtitle.textContent = this.t(`admin.${section}Subtitle`, t.subtitle)
+
+ // Load section data
+ this.loadSectionData(section)
+ }
+
+ async loadSectionData(section) {
+ switch (section) {
+ case 'dashboard':
+ await this.loadDashboardData()
+ break
+ case 'properties':
+ await this.loadProperties()
+ break
+ case 'leads':
+ await this.loadLeads()
+ break
+ case 'testimonials':
+ await this.loadTestimonials()
+ break
+ case 'faq':
+ await this.loadFAQ()
+ break
+ case 'services':
+ await this.loadServices()
+ break
+ }
+ }
+
+ initTopbar() {
+ // Sidebar toggle
+ const sidebarToggle = document.getElementById('sidebarToggle')
+ const sidebar = document.getElementById('sidebar')
+
+ if (sidebarToggle && sidebar) {
+ sidebarToggle.addEventListener('click', () => {
+ sidebar.classList.toggle('collapsed')
+ })
+ }
+
+ // Global search
+ const globalSearch = document.getElementById('globalSearch')
+ if (globalSearch) {
+ globalSearch.addEventListener('input', debounce((e) => {
+ this.handleGlobalSearch(e.target.value)
+ }, 300))
+ }
+ }
+
+ handleGlobalSearch(query) {
+ if (query.length < 2) return
+
+ // Search across properties and leads
+ console.log('Searching for:', query)
+ // Implementation would show search results dropdown
+ }
+
+ initLanguageSwitcher() {
+ document.querySelectorAll('.topbar-lang button').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const lang = e.target.dataset.lang
+ this.setLanguage(lang)
+ })
+ })
+ }
+
+ setLanguage(lang) {
+ this.lang = lang
+ localStorage.setItem('lang', lang)
+
+ if (window.i18n) {
+ window.i18n.setLanguage(lang)
+ }
+
+ // Update language buttons
+ document.querySelectorAll('.topbar-lang button').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.lang === lang)
+ })
+ }
+
+ t(key, fallback) {
+ if (window.i18n) {
+ return window.i18n.t(key, fallback)
+ }
+ return fallback || key
+ }
+
+ async loadDashboardData() {
+ try {
+ const [statsRes, leadsRes, propertiesRes] = await Promise.all([
+ API.getStats(),
+ API.getLeads({ limit: 5 }),
+ API.getProperties({ limit: 5, lang: this.lang })
+ ])
+
+ if (statsRes.success) {
+ this.updateStatCards(statsRes.data)
+ }
+
+ if (leadsRes.success) {
+ this.leads = leadsRes.data
+ this.updateLeadsTable()
+ }
+
+ if (propertiesRes.success) {
+ this.properties = propertiesRes.data
+ }
+ } catch (e) {
+ console.error('Failed to load dashboard data:', e)
+ }
+ }
+
+ updateStatCards(stats) {
+ const statViews = document.getElementById('statViews')
+ const statLeads = document.getElementById('statLeads')
+ const statProperties = document.getElementById('statProperties')
+
+ if (statViews) statViews.textContent = this.formatNumber(stats.totalViews || 0)
+ if (statLeads) statLeads.textContent = stats.totalLeads || 0
+ if (statProperties) statProperties.textContent = stats.activeProperties || 0
+ }
+
+ formatNumber(num) {
+ if (num >= 1000000) {
+ return (num / 1000000).toFixed(1) + 'M'
+ }
+ if (num >= 1000) {
+ return (num / 1000).toFixed(1) + 'K'
+ }
+ return num.toString()
+ }
+
+ updateLeadsTable() {
+ const tbody = document.querySelector('#leadsTable tbody')
+ if (!tbody) return
+
+ tbody.innerHTML = this.leads.map(lead => `
+
+
+
+ 
+
+ ${lead.name}
+ ${lead.phone || lead.email}
+
+
+ |
+
+
+ 
+
+ ${lead.property_id || 'General inquiry'}
+ ${new Date(lead.created_at).toLocaleDateString()}
+
+
+ |
+ ${lead.source || 'webform'} |
+ ${new Date(lead.created_at).toLocaleDateString()} |
+ ${this.t(`status.${lead.status}`, lead.status)} |
+
+
+
+
+
+
+ |
+
+ `).join('')
+ }
+
+ getSourceColor(source) {
+ const colors = {
+ whatsapp: 'success',
+ webform: 'secondary',
+ email: 'info',
+ phone: 'primary'
+ }
+ return colors[source] || 'secondary'
+ }
+
+ async loadProperties() {
+ try {
+ const res = await API.getProperties({ lang: this.lang, limit: 100 })
+ if (res.success) {
+ this.properties = res.data
+ this.renderPropertiesGrid()
+ }
+ } catch (e) {
+ console.error('Failed to load properties:', e)
+ }
+ }
+
+ renderPropertiesGrid() {
+ const container = document.getElementById('propertiesGrid')
+ if (!container) return
+
+ container.innerHTML = this.properties.map(prop => `
+
+
+
+
[0] : 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=400'})
+
+ ${this.t(`status.${prop.status}`, prop.status)}
+ ${this.t(`property.${prop.type}Land`, prop.type)}
+
+
+
+
+
${prop.title}
+
+
+ ${prop.city}, ${prop.zone || ''}
+
+
+
+
+ ${prop.views_count || 0}
+
+
+
+ ${prop.favorite_count || 0}
+
+
+
+ ${prop.inquiry_count || 0}
+
+
+
+ ${this.formatPrice(prop.price)}
+
+
+
+
+ `).join('')
+ }
+
+ formatPrice(price) {
+ return new Intl.NumberFormat('es-ES', {
+ style: 'currency',
+ currency: 'EUR',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0
+ }).format(price)
+ }
+
+ async loadLeads() {
+ try {
+ const res = await API.getLeads()
+ if (res.success) {
+ this.leads = res.data
+ this.updateLeadsTable()
+ this.updateLeadsChart()
+ }
+ } catch (e) {
+ console.error('Failed to load leads:', e)
+ }
+ }
+
+ updateLeadsChart() {
+ // Implementation for updating leads chart
+ if (this.charts.leadsChart) {
+ const statusCounts = this.leads.reduce((acc, lead) => {
+ acc[lead.status] = (acc[lead.status] || 0) + 1
+ return acc
+ }, {})
+
+ this.charts.leadsChart.data.datasets[0].data = [
+ statusCounts.new || 0,
+ statusCounts.contacted || 0,
+ statusCounts.qualified || 0,
+ statusCounts.negotiating || 0,
+ statusCounts.closed || 0
+ ]
+ this.charts.leadsChart.update()
+ }
+ }
+
+ async loadTestimonials() {
+ try {
+ const res = await API.getTestimonials(this.lang)
+ if (res.success) {
+ this.renderTestimonialsTable(res.data)
+ }
+ } catch (e) {
+ console.error('Failed to load testimonials:', e)
+ }
+ }
+
+ renderTestimonialsTable(testimonials) {
+ const tbody = document.querySelector('#testimonialsTable tbody')
+ if (!tbody) return
+
+ tbody.innerHTML = testimonials.map(t => `
+
+
+
+ 
+
+ ${t.name}
+
+
+ |
+ ${t.location} |
+ ${'★'.repeat(t.rating)}${'☆'.repeat(5 - t.rating)} |
+ ${t.text.substring(0, 50)}... |
+ ${t.is_approved ? 'Aprobado' : 'Pendiente'} |
+
+
+
+
+
+ |
+
+ `).join('')
+ }
+
+ async loadFAQ() {
+ try {
+ const res = await API.getFAQ(this.lang)
+ if (res.success) {
+ this.renderFAQTable(res.data)
+ }
+ } catch (e) {
+ console.error('Failed to load FAQ:', e)
+ }
+ }
+
+ renderFAQTable(faqs) {
+ const tbody = document.querySelector('#faqTable tbody')
+ if (!tbody) return
+
+ tbody.innerHTML = faqs.map(f => `
+
+ | ${f.question} |
+ ${f.answer.substring(0, 100)}... |
+ ${f.is_active ? 'Activo' : 'Inactivo'} |
+
+
+
+
+
+ |
+
+ `).join('')
+ }
+
+ async loadServices() {
+ try {
+ const res = await API.getServices(this.lang)
+ if (res.success) {
+ this.renderServicesTable(res.data)
+ }
+ } catch (e) {
+ console.error('Failed to load services:', e)
+ }
+ }
+
+ renderServicesTable(services) {
+ const tbody = document.querySelector('#servicesTable tbody')
+ if (!tbody) return
+
+ tbody.innerHTML = services.map(s => `
+
+ |
+ ${s.title} |
+ ${s.description.substring(0, 100)}... |
+ ${s.is_active ? 'Activo' : 'Inactivo'} |
+
+
+
+
+
+ |
+
+ `).join('')
+ }
+
+ initCharts() {
+ // Performance Chart
+ const performanceCtx = document.getElementById('performanceChart')?.getContext('2d')
+ if (performanceCtx && typeof Chart !== 'undefined') {
+ this.charts.performance = new Chart(performanceCtx, {
+ type: 'line',
+ data: {
+ labels: ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun'],
+ datasets: [{
+ label: 'Vistas',
+ data: [1200, 1900, 3000, 2500, 2800, 3200],
+ borderColor: '#1a5f4a',
+ backgroundColor: 'rgba(26, 95, 74, 0.1)',
+ tension: 0.4,
+ fill: true
+ }, {
+ label: 'Leads',
+ data: [20, 35, 50, 45, 60, 75],
+ borderColor: '#d4a853',
+ backgroundColor: 'rgba(212, 168, 83, 0.1)',
+ tension: 0.4,
+ fill: true
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'bottom'
+ }
+ },
+ scales: {
+ y: {
+ beginAtZero: true
+ }
+ }
+ }
+ })
+ }
+
+ // Traffic Chart
+ const trafficCtx = document.getElementById('trafficChart')?.getContext('2d')
+ if (trafficCtx && typeof Chart !== 'undefined') {
+ this.charts.traffic = new Chart(trafficCtx, {
+ type: 'doughnut',
+ data: {
+ labels: ['Directo', 'Búsqueda', 'Social', 'Referido', 'Email'],
+ datasets: [{
+ data: [35, 30, 20, 10, 5],
+ backgroundColor: [
+ '#1a5f4a',
+ '#d4a853',
+ '#e85d04',
+ '#3b82f6',
+ '#6c757d'
+ ]
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'bottom'
+ }
+ }
+ }
+ })
+ }
+
+ // Types Chart
+ const typesCtx = document.getElementById('typesChart')?.getContext('2d')
+ if (typesCtx && typeof Chart !== 'undefined') {
+ this.charts.types = new Chart(typesCtx, {
+ type: 'bar',
+ data: {
+ labels: ['Urbano', 'Agrícola', 'Casa', 'Apartamento', 'Ruinas'],
+ datasets: [{
+ label: 'Propiedades',
+ data: [15, 12, 8, 5, 3],
+ backgroundColor: '#1a5f4a'
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: false
+ }
+ }
+ }
+ })
+ }
+
+ // Leads Status Chart
+ const leadsCtx = document.getElementById('leadsChart')?.getContext('2d')
+ if (leadsCtx && typeof Chart !== 'undefined') {
+ this.charts.leadsChart = new Chart(leadsCtx, {
+ type: 'doughnut',
+ data: {
+ labels: ['Nuevo', 'Contactado', 'Calificado', 'Negociando', 'Cerrado'],
+ datasets: [{
+ data: [5, 3, 2, 1, 1],
+ backgroundColor: [
+ '#3b82f6',
+ '#f59e0b',
+ '#10b981',
+ '#8b5cf6',
+ '#1a5f4a'
+ ]
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'bottom'
+ }
+ }
+ }
+ })
+ }
+
+ // Top Properties Chart
+ const topCtx = document.getElementById('topPropertiesChart')?.getContext('2d')
+ if (topCtx && typeof Chart !== 'undefined') {
+ this.charts.top = new Chart(topCtx, {
+ type: 'bar',
+ data: {
+ labels: ['TP-001', 'TP-003', 'TP-002', 'TP-005', 'TP-004'],
+ datasets: [{
+ label: 'Vistas',
+ data: [2345, 1876, 892, 654, 432],
+ backgroundColor: '#d4a853'
+ }]
+ },
+ options: {
+ indexAxis: 'y',
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: false
+ }
+ }
+ }
+ })
+ }
+ }
+
+ // CRUD Operations
+ viewLead(id) {
+ const lead = this.leads.find(l => l.id === id)
+ if (!lead) return
+
+ // Show lead details modal
+ console.log('View lead:', lead)
+ }
+
+ editLead(id) {
+ const lead = this.leads.find(l => l.id === id)
+ if (!lead) return
+
+ // Show edit lead modal
+ console.log('Edit lead:', lead)
+ }
+
+ async deleteLead(id) {
+ if (!confirm('¿Está seguro de eliminar este lead?')) return
+
+ try {
+ // API call to delete
+ console.log('Deleting lead:', id)
+ this.leads = this.leads.filter(l => l.id !== id)
+ this.updateLeadsTable()
+ } catch (e) {
+ console.error('Failed to delete lead:', e)
+ }
+ }
+
+ editProperty(id) {
+ const property = this.properties.find(p => p.id === id)
+ if (!property) return
+
+ // Show edit property modal
+ console.log('Edit property:', property)
+ }
+
+ async deleteProperty(id) {
+ if (!confirm('¿Está seguro de eliminar esta propiedad?')) return
+
+ try {
+ // API call to delete
+ console.log('Deleting property:', id)
+ this.properties = this.properties.filter(p => p.id !== id)
+ this.renderPropertiesGrid()
+ } catch (e) {
+ console.error('Failed to delete property:', e)
+ }
+ }
+
+ editTestimonial(id) {
+ console.log('Edit testimonial:', id)
+ }
+
+ deleteTestimonial(id) {
+ if (!confirm('¿Está seguro de eliminar este testimonio?')) return
+ console.log('Deleting testimonial:', id)
+ }
+
+ editFAQ(id) {
+ console.log('Edit FAQ:', id)
+ }
+
+ deleteFAQ(id) {
+ if (!confirm('¿Está seguro de eliminar esta pregunta?')) return
+ console.log('Deleting FAQ:', id)
+ }
+
+ editService(id) {
+ console.log('Edit service:', id)
+ }
+
+ deleteService(id) {
+ if (!confirm('¿Está seguro de eliminar este servicio?')) return
+ console.log('Deleting service:', id)
+ }
+
+ // Modal handlers
+ showPropertyModal(property = null) {
+ // Implementation
+ }
+
+ showLeadModal(lead = null) {
+ // Implementation
+ }
+
+ showNotification(message, type = 'info') {
+ const notification = document.createElement('div')
+ notification.className = `alert alert-${type === 'success' ? 'success' : type === 'error' ? 'danger' : 'info'} alert-dismissible fade show position-fixed`
+ notification.style.cssText = 'top: 100px; right: 30px; z-index: 9999; max-width: 400px;'
+ notification.innerHTML = `
+ ${message}
+
+ `
+ document.body.appendChild(notification)
+
+ setTimeout(() => {
+ notification.remove()
+ }, 5000)
+ }
+
+ updateUI() {
+ // Update language buttons
+ document.querySelectorAll('.topbar-lang button').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.lang === this.lang)
+ })
+
+ // Update lead count badge
+ const leadsCountBadge = document.getElementById('leadsCount')
+ if (leadsCountBadge && this.leads.length > 0) {
+ const newLeads = this.leads.filter(l => l.status === 'new').length
+ leadsCountBadge.textContent = newLeads
+ }
+ }
+}
+
+// Utility function
+function debounce(func, wait) {
+ let timeout
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout)
+ func(...args)
+ }
+ clearTimeout(timeout)
+ timeout = setTimeout(later, wait)
+ }
+}
+
+// Initialize admin panel when DOM is ready
+document.addEventListener('DOMContentLoaded', () => {
+ window.admin = new AdminPanel()
+ admin.init()
+})
\ No newline at end of file
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..5eedb05
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,627 @@
+// TenerifeProp - Main Application JavaScript
+class TenerifeProp {
+ constructor() {
+ this.properties = []
+ this.filteredProperties = []
+ this.currentFilter = 'all'
+ this.map = null
+ this.markers = []
+ this.settings = {}
+ this.lang = localStorage.getItem('lang') || 'es'
+ }
+
+ async init() {
+ await Promise.all([
+ this.loadTranslations(),
+ this.loadSettings(),
+ this.loadProperties()
+ ])
+
+ this.initMap()
+ this.initFilters()
+ this.initLanguageSwitcher()
+ this.initForms()
+ this.initAnimations()
+ this.updateUI()
+ }
+
+ async loadTranslations() {
+ try {
+ const [esRes, ruRes] = await Promise.all([
+ fetch('/src/i18n/es.json'),
+ fetch('/src/i18n/ru.json')
+ ])
+
+ window.translations = {
+ es: await esRes.json(),
+ ru: await ruRes.json()
+ }
+
+ if (window.i18n) {
+ window.i18n.translations = window.translations
+ }
+ } catch (e) {
+ console.error('Failed to load translations:', e)
+ }
+ }
+
+ async loadSettings() {
+ try {
+ const res = await API.getSettings()
+ if (res.success) {
+ this.settings = res.data
+ }
+ } catch (e) {
+ console.error('Failed to load settings:', e)
+ }
+ }
+
+ async loadProperties() {
+ try {
+ const res = await API.getProperties({ lang: this.lang, limit: 50 })
+ if (res.success) {
+ this.properties = res.data
+ this.filteredProperties = res.data
+ this.renderProperties()
+ this.updateMapMarkers()
+ }
+ } catch (e) {
+ console.error('Failed to load properties:', e)
+ }
+ }
+
+ initMap() {
+ const mapContainer = document.getElementById('map')
+ if (!mapContainer) return
+
+ const defaultCenter = this.settings.default_map_center || { lat: 28.1227, lng: -16.6942 }
+ const defaultZoom = this.settings.default_map_zoom || 11
+
+ this.map = L.map('map').setView([defaultCenter.lat, defaultCenter.lng], defaultZoom)
+
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap contributors'
+ }).addTo(this.map)
+ }
+
+ updateMapMarkers() {
+ if (!this.map) return
+
+ // Clear existing markers
+ this.markers.forEach(m => this.map.removeLayer(m))
+ this.markers = []
+
+ // Add markers for filtered properties
+ this.filteredProperties.forEach(prop => {
+ if (!prop.lat || !prop.lng) return
+
+ const markerIcon = L.divIcon({
+ className: 'custom-marker',
+ html: ``,
+ iconSize: [30, 30]
+ })
+
+ const marker = L.marker([prop.lat, prop.lng], { icon: markerIcon })
+ .addTo(this.map)
+
+ const image = prop.images ? JSON.parse(prop.images)[0] : 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=200&q=60'
+ const title = prop.title || 'Property'
+ const price = this.formatPrice(prop.price)
+
+ marker.bindPopup(`
+
+ `)
+
+ this.markers.push(marker)
+ })
+ }
+
+ initFilters() {
+ // Catalog tabs
+ document.querySelectorAll('.catalog-tab').forEach(tab => {
+ tab.addEventListener('click', (e) => {
+ const filter = e.target.dataset.filter
+ this.setFilter(filter)
+ })
+ })
+
+ // Price filters
+ const priceMin = document.getElementById('priceMin')
+ const priceMax = document.getElementById('priceMax')
+ const areaMin = document.getElementById('areaMin')
+ const areaMax = document.getElementById('areaMax')
+
+ if (priceMin) priceMin.addEventListener('change', () => this.applyFilters())
+ if (priceMax) priceMax.addEventListener('change', () => this.applyFilters())
+ if (areaMin) areaMin.addEventListener('change', () => this.applyFilters())
+ if (areaMax) areaMax.addEventListener('change', () => this.applyFilters())
+
+ // Utility checkboxes
+ const filterWater = document.getElementById('filterWater')
+ const filterElectricity = document.getElementById('filterElectricity')
+ const filterRoad = document.getElementById('filterRoad')
+
+ if (filterWater) filterWater.addEventListener('change', () => this.applyFilters())
+ if (filterElectricity) filterElectricity.addEventListener('change', () => this.applyFilters())
+ if (filterRoad) filterRoad.addEventListener('change', () => this.applyFilters())
+
+ // Feature checkboxes
+ const filterRuins = document.getElementById('filterRuins')
+ const filterLicense = document.getElementById('filterLicense')
+ const filterSeaView = document.getElementById('filterSeaView')
+
+ if (filterRuins) filterRuins.addEventListener('change', () => this.applyFilters())
+ if (filterLicense) filterLicense.addEventListener('change', () => this.applyFilters())
+ if (filterSeaView) filterSeaView.addEventListener('change', () => this.applyFilters())
+ }
+
+ setFilter(filter) {
+ this.currentFilter = filter
+
+ // Update active tab
+ document.querySelectorAll('.catalog-tab').forEach(tab => {
+ tab.classList.toggle('active', tab.dataset.filter === filter)
+ })
+
+ this.applyFilters()
+ }
+
+ applyFilters() {
+ let filtered = [...this.properties]
+
+ // Type filter
+ if (this.currentFilter !== 'all') {
+ filtered = filtered.filter(p => p.type === this.currentFilter)
+ }
+
+ // Price filter
+ const priceMin = parseInt(document.getElementById('priceMin')?.value || 0)
+ const priceMax = parseInt(document.getElementById('priceMax')?.value || 999999999)
+ filtered = filtered.filter(p => p.price >= priceMin && p.price <= priceMax)
+
+ // Area filter
+ const areaMin = parseInt(document.getElementById('areaMin')?.value || 0)
+ const areaMax = parseInt(document.getElementById('areaMax')?.value || 999999999)
+ filtered = filtered.filter(p => p.area >= areaMin && p.area <= areaMax)
+
+ // Utility filters
+ if (document.getElementById('filterWater')?.checked) {
+ filtered = filtered.filter(p => p.water === 'available')
+ }
+ if (document.getElementById('filterElectricity')?.checked) {
+ filtered = filtered.filter(p => p.electricity === 'available')
+ }
+ if (document.getElementById('filterRoad')?.checked) {
+ filtered = filtered.filter(p => p.road === 'asphalt')
+ }
+
+ // Feature filters
+ if (document.getElementById('filterRuins')?.checked) {
+ filtered = filtered.filter(p => p.has_ruins === 1)
+ }
+ if (document.getElementById('filterLicense')?.checked) {
+ filtered = filtered.filter(p => p.has_license === 1)
+ }
+ if (document.getElementById('filterSeaView')?.checked) {
+ filtered = filtered.filter(p => p.views_sea === 1)
+ }
+
+ this.filteredProperties = filtered
+ this.renderProperties()
+ this.updateMapMarkers()
+ }
+
+ renderProperties() {
+ const container = document.getElementById('propertiesGrid')
+ if (!container) return
+
+ if (this.filteredProperties.length === 0) {
+ container.innerHTML = `
+
+
+
${this.t('catalog.noResults', 'No se encontraron propiedades')}
+
${this.t('catalog.tryFilters', 'Intenta ajustar los filtros')}
+
+ `
+ return
+ }
+
+ container.innerHTML = this.filteredProperties.map(prop => this.renderPropertyCard(prop)).join('')
+ }
+
+ renderPropertyCard(prop) {
+ const images = prop.images ? JSON.parse(prop.images) : []
+ const mainImage = images[0]?.url || 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=1920&q=80'
+ const badges = prop.badges ? JSON.parse(prop.badges) : []
+
+ const typeLabels = {
+ agricultural: this.t('type.agricultural', 'Agrícola'),
+ urban: this.t('type.urban', 'Urbano'),
+ house: this.t('type.house', 'Casa'),
+ apartment: this.t('type.apartment', 'Apartamento'),
+ ruins: this.t('type.ruins', 'Ruinas')
+ }
+
+ const typeBadgeClasses = {
+ agricultural: 'badge-agricultural',
+ urban: 'badge-urban',
+ house: 'badge-house',
+ apartment: 'badge-apartment',
+ ruins: 'badge-ruins'
+ }
+
+ const price = this.formatPrice(prop.price)
+ const area = prop.area.toLocaleString()
+
+ return `
+
+
+
+
+
+
+
+
+ ${typeLabels[prop.type] || prop.type}
+
+ ${prop.is_featured ? 'Destacado' : ''}
+ ${prop.is_exclusive ? 'Exclusivo' : ''}
+
+
+
+
+
${typeLabels[prop.type] || prop.type}
+
${prop.title}
+
+
+ ${prop.city}, ${prop.zone || prop.province}
+
+
+
+
+ ${area} m²
+
+ ${prop.bedrooms ? `
+
+
+ ${prop.bedrooms} ${this.t('property.bedrooms', 'hab.')}
+
+ ` : ''}
+ ${prop.bathrooms ? `
+
+
+ ${prop.bathrooms} ${this.t('property.bathrooms', 'baños')}
+
+ ` : ''}
+
+
+ ${prop.water === 'available' ? '
' : ''}
+ ${prop.electricity === 'available' ? '
' : ''}
+ ${prop.road === 'asphalt' ? '
' : ''}
+
+
+ ${price}
+ ${Math.round(prop.price / prop.area)} €/m²
+
+
+
+
+
+ `
+ }
+
+ formatPrice(price) {
+ return new Intl.NumberFormat('es-ES', {
+ style: 'currency',
+ currency: 'EUR',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0
+ }).format(price)
+ }
+
+ toggleFavorite(id) {
+ const favorites = JSON.parse(localStorage.getItem('favorites') || '[]')
+ const index = favorites.indexOf(id)
+
+ if (index === -1) {
+ favorites.push(id)
+ } else {
+ favorites.splice(index, 1)
+ }
+
+ localStorage.setItem('favorites', JSON.stringify(favorites))
+ this.renderProperties()
+
+ // Track analytics
+ API.trackEvent('favorite_toggle', { propertyId: id, action: index === -1 ? 'add' : 'remove' })
+ }
+
+ isFavorite(id) {
+ const favorites = JSON.parse(localStorage.getItem('favorites') || '[]')
+ return favorites.includes(id)
+ }
+
+ initLanguageSwitcher() {
+ document.querySelectorAll('.lang-btn, .lang-switcher button').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const lang = e.target.dataset.lang || e.target.textContent.toLowerCase()
+ this.setLanguage(lang)
+ })
+ })
+ }
+
+ setLanguage(lang) {
+ this.lang = lang
+ localStorage.setItem('lang', lang)
+
+ if (window.i18n) {
+ window.i18n.setLanguage(lang)
+ }
+
+ // Reload properties with new language
+ this.loadProperties()
+ }
+
+ t(key, fallback) {
+ if (window.i18n) {
+ return window.i18n.t(key, fallback)
+ }
+ return fallback || key
+ }
+
+ initForms() {
+ // Contact form
+ const contactForm = document.getElementById('contactForm')
+ if (contactForm) {
+ contactForm.addEventListener('submit', async (e) => {
+ e.preventDefault()
+ await this.handleContactForm(contactForm)
+ })
+ }
+
+ // Lead form
+ const leadForm = document.getElementById('leadForm')
+ if (leadForm) {
+ leadForm.addEventListener('submit', async (e) => {
+ e.preventDefault()
+ await this.handleLeadForm(leadForm)
+ })
+ }
+ }
+
+ async handleContactForm(form) {
+ const formData = new FormData(form)
+ const data = {
+ name: formData.get('name'),
+ email: formData.get('email'),
+ phone: formData.get('phone'),
+ message: formData.get('message'),
+ language: this.lang
+ }
+
+ try {
+ const res = await API.createLead(data)
+ if (res.success) {
+ this.showNotification(this.t('form.success', '¡Mensaje enviado! Nos pondremos en contacto pronto.'), 'success')
+ form.reset()
+ } else {
+ this.showNotification(this.t('form.error', 'Error al enviar. Inténtelo de nuevo.'), 'error')
+ }
+ } catch (e) {
+ this.showNotification(this.t('form.error', 'Error al enviar. Inténtelo de nuevo.'), 'error')
+ }
+ }
+
+ async handleLeadForm(form) {
+ const formData = new FormData(form)
+ const data = {
+ name: formData.get('name'),
+ email: formData.get('email'),
+ phone: formData.get('phone'),
+ message: formData.get('message'),
+ property_id: formData.get('property_id'),
+ language: this.lang
+ }
+
+ try {
+ const res = await API.createLead(data)
+ if (res.success) {
+ this.showNotification(this.t('form.success', '¡Solicitud enviada!'), 'success')
+ form.reset()
+ } else {
+ this.showNotification(this.t('form.error', 'Error al enviar.'), 'error')
+ }
+ } catch (e) {
+ this.showNotification(this.t('form.error', 'Error al enviar.'), 'error')
+ }
+ }
+
+ showNotification(message, type = 'info') {
+ const notification = document.createElement('div')
+ notification.className = `alert alert-${type === 'success' ? 'success' : type === 'error' ? 'danger' : 'info'} alert-dismissible fade show position-fixed`
+ notification.style.cssText = 'top: 100px; right: 30px; z-index: 9999; max-width: 400px;'
+ notification.innerHTML = `
+ ${message}
+
+ `
+ document.body.appendChild(notification)
+
+ setTimeout(() => {
+ notification.remove()
+ }, 5000)
+ }
+
+ initAnimations() {
+ // Init AOS if available
+ if (typeof AOS !== 'undefined') {
+ AOS.init({
+ duration: 800,
+ easing: 'ease-out',
+ once: true
+ })
+ }
+
+ // Navbar scroll effect
+ window.addEventListener('scroll', () => {
+ const navbar = document.querySelector('.navbar')
+ if (navbar) {
+ navbar.classList.toggle('scrolled', window.scrollY > 50)
+ }
+ })
+ }
+
+ updateUI() {
+ // Update stats
+ this.updateStats()
+
+ // Load testimonials
+ this.loadTestimonials()
+
+ // Load FAQ
+ this.loadFAQ()
+
+ // Load services
+ this.loadServices()
+
+ // Update language buttons
+ document.querySelectorAll('.lang-btn, .lang-switcher button').forEach(btn => {
+ const btnLang = btn.dataset.lang || btn.textContent.toLowerCase()
+ btn.classList.toggle('active', btnLang === this.lang)
+ })
+ }
+
+ async updateStats() {
+ try {
+ const res = await API.getStats()
+ if (res.success) {
+ document.getElementById('statViews')?.textContent = this.formatNumber(res.data.totalViews)
+ document.getElementById('statLeads')?.textContent = this.formatNumber(res.data.totalLeads)
+ document.getElementById('statProperties')?.textContent = this.formatNumber(res.data.activeProperties)
+ }
+ } catch (e) {
+ console.error('Failed to load stats:', e)
+ }
+ }
+
+ formatNumber(num) {
+ if (num >= 1000000) {
+ return (num / 1000000).toFixed(1) + 'M'
+ }
+ if (num >= 1000) {
+ return (num / 1000).toFixed(1) + 'K'
+ }
+ return num.toString()
+ }
+
+ async loadTestimonials() {
+ const container = document.getElementById('testimonialsContainer')
+ if (!container) return
+
+ try {
+ const res = await API.getTestimonials(this.lang)
+ if (res.success && res.data.length > 0) {
+ container.innerHTML = res.data.map(t => `
+
+
+ ${Array(5).fill(0).map((_, i) => ``).join('')}
+
+
"${t.text}"
+
+

+
+
${t.name}
+
${t.location}
+
+
+
+ `).join('')
+ }
+ } catch (e) {
+ console.error('Failed to load testimonials:', e)
+ }
+ }
+
+ async loadFAQ() {
+ const container = document.getElementById('faqContainer')
+ if (!container) return
+
+ try {
+ const res = await API.getFAQ(this.lang)
+ if (res.success && res.data.length > 0) {
+ container.innerHTML = res.data.map((f, i) => `
+
+ `).join('')
+ }
+ } catch (e) {
+ console.error('Failed to load FAQ:', e)
+ }
+ }
+
+ async loadServices() {
+ const container = document.getElementById('servicesContainer')
+ if (!container) return
+
+ try {
+ const res = await API.getServices(this.lang)
+ if (res.success && res.data.length > 0) {
+ container.innerHTML = res.data.map(s => `
+
+
+
+
+
+
${s.title}
+
${s.description}
+
+
+ `).join('')
+ }
+ } catch (e) {
+ console.error('Failed to load services:', e)
+ }
+ }
+
+ // Smooth scroll for anchor links
+ initSmoothScroll() {
+ document.querySelectorAll('a[href^="#"]').forEach(anchor => {
+ anchor.addEventListener('click', (e) => {
+ e.preventDefault()
+ const target = document.querySelector(anchor.getAttribute('href'))
+ if (target) {
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ }
+ })
+ })
+ }
+}
+
+// Initialize app when DOM is ready
+document.addEventListener('DOMContentLoaded', () => {
+ window.app = new TenerifeProp()
+ app.init()
+})
\ No newline at end of file
diff --git a/public/js/property.js b/public/js/property.js
new file mode 100644
index 0000000..e20d685
--- /dev/null
+++ b/public/js/property.js
@@ -0,0 +1,703 @@
+// TenerifeProp - Property Page JavaScript
+class PropertyPage {
+ constructor() {
+ this.property = null
+ this.currentImageIndex = 0
+ this.images = []
+ this.lang = localStorage.getItem('lang') || 'es'
+ this.similarProperties = []
+ }
+
+ async init() {
+ await this.loadTranslations()
+ const slug = this.getPropertySlug()
+ if (slug) {
+ await this.loadProperty(slug)
+ }
+ this.initGallery()
+ this.initMap()
+ this.initCalculator()
+ this.initTabs()
+ this.initLanguageSwitcher()
+ this.initForms()
+ this.updateUI()
+ }
+
+ getPropertySlug() {
+ const path = window.location.pathname
+ const match = path.match(/\/property\/([^/]+)/)
+ return match ? match[1] : null
+ }
+
+ async loadTranslations() {
+ try {
+ const [esRes, ruRes] = await Promise.all([
+ fetch('/src/i18n/es.json'),
+ fetch('/src/i18n/ru.json')
+ ])
+
+ window.translations = {
+ es: await esRes.json(),
+ ru: await ruRes.json()
+ }
+
+ if (window.i18n) {
+ window.i18n.translations = window.translations
+ }
+ } catch (e) {
+ console.error('Failed to load translations:', e)
+ }
+ }
+
+ async loadProperty(slug) {
+ try {
+ const res = await API.getProperty(slug, this.lang)
+ if (res.success) {
+ this.property = res.data
+ this.updatePage()
+ await this.loadSimilarProperties()
+ } else {
+ this.showError('Property not found')
+ }
+ } catch (e) {
+ console.error('Failed to load property:', e)
+ this.showError('Failed to load property')
+ }
+ }
+
+ updatePage() {
+ if (!this.property) return
+
+ // Update SEO
+ document.title = this.property.meta_title || `${this.property.title} | TenerifeProp`
+ const metaDesc = document.querySelector('meta[name="description"]')
+ if (metaDesc) {
+ metaDesc.setAttribute('content', this.property.meta_description || this.property.description?.substring(0, 160))
+ }
+
+ // Update gallery
+ this.images = this.property.images ? JSON.parse(this.property.images) : []
+ this.updateGallery()
+
+ // Update title and location
+ const galleryTitle = document.getElementById('galleryTitle')
+ const galleryLocation = document.getElementById('galleryLocation')
+ const galleryPrice = document.getElementById('galleryPrice')
+ const galleryPriceM2 = document.getElementById('galleryPriceM2')
+
+ if (galleryTitle) galleryTitle.textContent = this.property.title
+ if (galleryLocation) galleryLocation.innerHTML = `${this.property.city}, ${this.property.zone || ''}`
+ if (galleryPrice) galleryPrice.textContent = this.formatPrice(this.property.price)
+ if (galleryPriceM2) galleryPriceM2.textContent = `${Math.round(this.property.price / this.property.area)} €/m²`
+
+ // Update property header
+ const propertyTitle = document.getElementById('propertyTitle')
+ const propertyLocation = document.getElementById('propertyLocation')
+ const quickArea = document.getElementById('quickArea')
+ const propertyPrice = document.getElementById('propertyPrice')
+ const pricePerM2 = document.getElementById('pricePerM2')
+
+ if (propertyTitle) propertyTitle.textContent = this.property.title
+ if (propertyLocation) propertyLocation.innerHTML = `${this.property.address}, ${this.property.city}, ${this.property.zone || ''}`
+ if (quickArea) quickArea.textContent = `${this.property.area.toLocaleString()} m²`
+ if (propertyPrice) propertyPrice.textContent = this.formatPrice(this.property.price)
+ if (pricePerM2) pricePerM2.textContent = `${Math.round(this.property.price / this.property.area)} €/m²`
+
+ // Update description
+ const descText1 = document.getElementById('descriptionText1')
+ const descText2 = document.getElementById('descriptionText2')
+
+ if (descText1 && this.property.description) {
+ const paragraphs = this.property.description.split('\n\n')
+ descText1.textContent = paragraphs[0] || ''
+ if (descText2) descText2.textContent = paragraphs[1] || ''
+ }
+
+ // Update badges
+ const mainBadge = document.getElementById('mainBadge')
+ if (mainBadge && this.property.is_exclusive) {
+ mainBadge.innerHTML = 'Exclusivo'
+ mainBadge.className = 'gallery-badge exclusive'
+ }
+
+ // Update features
+ this.updateFeatures()
+
+ // Update utilities
+ this.updateUtilities()
+
+ // Update documents
+ this.updateDocuments()
+
+ // Track view
+ API.trackEvent('property_view', { propertyId: this.property.id, slug: this.property.slug })
+ }
+
+ updateGallery() {
+ if (this.images.length === 0) {
+ // Use placeholder images
+ this.images = [
+ { url: 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=1920&q=80', alt: 'Vista principal' },
+ { url: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=1920&q=80', alt: 'Vista 2' },
+ { url: 'https://images.unsplash.com/photo-1500382017468-9049fed747ef?w=1920&q=80', alt: 'Vista 3' }
+ ]
+ }
+
+ const mainImage = document.getElementById('mainImage')
+ if (mainImage) {
+ mainImage.src = this.images[0]?.url
+ mainImage.alt = this.images[0]?.alt || this.property?.title
+ }
+
+ const thumbnails = document.querySelector('.gallery-thumbnails')
+ if (thumbnails) {
+ thumbnails.innerHTML = this.images.map((img, i) => `
+
+
.replace('q=80', 'q=60')})
+
+ `).join('')
+ }
+ }
+
+ setMainImage(index) {
+ if (index < 0 || index >= this.images.length) return
+ this.currentImageIndex = index
+
+ const mainImage = document.getElementById('mainImage')
+ if (mainImage) {
+ mainImage.src = this.images[index].url
+ }
+
+ document.querySelectorAll('.gallery-thumb').forEach((thumb, i) => {
+ thumb.classList.toggle('active', i === index)
+ })
+ }
+
+ prevImage() {
+ const newIndex = this.currentImageIndex > 0 ? this.currentImageIndex - 1 : this.images.length - 1
+ this.setMainImage(newIndex)
+ }
+
+ nextImage() {
+ const newIndex = this.currentImageIndex < this.images.length - 1 ? this.currentImageIndex + 1 : 0
+ this.setMainImage(newIndex)
+ }
+
+ updateFeatures() {
+ const featuresGrid = document.querySelector('#featuresTab .features-grid')
+ if (!featuresGrid || !this.property) return
+
+ const features = [
+ { icon: 'bi-rulers', label: `Superficie: ${this.property.area?.toLocaleString()} m²` },
+ { icon: 'bi-grid-3x3', label: `Tipo: ${this.getTypeLabel(this.property.type)}` },
+ { icon: 'bi-building', label: `Edificabilidad: ${this.property.buildability_ratio || 0.2} m²/m²` },
+ { icon: 'bi-ruler-combined', label: `Altura máx.: ${this.property.max_floors || 2} plantas` },
+ { icon: 'bi-sun', label: `Orientación: ${this.getOrientationLabel(this.property.orientation)}` },
+ { icon: 'bi-eye', label: `Vistas: ${this.getViewsLabel(this.property)}` },
+ { icon: 'bi-tree', label: `Topografía: ${this.getTopographyLabel(this.property.topography)}` },
+ { icon: 'bi-car-front', label: `Acceso: ${this.getRoadLabel(this.property.road)}` }
+ ]
+
+ if (this.property.has_license) {
+ features.push({ icon: 'bi-calendar-plus', label: 'Licencia obras: Vigente' })
+ }
+
+ if (this.property.reference) {
+ features.push({ icon: 'bi-wallet2', label: `Ref: ${this.property.reference}` })
+ }
+
+ featuresGrid.innerHTML = features.map(f => `
+
+
+ ${f.label}
+
+ `).join('')
+ }
+
+ updateUtilities() {
+ const utilitiesTab = document.getElementById('utilitiesTab')
+ if (!utilitiesTab || !this.property) return
+
+ const utilities = [
+ { key: 'water', icon: 'bi-droplet-fill', label: 'Agua Potable' },
+ { key: 'electricity', icon: 'bi-lightning-fill', label: 'Electricidad' },
+ { key: 'phone', icon: 'bi-phone-fill', label: 'Teléfono' },
+ { key: 'drainage', icon: 'bi-droplet', label: 'Alcantarillado' },
+ { key: 'road', icon: 'bi-car-front-fill', label: 'Acceso Rodado' },
+ { key: 'gas', icon: 'bi-geo-alt', label: 'Gas Natural' }
+ ]
+
+ const row = utilitiesTab.querySelector('.row')
+ if (row) {
+ row.innerHTML = utilities.map(u => {
+ const status = this.property[u.key]
+ const statusClass = status === 'available' || status === 'asphalt' ? 'available' : status === 'planned' || status === 'nearby' ? 'planned' : 'unavailable'
+ const statusLabel = this.getUtilityStatusLabel(status)
+
+ return `
+
+
+
+
+
+
${u.label}
+
${statusLabel}
+
+
+ `
+ }).join('')
+ }
+ }
+
+ updateDocuments() {
+ const documentsTab = document.getElementById('documentsTab')
+ if (!documentsTab || !this.property) return
+
+ const documents = this.property.documents ? JSON.parse(this.property.documents) : [
+ { type: 'escritura', name: 'Escritura Pública', status: 'complete' },
+ { type: 'catastro', name: 'Referencia Catastral', status: 'complete' }
+ ]
+
+ documentsTab.innerHTML = documents.map(doc => `
+
+
+
+
${doc.name}
+
${doc.description || ''}
+
+
${this.getDocumentStatusLabel(doc.status)}
+
+ `).join('')
+ }
+
+ getDocumentIcon(type) {
+ const icons = {
+ escritura: 'bi-file-earmark-check',
+ catastro: 'bi-geo-alt',
+ license: 'bi-file-text',
+ plan: 'bi-building',
+ certificate: 'bi-file-earmark-pdf'
+ }
+ return icons[type] || 'bi-file-earmark'
+ }
+
+ getDocumentStatusLabel(status) {
+ const labels = {
+ complete: 'Completo',
+ pending: 'Pendiente',
+ missing: 'Falta'
+ }
+ return labels[status] || status
+ }
+
+ getTypeLabel(type) {
+ const labels = {
+ agricultural: 'Agrícola',
+ urban: 'Urbano',
+ house: 'Casa',
+ apartment: 'Apartamento',
+ ruins: 'Ruinas'
+ }
+ return labels[type] || type
+ }
+
+ getOrientationLabel(orientation) {
+ const labels = {
+ north: 'Norte',
+ south: 'Sur',
+ east: 'Este',
+ west: 'Oeste'
+ }
+ return labels[orientation] || orientation || 'Sur'
+ }
+
+ getViewsLabel(prop) {
+ const views = []
+ if (prop.views_sea) views.push('mar')
+ if (prop.views_mountain) views.push('montaña')
+ if (prop.views_valley) views.push('valle')
+ return views.length > 0 ? views.join(', ') : '—'
+ }
+
+ getTopographyLabel(topography) {
+ const labels = {
+ flat: 'Llana',
+ slope: 'En pendiente',
+ terraced: 'En terrazas'
+ }
+ return labels[topography] || topography || 'Llana'
+ }
+
+ getRoadLabel(road) {
+ const labels = {
+ asphalt: 'Carretera asfaltada',
+ dirt: 'Camino de tierra',
+ planned: 'Planeado'
+ }
+ return labels[road] || road || '—'
+ }
+
+ getUtilityStatusLabel(status) {
+ const labels = {
+ available: 'Disponible',
+ unavailable: 'No disponible',
+ planned: 'En proyecto',
+ nearby: 'Cerca'
+ }
+ return labels[status] || status
+ }
+
+ formatPrice(price) {
+ return new Intl.NumberFormat('es-ES', {
+ style: 'currency',
+ currency: 'EUR',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0
+ }).format(price)
+ }
+
+ initGallery() {
+ // Keyboard navigation
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'ArrowLeft') this.prevImage()
+ if (e.key === 'ArrowRight') this.nextImage()
+ })
+
+ // Touch swipe
+ const gallery = document.querySelector('.gallery-main')
+ if (gallery) {
+ let startX = 0
+ gallery.addEventListener('touchstart', (e) => {
+ startX = e.touches[0].clientX
+ })
+ gallery.addEventListener('touchend', (e) => {
+ const endX = e.changedTouches[0].clientX
+ const diff = startX - endX
+ if (Math.abs(diff) > 50) {
+ if (diff > 0) this.nextImage()
+ else this.prevImage()
+ }
+ })
+ }
+ }
+
+ initMap() {
+ const mapContainer = document.getElementById('propertyMap')
+ if (!mapContainer || !this.property) return
+
+ const lat = this.property.lat || 28.1227
+ const lng = this.property.lng || -16.6942
+
+ this.map = L.map('propertyMap').setView([lat, lng], 14)
+
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap contributors'
+ }).addTo(this.map)
+
+ const markerIcon = L.divIcon({
+ className: 'custom-marker',
+ html: '',
+ iconSize: [40, 40],
+ iconAnchor: [20, 40]
+ })
+
+ L.marker([lat, lng], { icon: markerIcon }).addTo(this.map)
+ .bindPopup(`${this.property.title}
${this.formatPrice(this.property.price)}`)
+ }
+
+ initCalculator() {
+ const priceInput = document.getElementById('calcPrice')
+ const downPaymentInput = document.getElementById('calcDownPayment')
+ const yearsInput = document.getElementById('calcYears')
+ const rateInput = document.getElementById('calcRate')
+
+ const calculate = () => {
+ const price = parseFloat(priceInput?.value) || this.property?.price || 0
+ const downPayment = parseFloat(downPaymentInput?.value) || 0
+ const years = parseInt(yearsInput?.value) || 20
+ const rate = parseFloat(rateInput?.value) || 3.5
+
+ const loan = price - downPayment
+ const monthlyRate = (rate / 100) / 12
+ const months = years * 12
+
+ const monthlyPayment = loan * (monthlyRate * Math.pow(1 + monthlyRate, months)) / (Math.pow(1 + monthlyRate, months) - 1)
+ const totalPayment = monthlyPayment * months
+ const totalInterest = totalPayment - loan
+
+ // Update results
+ document.getElementById('calcDownPaymentResult').textContent = this.formatPrice(downPayment)
+ document.getElementById('calcLoanAmount').textContent = this.formatPrice(loan)
+ document.getElementById('calcMonthlyPayment').textContent = this.formatPrice(monthlyPayment)
+ document.getElementById('calcTotalInterest').textContent = this.formatPrice(totalInterest)
+ document.getElementById('calcTotalPayment').textContent = this.formatPrice(totalPayment)
+ }
+
+ if (priceInput) {
+ priceInput.value = this.property?.price || ''
+ priceInput.addEventListener('input', calculate)
+ }
+ if (downPaymentInput) downPaymentInput.addEventListener('input', calculate)
+ if (yearsInput) yearsInput.addEventListener('change', calculate)
+ if (rateInput) rateInput.addEventListener('input', calculate)
+
+ // Initial calculation
+ calculate()
+ }
+
+ initTabs() {
+ document.querySelectorAll('.nav-tab-custom').forEach(tab => {
+ tab.addEventListener('click', (e) => {
+ const tabName = e.target.getAttribute('onclick')?.match(/switchTab\('(\w+)'\)/)?.[1]
+ if (!tabName) return
+
+ // Update active tab
+ document.querySelectorAll('.nav-tab-custom').forEach(t => t.classList.remove('active'))
+ e.target.classList.add('active')
+
+ // Show correct content
+ document.querySelectorAll('.tab-content-custom').forEach(content => {
+ content.classList.toggle('active', content.id === `${tabName}Tab`)
+ })
+ })
+ })
+ }
+
+ switchTab(tabName) {
+ document.querySelectorAll('.nav-tab-custom').forEach(tab => {
+ const currentTab = tab.getAttribute('onclick')?.match(/'(\w+)'/)?.[1]
+ tab.classList.toggle('active', currentTab === tabName)
+ })
+
+ document.querySelectorAll('.tab-content-custom').forEach(content => {
+ content.classList.toggle('active', content.id === `${tabName}Tab`)
+ })
+ }
+
+ async loadSimilarProperties() {
+ if (!this.property) return
+
+ try {
+ const res = await API.getProperties({
+ type: this.property.type,
+ city: this.property.city,
+ limit: 3,
+ lang: this.lang
+ })
+
+ if (res.success) {
+ this.similarProperties = res.data.filter(p => p.id !== this.property.id).slice(0, 3)
+ this.renderSimilarProperties()
+ }
+ } catch (e) {
+ console.error('Failed to load similar properties:', e)
+ }
+ }
+
+ renderSimilarProperties() {
+ const container = document.getElementById('similarProperties')
+ if (!container) return
+
+ if (this.similarProperties.length === 0) {
+ container.innerHTML = 'No hay propiedades similares disponibles
'
+ return
+ }
+
+ container.innerHTML = this.similarProperties.map(prop => `
+
+
+
+
+
+
+
${this.getTypeLabel(prop.type)}
+
+
+
${prop.title}
+
+
+ ${prop.city}, ${prop.zone || prop.province}
+
+
+
+
+
+ `).join('')
+ }
+
+ initLanguageSwitcher() {
+ document.querySelectorAll('.lang-btn, .lang-switcher button').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const lang = e.target.dataset.lang || e.target.textContent.toLowerCase()
+ this.setLanguage(lang)
+ })
+ })
+ }
+
+ setLanguage(lang) {
+ this.lang = lang
+ localStorage.setItem('lang', lang)
+
+ if (window.i18n) {
+ window.i18n.setLanguage(lang)
+ }
+
+ // Reload property with new language
+ const slug = this.getPropertySlug()
+ if (slug) {
+ this.loadProperty(slug)
+ }
+
+ // Update language buttons
+ document.querySelectorAll('.lang-btn, .lang-switcher button').forEach(btn => {
+ const btnLang = btn.dataset.lang || btn.textContent.toLowerCase()
+ btn.classList.toggle('active', btnLang === lang)
+ })
+ }
+
+ t(key, fallback) {
+ if (window.i18n) {
+ return window.i18n.t(key, fallback)
+ }
+ return fallback || key
+ }
+
+ initForms() {
+ const leadForm = document.getElementById('leadForm')
+ if (leadForm) {
+ leadForm.addEventListener('submit', async (e) => {
+ e.preventDefault()
+ await this.handleLeadForm(leadForm)
+ })
+ }
+ }
+
+ async handleLeadForm(form) {
+ const formData = new FormData(form)
+ const data = {
+ name: formData.get('name'),
+ email: formData.get('email'),
+ phone: formData.get('phone'),
+ message: formData.get('message'),
+ property_id: this.property?.id,
+ language: this.lang
+ }
+
+ try {
+ const res = await API.createLead(data)
+ if (res.success) {
+ this.showNotification(this.t('form.success', '¡Solicitud enviada!'), 'success')
+ form.reset()
+ } else {
+ this.showNotification(this.t('form.error', 'Error al enviar.'), 'error')
+ }
+ } catch (e) {
+ this.showNotification(this.t('form.error', 'Error al enviar.'), 'error')
+ }
+ }
+
+ showNotification(message, type = 'info') {
+ const notification = document.createElement('div')
+ notification.className = `alert alert-${type === 'success' ? 'success' : type === 'error' ? 'danger' : 'info'} alert-dismissible fade show position-fixed`
+ notification.style.cssText = 'top: 100px; right: 30px; z-index: 9999; max-width: 400px;'
+ notification.innerHTML = `
+ ${message}
+
+ `
+ document.body.appendChild(notification)
+
+ setTimeout(() => notification.remove(), 5000)
+ }
+
+ toggleFavorite() {
+ if (!this.property) return
+
+ const favorites = JSON.parse(localStorage.getItem('favorites') || '[]')
+ const index = favorites.indexOf(this.property.id)
+
+ if (index === -1) {
+ favorites.push(this.property.id)
+ this.showNotification('Añadido a favoritos', 'success')
+ } else {
+ favorites.splice(index, 1)
+ this.showNotification('Eliminado de favoritos', 'info')
+ }
+
+ localStorage.setItem('favorites', JSON.stringify(favorites))
+ this.updateFavoriteButton()
+ }
+
+ updateFavoriteButton() {
+ const btn = document.getElementById('favoriteBtn')
+ if (!btn || !this.property) return
+
+ const favorites = JSON.parse(localStorage.getItem('favorites') || '[]')
+ const isFavorite = favorites.includes(this.property.id)
+
+ const icon = btn.querySelector('i')
+ if (icon) {
+ icon.className = `bi bi-heart${isFavorite ? '-fill' : ''}`
+ }
+ }
+
+ shareProperty() {
+ const url = window.location.href
+ const title = this.property?.title || 'Property'
+
+ if (navigator.share) {
+ navigator.share({
+ title: title,
+ url: url
+ })
+ } else {
+ navigator.clipboard.writeText(url)
+ this.showNotification('Enlace copiado al portapapeles', 'success')
+ }
+ }
+
+ showUIMessage(message, type) {
+ this.showNotification(message, type)
+ }
+
+ showError(message) {
+ const main = document.querySelector('main')
+ if (main) {
+ main.innerHTML = `
+
+
+
${message}
+
Volver al inicio
+
+ `
+ }
+ }
+
+ updateUI() {
+ this.updateFavoriteButton()
+
+ // Update language buttons
+ document.querySelectorAll('.lang-btn, .lang-switcher button').forEach(btn => {
+ const btnLang = btn.dataset.lang || btn.textContent.toLowerCase()
+ btn.classList.toggle('active', btnLang === this.lang)
+ })
+ }
+}
+
+// Global function for tab switching
+function switchTab(tabName) {
+ if (window.propertyPage) {
+ propertyPage.switchTab(tabName)
+ }
+}
+
+// Initialize property page when DOM is ready
+document.addEventListener('DOMContentLoaded', () => {
+ window.propertyPage = new PropertyPage()
+ propertyPage.init()
+})
\ No newline at end of file
diff --git a/src/server/index.ts b/src/server/index.ts
index e37a0c8..2e029bd 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -3,6 +3,7 @@ import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { serveStatic } from 'hono/bun'
import { Database } from 'bun:sqlite'
+import * as bcrypt from 'bcrypt'
const app = new Hono()
const db = new Database('./data/tenerifeprop.db')
@@ -158,15 +159,31 @@ db.run(`
app.use('*', cors())
app.use('*', logger())
+// Global error handler
+app.use('*', async (c, next) => {
+ try {
+ await next()
+ } catch (error) {
+ console.error('Server error:', error)
+ return c.json({
+ success: false,
+ error: 'Internal server error',
+ message: error instanceof Error ? error.message : 'Unknown error'
+ }, 500)
+ }
+})
+
// Serve static files
app.use('/public/*', serveStatic({ root: './' }))
-app.use('/api/*', async (c, next) => {
- await next()
-})
// Helper
const genId = () => crypto.randomUUID()
+// Helper for password hashing
+async function hashPassword(password: string): Promise {
+ return await bcrypt.hash(password, 10)
+}
+
// Seed data
function seedData() {
const existing = db.query('SELECT COUNT(*) as count FROM properties').get() as any
@@ -365,29 +382,27 @@ app.get('/api/properties/featured', (c) => {
})
})
-// 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 })
-})
-
+// Leads - Public endpoint (only allow creating leads)
app.post('/api/leads', async (c) => {
- const body = await c.req.json()
- const id = genId()
+ try {
+ 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']
- )
+ // Basic validation
+ if (!body.name || !body.email || !body.phone) {
+ return c.json({ success: false, error: 'Missing required fields: name, email, phone' }, 400)
+ }
- return c.json({ success: true, data: { id } })
-})
+ db.run(
+ 'INSERT INTO leads (id, name, email, phone, message, property_id, language, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+ [id, body.name, body.email, body.phone, body.message || null, body.property_id || null, body.language || 'es', body.source || 'webform']
+ )
-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 })
+ return c.json({ success: true, data: { id } })
+ } catch (error) {
+ console.error('Error creating lead:', error)
+ return c.json({ success: false, error: 'Failed to create lead' }, 500)
+ }
})
// Testimonials
@@ -459,6 +474,368 @@ app.get('/api/stats', (c) => {
return c.json({ success: true, data: { totalViews: views, totalLeads: leads, activeProperties: properties } })
})
+// ============ AUTH ENDPOINTS ============
+const sessions = new Map()
+
+app.post('/api/auth/login', async (c) => {
+ const body = await c.req.json()
+ const { email, password } = body
+
+ const user = db.query('SELECT * FROM users WHERE email = ?').get(email) as any
+
+ if (!user) {
+ return c.json({ success: false, error: 'Invalid credentials' }, 401)
+ }
+
+ // For demo, accept demo password
+ const isValid = password === 'admin123' || await Bun.password.verify(password, user.password_hash)
+
+ if (!isValid) {
+ return c.json({ success: false, error: 'Invalid credentials' }, 401)
+ }
+
+ if (!user.is_active) {
+ return c.json({ success: false, error: 'Account is inactive' }, 403)
+ }
+
+ const sessionId = genId()
+ sessions.set(sessionId, {
+ userId: user.id,
+ role: user.role,
+ expires: Date.now() + 7 * 24 * 60 * 60 * 1000 // 7 days
+ })
+
+ // Set cookie
+ c.header('Set-Cookie', `session=${sessionId}; Path=/; HttpOnly; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`)
+
+ return c.json({
+ success: true,
+ data: {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ role: user.role,
+ language: user.language
+ }
+ })
+})
+
+app.post('/api/auth/logout', async (c) => {
+ const sessionId = c.req.header('Cookie')?.match(/session=([^;]+)/)?.[1]
+ if (sessionId) {
+ sessions.delete(sessionId)
+ }
+ c.header('Set-Cookie', 'session=; Path=/; HttpOnly; Max-Age=0')
+ return c.json({ success: true })
+})
+
+app.get('/api/auth/me', async (c) => {
+ const sessionId = c.req.header('Cookie')?.match(/session=([^;]+)/)?.[1]
+ if (!sessionId) {
+ return c.json({ success: false, error: 'Not authenticated' }, 401)
+ }
+
+ const session = sessions.get(sessionId)
+ if (!session || session.expires < Date.now()) {
+ sessions.delete(sessionId)
+ return c.json({ success: false, error: 'Session expired' }, 401)
+ }
+
+ const user = db.query('SELECT id, email, name, role, language FROM users WHERE id = ?').get(session.userId)
+
+ return c.json({ success: true, data: user })
+})
+
+// Auth middleware for admin routes
+const requireAuth = async (c: any, next: any) => {
+ const sessionId = c.req.header('Cookie')?.match(/session=([^;]+)/)?.[1]
+
+ if (!sessionId) {
+ return c.json({ success: false, error: 'Not authenticated' }, 401)
+ }
+
+ const session = sessions.get(sessionId)
+ if (!session || session.expires < Date.now()) {
+ sessions.delete(sessionId)
+ return c.json({ success: false, error: 'Session expired' }, 401)
+ }
+
+ c.set('user', { id: session.userId, role: session.role })
+ await next()
+}
+
+const requireAdmin = async (c: any, next: any) => {
+ const sessionId = c.req.header('Cookie')?.match(/session=([^;]+)/)?.[1]
+
+ if (!sessionId) {
+ return c.json({ success: false, error: 'Not authenticated' }, 401)
+ }
+
+ const session = sessions.get(sessionId)
+ if (!session || session.expires < Date.now()) {
+ sessions.delete(sessionId)
+ return c.json({ success: false, error: 'Session expired' }, 401)
+ }
+
+ if (session.role !== 'admin' && session.role !== 'agent') {
+ return c.json({ success: false, error: 'Forbidden' }, 403)
+ }
+
+ c.set('user', { id: session.userId, role: session.role })
+ await next()
+}
+
+// ============ ADMIN PROPERTIES ============
+app.post('/api/admin/properties', requireAdmin, async (c) => {
+ const body = await c.req.json()
+ const id = genId()
+ const slug = body.title_es.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
+
+ 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, agent_id
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `)
+
+ stmt.run(
+ id, slug, body.reference || `TP-${Date.now()}`, body.type, body.status || 'active', body.land_type || 'urban',
+ body.title_es, body.title_ru || body.title_es, body.description_es, body.description_ru || body.description_es,
+ body.short_description_es, body.short_description_ru,
+ body.address, body.city, body.postal_code, body.zone, body.lat || 28.1227, body.lng || -16.6942,
+ body.area, body.price, body.price_per_m2 || Math.round(body.price / body.area),
+ body.bedrooms, body.bathrooms,
+ body.water || 'available', body.electricity || 'available', body.phone, body.drainage, body.road || 'asphalt', body.gas,
+ body.orientation || 'south', body.views_sea ? 1 : 0, body.views_mountain ? 1 : 0, body.views_valley ? 1 : 0,
+ body.topography || 'flat', body.has_ruins ? 1 : 0, body.has_license ? 1 : 0, body.is_buildable ? 1 : 0, body.max_floors || 2,
+ JSON.stringify(body.images || []), JSON.stringify(body.videos || []), JSON.stringify(body.badges || []),
+ body.is_featured ? 1 : 0, body.is_exclusive ? 1 : 0, 'user-001' // agent_id from auth context
+ )
+
+ return c.json({ success: true, data: { id, slug } })
+})
+
+app.put('/api/admin/properties/:id', requireAdmin, async (c) => {
+ const id = c.req.param('id')
+ const body = await c.req.json()
+
+ const updates: string[] = []
+ const values: any[] = []
+
+ Object.keys(body).forEach(key => {
+ if (key !== 'id') {
+ updates.push(`${key} = ?`)
+ values.push(body[key])
+ }
+ })
+
+ updates.push('updated_at = datetime("now")')
+ values.push(id)
+
+ db.run(`UPDATE properties SET ${updates.join(', ')} WHERE id = ?`, values)
+
+ return c.json({ success: true })
+})
+
+app.delete('/api/admin/properties/:id', requireAdmin, async (c) => {
+ const id = c.req.param('id')
+ db.run('DELETE FROM properties WHERE id = ?', [id])
+ return c.json({ success: true })
+})
+
+// ============ ADMIN LEADS ============
+app.put('/api/admin/leads/:id', requireAdmin, async (c) => {
+ const id = c.req.param('id')
+ const body = await c.req.json()
+
+ const updates: string[] = []
+ const values: any[] = []
+
+ Object.keys(body).forEach(key => {
+ if (key !== 'id') {
+ updates.push(`${key} = ?`)
+ values.push(body[key])
+ }
+ })
+
+ updates.push('updated_at = datetime("now")')
+ values.push(id)
+
+ db.run(`UPDATE leads SET ${updates.join(', ')} WHERE id = ?`, values)
+
+ return c.json({ success: true })
+})
+
+app.delete('/api/admin/leads/:id', requireAdmin, async (c) => {
+ const id = c.req.param('id')
+ db.run('DELETE FROM leads WHERE id = ?', [id])
+ return c.json({ success: true })
+})
+
+// ============ ADMIN TESTIMONIALS ============
+app.post('/api/admin/testimonials', requireAdmin, async (c) => {
+ const body = await c.req.json()
+ const id = genId()
+
+ db.run(
+ 'INSERT INTO testimonials (id, name, avatar, location, rating, text_es, text_ru, property_id, is_approved, is_featured) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ [id, body.name, body.avatar, body.location, body.rating, body.text_es, body.text_ru, body.property_id, body.is_approved ? 1 : 0, body.is_featured ? 1 : 0]
+ )
+
+ return c.json({ success: true, data: { id } })
+})
+
+app.put('/api/admin/testimonials/:id', requireAdmin, async (c) => {
+ const id = c.req.param('id')
+ const body = await c.req.json()
+
+ db.run(
+ 'UPDATE testimonials SET name = ?, avatar = ?, location = ?, rating = ?, text_es = ?, text_ru = ?, is_approved = ?, is_featured = ? WHERE id = ?',
+ [body.name, body.avatar, body.location, body.rating, body.text_es, body.text_ru, body.is_approved ? 1 : 0, body.is_featured ? 1 : 0, id]
+ )
+
+ return c.json({ success: true })
+})
+
+app.delete('/api/admin/testimonials/:id', requireAdmin, async (c) => {
+ const id = c.req.param('id')
+ db.run('DELETE FROM testimonials WHERE id = ?', [id])
+ return c.json({ success: true })
+})
+
+// ============ ADMIN FAQ ============
+app.post('/api/admin/faq', requireAdmin, async (c) => {
+ const body = await c.req.json()
+ const id = genId()
+
+ db.run(
+ 'INSERT INTO faq (id, question_es, question_ru, answer_es, answer_ru, category, order_num, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+ [id, body.question_es, body.question_ru, body.answer_es, body.answer_ru, body.category || 'general', body.order_num || 0, body.is_active ? 1 : 0]
+ )
+
+ return c.json({ success: true, data: { id } })
+})
+
+app.put('/api/admin/faq/:id', requireAdmin, async (c) => {
+ const id = c.req.param('id')
+ const body = await c.req.json()
+
+ db.run(
+ 'UPDATE faq SET question_es = ?, question_ru = ?, answer_es = ?, answer_ru = ?, category = ?, order_num = ?, is_active = ? WHERE id = ?',
+ [body.question_es, body.question_ru, body.answer_es, body.answer_ru, body.category, body.order_num, body.is_active ? 1 : 0, id]
+ )
+
+ return c.json({ success: true })
+})
+
+app.delete('/api/admin/faq/:id', requireAdmin, async (c) => {
+ const id = c.req.param('id')
+ db.run('DELETE FROM faq WHERE id = ?', [id])
+ return c.json({ success: true })
+})
+
+// ============ ADMIN SERVICES ============
+app.post('/api/admin/services', requireAdmin, async (c) => {
+ const body = await c.req.json()
+ const id = genId()
+
+ db.run(
+ 'INSERT INTO services (id, icon, title_es, title_ru, description_es, description_ru, order_num, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+ [id, body.icon, body.title_es, body.title_ru, body.description_es, body.description_ru, body.order_num || 0, body.is_active ? 1 : 0]
+ )
+
+ return c.json({ success: true, data: { id } })
+})
+
+app.put('/api/admin/services/:id', requireAdmin, async (c) => {
+ const id = c.req.param('id')
+ const body = await c.req.json()
+
+ db.run(
+ 'UPDATE services SET icon = ?, title_es = ?, title_ru = ?, description_es = ?, description_ru = ?, order_num = ?, is_active = ? WHERE id = ?',
+ [body.icon, body.title_es, body.title_ru, body.description_es, body.description_ru, body.order_num, body.is_active ? 1 : 0, id]
+ )
+
+ return c.json({ success: true })
+})
+
+app.delete('/api/admin/services/:id', requireAdmin, async (c) => {
+ const id = c.req.param('id')
+ db.run('DELETE FROM services WHERE id = ?', [id])
+ return c.json({ success: true })
+})
+
+// ============ ADMIN SETTINGS ============
+app.put('/api/admin/settings', requireAdmin, async (c) => {
+ const body = await c.req.json()
+
+ Object.keys(body).forEach(key => {
+ const value = typeof body[key] === 'object' ? JSON.stringify(body[key]) : String(body[key])
+ db.run('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', [key, value])
+ })
+
+ return c.json({ success: true })
+})
+
+// ============ ADMIN STATS ============
+app.get('/api/admin/stats', requireAdmin, (c) => {
+ const today = new Date().toISOString().split('T')[0]
+
+ const totalProperties = (db.query('SELECT COUNT(*) as count FROM properties').get() as any)?.count || 0
+ const activeProperties = (db.query('SELECT COUNT(*) as count FROM properties WHERE status = ?').get('active') as any)?.count || 0
+ const reservedProperties = (db.query('SELECT COUNT(*) as count FROM properties WHERE status = ?').get('reserved') as any)?.count || 0
+ const soldProperties = (db.query('SELECT COUNT(*) as count FROM properties WHERE status = ?').get('sold') as any)?.count || 0
+
+ const totalLeads = (db.query('SELECT COUNT(*) as count FROM leads').get() as any)?.count || 0
+ const newLeads = (db.query('SELECT COUNT(*) as count FROM leads WHERE status = ?').get('new') as any)?.count || 0
+ const contactedLeads = (db.query('SELECT COUNT(*) as count FROM leads WHERE status = ?').get('contacted') as any)?.count || 0
+ const qualifiedLeads = (db.query('SELECT COUNT(*) as count FROM leads WHERE status = ?').get('qualified') as any)?.count || 0
+ const closedLeads = (db.query('SELECT COUNT(*) as count FROM leads WHERE status = ?').get('closed') as any)?.count || 0
+
+ const totalViews = (db.query('SELECT SUM(views_count) as total FROM properties').get() as any)?.total || 0
+ const totalFavorites = (db.query('SELECT SUM(favorite_count) as total FROM properties').get() as any)?.total || 0
+ const totalInquiries = (db.query('SELECT SUM(inquiry_count) as total FROM properties').get() as any)?.total || 0
+
+ const avgPrice = (db.query('SELECT AVG(price) as avg FROM properties WHERE status = ?').get('active') as any)?.avg || 0
+ const avgArea = (db.query('SELECT AVG(area) as avg FROM properties WHERE status = ?').get('active') as any)?.avg || 0
+
+ return c.json({
+ success: true,
+ data: {
+ properties: {
+ total: totalProperties,
+ active: activeProperties,
+ reserved: reservedProperties,
+ sold: soldProperties
+ },
+ leads: {
+ total: totalLeads,
+ new: newLeads,
+ contacted: contactedLeads,
+ qualified: qualifiedLeads,
+ closed: closedLeads
+ },
+ analytics: {
+ views: totalViews,
+ favorites: totalFavorites,
+ inquiries: totalInquiries
+ },
+ averages: {
+ price: Math.round(avgPrice),
+ area: Math.round(avgArea),
+ pricePerM2: Math.round(avgPrice / avgArea)
+ }
+ }
+ })
+})
+
+// Serve static files and SPA routes
+app.get('/property/*', serveStatic({ path: './public/property.html' }))
+app.get('/admin/*', serveStatic({ path: './public/admin.html' }))
+
// Serve index.html for all other routes
app.get('*', serveStatic({ path: './public/index.html' }))