Files
kashilo/js/components/pages/page-home.js

702 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.sort = 'newest'
this.minPrice = null
this.maxPrice = null
// 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.unsubscribe = i18n.subscribe(() => {
this.render()
this.setupEventListeners()
})
// Re-render listings on auth change to show owner badges
this.authUnsubscribe = 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() {
if (this.unsubscribe) this.unsubscribe()
if (this.authUnsubscribe) this.authUnsubscribe()
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.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="${escapeHTML(listing.title || '')}"
price="${listing.price || ''}"
currency="${listing.currency || 'EUR'}"
location="${escapeHTML(locationName)}"
image="${imageUrl}"
owner-id="${listing.user_created || ''}"
></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
}
}
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)