Files
kashilo/js/components/search-box.js
2026-01-28 07:35:00 +01:00

912 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { t, i18n } from '../i18n.js';
const CATEGORIES = {
electronics: ['phones', 'computers', 'tv_audio', 'gaming', 'appliances'],
vehicles: ['cars', 'motorcycles', 'bikes', 'parts'],
furniture: ['living', 'bedroom', 'office', 'outdoor_furniture'],
clothing: ['women', 'men', 'kids', 'shoes', 'accessories'],
sports: ['fitness', 'outdoor', 'winter', 'water', 'team_sports'],
books: ['fiction', 'nonfiction', 'textbooks', 'music_movies'],
garden: ['plants', 'tools', 'outdoor_living', 'decoration'],
other: ['collectibles', 'art', 'handmade', 'services']
};
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.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.render();
this.setupEventListeners();
this.unsubscribe = i18n.subscribe(() => {
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 */`
<form class="search-box" id="search-form">
<div class="search-row search-row-query">
<div class="search-field search-field-query">
<svg class="field-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" id="search-query" placeholder="${t('header.searchPlaceholder')}" value="${this.searchQuery}" />
</div>
</div>
${this.renderFilters()}
<div class="search-row search-row-submit">
<button type="submit" class="btn btn-primary btn-search">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<span class="btn-search-text">${t('search.searchButton')}</span>
</button>
</div>
</form>
`;
}
renderFilters() {
// Track which category accordion is expanded
this._expandedCategory = this._expandedCategory || '';
return /* html */`
<!-- Accordion Category Dropdown -->
<div class="search-row search-row-filters">
<div class="category-dropdown">
<button type="button" class="category-dropdown-trigger" id="category-trigger">
<span class="category-dropdown-label">
${this.selectedCategory
? (this.selectedSubcategory
? `${t(`categories.${this.selectedCategory}`)} ${t(`subcategories.${this.selectedSubcategory}`)}`
: t(`categories.${this.selectedCategory}`))
: t('search.allCategories')}
</span>
<svg class="category-dropdown-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div class="category-menu" id="category-menu">
<div class="category-menu-inner">
<button type="button" class="category-item category-item--all ${!this.selectedCategory ? 'active' : ''}" data-category="" data-subcategory="">
${t('search.allCategories')}
</button>
${Object.keys(CATEGORIES).map(cat => `
<div class="category-accordion ${this._expandedCategory === cat ? 'expanded' : ''}">
<button type="button" class="category-item ${this.selectedCategory === cat ? 'active' : ''}" data-category="${cat}">
<span>${t(`categories.${cat}`)}</span>
<svg class="category-item-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div class="subcategory-list">
<button type="button" class="subcategory-item ${this.selectedCategory === cat && !this.selectedSubcategory ? 'active' : ''}" data-category="${cat}" data-subcategory="">
${t('search.allIn')} ${t(`categories.${cat}`)}
</button>
${CATEGORIES[cat].map(sub => `
<button type="button" class="subcategory-item ${this.selectedCategory === cat && this.selectedSubcategory === sub ? 'active' : ''}" data-category="${cat}" data-subcategory="${sub}">
${t(`subcategories.${sub}`)}
</button>
`).join('')}
</div>
</div>
`).join('')}
</div>
</div>
</div>
<!-- Location (inline on desktop) -->
<div class="filter-location">
<div class="search-field search-field-country">
<svg class="field-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<select id="country-select">
<option value="current" ${this.useCurrentLocation ? 'selected' : ''}>
📍 ${t('search.currentLocation')}
</option>
${COUNTRIES.map(c => `
<option value="${c}" ${!this.useCurrentLocation && this.selectedCountry === c ? 'selected' : ''}>
${t(`countries.${c}`)}
</option>
`).join('')}
</select>
</div>
</div>
<!-- Radius (inline on desktop) -->
<div class="filter-radius ${!this.useCurrentLocation ? 'hidden' : ''}">
<div class="search-field search-field-radius">
<select id="radius-select">
${RADIUS_OPTIONS.map(r => `
<option value="${r}" ${this.selectedRadius === r ? 'selected' : ''}>
${r} km
</option>
`).join('')}
</select>
</div>
</div>
</div>
<!-- Mobile only: Location row -->
<div class="search-row search-row-location mobile-only">
<div class="search-field search-field-country">
<svg class="field-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<select id="country-select-mobile">
<option value="current" ${this.useCurrentLocation ? 'selected' : ''}>
📍 ${t('search.currentLocation')}
</option>
${COUNTRIES.map(c => `
<option value="${c}" ${!this.useCurrentLocation && this.selectedCountry === c ? 'selected' : ''}>
${t(`countries.${c}`)}
</option>
`).join('')}
</select>
</div>
</div>
<!-- Mobile only: Radius row -->
<div class="search-row search-row-radius mobile-only ${!this.useCurrentLocation ? 'hidden' : ''}">
<div class="search-field search-field-radius">
<select id="radius-select-mobile">
${RADIUS_OPTIONS.map(r => `
<option value="${r}" ${this.selectedRadius === r ? 'selected' : ''}>
${r} km
</option>
`).join('')}
</select>
</div>
</div>
`;
}
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 = '';
this.saveFiltersToStorage();
categoryMenu?.classList.remove('open');
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 = '';
this.saveFiltersToStorage();
categoryMenu?.classList.remove('open');
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.saveFiltersToStorage();
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.saveFiltersToStorage();
};
radiusSelect?.addEventListener('change', handleRadiusChange);
radiusSelectMobile?.addEventListener('change', handleRadiusChange);
// Adjust select width to selected option (desktop only)
this.adjustSelectWidth(countrySelect);
this.adjustSelectWidth(radiusSelect);
}
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();
}
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: {
query: this.searchQuery,
category: this.selectedCategory,
subcategory: this.selectedSubcategory,
country: this.selectedCountry,
useCurrentLocation: this.useCurrentLocation,
lat: this.currentLat,
lng: this.currentLng,
radius: this.selectedRadius,
params: params.toString()
}
});
const cancelled = !this.dispatchEvent(event);
// Navigate to search page unless event was cancelled
if (!cancelled && !this.hasAttribute('no-navigate')) {
const url = '#/search' + (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();
},
(error) => {
console.warn('Geolocation error:', error);
this.handleGeoError();
},
{ timeout: 10000, enableHighAccuracy: false }
);
}
handleGeoError() {
// Keep useCurrentLocation = true, just stop loading indicator
// User can still search by current location (backend will handle it)
this.geoLoading = false;
this.updateGeoButton();
}
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();
this.render();
this.setupEventListeners();
}
clearFilters() {
this.resetFilters();
localStorage.removeItem('searchFilters');
this.render();
this.setupEventListeners();
}
}
customElements.define('search-box', SearchBox);
const style = document.createElement('style');
style.textContent = /* css */`
search-box {
display: block;
}
search-box .search-box {
max-width: 800px;
width: 100%;
margin: 0 auto;
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;
}
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);
}
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);
}
search-box .search-box .btn-search-text {
display: none;
}
/* Category truncation */
search-box .search-box .category-dropdown-label {
max-width: 250px;
}
}
/* 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);
}
`;
document.head.appendChild(style);
export { SearchBox, CATEGORIES, COUNTRIES, RADIUS_OPTIONS };