import { t, i18n } from '../../i18n.js' import { listingsService } from '../../services/listings.js' import { directus } from '../../services/directus.js' import { locationsService } from '../../services/locations.js' import { auth } from '../../services/auth.js' import { escapeHTML } from '../../utils/helpers.js' import '../listing-card.js' import '../skeleton-card.js' import '../search-box.js' class PageHome extends HTMLElement { constructor() { super() this.listings = [] this.loading = false this.loadingMore = false this.error = null this.page = 1 this.hasMore = true // Filter state this.query = '' this.category = '' this.subcategory = '' this.sort = 'newest' this.minPrice = null this.maxPrice = null this.priceFilterOpen = false // Geo state this.userLat = null this.userLng = null this._onHashChange = this.handleHashChange.bind(this) } connectedCallback() { this.parseUrlParams() this.render() this.setupEventListeners() this.setupPullToRefresh() this.loadListings() this._unsubs = [] this._unsubs.push(i18n.subscribe(() => { this.updateTextContent() })) // Re-render listings on auth change to show owner badges this._unsubs.push(auth.subscribe(() => { const container = this.querySelector('#listings-container') if (container) { container.innerHTML = this.renderListings() } })) // Listen for URL changes (back/forward navigation) window.addEventListener('hashchange', this._onHashChange) } disconnectedCallback() { this._unsubs.forEach(fn => fn()) this._unsubs = [] window.removeEventListener('hashchange', this._onHashChange) } handleHashChange() { const hash = window.location.hash if (hash === '#/' || hash.startsWith('#/?')) { this.parseUrlParams() this.resetAndSearch() } } parseUrlParams() { const params = new URLSearchParams(window.location.hash.split('?')[1] || '') this.query = params.get('q') || '' this.category = params.get('category') || '' this.sort = params.get('sort') || 'newest' this.minPrice = params.get('min') ? parseFloat(params.get('min')) : null this.maxPrice = params.get('max') ? parseFloat(params.get('max')) : null } updateUrl() { const params = new URLSearchParams() if (this.query) params.set('q', this.query) if (this.category) params.set('category', this.category) if (this.sort !== 'newest') params.set('sort', this.sort) if (this.minPrice) params.set('min', this.minPrice) if (this.maxPrice) params.set('max', this.maxPrice) const paramString = params.toString() const newHash = paramString ? `#/?${paramString}` : '#/' if (window.location.hash !== newHash) { history.replaceState(null, '', newHash) } } hasActiveFilters() { return this.query || this.category || this.minPrice || this.maxPrice || this.sort !== 'newest' } hasUserLocation() { return this.userLat != null && this.userLng != null } updateUserLocation(detail) { if (detail.useCurrentLocation && detail.lat && detail.lng) { this.userLat = detail.lat this.userLng = detail.lng } else { this.userLat = null this.userLng = null // If sorting by distance but no location, reset sort if (this.sort === 'distance') { this.sort = 'newest' } } } resetAndSearch() { this.page = 1 this.listings = [] this.hasMore = true this.loadListings() } setupEventListeners() { const searchBox = this.querySelector('search-box') searchBox?.addEventListener('filter-change', (e) => { const hadLocation = this.hasUserLocation() this.category = e.detail.category || '' this.subcategory = e.detail.subcategory || '' this.query = e.detail.query || '' this.updateUserLocation(e.detail) this.updateUrl() // Re-render if location status changed (to show/hide distance sort) if (hadLocation !== this.hasUserLocation()) { this.render() this.setupEventListeners() } this.resetAndSearch() }) searchBox?.addEventListener('search', (e) => { e.preventDefault() e.stopPropagation() this.query = e.detail.query || '' this.category = e.detail.category || '' this.subcategory = e.detail.subcategory || '' this.updateUserLocation(e.detail) this.updateUrl() this.resetAndSearch() return false }) // Toggle price filter this.querySelector('#toggle-price-filter')?.addEventListener('click', () => { this.togglePriceFilter() }) // Sort change this.querySelector('#sort-select')?.addEventListener('change', (e) => { this.sort = e.target.value this.updateUrl() this.resetAndSearch() }) // Price filter this.querySelector('#apply-price')?.addEventListener('click', () => { this.minPrice = parseFloat(this.querySelector('#min-price')?.value) || null this.maxPrice = parseFloat(this.querySelector('#max-price')?.value) || null this.updateUrl() this.resetAndSearch() }) // Enter key on price inputs this.querySelectorAll('#min-price, #max-price').forEach(input => { input.addEventListener('keypress', (e) => { if (e.key === 'Enter') { this.minPrice = parseFloat(this.querySelector('#min-price')?.value) || null this.maxPrice = parseFloat(this.querySelector('#max-price')?.value) || null this.updateUrl() this.resetAndSearch() } }) }) // Clear filters this.querySelector('#clear-filters')?.addEventListener('click', () => { this.query = '' this.category = '' this.subcategory = '' this.sort = 'newest' this.minPrice = null this.maxPrice = null this.priceFilterOpen = false this.updateUrl() this.render() this.setupEventListeners() this.resetAndSearch() }) } async loadListings() { if (this.page === 1) { this.loading = true } else { this.loadingMore = true } this.error = null this.updateListingsSection() try { // For distance sort, we need to load more items and sort client-side const isDistanceSort = this.sort === 'distance' && this.hasUserLocation() const filters = { search: this.query || undefined, category: this.category || undefined, subcategory: this.subcategory || undefined, sort: isDistanceSort ? 'newest' : this.sort, // Backend can't sort by distance minPrice: this.minPrice, maxPrice: this.maxPrice, limit: isDistanceSort ? 100 : 20, // Load more for client-side sorting page: isDistanceSort ? 1 : this.page } const response = await listingsService.getListingsWithFilters(filters) let newItems = response.items || [] // Client-side distance sorting if (isDistanceSort) { newItems = this.sortByDistance(newItems) // Paginate client-side const start = (this.page - 1) * 20 const end = start + 20 newItems = newItems.slice(start, end) } if (this.page === 1) { this.listings = newItems this.preloadLcpImage(newItems) } else { this.listings = [...this.listings, ...newItems] } this.hasMore = newItems.length === 20 this.loading = false this.loadingMore = false this.updateListingsSection() this.setupLoadMoreButton() } catch (err) { console.error('Failed to load listings:', err) this.error = err.message this.loading = false this.loadingMore = false this.updateListingsSection() } } preloadLcpImage(listings) { const first = listings[0] if (!first) return const imageId = first.images?.[0]?.directus_files_id?.id || first.images?.[0]?.directus_files_id if (!imageId) return const url = directus.getThumbnailUrl(imageId, 180) if (document.querySelector(`link[rel="preload"][href="${url}"]`)) return const link = document.createElement('link') link.rel = 'preload' link.as = 'image' link.href = url link.fetchPriority = 'high' document.head.appendChild(link) } sortByDistance(listings) { if (!this.hasUserLocation()) return listings return listings .map(listing => { const lat = listing.location?.latitude const lng = listing.location?.longitude if (lat && lng) { listing._distance = locationsService.calculateDistance( this.userLat, this.userLng, lat, lng ) } else { listing._distance = Infinity } return listing }) .sort((a, b) => a._distance - b._distance) } setupLoadMoreButton() { const btn = this.querySelector('#load-more-btn') btn?.addEventListener('click', () => { this.page++ this.loadListings() }) } setupPullToRefresh() { let startY = 0 let pulling = false const threshold = 80 const indicator = document.createElement('div') indicator.className = 'pull-indicator' indicator.innerHTML = `
${t('home.pullToRefresh')}` this.prepend(indicator) this.addEventListener('touchstart', (e) => { if (window.scrollY === 0) { startY = e.touches[0].clientY pulling = true } }, { passive: true }) this.addEventListener('touchmove', (e) => { if (!pulling) return const currentY = e.touches[0].clientY const diff = currentY - startY if (diff > 0 && diff < threshold * 2) { indicator.style.transform = `translateY(${Math.min(diff / 2, threshold)}px)` indicator.style.opacity = Math.min(diff / threshold, 1) if (diff > threshold) { indicator.classList.add('ready') } else { indicator.classList.remove('ready') } } }, { passive: true }) this.addEventListener('touchend', () => { if (!pulling) return pulling = false if (indicator.classList.contains('ready')) { indicator.classList.add('refreshing') indicator.classList.remove('ready') // Refresh this.page = 1 this.listings = [] this.hasMore = true this.loadListings().then(() => { indicator.classList.remove('refreshing') indicator.style.transform = '' indicator.style.opacity = '' }) } else { indicator.style.transform = '' indicator.style.opacity = '' } }, { passive: true }) } updateListingsSection() { const listingsContainer = this.querySelector('#listings-container') if (listingsContainer) { listingsContainer.innerHTML = this.renderListings() this.setupLoadMoreButton() } const titleEl = this.querySelector('.listings-title') if (titleEl) { titleEl.textContent = this.getListingsTitle() } } updateTextContent() { const titleEl = this.querySelector('.listings-title') if (titleEl) titleEl.textContent = this.getListingsTitle() const sortSelect = this.querySelector('#sort-select') if (sortSelect) { sortSelect.querySelectorAll('option').forEach(opt => { const key = { distance: 'search.sortDistance', newest: 'search.sortNewest', oldest: 'search.sortOldest', price_asc: 'search.sortPriceAsc', price_desc: 'search.sortPriceDesc' }[opt.value] if (key) opt.textContent = t(key) }) } const priceBtn = this.querySelector('#toggle-price-filter span') if (priceBtn) priceBtn.textContent = t('search.priceRange') const clearBtn = this.querySelector('#clear-filters') if (clearBtn) clearBtn.textContent = t('search.clearAll') const applyBtn = this.querySelector('#apply-price') if (applyBtn) applyBtn.textContent = t('search.apply') const minInput = this.querySelector('#min-price') if (minInput) minInput.placeholder = t('search.min') const maxInput = this.querySelector('#max-price') if (maxInput) maxInput.placeholder = t('search.max') const searchBox = this.querySelector('search-box') if (searchBox) searchBox.loadAndRender() } getListingsTitle() { if (this.hasActiveFilters()) { const count = this.listings.length return t('search.resultsCount', { count }) || `${count} Ergebnisse` } return t('home.recentListings') } render() { const showFilters = this.hasActiveFilters() const hasPriceFilter = this.minPrice || this.maxPrice this.innerHTML = /* html */`${t('common.error')}
${this.hasActiveFilters() ? t('search.noResults') : t('home.noListings')}
${t('common.loading')}
` : ` `}