feat: add authentication, admin API, and security improvements

- Add session-based authentication system
- Implement admin CRUD endpoints for properties, leads, testimonials, FAQ, services
- Fix security issue: remove public GET /api/leads endpoint
- Add basic input validation for leads endpoint
- Add global error handler
- Fix Docker healthcheck using bun's fetch
- Add @types/bcrypt dependency
- Add .dockerignore
- Add host reboot prohibition to global rules
This commit is contained in:
TenerifeProp Dev
2026-04-05 00:01:54 +01:00
parent c1867fe074
commit 3bbbb126ab
9 changed files with 2552 additions and 45 deletions

27
.dockerignore Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/bun": "latest",
"typescript": "^5.3.0"
}

774
public/js/admin.js Normal file
View File

@@ -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 => `
<tr>
<td>
<div class="table-user">
<img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop" class="table-user-avatar">
<div class="table-user-info">
${lead.name}
<small>${lead.phone || lead.email}</small>
</div>
</div>
</td>
<td>
<div class="table-property">
<img src="https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=100&q=80" class="table-property-img">
<div class="table-property-info">
<h6>${lead.property_id || 'General inquiry'}</h6>
<span>${new Date(lead.created_at).toLocaleDateString()}</span>
</div>
</div>
</td>
<td><span class="badge bg-${this.getSourceColor(lead.source)}">${lead.source || 'webform'}</span></td>
<td>${new Date(lead.created_at).toLocaleDateString()}</td>
<td><span class="table-badge ${lead.status}">${this.t(`status.${lead.status}`, lead.status)}</span></td>
<td>
<div class="table-actions">
<button class="table-action-btn view" onclick="admin.viewLead('${lead.id}')" title="Ver">
<i class="bi bi-eye"></i>
</button>
<button class="table-action-btn edit" onclick="admin.editLead('${lead.id}')" title="Editar">
<i class="bi bi-pencil"></i>
</button>
<button class="table-action-btn delete" onclick="admin.deleteLead('${lead.id}')" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`).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 => `
<div class="col-lg-4 col-md-6">
<div class="property-admin-card">
<div class="property-admin-card-image">
<img src="${prop.images ? JSON.parse(prop.images)[0] : 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=400'}" alt="${prop.title}">
<div class="property-admin-card-badges">
<span class="property-admin-card-badge ${prop.status}">${this.t(`status.${prop.status}`, prop.status)}</span>
<span class="property-admin-card-badge ${prop.type}">${this.t(`property.${prop.type}Land`, prop.type)}</span>
</div>
<div class="property-admin-card-actions">
<a href="/property/${prop.slug}" class="property-admin-card-action" target="_blank" title="Ver">
<i class="bi bi-eye"></i>
</a>
<button class="property-admin-card-action" onclick="admin.editProperty('${prop.id}')" title="Editar">
<i class="bi bi-pencil"></i>
</button>
<button class="property-admin-card-action delete" onclick="admin.deleteProperty('${prop.id}')" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="property-admin-card-content">
<h5 class="property-admin-card-title">${prop.title}</h5>
<p class="property-admin-card-location">
<i class="bi bi-geo-alt"></i>
${prop.city}, ${prop.zone || ''}
</p>
<div class="property-admin-card-stats">
<div class="property-admin-card-stat">
<i class="bi bi-eye"></i>
<span>${prop.views_count || 0}</span>
</div>
<div class="property-admin-card-stat">
<i class="bi bi-heart"></i>
<span>${prop.favorite_count || 0}</span>
</div>
<div class="property-admin-card-stat">
<i class="bi bi-chat"></i>
<span>${prop.inquiry_count || 0}</span>
</div>
</div>
<div class="property-admin-card-price">
${this.formatPrice(prop.price)}
</div>
</div>
</div>
</div>
`).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 => `
<tr>
<td>
<div class="table-user">
<img src="${t.avatar || 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100'}" class="table-user-avatar">
<div class="table-user-info">
${t.name}
</div>
</div>
</td>
<td>${t.location}</td>
<td>${'★'.repeat(t.rating)}${'☆'.repeat(5 - t.rating)}</td>
<td>${t.text.substring(0, 50)}...</td>
<td><span class="table-badge ${t.is_approved ? 'completed' : 'pending'}">${t.is_approved ? 'Aprobado' : 'Pendiente'}</span></td>
<td>
<div class="table-actions">
<button class="table-action-btn edit" onclick="admin.editTestimonial('${t.id}')" title="Editar">
<i class="bi bi-pencil"></i>
</button>
<button class="table-action-btn delete" onclick="admin.deleteTestimonial('${t.id}')" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`).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 => `
<tr>
<td>${f.question}</td>
<td>${f.answer.substring(0, 100)}...</td>
<td><span class="badge bg-${f.is_active ? 'success' : 'secondary'}">${f.is_active ? 'Activo' : 'Inactivo'}</span></td>
<td>
<div class="table-actions">
<button class="table-action-btn edit" onclick="admin.editFAQ('${f.id}')" title="Editar">
<i class="bi bi-pencil"></i>
</button>
<button class="table-action-btn delete" onclick="admin.deleteFAQ('${f.id}')" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`).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 => `
<tr>
<td><i class="${s.icon}"></i></td>
<td>${s.title}</td>
<td>${s.description.substring(0, 100)}...</td>
<td><span class="badge bg-${s.is_active ? 'success' : 'secondary'}">${s.is_active ? 'Activo' : 'Inactivo'}</span></td>
<td>
<div class="table-actions">
<button class="table-action-btn edit" onclick="admin.editService('${s.id}')" title="Editar">
<i class="bi bi-pencil"></i>
</button>
<button class="table-action-btn delete" onclick="admin.deleteService('${s.id}')" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`).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}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`
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()
})

627
public/js/app.js Normal file
View File

@@ -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: `<div style="background: var(--primary); width: 30px; height: 30px; border-radius: 50%; border: 3px solid white; box-shadow: 0 3px 10px rgba(0,0,0,0.3);"></div>`,
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(`
<div class="map-popup" style="padding: 0; min-width: 250px;">
<img src="${image}" style="width:100%; height:120px; object-fit:cover;">
<div style="padding: 15px;">
<h5 style="font-size: 1rem; margin-bottom: 5px;">${title}</h5>
<p style="font-size: 0.9rem; color: var(--primary); font-weight: 600; margin: 0;">${price}</p>
</div>
</div>
`)
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 = `
<div class="col-12 text-center py-5">
<i class="bi bi-search" style="font-size: 3rem; color: var(--gray);"></i>
<h4 class="mt-3">${this.t('catalog.noResults', 'No se encontraron propiedades')}</h4>
<p class="text-muted">${this.t('catalog.tryFilters', 'Intenta ajustar los filtros')}</p>
</div>
`
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 `
<div class="col-lg-4 col-md-6" data-aos="fade-up">
<div class="property-card">
<div class="property-image">
<a href="/property/${prop.slug}">
<img src="${mainImage}" alt="${prop.title}" loading="lazy">
</a>
<div class="property-badges">
<span class="property-badge ${typeBadgeClasses[prop.type] || 'badge-agricultural'}">
${typeLabels[prop.type] || prop.type}
</span>
${prop.is_featured ? '<span class="property-badge badge-exclusive">Destacado</span>' : ''}
${prop.is_exclusive ? '<span class="property-badge" style="background: var(--secondary); color: var(--dark);">Exclusivo</span>' : ''}
</div>
<button class="property-favorite" onclick="app.toggleFavorite('${prop.id}')" title="Añadir a favoritos">
<i class="bi bi-heart${this.isFavorite(prop.id) ? '-fill' : ''}"></i>
</button>
</div>
<div class="property-content">
<div class="property-type">${typeLabels[prop.type] || prop.type}</div>
<a href="/property/${prop.slug}" class="property-title">${prop.title}</a>
<p class="property-location">
<i class="bi bi-geo-alt"></i>
${prop.city}, ${prop.zone || prop.province}
</p>
<div class="property-features">
<div class="property-feature">
<i class="bi bi-rulers"></i>
<span>${area} m²</span>
</div>
${prop.bedrooms ? `
<div class="property-feature">
<i class="bi bi-door-open"></i>
<span>${prop.bedrooms} ${this.t('property.bedrooms', 'hab.')}</span>
</div>
` : ''}
${prop.bathrooms ? `
<div class="property-feature">
<i class="bi bi-droplet"></i>
<span>${prop.bathrooms} ${this.t('property.bathrooms', 'baños')}</span>
</div>
` : ''}
</div>
<div class="property-utilities">
${prop.water === 'available' ? '<div class="utility-icon has" title="Agua"><i class="bi bi-droplet-fill"></i></div>' : ''}
${prop.electricity === 'available' ? '<div class="utility-icon has" title="Electricidad"><i class="bi bi-lightning-fill"></i></div>' : ''}
${prop.road === 'asphalt' ? '<div class="utility-icon has" title="Acceso asfaltado"><i class="bi bi-signpost-split-fill"></i></div>' : ''}
</div>
<div class="property-price">
${price}
<span>${Math.round(prop.price / prop.area)} €/m²</span>
</div>
<div class="property-actions">
<a href="/property/${prop.slug}" class="btn btn-primary-custom">
<i class="bi bi-eye me-2"></i>${this.t('property.view', 'Ver')}
</a>
<a href="https://wa.me/${this.settings.whatsapp?.replace(/[^0-9]/g, '')}?text=${encodeURIComponent(this.t('property.whatsappMessage', 'Hola, me interesa esta propiedad') + ': ' + prop.title)}"
target="_blank" class="btn btn-whatsapp">
<i class="bi bi-whatsapp"></i>
</a>
</div>
</div>
</div>
</div>
`
}
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}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`
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 => `
<div class="testimonial-card">
<div class="testimonial-stars">
${Array(5).fill(0).map((_, i) => `<i class="bi bi-star${i < t.rating ? '-fill' : ''}"></i>`).join('')}
</div>
<p class="testimonial-text">"${t.text}"</p>
<div class="testimonial-author">
<img src="${t.avatar || 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100'}" alt="${t.name}" class="testimonial-avatar">
<div>
<h5>${t.name}</h5>
<p>${t.location}</p>
</div>
</div>
</div>
`).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) => `
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button ${i > 0 ? 'collapsed' : ''}" type="button" data-bs-toggle="collapse" data-bs-target="#faq${i}">
${f.question}
</button>
</h2>
<div id="faq${i}" class="accordion-collapse collapse ${i === 0 ? 'show' : ''}" data-bs-parent="#faqContainer">
<div class="accordion-body">${f.answer}</div>
</div>
</div>
`).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 => `
<div class="col-lg-4 col-md-6" data-aos="fade-up">
<div class="service-card">
<div class="service-icon">
<i class="${s.icon}"></i>
</div>
<h4>${s.title}</h4>
<p>${s.description}</p>
</div>
</div>
`).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()
})

