import { t, i18n } from '../i18n.js' import { escapeHTML } from '../utils/helpers.js' import { categoriesService } from '../services/categories.js' const COUNTRIES = ['ch', 'de', 'at', 'fr', 'it', 'li'] const RADIUS_OPTIONS = [5, 10, 20, 50, 100, 200] class SearchBox extends HTMLElement { static get observedAttributes() { return ['category', 'subcategory', 'country', 'query'] } constructor() { super() this.loadFiltersFromStorage() } loadFiltersFromStorage() { const saved = localStorage.getItem('searchFilters') if (saved) { try { const filters = JSON.parse(saved) this.selectedCategory = filters.category || '' this.selectedSubcategory = filters.subcategory || '' this.selectedCountry = filters.country || 'ch' this.selectedRadius = filters.radius || 50 this.useCurrentLocation = filters.useCurrentLocation || false this.searchQuery = filters.query || '' } catch (e) { this.resetFilters() } } else { this.resetFilters() } } resetFilters() { this.selectedCategory = '' this.selectedSubcategory = '' this.selectedCountry = 'ch' this.selectedRadius = 50 this.useCurrentLocation = false this.searchQuery = '' this.categoryTree = [] this.geoLoading = false this.currentLat = null this.currentLng = null } saveFiltersToStorage() { const filters = { category: this.selectedCategory, subcategory: this.selectedSubcategory, country: this.selectedCountry, radius: this.selectedRadius, useCurrentLocation: this.useCurrentLocation, query: this.searchQuery } localStorage.setItem('searchFilters', JSON.stringify(filters)) } connectedCallback() { // Override from attributes if provided if (this.hasAttribute('category')) { this.selectedCategory = this.getAttribute('category') } if (this.hasAttribute('subcategory')) { this.selectedSubcategory = this.getAttribute('subcategory') } if (this.hasAttribute('country')) { this.selectedCountry = this.getAttribute('country') } if (this.hasAttribute('query')) { this.searchQuery = this.getAttribute('query') } this.loadAndRender() this.unsubscribe = i18n.subscribe(() => this.loadAndRender()) } async loadAndRender() { try { this.categoryTree = await categoriesService.getTree() } catch (e) { this.categoryTree = [] } this.render() this.setupEventListeners() } disconnectedCallback() { if (this.unsubscribe) this.unsubscribe() if (this._closeDropdown) { document.removeEventListener('click', this._closeDropdown) } } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return switch (name) { case 'category': this.selectedCategory = newValue || '' break case 'subcategory': this.selectedSubcategory = newValue || '' break case 'country': this.selectedCountry = newValue || 'ch' break case 'query': this.searchQuery = newValue || '' break } if (this.isConnected) { this.render() this.setupEventListeners() } } render() { this.innerHTML = /* html */` ${this.renderFilterBadges()} ` } findCategoryBySlug(tree, slug) { for (const cat of tree) { if (cat.slug === slug) return cat if (cat.children?.length) { const found = this.findCategoryBySlug(cat.children, slug) if (found) return found } } return null } getCategoryLabel() { const cat = this.findCategoryBySlug(this.categoryTree, this.selectedCategory) if (!cat) return this.selectedCategory const catName = categoriesService.getTranslatedName(cat) if (this.selectedSubcategory) { const sub = this.findCategoryBySlug(cat.children || [], this.selectedSubcategory) if (sub) return `${catName} › ${categoriesService.getTranslatedName(sub)}` } return catName } hasActiveFilters() { return this.searchQuery || this.selectedCategory || this.useCurrentLocation } getActiveFilterCount() { let count = 0 if (this.searchQuery) count++ if (this.selectedCategory) count++ if (this.useCurrentLocation) count++ return count } renderFilterBadges() { if (!this.hasActiveFilters()) return '' const badges = [] // Query badge if (this.searchQuery) { badges.push(/* html */` `) } // Category badge if (this.selectedCategory) { const categoryLabel = this.getCategoryLabel() badges.push(/* html */` `) } // Location badge (only when using current location with radius) if (this.useCurrentLocation) { const radiusText = t('search.radiusAround', { radius: this.selectedRadius }) badges.push(/* html */` `) } // Only show "clear all" if more than one filter is active const showClearAll = this.getActiveFilterCount() > 1 return /* html */`
${badges.join('')} ${showClearAll ? ` ` : ''}
` } renderFilters() { // Track which category accordion is expanded this._expandedCategory = this._expandedCategory || '' return /* html */`
${(this.categoryTree || []).map(cat => `
${(cat.children || []).map(sub => ` `).join('')}
`).join('')}
` } setupEventListeners() { const form = this.querySelector('#search-form') const queryInput = this.querySelector('#search-query') // Desktop selects const countrySelect = this.querySelector('#country-select') const radiusSelect = this.querySelector('#radius-select') // Mobile selects const countrySelectMobile = this.querySelector('#country-select-mobile') const radiusSelectMobile = this.querySelector('#radius-select-mobile') // Accordion dropdown const categoryTrigger = this.querySelector('#category-trigger') const categoryMenu = this.querySelector('#category-menu') form?.addEventListener('submit', (e) => { e.preventDefault() this.handleSearch() }) queryInput?.addEventListener('input', (e) => { this.searchQuery = e.target.value }) // Toggle dropdown if (categoryTrigger && categoryMenu) { categoryTrigger.addEventListener('click', (e) => { e.preventDefault() e.stopPropagation() categoryMenu.classList.toggle('open') }) } // Close dropdown on outside click this._closeDropdown = (e) => { if (!this.contains(e.target)) { categoryMenu?.classList.remove('open') } } document.addEventListener('click', this._closeDropdown) // Category accordion headers - toggle expand this.querySelectorAll('.category-accordion > .category-item').forEach(item => { item.addEventListener('click', (e) => { e.preventDefault() e.stopPropagation() const cat = item.dataset.category const accordion = item.closest('.category-accordion') // Toggle this accordion if (this._expandedCategory === cat) { this._expandedCategory = '' accordion?.classList.remove('expanded') } else { // Close other accordions this.querySelectorAll('.category-accordion.expanded').forEach(el => { el.classList.remove('expanded') }) this._expandedCategory = cat accordion?.classList.add('expanded') } }) }) // "All categories" button this.querySelector('.category-item--all')?.addEventListener('click', (e) => { e.stopPropagation() this.selectedCategory = '' this.selectedSubcategory = '' this._expandedCategory = '' categoryMenu?.classList.remove('open') this.emitFilterChange() this.render() this.setupEventListeners() }) // Subcategory items - select category + subcategory this.querySelectorAll('.subcategory-item').forEach(item => { item.addEventListener('click', (e) => { e.stopPropagation() this.selectedCategory = item.dataset.category this.selectedSubcategory = item.dataset.subcategory this._expandedCategory = '' categoryMenu?.classList.remove('open') this.emitFilterChange() this.render() this.setupEventListeners() }) }) // Country select handler (both desktop and mobile) const handleCountryChange = (e) => { if (e.target.value === 'current') { this.useCurrentLocation = true this.requestGeolocation() } else { this.useCurrentLocation = false this.selectedCountry = e.target.value } this.emitFilterChange() this.render() this.setupEventListeners() } countrySelect?.addEventListener('change', handleCountryChange) countrySelectMobile?.addEventListener('change', handleCountryChange) // Radius select handler (both desktop and mobile) const handleRadiusChange = (e) => { this.selectedRadius = parseInt(e.target.value) this.emitFilterChange() this.updateFilterBadges() } radiusSelect?.addEventListener('change', handleRadiusChange) radiusSelectMobile?.addEventListener('change', handleRadiusChange) // Filter badge click handlers this.querySelectorAll('.filter-badge').forEach(badge => { badge.addEventListener('click', (e) => { e.preventDefault() const filterType = badge.dataset.filter this.removeFilter(filterType) }) }) // Adjust select width to selected option (desktop only) this.adjustSelectWidth(countrySelect) this.adjustSelectWidth(radiusSelect) } updateFilterBadges() { const badgesContainer = this.querySelector('.filter-badges') if (badgesContainer) { badgesContainer.outerHTML = this.renderFilterBadges() // Re-attach event listeners for new badges this.querySelectorAll('.filter-badge').forEach(badge => { badge.addEventListener('click', (e) => { e.preventDefault() const filterType = badge.dataset.filter this.removeFilter(filterType) }) }) } } removeFilter(filterType) { switch (filterType) { case 'query': this.searchQuery = '' break case 'category': this.selectedCategory = '' this.selectedSubcategory = '' break case 'location': this.useCurrentLocation = false this.currentLat = null this.currentLng = null break case 'all': this.searchQuery = '' this.selectedCategory = '' this.selectedSubcategory = '' this.useCurrentLocation = false this.currentLat = null this.currentLng = null break } this.saveFiltersToStorage() this.emitFilterChange() this.render() this.setupEventListeners() } adjustSelectWidth(select) { if (!select) return // Only apply fixed width on desktop (768px+) if (window.innerWidth < 768) { select.style.width = '' return } // Create hidden span to measure text width const measurer = document.createElement('span') measurer.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:inherit;' select.parentElement.appendChild(measurer) const selectedOption = select.options[select.selectedIndex] measurer.textContent = selectedOption ? selectedOption.textContent : '' // Add padding for arrow, icon and buffer select.style.width = (measurer.offsetWidth + 90) + 'px' measurer.remove() } getFilterDetails() { return { query: this.searchQuery, category: this.selectedCategory, subcategory: this.selectedSubcategory, country: this.selectedCountry, useCurrentLocation: this.useCurrentLocation, lat: this.currentLat, lng: this.currentLng, radius: this.selectedRadius } } emitFilterChange() { this.saveFiltersToStorage() this.dispatchEvent(new CustomEvent('filter-change', { bubbles: true, detail: this.getFilterDetails() })) } handleSearch() { const params = new URLSearchParams() if (this.searchQuery) params.set('q', this.searchQuery) if (this.selectedCategory) params.set('category', this.selectedCategory) if (this.selectedSubcategory) params.set('sub', this.selectedSubcategory) if (this.useCurrentLocation && this.currentLat && this.currentLng) { params.set('lat', this.currentLat) params.set('lng', this.currentLng) params.set('radius', this.selectedRadius) } else if (!this.useCurrentLocation) { params.set('country', this.selectedCountry) } this.saveFiltersToStorage() // Emit custom event const event = new CustomEvent('search', { bubbles: true, cancelable: true, detail: { ...this.getFilterDetails(), params: params.toString() } }) const cancelled = !this.dispatchEvent(event) // Navigate to home page with params unless event was cancelled if (!cancelled && !this.hasAttribute('no-navigate')) { const url = '#/' + (params.toString() ? '?' + params.toString() : '') window.location.hash = url } } requestGeolocation() { if (!('geolocation' in navigator)) { this.handleGeoError() return } this.geoLoading = true this.updateGeoButton() navigator.geolocation.getCurrentPosition( (position) => { this.currentLat = position.coords.latitude this.currentLng = position.coords.longitude this.geoLoading = false this.updateGeoButton() this.emitFilterChange() }, (error) => { console.warn('Geolocation error:', error) this.handleGeoError() }, { timeout: 10000, enableHighAccuracy: false } ) } handleGeoError() { // 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() { const countrySelect = this.querySelector('#country-select') if (!countrySelect) return if (this.geoLoading) { countrySelect.disabled = true const currentOption = countrySelect.querySelector('option[value="current"]') if (currentOption) { currentOption.textContent = `⏳ ${t('search.locating')}` } } else { countrySelect.disabled = false const currentOption = countrySelect.querySelector('option[value="current"]') if (currentOption) { currentOption.textContent = `📍 ${t('search.currentLocation')}` } } } // Public API getFilters() { return { query: this.searchQuery, category: this.selectedCategory, subcategory: this.selectedSubcategory, country: this.selectedCountry, useCurrentLocation: this.useCurrentLocation, lat: this.currentLat, lng: this.currentLng, radius: this.selectedRadius } } setFilters(filters) { if (filters.query !== undefined) this.searchQuery = filters.query if (filters.category !== undefined) this.selectedCategory = filters.category if (filters.subcategory !== undefined) this.selectedSubcategory = filters.subcategory if (filters.country !== undefined) this.selectedCountry = filters.country if (filters.radius !== undefined) this.selectedRadius = filters.radius if (filters.useCurrentLocation !== undefined) this.useCurrentLocation = filters.useCurrentLocation this.saveFiltersToStorage() if (this.categoryTree && this.categoryTree.length > 0) { this.render() this.setupEventListeners() } } clearFilters() { this.resetFilters() localStorage.removeItem('searchFilters') this.loadAndRender() } } customElements.define('search-box', SearchBox) const style = document.createElement('style') style.textContent = /* css */` search-box { display: block; } search-box .search-box { width: 100%; background: var(--color-bg); border: 2px solid var(--color-border); border-radius: var(--radius-lg); box-sizing: border-box; } search-box .search-row { display: flex; border-bottom: 1px solid var(--color-border); } search-box .search-row:last-child { border-bottom: none; } search-box .search-field { flex: 1; display: flex; align-items: center; position: relative; } search-box .search-field + .search-field { border-left: 1px solid var(--color-border); } search-box .search-field.hidden, search-box .search-row.hidden { display: none; } search-box .field-icon { position: absolute; left: var(--space-md); color: var(--color-text-muted); pointer-events: none; } search-box .search-field input, search-box .search-field select { width: 100%; padding: var(--space-md); padding-left: 2.75rem; border: none; background: transparent; font-size: var(--font-size-base); color: var(--color-text); } search-box .search-field select { padding-left: var(--space-md); cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236B5B95' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right var(--space-md) center; padding-right: 2.5rem; } search-box .search-field-country select { padding-left: 2.75rem; } search-box .search-field input:focus, search-box .search-field select:focus { background: var(--color-bg-secondary); } search-box .search-field input:focus-visible, search-box .search-field select:focus-visible { outline: 2px solid var(--color-primary); outline-offset: -2px; } /* Mobile: Search query field top corners */ search-box .search-field-query input { border-radius: calc(var(--radius-lg) - 2px) calc(var(--radius-lg) - 2px) 0 0; } search-box .search-field-query input:focus { border-radius: calc(var(--radius-lg) - 2px) calc(var(--radius-lg) - 2px) 0 0; } search-box .search-field input::placeholder { color: var(--color-text-muted); } search-box .search-row-submit { padding: var(--space-sm); background: var(--color-bg-secondary); border-radius: 0 0 calc(var(--radius-lg) - 2px) calc(var(--radius-lg) - 2px); } search-box .btn-search { width: 100%; padding: var(--space-md); gap: var(--space-sm); } /* Mobile: full width for location/radius */ search-box .search-row-location .search-field, search-box .search-row-radius .search-field { flex: 1; } search-box .search-row-location .search-field select, search-box .search-row-radius .search-field select { width: 100%; } /* Mobile: hide desktop-only filter elements */ search-box .filter-location, search-box .filter-radius { display: none; } /* Desktop: two-row layout */ @media (min-width: 768px) { /* Hide mobile-only rows on desktop */ search-box .mobile-only { display: none !important; } /* Show inline filters on desktop */ search-box .filter-location, search-box .filter-radius { display: flex; border-left: 1px solid var(--color-border); } search-box .filter-radius.hidden { display: none; } search-box .filter-location .search-field, search-box .filter-radius .search-field { flex: 0 0 auto; } search-box .filter-location select, search-box .filter-radius select { width: auto; } search-box .search-box { display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto; border-radius: var(--radius-xl); } search-box .search-box .search-row { border-bottom: none; } /* Row 1: Search field + Button */ search-box .search-box .search-row-query { grid-column: 1; grid-row: 1; border-right: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); } search-box .search-box .search-row-submit { grid-column: 2; grid-row: 1; padding: var(--space-xs); background: transparent; border-bottom: 1px solid var(--color-border); } /* Row 2: Filters */ search-box .search-box .search-row-filters { grid-column: 1 / -1; grid-row: 2; display: flex; border-right: none; } search-box .search-box .btn-search { width: auto; padding: var(--space-md); border-radius: var(--radius-md); } /* Desktop: Submit row - top-right corner inherits from parent */ search-box .search-box .search-row-submit { border-radius: 0 calc(var(--radius-xl) - 2px) 0 0; } search-box .search-box .btn-search-text { display: none; } /* Category truncation */ search-box .search-box .category-dropdown-label { max-width: 250px; } /* Desktop: Search query field - only top-left rounded */ search-box .search-field-query input { border-radius: calc(var(--radius-xl) - 2px) 0 0 0; } /* Desktop: Category dropdown trigger - bottom-left rounded on hover/focus */ search-box .category-dropdown-trigger:hover, search-box .category-dropdown-trigger:focus { border-radius: 0 0 0 calc(var(--radius-xl) - 2px); } /* Desktop: Last filter (location/radius) - bottom-right rounded */ search-box .filter-location:last-child select, search-box .filter-radius:not(.hidden):last-child select { border-radius: 0 0 calc(var(--radius-xl) - 2px) 0; } search-box .filter-location:last-child select:hover, search-box .filter-location:last-child select:focus, search-box .filter-radius:not(.hidden):last-child select:hover, search-box .filter-radius:not(.hidden):last-child select:focus { background: var(--color-bg-secondary); border-radius: 0 0 calc(var(--radius-xl) - 2px) 0; } } /* Category Dropdown (Desktop) */ search-box .category-dropdown { position: relative; flex: 1; min-width: 0; } search-box .category-dropdown-trigger { width: 100%; display: flex; align-items: center; justify-content: space-between; padding: var(--space-md); background: transparent; border: none; font-size: var(--font-size-base); color: var(--color-text); cursor: pointer; text-align: left; transition: background var(--transition-fast); } search-box .category-dropdown-trigger:hover { background: var(--color-bg-secondary); } search-box .category-dropdown-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } search-box .category-dropdown-arrow { flex-shrink: 0; margin-left: var(--space-sm); color: var(--color-text-muted); transition: transform var(--transition-fast); } search-box .category-menu.open + .category-dropdown-arrow, search-box .category-dropdown-trigger:focus .category-dropdown-arrow { transform: rotate(180deg); } search-box .category-menu { position: absolute; top: 100%; left: 0; right: 0; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); opacity: 0; visibility: hidden; transform: translateY(-8px); transition: all var(--transition-fast); z-index: var(--z-dropdown); max-width: calc(100vw - 2 * var(--space-md)); } search-box .category-menu.open { opacity: 1; visibility: visible; transform: translateY(4px); } search-box .category-menu-inner { padding: var(--space-xs); max-height: 400px; overflow-y: auto; } search-box .category-item, search-box .category-item--all { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); padding: var(--space-sm) var(--space-md); background: transparent; border: none; border-radius: var(--radius-sm); font-size: var(--font-size-sm); color: var(--color-text); cursor: pointer; text-align: left; transition: background var(--transition-fast); } search-box .category-item span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } search-box .category-item:hover, search-box .category-item--all:hover { background: var(--color-bg-secondary); } search-box .category-item.active, search-box .category-item--all.active { background: var(--color-primary-light); color: var(--color-primary); } search-box .category-item-arrow { flex-shrink: 0; color: var(--color-text-muted); transition: transform var(--transition-fast); } /* Accordion */ search-box .category-accordion { border-top: 1px solid var(--color-border); } search-box .category-accordion:first-of-type { border-top: none; } search-box .category-accordion .category-item-arrow { transform: rotate(0deg); } search-box .category-accordion.expanded .category-item-arrow { transform: rotate(180deg); } search-box .subcategory-list { display: none; padding: 0 var(--space-xs) var(--space-xs); } search-box .category-accordion.expanded .subcategory-list { display: block; } search-box .subcategory-item { width: 100%; display: block; padding: var(--space-sm) var(--space-md); padding-left: var(--space-xl); background: transparent; border: none; border-radius: var(--radius-sm); font-size: var(--font-size-sm); color: var(--color-text-secondary); cursor: pointer; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: background var(--transition-fast), color var(--transition-fast); } search-box .subcategory-item:hover { background: var(--color-bg-secondary); color: var(--color-text); } search-box .subcategory-item.active { background: var(--color-primary-light); color: var(--color-primary); } /* Filter Badges */ search-box .filter-badges { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-top: var(--space-sm); justify-content: center; } search-box .filter-badge { display: inline-flex; align-items: center; gap: var(--space-xs); padding: var(--space-xs) var(--space-sm); background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-full); font-size: var(--font-size-sm); color: var(--color-text); cursor: pointer; transition: all var(--transition-fast); } search-box .filter-badge:hover { background: var(--color-bg-tertiary); border-color: var(--color-text-muted); } search-box .filter-badge-text { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } search-box .filter-badge-close { flex-shrink: 0; color: var(--color-text-muted); transition: color var(--transition-fast); } search-box .filter-badge:hover .filter-badge-close { color: var(--color-text); } search-box .filter-badge-clear { background: transparent; border-style: dashed; color: var(--color-text-muted); } search-box .filter-badge-clear:hover { background: var(--color-bg-secondary); color: var(--color-text); } ` document.head.appendChild(style) export { SearchBox, COUNTRIES, RADIUS_OPTIONS }