diff --git a/docs/DIRECTUS-SCHEMA.md b/docs/DIRECTUS-SCHEMA.md new file mode 100644 index 0000000..19cb73a --- /dev/null +++ b/docs/DIRECTUS-SCHEMA.md @@ -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 | diff --git a/js/components/app-shell.js b/js/components/app-shell.js index 734cf28..c47c87d 100644 --- a/js/components/app-shell.js +++ b/js/components/app-shell.js @@ -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') diff --git a/js/components/pages/page-home.js b/js/components/pages/page-home.js index 29b10b4..18594d3 100644 --- a/js/components/pages/page-home.js +++ b/js/components/pages/page-home.js @@ -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 */`
-
-

${this.getListingsTitle()}

-
+ ${showFilters ? this.renderFilterBar() : ''} + +
+
+

${this.getListingsTitle()}

+ ${!showFilters ? ` +
+ +
+ ` : ''} +
+
${this.renderListings()}
` + + // 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 */` +
+
+
+ +
+ + + + +
+
+ +
+ + +
+ + +
+
+ ` } renderListings() { if (this.loading) { - return /* html */`

${t('common.loading')}

` + return /* html */` +
+
+

${t('common.loading')}

+
+ ` } if (this.error) { - return /* html */`

${t('common.error')}

` + return /* html */` +
+
⚠️
+

${t('common.error')}

+
+ ` } if (this.listings.length === 0) { - return /* html */`

${t('home.noListings')}

` + return /* html */` +
+
${this.hasActiveFilters() ? '😕' : '📦'}
+

${this.hasActiveFilters() ? t('search.noResults') : t('home.noListings')}

+
+ ` } - 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 */` ` }).join('') + + const sentinelHtml = this.hasMore ? ` +
+ ${this.loadingMore ? ` +
+

${t('common.loading')}

+ ` : ''} +
+ ` : '' + + 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) diff --git a/js/components/pages/page-search.js b/js/components/pages/page-search.js deleted file mode 100644 index e3cbe1a..0000000 --- a/js/components/pages/page-search.js +++ /dev/null @@ -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 */` -
-
- -
- -
- ${this.renderResults()} -
-
- ` - } - - 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 */` -
-
-

${t('search.loading')}

-
- ` - } - - if (this.error) { - return /* html */` -
-
⚠️
-

${t('common.error')}

-
- ` - } - - if (!this.hasFilters() && this.results.length === 0) { - return /* html */` -
-
🔍
-

${t('search.enterQuery')}

-
- ` - } - - if (this.results.length === 0) { - return /* html */` -
-
😕
-

${t('search.noResults')}

-
- ` - } - - return /* html */` -

${t('search.resultsCount', { count: this.results.length })}

-
- ${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 */` - - ` - }).join('')} -
- ` - } - - 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) diff --git a/js/components/search-box.js b/js/components/search-box.js index d55b65b..6b21259 100644 --- a/js/components/search-box.js +++ b/js/components/search-box.js @@ -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() { diff --git a/js/services/currency.js b/js/services/currency.js index 2c0ba66..eb7dc2e 100644 --- a/js/services/currency.js +++ b/js/services/currency.js @@ -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} 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) { diff --git a/js/services/directus.js b/js/services/directus.js index 507eda0..4f838a0 100644 --- a/js/services/directus.js +++ b/js/services/directus.js @@ -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'], diff --git a/js/services/listings.js b/js/services/listings.js index f5bf65c..2f0e003 100644 --- a/js/services/listings.js +++ b/js/services/listings.js @@ -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 } diff --git a/locales/de.json b/locales/de.json index 1c62bee..e3fddd2 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index d198602..e82bbb6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/fr.json b/locales/fr.json index afb30ec..377fac0 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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",