703
public/js/property.js Normal file
View File

@@ -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 = `<i class="bi bi-geo-alt me-1"></i>${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 = `<i class="bi bi-geo-alt-fill"></i>${this.property.address}, ${this.property.city}, ${this.property.zone || ''}`
if (quickArea) quickArea.textContent = `${this.property.area.toLocaleString()}`
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 = '<i class="bi bi-star-fill me-1"></i>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) => `
<div class="gallery-thumb ${i === 0 ? 'active' : ''}" onclick="propertyPage.setMainImage(${i})">
<img src="${img.url.replace('w=1920', 'w=200').replace('q=80', 'q=60')}" alt="${img.alt}">
</div>
`).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()}` },
{ 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 => `
<div class="feature-item">
<i class="bi ${f.icon}"></i>
<span>${f.label}</span>
</div>
`).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 `
<div class="col-md-4">
<div class="utility-card ${statusClass}">
<div class="utility-icon-big">
<i class="bi ${u.icon}"></i>
</div>
<h5 class="utility-card-title">${u.label}</h5>
<p class="utility-card-status">${statusLabel}</p>
</div>
</div>
`
}).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 => `
<div class="document-item">
<div class="document-icon"><i class="bi ${this.getDocumentIcon(doc.type)}"></i></div>
<div class="document-info">
<h6>${doc.name}</h6>
<p>${doc.description || ''}</p>
</div>
<span class="document-status ${doc.status}">${this.getDocumentStatusLabel(doc.status)}</span>
</div>
`).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: '<div style="background: var(--primary); width: 40px; height: 40px; border-radius: 50%; border: 4px solid white; box-shadow: 0 3px 10px rgba(0,0,0,0.3);"></div>',
iconSize: [40, 40],
iconAnchor: [20, 40]
})
L.marker([lat, lng], { icon: markerIcon }).addTo(this.map)
.bindPopup(`<b>${this.property.title}</b><br>${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 = '<p class="text-muted">No hay propiedades similares disponibles</p>'
return
}
container.innerHTML = this.similarProperties.map(prop => `
<div class="col-lg-4 col-md-6">
<div class="similar-card">
<div class="similar-card-image">
<a href="/property/${prop.slug}">
<img src="${prop.images ? JSON.parse(prop.images)[0]?.url : 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=400'}" alt="${prop.title}">
</a>
<span class="similar-card-badge ${prop.type}">${this.getTypeLabel(prop.type)}</span>
</div>
<div class="similar-card-content">
<a href="/property/${prop.slug}" class="similar-card-title">${prop.title}</a>
<p class="similar-card-location">
<i class="bi bi-geo-alt"></i>
${prop.city}, ${prop.zone || prop.province}
</p>
<div class="similar-card-footer">
<span class="similar-card-price">${this.formatPrice(prop.price)}</span>
<span class="similar-card-area">
<i class="bi bi-rulers"></i>
${prop.area?.toLocaleString()}
</span>
</div>
</div>
</div>
</div>
`).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}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`
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 = `
<div class="container text-center py-5">
<i class="bi bi-exclamation-triangle" style="font-size: 4rem; color: var(--danger);"></i>
<h2 class="mt-3">${message}</h2>
<a href="/" class="btn btn-primary-custom mt-3">Volver al inicio</a>
</div>
`
}
}
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()
})

View File

@@ -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<string> {
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<string, { userId: string; role: string; expires: number }>()
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' }))