Files
TenerifeProp/public/js/admin.js
TenerifeProp Dev 3bbbb126ab 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
2026-04-05 00:01:54 +01:00

774 lines
23 KiB
JavaScript

// 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()
})