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

197
docs/DIRECTUS-SCHEMA.md Normal file
View File

@@ -0,0 +1,197 @@
# Directus Schema Documentation
**Directus Version:** 11.14.1
**Database:** PostgreSQL
**API Endpoint:** https://api.dgray.io
## Collections Overview
| Collection | Beschreibung |
|------------|--------------|
| `listings` | Haupttabelle für Anzeigen |
| `listings_files` | Junction-Table für Listing-Bilder |
| `categories` | Kategorien mit Übersetzungen |
| `categories_translations` | Kategorie-Übersetzungen (i18n) |
| `locations` | Standorte für Anzeigen |
| `languages` | Verfügbare Sprachen |
| `conversations` | Chat-Konversationen |
| `messages` | Chat-Nachrichten |
| `favorites` | Favoriten (User-Listing Relation) |
| `reports` | Meldungen/Beschwerden |
---
## listings
Haupttabelle für alle Anzeigen.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | UUID | Primary Key |
| `status` | string | `draft`, `published`, `archived` |
| `sort` | integer | Sortierung |
| `title` | string | Titel der Anzeige |
| `slug` | string | URL-freundlicher Titel |
| `description` | text | Beschreibung |
| `price` | decimal | Preis |
| `currency` | string | Währung: `EUR`, `CHF`, `USD`, `XMR` |
| `price_mode` | string | `fiat` oder `xmr` (Referenzwährung) |
| `price_type` | string | `fixed`, `negotiable`, `free` |
| `condition` | string | `new`, `like_new`, `good`, `fair`, `poor` |
| `shipping` | boolean | Versand möglich |
| `shipping_cost` | decimal | Versandkosten |
| `views` | integer | Aufrufzähler |
| `expires_at` | datetime | Ablaufdatum |
| `monero_address` | string | XMR-Adresse für Zahlung |
| `date_created` | datetime | Erstellungsdatum |
| `date_updated` | datetime | Änderungsdatum |
| `user_created` | UUID | Ersteller (FK → directus_users) |
| `category` | UUID | Kategorie (FK → categories) |
| `location` | UUID | Standort (FK → locations) |
| `images` | o2m | Bilder (→ listings_files) |
---
## listings_files
Junction-Table für Listing-Bilder (Many-to-Many).
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | integer | Primary Key |
| `listings_id` | UUID | FK → listings |
| `directus_files_id` | UUID | FK → directus_files |
| `sort` | integer | Sortierung der Bilder |
---
## categories
Kategorien mit hierarchischer Struktur.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | UUID | Primary Key |
| `status` | string | `draft`, `published`, `archived` |
| `sort` | integer | Sortierung |
| `name` | string | Kategorie-Name (Fallback) |
| `slug` | string | URL-freundlicher Name |
| `icon` | string | Icon (Emoji oder Icon-Name) |
| `parent` | UUID | Parent-Kategorie (FK → categories) |
| `translations` | o2m | Übersetzungen (→ categories_translations) |
---
## categories_translations
Übersetzungen für Kategorien.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | integer | Primary Key |
| `categories_id` | UUID | FK → categories |
| `languages_code` | string | Sprachcode (`de`, `en`, `fr`) |
| `name` | string | Übersetzter Name |
---
## locations
Standorte für Anzeigen.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | UUID | Primary Key |
| `name` | string | Ortsname |
| `postal_code` | string | Postleitzahl |
| `region` | string | Region/Kanton |
| `country` | string | Land: `DE`, `AT`, `CH`, `FR`, `IT`, `LI` |
| `latitude` | float | Breitengrad |
| `longitude` | float | Längengrad |
---
## languages
Verfügbare Sprachen.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `code` | string | Primary Key, z.B. `de`, `en`, `fr` |
| `name` | string | Sprachname |
---
## conversations
Chat-Konversationen zwischen Usern.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | UUID | Primary Key |
| `listing` | UUID | FK → listings |
| `buyer` | UUID | FK → directus_users (Interessent) |
| `seller` | UUID | FK → directus_users (Verkäufer) |
| `date_created` | datetime | Erstellungsdatum |
| `date_updated` | datetime | Letzte Nachricht |
---
## messages
Nachrichten in Konversationen.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | UUID | Primary Key |
| `conversation` | UUID | FK → conversations |
| `sender` | UUID | FK → directus_users |
| `content` | text | Verschlüsselter Inhalt |
| `date_created` | datetime | Zeitstempel |
---
## favorites
User-Favoriten.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | UUID | Primary Key |
| `user` | UUID | FK → directus_users |
| `listing` | UUID | FK → listings |
| `date_created` | datetime | Hinzugefügt am |
---
## reports
Meldungen von Anzeigen.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | UUID | Primary Key |
| `listing` | UUID | FK → listings |
| `reporter` | UUID | FK → directus_users |
| `reason` | string | Grund der Meldung |
| `description` | text | Details |
| `status` | string | `pending`, `reviewed`, `resolved` |
| `date_created` | datetime | Meldungsdatum |
---
## Public Role Permissions
| Collection | Read | Create | Update | Delete | Hinweise |
|------------|:----:|:------:|:------:|:------:|----------|
| `listings` | ✓ | ✓ | - | - | Nur `status=published` lesen |
| `listings_files` | ✓ | ✓ | - | - | Für Bilder-Upload |
| `directus_files` | ✓ | ✓ | - | - | Asset-Upload |
| `categories` | ✓ | - | - | - | Nur `status=published` |
| `categories_translations` | ✓ | - | - | - | Für i18n |
| `locations` | ✓ | ✓ | - | - | User kann neue Orte anlegen |
| `languages` | ✓ | - | - | - | Für Sprach-Auswahl |
| `conversations` | ✓ | ✓ | - | - | Nur eigene |
| `messages` | ✓ | ✓ | - | - | Nur in eigenen Konversationen |
| `favorites` | ✓ | ✓ | - | ✓ | Nur eigene |
| `reports` | - | ✓ | - | - | Nur erstellen |

