feat: merge home/search pages, add filters/sorting/infinite scroll, nearby sort with IP fallback
This commit is contained in:
197
docs/DIRECTUS-SCHEMA.md
Normal file
197
docs/DIRECTUS-SCHEMA.md
Normal 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 |
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
} 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)
|
||||
}
|
||||
|
||||
if (sectionTitle) {
|
||||
sectionTitle.textContent = this.getListingsTitle()
|
||||
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()
|
||||
}
|
||||
|
||||
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">
|
||||
${showFilters ? this.renderFilterBar() : ''}
|
||||
|
||||
<section class="listings-section">
|
||||
<div class="listings-header">
|
||||
<h2 class="listings-title">${this.getListingsTitle()}</h2>
|
||||
<div class="listings-grid">
|
||||
${!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 .loading-text,
|
||||
page-home .error-text,
|
||||
page-home .empty-text {
|
||||
text-align: center;
|
||||
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 .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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user