694 lines
23 KiB
JavaScript
694 lines
23 KiB
JavaScript
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 '../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.sort = 'newest'
|
||
this.minPrice = null
|
||
this.maxPrice = null
|
||
|
||
// Geo state
|
||
this.userLat = null
|
||
this.userLng = null
|
||
}
|
||
|
||
connectedCallback() {
|
||
this.parseUrlParams()
|
||
this.render()
|
||
this.setupEventListeners()
|
||
this.setupPullToRefresh()
|
||
this.loadListings()
|
||
this.unsubscribe = i18n.subscribe(() => {
|
||
this.render()
|
||
this.setupEventListeners()
|
||
})
|
||
|
||
// Listen for URL changes (back/forward navigation)
|
||
window.addEventListener('hashchange', this.handleHashChange.bind(this))
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
if (this.unsubscribe) this.unsubscribe()
|
||
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')
|
||
|
||
searchBox?.addEventListener('filter-change', (e) => {
|
||
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()
|
||
})
|
||
|
||
searchBox?.addEventListener('search', (e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
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() {
|
||
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,
|
||
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
|
||
} 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()
|
||
}
|
||
}
|
||
|
||
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 = `<div class="pull-spinner"></div><span>${t('home.pullToRefresh')}</span>`
|
||
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()
|
||
}
|
||
}
|
||
|
||
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()
|
||
|
||
this.innerHTML = /* html */`
|
||
<section class="search-section">
|
||
<search-box no-navigate></search-box>
|
||
</section>
|
||
|
||
${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) {
|
||
// Show skeleton cards while loading
|
||
return Array(8).fill(0).map(() => '<skeleton-card></skeleton-card>').join('')
|
||
}
|
||
|
||
if (this.error) {
|
||
return /* html */`
|
||
<div class="empty-state">
|
||
<div class="empty-icon">⚠️</div>
|
||
<p>${t('common.error')}</p>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
if (this.listings.length === 0) {
|
||
return /* html */`
|
||
<div class="empty-state">
|
||
<div class="empty-icon">${this.hasActiveFilters() ? '😕' : '📦'}</div>
|
||
<p>${this.hasActiveFilters() ? t('search.noResults') : t('home.noListings')}</p>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
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 || ''
|
||
|
||
return /* html */`
|
||
<listing-card
|
||
listing-id="${listing.id}"
|
||
title="${this.escapeHtml(listing.title || '')}"
|
||
price="${listing.price || ''}"
|
||
currency="${listing.currency || 'EUR'}"
|
||
location="${this.escapeHtml(locationName)}"
|
||
image="${imageUrl}"
|
||
></listing-card>
|
||
`
|
||
}).join('')
|
||
|
||
const loadMoreHtml = this.hasMore ? `
|
||
<div class="load-more-container">
|
||
${this.loadingMore ? `
|
||
<div class="spinner"></div>
|
||
<p>${t('common.loading')}</p>
|
||
` : `
|
||
<button id="load-more-btn" class="btn btn-outline">
|
||
${t('home.loadMore')}
|
||
</button>
|
||
`}
|
||
</div>
|
||
` : ''
|
||
|
||
return listingsHtml + loadMoreHtml
|
||
}
|
||
|
||
escapeHtml(text) {
|
||
const div = document.createElement('div')
|
||
div.textContent = text
|
||
return div.innerHTML
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/* 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-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)
|