View File

@@ -5,7 +5,6 @@ import './app-header.js'
import './app-footer.js'
import './auth-modal.js'
import './pages/page-home.js'
import './pages/page-search.js'
import './pages/page-listing.js'
import './pages/page-create.js'
import './pages/page-not-found.js'
@@ -45,7 +44,7 @@ class AppShell extends HTMLElement {
router
.register('/', 'page-home')
.register('/search', 'page-search')
.register('/search', 'page-home') // Redirect search to home
.register('/listing/:id', 'page-listing')
.register('/create', 'page-create')

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)

View File

@@ -1,244 +0,0 @@
import { t, i18n } from '../../i18n.js'
import { router } from '../../router.js'
import { listingsService } from '../../services/listings.js'
import { directus } from '../../services/directus.js'
import '../search-box.js'
import '../listing-card.js'
class PageSearch extends HTMLElement {
constructor() {
super()
this.results = []
this.loading = false
this.error = null
}
connectedCallback() {
this.parseUrlParams()
this.render()
this.afterRender()
this.unsubscribe = i18n.subscribe(() => {
this.render()
this.afterRender()
})
if (this.hasFilters()) {
this.performSearch()
}
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
}
parseUrlParams() {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
this.query = params.get('q') || ''
this.category = params.get('category') || ''
this.subcategory = params.get('sub') || ''
this.country = params.get('country') || 'ch'
this.useCurrentLocation = params.get('location') === 'current'
this.radius = parseInt(params.get('radius')) || 50
this.lat = params.get('lat') ? parseFloat(params.get('lat')) : null
this.lng = params.get('lng') ? parseFloat(params.get('lng')) : null
}
hasFilters() {
return this.query || this.category || this.subcategory
}
afterRender() {
const searchBox = this.querySelector('search-box')
if (searchBox) {
searchBox.setFilters({
query: this.query,
category: this.category,
subcategory: this.subcategory,
country: this.country,
useCurrentLocation: this.useCurrentLocation,
radius: this.radius
})
searchBox.addEventListener('search', (e) => {
this.handleSearch(e.detail)
})
}
}
handleSearch(filters) {
this.query = filters.query
this.category = filters.category
this.subcategory = filters.subcategory
this.country = filters.country
this.useCurrentLocation = filters.useCurrentLocation
this.radius = filters.radius
this.lat = filters.lat
this.lng = filters.lng
this.performSearch()
}
render() {
this.innerHTML = /* html */`
<div class="search-page">
<section class="search-header">
<search-box no-navigate></search-box>
</section>
<section class="search-results" id="results">
${this.renderResults()}
</section>
</div>
`
}
async performSearch() {
this.loading = true
this.error = null
this.updateResults()
try {
const filters = {
search: this.query || undefined,
category: this.category || undefined,
limit: 50
}
const response = await listingsService.getListingsWithFilters(filters)
this.results = response.items || []
this.loading = false
this.updateResults()
} catch (err) {
console.error('Search failed:', err)
this.error = err.message
this.loading = false
this.updateResults()
}
}
updateResults() {
const resultsContainer = this.querySelector('#results')
if (resultsContainer) {
resultsContainer.innerHTML = this.renderResults()
}
}
renderResults() {
if (this.loading) {
return /* html */`
<div class="loading">
<div class="spinner"></div>
<p>${t('search.loading')}</p>
</div>
`
}
if (this.error) {
return /* html */`
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<p>${t('common.error')}</p>
</div>
`
}
if (!this.hasFilters() && this.results.length === 0) {
return /* html */`
<div class="empty-state">
<div class="empty-state-icon">🔍</div>
<p>${t('search.enterQuery')}</p>
</div>
`
}
if (this.results.length === 0) {
return /* html */`
<div class="empty-state">
<div class="empty-state-icon">😕</div>
<p>${t('search.noResults')}</p>
</div>
`
}
return /* html */`
<p class="results-count">${t('search.resultsCount', { count: this.results.length })}</p>
<div class="listings-grid">
${this.results.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 || listing.location || ''
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('')}
</div>
`
}
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
}
customElements.define('page-search', PageSearch)
const style = document.createElement('style')
style.textContent = /* css */`
page-search .search-page {
padding: var(--space-lg) 0;
}
page-search .search-header {
margin-bottom: var(--space-xl);
}
page-search .results-count {
color: var(--color-text-muted);
margin-bottom: var(--space-md);
}
page-search .loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-2xl);
color: var(--color-text-muted);
}
page-search .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-search .empty-state {
text-align: center;
padding: var(--space-2xl);
color: var(--color-text-muted);
}
page-search .empty-state-icon {
font-size: 3rem;
margin-bottom: var(--space-md);
}
`
document.head.appendChild(style)

