- 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
758 lines
24 KiB
JavaScript
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()} m²`
|
|
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()} 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.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()} 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)
|
|
})
|
|
|
|
// 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()
|
|
}) |