+ ${showFilters ? this.renderFilterBar() : ''}
+
+
+
+
${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 ? `
+
+ ` : ''
+
+ 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