View File

@@ -603,9 +603,9 @@ class SearchBox extends HTMLElement {
const cancelled = !this.dispatchEvent(event)
// Navigate to search page unless event was cancelled
// Navigate to home page with params unless event was cancelled
if (!cancelled && !this.hasAttribute('no-navigate')) {
const url = '#/search' + (params.toString() ? '?' + params.toString() : '')
const url = '#/' + (params.toString() ? '?' + params.toString() : '')
window.location.hash = url
}
}
@@ -625,6 +625,7 @@ class SearchBox extends HTMLElement {
this.currentLng = position.coords.longitude
this.geoLoading = false
this.updateGeoButton()
this.emitFilterChange()
},
(error) => {
console.warn('Geolocation error:', error)
@@ -635,10 +636,27 @@ class SearchBox extends HTMLElement {
}
handleGeoError() {
// Keep useCurrentLocation = true, just stop loading indicator
// User can still search by current location (backend will handle it)
// Try IP-based geolocation as fallback
this.fetchIpLocation()
}
async fetchIpLocation() {
try {
const response = await fetch('https://ipapi.co/json/')
if (response.ok) {
const data = await response.json()
if (data.latitude && data.longitude) {
this.currentLat = data.latitude
this.currentLng = data.longitude
console.log('IP-based location:', data.city, data.country_code)
}
}
} catch (e) {
console.warn('IP geolocation failed:', e)
}
this.geoLoading = false
this.updateGeoButton()
this.emitFilterChange()
}
updateGeoButton() {

View File

@@ -16,14 +16,44 @@ const CURRENCY_SYMBOLS = {
JPY: '¥'
}
const CACHE_DURATION = 30 * 60 * 1000 // 30 minutes (CoinGecko free tier is strict)
const MIN_REQUEST_INTERVAL = 60 * 1000 // 60 seconds between requests
const CACHE_DURATION = 60 * 60 * 1000 // 60 minutes (CoinGecko free tier is strict)
const MIN_REQUEST_INTERVAL = 120 * 1000 // 2 minutes between requests
const STORAGE_KEY = 'xmr_rates_cache'
let cachedRates = null
let cacheTimestamp = 0
let lastRequestTime = 0
let pendingRequest = null
// Load from localStorage on init
function loadFromStorage() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const { rates, timestamp } = JSON.parse(stored)
if (rates && timestamp && Date.now() - timestamp < CACHE_DURATION) {
cachedRates = rates
cacheTimestamp = timestamp
}
}
} catch (e) {
// Ignore storage errors
}
}
function saveToStorage() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
rates: cachedRates,
timestamp: cacheTimestamp
}))
} catch (e) {
// Ignore storage errors
}
}
loadFromStorage()
/**
* Fetches current XMR rates from CoinGecko with rate limiting
* @returns {Promise<Object>} Rates per currency (e.g. { EUR: 329.05, USD: 388.87 })
@@ -87,6 +117,7 @@ async function fetchRates() {
// Update cache
cachedRates = rates
cacheTimestamp = Date.now()
saveToStorage()
return rates
} catch (error) {

View File

@@ -269,7 +269,9 @@ class DirectusService {
'location.id',
'location.name',
'location.postal_code',
'location.country'
'location.country',
'location.latitude',
'location.longitude'
],
filter: options.filter || { status: { _eq: 'published' } },
sort: options.sort || ['-date_created'],

View File

@@ -55,12 +55,12 @@ class ListingsService {
directusFilter.location = { _eq: filters.location }
}
if (filters.minPrice !== undefined) {
if (filters.minPrice != null) {
directusFilter.price = directusFilter.price || {}
directusFilter.price._gte = filters.minPrice
}
if (filters.maxPrice !== undefined) {
if (filters.maxPrice != null) {
directusFilter.price = directusFilter.price || {}
directusFilter.price._lte = filters.maxPrice
}

View File

@@ -59,7 +59,17 @@
"resultsCount": "{{count}} Ergebnisse gefunden",
"allIn": "Alles in",
"clearAll": "Alle löschen",
"radiusAround": "{{radius}} km Umkreis"
"radiusAround": "{{radius}} km Umkreis",
"priceRange": "Preis",
"min": "Min",
"max": "Max",
"apply": "Anwenden",
"sortBy": "Sortieren",
"sortNewest": "Neueste zuerst",
"sortOldest": "Älteste zuerst",
"sortPriceAsc": "Preis aufsteigend",
"sortPriceDesc": "Preis absteigend",
"sortDistance": "In der Nähe"
},
"subcategories": {
"phones": "Handy & Telefon",

View File

@@ -59,7 +59,17 @@
"resultsCount": "{{count}} results found",
"allIn": "All in",
"clearAll": "Clear all",
"radiusAround": "{{radius}} km radius"
"radiusAround": "{{radius}} km radius",
"priceRange": "Price",
"min": "Min",
"max": "Max",
"apply": "Apply",
"sortBy": "Sort by",
"sortNewest": "Newest first",
"sortOldest": "Oldest first",
"sortPriceAsc": "Price: low to high",
"sortPriceDesc": "Price: high to low",
"sortDistance": "Nearby"
},
"subcategories": {
"phones": "Phones & Tablets",

View File

@@ -59,7 +59,17 @@
"resultsCount": "{{count}} résultats trouvés",
"allIn": "Tout dans",
"clearAll": "Tout effacer",
"radiusAround": "{{radius}} km autour"
"radiusAround": "{{radius}} km autour",
"priceRange": "Prix",
"min": "Min",
"max": "Max",
"apply": "Appliquer",
"sortBy": "Trier par",
"sortNewest": "Plus récent",
"sortOldest": "Plus ancien",
"sortPriceAsc": "Prix croissant",
"sortPriceDesc": "Prix décroissant",
"sortDistance": "À proximité"
},
"subcategories": {
"phones": "Téléphones & Tablettes",