Fixed syntax error where optional chaining (?.) was used for assignment in updateStats method. Changed to use if checks for null-protection.
734 lines
24 KiB
JavaScript
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()
|
|
}) |