feat: merge home/search pages, add filters/sorting/infinite scroll, nearby sort with IP fallback

This commit is contained in:
2026-02-04 11:39:42 +01:00
parent 96538ab1db
commit de0f3628ce
11 changed files with 765 additions and 298 deletions

View File

@@ -1,6 +1,7 @@
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 '../listing-card.js'
import '../search-box.js'
@@ -8,12 +9,27 @@ class PageHome extends HTMLElement {
constructor() {
super()
this.listings = []
this.loading = true
this.loading = false
this.loadingMore = false
this.error = null
this.currentFilters = {}
this.page = 1
this.hasMore = true
this.observer = null
// Filter state
this.query = ''
this.category = ''
this.sort = 'newest'
this.minPrice = null
this.maxPrice = null
// Geo state
this.userLat = null
this.userLng = null
}
connectedCallback() {
this.parseUrlParams()
this.render()
this.setupEventListeners()
this.loadListings()
@@ -21,70 +37,255 @@ class PageHome extends HTMLElement {
this.render()
this.setupEventListeners()
})
// Listen for URL changes (back/forward navigation)
window.addEventListener('hashchange', this.handleHashChange.bind(this))
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
if (this.observer) this.observer.disconnect()
window.removeEventListener('hashchange', this.handleHashChange.bind(this))
}
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')
// Listen for filter changes (live update)
searchBox?.addEventListener('filter-change', (e) => {
this.currentFilters = e.detail
this.loadListings()
const hadLocation = this.hasUserLocation()
this.category = e.detail.category || ''
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()
})
// Listen for search submit (with query)
searchBox?.addEventListener('search', (e) => {
e.preventDefault()
e.stopPropagation()
this.currentFilters = e.detail
this.loadListings()
this.query = e.detail.query || ''
this.category = e.detail.category || ''
this.updateUserLocation(e.detail)
this.updateUrl()
this.resetAndSearch()
return false
})
// 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.sort = 'newest'
this.minPrice = null
this.maxPrice = null
this.updateUrl()
this.render()
this.setupEventListeners()
this.resetAndSearch()
})
}
async loadListings() {
try {
if (this.page === 1) {
this.loading = true
this.error = null
this.updateListingsSection()
} 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.currentFilters.query || undefined,
category: this.currentFilters.category || undefined,
limit: 20
search: this.query || undefined,
category: this.category || 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)
this.listings = response.items || []
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
} else {
this.listings = [...this.listings, ...newItems]
}
this.hasMore = newItems.length === 20
this.loading = false
this.loadingMore = false
this.updateListingsSection()
this.setupInfiniteScroll()
} catch (err) {
console.error('Failed to load listings:', err)
this.error = err.message
this.loading = false
this.loadingMore = false
this.updateListingsSection()
}
}
updateListingsSection() {
const listingsGrid = this.querySelector('.listings-grid')
const sectionTitle = this.querySelector('.listings-title')
sortByDistance(listings) {
if (!this.hasUserLocation()) return listings
if (listingsGrid) {
listingsGrid.innerHTML = this.renderListings()
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)
}
setupInfiniteScroll() {
if (this.observer) this.observer.disconnect()
const sentinel = this.querySelector('#scroll-sentinel')
if (!sentinel || !this.hasMore) return
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.loadingMore && this.hasMore) {
this.page++
this.loadListings()
}
}, { rootMargin: '100px' })
this.observer.observe(sentinel)
}
updateListingsSection() {
const listingsContainer = this.querySelector('#listings-container')
if (listingsContainer) {
listingsContainer.innerHTML = this.renderListings()
this.setupInfiniteScroll()
}
if (sectionTitle) {
sectionTitle.textContent = this.getListingsTitle()
const titleEl = this.querySelector('.listings-title')
if (titleEl) {
titleEl.textContent = this.getListingsTitle()
}
}
getListingsTitle() {
if (this.currentFilters.query || this.currentFilters.category) {
if (this.hasActiveFilters()) {
const count = this.listings.length
return t('search.resultsCount', { count }) || `${count} Ergebnisse`
}
@@ -92,34 +293,110 @@ class PageHome extends HTMLElement {
}
render() {
const showFilters = this.hasActiveFilters()
this.innerHTML = /* html */`
<section class="search-section">
<search-box no-navigate></search-box>
</section>
<section class="recent-listings">
<h2 class="listings-title">${this.getListingsTitle()}</h2>
<div class="listings-grid">
${showFilters ? this.renderFilterBar() : ''}
<section class="listings-section">
<div class="listings-header">
<h2 class="listings-title">${this.getListingsTitle()}</h2>
${!showFilters ? `
<div class="sort-inline">
<select id="sort-select">
${this.hasUserLocation() ? `<option value="distance" ${this.sort === 'distance' ? 'selected' : ''}>${t('search.sortDistance')}</option>` : ''}
<option value="newest" ${this.sort === 'newest' ? 'selected' : ''}>${t('search.sortNewest')}</option>
<option value="oldest" ${this.sort === 'oldest' ? 'selected' : ''}>${t('search.sortOldest')}</option>
<option value="price_asc" ${this.sort === 'price_asc' ? 'selected' : ''}>${t('search.sortPriceAsc')}</option>
<option value="price_desc" ${this.sort === 'price_desc' ? 'selected' : ''}>${t('search.sortPriceDesc')}</option>
</select>
</div>
` : ''}
</div>
<div id="listings-container" class="listings-grid">
${this.renderListings()}
</div>
</section>
`
// Sync search-box with current filters
const searchBox = this.querySelector('search-box')
if (searchBox) {
searchBox.setFilters({
query: this.query,
category: this.category
})
}
}
renderFilterBar() {
return /* html */`
<section class="filter-bar">
<div class="filter-row">
<div class="price-filter">
<label>${t('search.priceRange')}</label>
<div class="price-inputs">
<input type="number" id="min-price" placeholder="${t('search.min')}"
value="${this.minPrice || ''}" min="0" step="1">
<span class="price-separator"></span>
<input type="number" id="max-price" placeholder="${t('search.max')}"
value="${this.maxPrice || ''}" min="0" step="1">
<button class="btn btn-sm btn-outline" id="apply-price">${t('search.apply')}</button>
</div>
</div>
<div class="sort-filter">
<label for="sort-select">${t('search.sortBy')}</label>
<select id="sort-select">
${this.hasUserLocation() ? `<option value="distance" ${this.sort === 'distance' ? 'selected' : ''}>${t('search.sortDistance')}</option>` : ''}
<option value="newest" ${this.sort === 'newest' ? 'selected' : ''}>${t('search.sortNewest')}</option>
<option value="oldest" ${this.sort === 'oldest' ? 'selected' : ''}>${t('search.sortOldest')}</option>
<option value="price_asc" ${this.sort === 'price_asc' ? 'selected' : ''}>${t('search.sortPriceAsc')}</option>
<option value="price_desc" ${this.sort === 'price_desc' ? 'selected' : ''}>${t('search.sortPriceDesc')}</option>
</select>
</div>
<button class="btn btn-sm btn-ghost" id="clear-filters">
${t('search.clearAll')}
</button>
</div>
</section>
`
}
renderListings() {
if (this.loading) {
return /* html */`<p class="loading-text">${t('common.loading')}</p>`
return /* html */`
<div class="loading-state">
<div class="spinner"></div>
<p>${t('common.loading')}</p>
</div>
`
}
if (this.error) {
return /* html */`<p class="error-text">${t('common.error')}</p>`
return /* html */`
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<p>${t('common.error')}</p>
</div>
`
}
if (this.listings.length === 0) {
return /* html */`<p class="empty-text">${t('home.noListings')}</p>`
return /* html */`
<div class="empty-state">
<div class="empty-icon">${this.hasActiveFilters() ? '😕' : '📦'}</div>
<p>${this.hasActiveFilters() ? t('search.noResults') : t('home.noListings')}</p>
</div>
`
}
return this.listings.map(listing => {
const listingsHtml = this.listings.map(listing => {
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : ''
const locationName = listing.location?.name || ''
@@ -127,14 +404,31 @@ class PageHome extends HTMLElement {
return /* html */`
<listing-card
listing-id="${listing.id}"
title="${listing.title || ''}"
title="${this.escapeHtml(listing.title || '')}"
price="${listing.price || ''}"
currency="${listing.currency || 'EUR'}"
location="${locationName}"
location="${this.escapeHtml(locationName)}"
image="${imageUrl}"
></listing-card>
`
}).join('')
const sentinelHtml = this.hasMore ? `
<div id="scroll-sentinel" class="scroll-sentinel">
${this.loadingMore ? `
<div class="spinner"></div>
<p>${t('common.loading')}</p>
` : ''}
</div>
` : ''
return listingsHtml + sentinelHtml
}
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
}
@@ -144,25 +438,165 @@ const style = document.createElement('style')
style.textContent = /* css */`
/* Search Section */
page-home .search-section {
padding: var(--space-xl) 0;
padding: var(--space-xl) 0 var(--space-lg);
}
/* Listings */
page-home .recent-listings {
/* Filter Bar */
page-home .filter-bar {
margin-bottom: var(--space-lg);
padding: var(--space-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
page-home .filter-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-lg);
align-items: flex-end;
}
page-home .price-filter,
page-home .sort-filter {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
page-home .price-filter label,
page-home .sort-filter label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
page-home .price-inputs {
display: flex;
align-items: center;
gap: var(--space-xs);
}
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);
}
page-home .sort-filter select,
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);
}
@media (max-width: 768px) {
page-home .filter-row {
flex-direction: column;
align-items: stretch;
}
page-home .price-inputs input {
flex: 1;
min-width: 0;
}
page-home .sort-filter select {
width: 100%;
}
}
/* Listings Section */
page-home .listings-section {
margin-bottom: var(--space-xl);
}
page-home .listings-title {
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-text,
page-home .error-text,
page-home .empty-text {
text-align: center;
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 .scroll-sentinel {
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 .scroll-sentinel .spinner {
width: 30px;
height: 30px;
margin-bottom: var(--space-sm);
}
`
document.head.appendChild(style)