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:
27
.dockerignore
Normal file
27
.dockerignore
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
38
Dockerfile
38
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"]
|
||||
3
bun.lock
3
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=="],
|
||||
|
||||
@@ -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
774
public/js/admin.js
Normal 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
627
public/js/app.js
Normal 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
703
public/js/property.js
Normal 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()} 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 = '<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()} 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 => `
|
||||
<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()} m²
|
||||
</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()
|
||||
})
|
||||
@@ -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' }))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user