Files
TenerifeProp/public/js/property.js
TenerifeProp Dev 503eb8a62f feat: implement property page navigation and security fixes
- Fix XSS vulnerabilities with escapeHtml() utility
- Fix SQL injection in admin endpoints with column whitelisting
- Add CSRF protection middleware
- Remove hardcoded password backdoor
- Implement property navigation functions
- Add test coverage

Closes #9
2026-04-05 01:34:48 +01:00

758 lines
24 KiB
JavaScript

// TenerifeProp - Property Page JavaScript
class PropertyPage {
constructor() {
this.property = null
this.currentImageIndex = 0
this.images = []
this.lang = localStorage.getItem('lang') || 'es'
this.similarProperties = []
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 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.initBackNavigation()
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')
])
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 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.safeJsonParse(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 = `${this.property.area ? Math.round(this.property.price / this.property.area) : 0} €/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 || 0).toLocaleString()}`
if (propertyPrice) propertyPrice.textContent = this.formatPrice(this.property.price)
if (pricePerM2) pricePerM2.textContent = `${this.property.area ? Math.round(this.property.price / this.property.area) : 0} €/m²`
// Update description
const descText1 = document.getElementById('descriptionText1')
const descText2 = document.getElementById('descriptionText2')
if (descText1 && this.property.description) {
const paragraphs = this.property.description.split('\n\n')
descText1.textContent = paragraphs[0] || ''
if (descText2) descText2.textContent = paragraphs[1] || ''
}
// Update badges
const mainBadge = document.getElementById('mainBadge')
if (mainBadge && this.property.is_exclusive) {
mainBadge.innerHTML = '<i class="bi bi-star-fill me-1"></i>Exclusivo'
mainBadge.className = 'gallery-badge exclusive'
}
// Update features
this.updateFeatures()
// Update utilities
this.updateUtilities()
// Update documents
this.updateDocuments()
// Track view
API.trackEvent('property_view', { propertyId: this.property.id, slug: this.property.slug })
}
updateGallery() {
if (this.images.length === 0) {
// Use placeholder images
this.images = [
{ url: 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=1920&q=80', alt: 'Vista principal' },
{ url: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=1920&q=80', alt: 'Vista 2' },
{ url: 'https://images.unsplash.com/photo-1500382017468-9049fed747ef?w=1920&q=80', alt: 'Vista 3' }
]
}
const mainImage = document.getElementById('mainImage')
if (mainImage) {
mainImage.src = this.images[0]?.url
mainImage.alt = this.images[0]?.alt || this.property?.title
}
const thumbnails = document.querySelector('.gallery-thumbnails')
if (thumbnails) {
thumbnails.innerHTML = this.images.map((img, i) => `
<div class="gallery-thumb ${i === 0 ? 'active' : ''}" onclick="propertyPage.setMainImage(${i})">
<img src="${img.url.replace('w=1920', 'w=200').replace('q=80', 'q=60')}" alt="${img.alt}">
</div>
`).join('')
}
}
setMainImage(index) {
if (index < 0 || index >= this.images.length) return
this.currentImageIndex = index
const mainImage = document.getElementById('mainImage')
if (mainImage) {
mainImage.src = this.images[index].url
}
document.querySelectorAll('.gallery-thumb').forEach((thumb, i) => {
thumb.classList.toggle('active', i === index)
})
}
prevImage() {
const newIndex = this.currentImageIndex > 0 ? this.currentImageIndex - 1 : this.images.length - 1
this.setMainImage(newIndex)
}
nextImage() {
const newIndex = this.currentImageIndex < this.images.length - 1 ? this.currentImageIndex + 1 : 0
this.setMainImage(newIndex)
}
updateFeatures() {
const featuresGrid = document.querySelector('#featuresTab .features-grid')
if (!featuresGrid || !this.property) return
const features = [
{ icon: 'bi-rulers', label: `Superficie: ${this.property.area?.toLocaleString()}` },
{ icon: 'bi-grid-3x3', label: `Tipo: ${this.getTypeLabel(this.property.type)}` },
{ icon: 'bi-building', label: `Edificabilidad: ${this.property.buildability_ratio || 0.2} m²/m²` },
{ icon: 'bi-ruler-combined', label: `Altura máx.: ${this.property.max_floors || 2} plantas` },
{ icon: 'bi-sun', label: `Orientación: ${this.getOrientationLabel(this.property.orientation)}` },
{ icon: 'bi-eye', label: `Vistas: ${this.getViewsLabel(this.property)}` },
{ icon: 'bi-tree', label: `Topografía: ${this.getTopographyLabel(this.property.topography)}` },
{ icon: 'bi-car-front', label: `Acceso: ${this.getRoadLabel(this.property.road)}` }
]
if (this.property.has_license) {
features.push({ icon: 'bi-calendar-plus', label: 'Licencia obras: Vigente' })
}
if (this.property.reference) {
features.push({ icon: 'bi-wallet2', label: `Ref: ${this.property.reference}` })
}
featuresGrid.innerHTML = features.map(f => `
<div class="feature-item">
<i class="bi ${f.icon}"></i>
<span>${f.label}</span>
</div>
`).join('')
}
updateUtilities() {
const utilitiesTab = document.getElementById('utilitiesTab')
if (!utilitiesTab || !this.property) return
const utilities = [
{ key: 'water', icon: 'bi-droplet-fill', label: 'Agua Potable' },
{ key: 'electricity', icon: 'bi-lightning-fill', label: 'Electricidad' },
{ key: 'phone', icon: 'bi-phone-fill', label: 'Teléfono' },
{ key: 'drainage', icon: 'bi-droplet', label: 'Alcantarillado' },
{ key: 'road', icon: 'bi-car-front-fill', label: 'Acceso Rodado' },
{ key: 'gas', icon: 'bi-geo-alt', label: 'Gas Natural' }
]
const row = utilitiesTab.querySelector('.row')
if (row) {
row.innerHTML = utilities.map(u => {
const status = this.property[u.key]
const statusClass = status === 'available' || status === 'asphalt' ? 'available' : status === 'planned' || status === 'nearby' ? 'planned' : 'unavailable'
const statusLabel = this.getUtilityStatusLabel(status)
return `
<div class="col-md-4">
<div class="utility-card ${statusClass}">
<div class="utility-icon-big">
<i class="bi ${u.icon}"></i>
</div>
<h5 class="utility-card-title">${u.label}</h5>
<p class="utility-card-status">${statusLabel}</p>
</div>
</div>
`
}).join('')
}
}
updateDocuments() {
const documentsTab = document.getElementById('documentsTab')
if (!documentsTab || !this.property) return
const documents = this.safeJsonParse(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.escapeHtml(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 => {
// 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 simImages = this.safeJsonParse(prop.images)
return `
<div class="col-lg-4 col-md-6">
<div class="similar-card">
<div class="similar-card-image">
<a href="/property/${prop.slug}">
<img src="${simImages[0]?.url || 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=400'}" alt="${safeTitle}">
</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">${safeTitle}</a>
<p class="similar-card-location">
<i class="bi bi-geo-alt"></i>
${safeCity}, ${safeZone}
</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 || 0).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
`
}).join('')
}
initLanguageSwitcher() {
document.querySelectorAll('.lang-btn, .lang-switcher button').forEach(btn => {
btn.addEventListener('click', (e) => {
const lang = e.target.dataset.lang || e.target.textContent.toLowerCase()
this.setLanguage(lang)
})
})
}
setLanguage(lang) {
this.lang = lang
localStorage.setItem('lang', lang)
if (window.i18n) {
window.i18n.setLanguage(lang)
}
// Reload property with new language
const slug = this.getPropertySlug()
if (slug) {
this.loadProperty(slug)
}
// Update language buttons
document.querySelectorAll('.lang-btn, .lang-switcher button').forEach(btn => {
const btnLang = btn.dataset.lang || btn.textContent.toLowerCase()
btn.classList.toggle('active', btnLang === lang)
})
// Update URL with language parameter for back navigation
const url = new URL(window.location)
url.searchParams.set('lang', lang)
window.history.replaceState({}, '', url)
}
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)
})
}
}
initBackNavigation() {
// This will be called when navigating back
window.addEventListener('popstate', () => {
// Preserve any state needed for back navigation
// In this case, we're just ensuring the page loads correctly
})
}
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') {
// 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)
}
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()
})