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 */`
${showFilters ? `

${this.getListingsTitle()}

` : ''}
${showFilters ? ` ` : ''}
${this.renderListings()}
` // Sync search-box with current filters const searchBox = this.querySelector('search-box') if (searchBox) { searchBox.setFilters({ query: this.query, category: this.category }) } } togglePriceFilter() { this.priceFilterOpen = !this.priceFilterOpen const panel = this.querySelector('#price-filter-panel') if (panel) panel.classList.toggle('open', this.priceFilterOpen) } renderListings() { if (this.loading) { // Show skeleton cards while loading return Array(8).fill(0).map(() => '').join('') } if (this.error) { return /* html */`
⚠️

${t('common.error')}

` } if (this.listings.length === 0) { return /* html */`
${this.hasActiveFilters() ? '😕' : '📦'}

${this.hasActiveFilters() ? t('search.noResults') : t('home.noListings')}

` } const listingsHtml = this.listings.map((listing, index) => { const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : '' const locationName = listing.location?.name || '' return /* html */` ` }).join('') const loadMoreHtml = this.hasMore ? `
${this.loadingMore ? `

${t('common.loading')}

` : ` `}
` : '' return listingsHtml + loadMoreHtml } } customElements.define('page-home', PageHome) const style = document.createElement('style') style.textContent = /* css */` /* Search Section */ page-home .search-section { padding: var(--space-xl) 0 var(--space-lg); } /* Toolbar */ page-home .listings-toolbar { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; } page-home .sort-inline select { padding: var(--space-xs) var(--space-sm); border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-bg); color: var(--color-text); font-size: var(--font-size-sm); min-width: 150px; } page-home .btn-sm { padding: var(--space-xs) var(--space-sm); font-size: var(--font-size-sm); } page-home .btn-ghost { background: transparent; border: none; color: var(--color-text-muted); cursor: pointer; } page-home .btn-ghost:hover { color: var(--color-text); } page-home .filter-active-dot { width: 6px; height: 6px; border-radius: var(--radius-full); background: var(--color-accent); display: inline-block; } /* Price Filter Panel */ page-home .price-filter-panel { display: none; padding: var(--space-md); margin-bottom: var(--space-md); background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-md); } page-home .price-filter-panel.open { display: block; } page-home .price-inputs { display: flex; align-items: center; gap: var(--space-md); } page-home .price-inputs input { width: 100px; padding: var(--space-xs) var(--space-sm); border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-bg); color: var(--color-text); font-size: var(--font-size-sm); } page-home .price-separator { color: var(--color-text-muted); } @media (max-width: 768px) { page-home .listings-toolbar { width: 100%; } page-home .sort-inline { flex: 1; } page-home .sort-inline select { width: 100%; min-width: 0; } page-home .price-inputs { flex-wrap: wrap; } page-home .price-inputs input { flex: 1; min-width: 0; width: auto; } page-home .price-inputs .btn { width: 100%; } } /* Listings Section */ page-home .listings-section { margin-bottom: var(--space-xl); } page-home .listings-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-lg); flex-wrap: wrap; gap: var(--space-sm); } page-home .listings-title { margin: 0; } page-home .loading-state, page-home .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-2xl); color: var(--color-text-muted); grid-column: 1 / -1; } page-home .empty-icon { font-size: 3rem; margin-bottom: var(--space-md); filter: grayscale(1); } page-home .spinner { width: 40px; height: 40px; border: 3px solid var(--color-border); border-top-color: var(--color-primary); border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: var(--space-md); } @keyframes spin { to { transform: rotate(360deg); } } page-home .load-more-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-xl); color: var(--color-text-muted); grid-column: 1 / -1; } page-home .load-more-container .spinner { width: 30px; height: 30px; margin-bottom: var(--space-sm); } /* Pull to Refresh */ page-home .pull-indicator { position: absolute; top: 0; left: 50%; transform: translateX(-50%) translateY(-60px); display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-sm) var(--space-md); background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-full); opacity: 0; transition: opacity 0.2s ease; z-index: 100; font-size: var(--font-size-sm); color: var(--color-text-muted); } page-home .pull-indicator.ready { color: var(--color-primary); } page-home .pull-indicator.ready span::after { content: ' ↓'; } page-home .pull-spinner { width: 16px; height: 16px; border: 2px solid var(--color-border); border-top-color: var(--color-primary); border-radius: 50%; } page-home .pull-indicator.refreshing .pull-spinner { animation: spin 1s linear infinite; } ` document.head.appendChild(style)