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

761 lines
25 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.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
} 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()
}
}
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 */`
<section class="search-section">
<search-box no-navigate></search-box>
</section>
<section class="listings-section">
<div class="listings-header">
${showFilters ? `<h2 class="listings-title">${this.getListingsTitle()}</h2>` : ''}
<div class="listings-toolbar">
<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>
<button class="btn btn-sm btn-outline" id="toggle-price-filter">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="4" y1="21" x2="4" y2="14"></line>
<line x1="4" y1="10" x2="4" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="3"></line>
<line x1="20" y1="21" x2="20" y2="16"></line>
<line x1="20" y1="12" x2="20" y2="3"></line>
<line x1="1" y1="14" x2="7" y2="14"></line>
<line x1="9" y1="8" x2="15" y2="8"></line>
<line x1="17" y1="16" x2="23" y2="16"></line>
</svg>
<span>${t('search.priceRange')}</span>
${hasPriceFilter ? '<span class="filter-active-dot"></span>' : ''}
</button>
${showFilters ? `
<button class="btn btn-sm btn-ghost" id="clear-filters">${t('search.clearAll')}</button>
` : ''}
</div>
</div>
<div class="price-filter-panel ${this.priceFilterOpen ? 'open' : ''}" id="price-filter-panel">
<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-primary" id="apply-price">${t('search.apply')}</button>
</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
})
}
}
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(() => '<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);
}
/* 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)