feat: merge home/search pages, add filters/sorting/infinite scroll, nearby sort with IP fallback
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user