Files
TenerifeProp/public/js/app.js
TenerifeProp Dev 86e4b2a31e fix: optional chaining cannot be used for assignment
Fixed syntax error where optional chaining (?.) was used for assignment
in updateStats method. Changed to use if checks for null-protection.
2026-04-05 12:49:10 +01:00

734 lines
24 KiB
JavaScript

// 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'
this.notificationTimeouts = []
}
// Utility: Escape HTML to prevent XSS
escapeHtml(text) {
if (text == null) return ''
const div = document.createElement('div')
div.textContent = String(text)
return div.innerHTML
}
// Utility: Safe JSON parse
safeJsonParse(str, defaultValue = []) {
if (!str) return defaultValue
try {
return JSON.parse(str)
} catch (e) {
console.warn('JSON parse error:', e)
return defaultValue
}
}
async init() {
await Promise.all([
this.loadTranslations(),
this.loadSettings(),
this.loadProperties()
])
this.initMap()
this.initFilters()
this.initLanguageSwitcher()
this.initForms()
this.initAnimations()
this.initPropertyCardHandlers()
this.initMobileNavigation()
this.restoreFilters()
this.updateUI()
}
async loadTranslations() {
try {
const [esRes, ruRes] = await Promise.all([
fetch('/src/i18n/es.json'),
fetch('/src/i18n/ru.json')
])
if (!esRes.ok || !ruRes.ok) {
throw new Error('Failed to load translation files')
}
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)
this.showNotification('Error loading translations', 'error')
}
}
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 images = this.safeJsonParse(prop.images)
const image = images[0]?.url || 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=200&q=60'
const title = this.escapeHtml(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 = this.safeJsonParse(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) : []
// Escape user data for XSS prevention
const safeTitle = this.escapeHtml(prop.title)
const safeCity = this.escapeHtml(prop.city)
const safeZone = this.escapeHtml(prop.zone || prop.province)
const safeType = this.escapeHtml(prop.type)
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 ? prop.area.toLocaleString() : '0'
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}" data-property-id="${prop.id}">
<img src="${mainImage}" alt="${safeTitle}" loading="lazy">
</a>
<div class="property-badges">
<span class="property-badge ${typeBadgeClasses[prop.type] || 'badge-agricultural'}">
${typeLabels[prop.type] || safeType}
</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] || safeType}</div>
<a href="/property/${prop.slug}" class="property-title">${safeTitle}</a>
<p class="property-location">
<i class="bi bi-geo-alt"></i>
${safeCity}, ${safeZone}
</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>${prop.area ? Math.round(prop.price / prop.area) : 0} €/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') + ': ' + safeTitle)}"
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) {
let favorites = []
try {
favorites = JSON.parse(localStorage.getItem('favorites') || '[]')
} catch (e) {
console.warn('Failed to parse favorites:', e)
}
const index = favorites.indexOf(id)
if (index === -1) {
favorites.push(id)
} else {
favorites.splice(index, 1)
}
try {
localStorage.setItem('favorites', JSON.stringify(favorites))
} catch (e) {
console.warn('Failed to save favorites:', e)
}
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)
})
})
}
initMobileNavigation() {
const navbarToggle = document.querySelector('.navbar-toggler')
const navbarCollapse = document.getElementById('navbarNav')
if (navbarToggle && navbarCollapse) {
navbarToggle.addEventListener('click', () => {
navbarCollapse.classList.toggle('show')
})
}
}
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') {
// Clear existing notification timeouts to prevent memory leak
this.notificationTimeouts.forEach(timeoutId => clearTimeout(timeoutId))
this.notificationTimeouts = []
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 = `
${this.escapeHtml(message)}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`
document.body.appendChild(notification)
const timeoutId = setTimeout(() => {
notification.remove()
this.notificationTimeouts = this.notificationTimeouts.filter(id => id !== timeoutId)
}, 5000)
this.notificationTimeouts.push(timeoutId)
}
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)
}
})
}
initPropertyCardHandlers() {
// Add click event listeners to property cards
document.addEventListener('click', (e) => {
const propertyLink = e.target.closest('[data-property-id]')
if (propertyLink) {
// Store filters in session storage for back navigation
sessionStorage.setItem('propertyFilters', JSON.stringify({
filter: this.currentFilter,
priceMin: document.getElementById('priceMin')?.value,
priceMax: document.getElementById('priceMax')?.value,
areaMin: document.getElementById('areaMin')?.value,
areaMax: document.getElementById('areaMax')?.value
}))
}
})
}
restoreFilters() {
// Restore filters from session storage
const filterData = sessionStorage.getItem('propertyFilters')
if (filterData) {
try {
const filters = JSON.parse(filterData)
if (filters.filter) {
this.setFilter(filters.filter)
}
// Restore price and area filters
if (filters.priceMin) document.getElementById('priceMin').value = filters.priceMin
if (filters.priceMax) document.getElementById('priceMax').value = filters.priceMax
if (filters.areaMin) document.getElementById('areaMin').value = filters.areaMin
if (filters.areaMax) document.getElementById('areaMax').value = filters.areaMax
// Apply restored filters
this.applyFilters()
} catch (e) {
console.warn('Failed to restore filters:', e)
}
}
}
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) {
const viewsEl = document.getElementById('statViews')
const leadsEl = document.getElementById('statLeads')
const propsEl = document.getElementById('statProperties')
if (viewsEl) viewsEl.textContent = this.formatNumber(res.data.totalViews)
if (leadsEl) leadsEl.textContent = this.formatNumber(res.data.totalLeads)
if (propsEl) propsEl.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()
})