refactor: replace hardcoded categories with Directus-powered category tree and translations
This commit is contained in:
@@ -21,7 +21,7 @@ class PageCreate extends HTMLElement {
|
||||
this.formData = this.loadDraft() || this.getEmptyFormData()
|
||||
this.imageFiles = []
|
||||
this.imagePreviews = []
|
||||
this.categories = []
|
||||
this.categoryTree = []
|
||||
this.submitting = false
|
||||
this.isNewAccount = true
|
||||
}
|
||||
@@ -183,10 +183,10 @@ class PageCreate extends HTMLElement {
|
||||
|
||||
async loadCategories() {
|
||||
try {
|
||||
this.categories = await categoriesService.getAll()
|
||||
this.categoryTree = await categoriesService.getTree()
|
||||
} catch (e) {
|
||||
console.error('Failed to load categories:', e)
|
||||
this.categories = []
|
||||
this.categoryTree = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,10 +238,14 @@ class PageCreate extends HTMLElement {
|
||||
<label class="label" for="category">${t('create.category')}</label>
|
||||
<select class="input" id="category" name="category" required>
|
||||
<option value="">${t('create.selectCategory')}</option>
|
||||
${this.categories.map(cat => `
|
||||
<option value="${cat.id}" ${this.formData.category === cat.id ? 'selected' : ''}>
|
||||
${t(`categories.${cat.slug}`) || cat.name}
|
||||
</option>
|
||||
${(this.categoryTree || []).map(cat => `
|
||||
<optgroup label="${categoriesService.getTranslatedName(cat)}">
|
||||
${(cat.children || []).map(sub => `
|
||||
<option value="${sub.id}" ${this.formData.category === sub.id ? 'selected' : ''}>
|
||||
${categoriesService.getTranslatedName(sub)}
|
||||
</option>
|
||||
`).join('')}
|
||||
</optgroup>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ class PageHome extends HTMLElement {
|
||||
// Filter state
|
||||
this.query = ''
|
||||
this.category = ''
|
||||
this.subcategory = ''
|
||||
this.sort = 'newest'
|
||||
this.minPrice = null
|
||||
this.maxPrice = null
|
||||
@@ -39,8 +40,7 @@ class PageHome extends HTMLElement {
|
||||
this.setupPullToRefresh()
|
||||
this.loadListings()
|
||||
this.unsubscribe = i18n.subscribe(() => {
|
||||
this.render()
|
||||
this.setupEventListeners()
|
||||
this.updateTextContent()
|
||||
})
|
||||
|
||||
// Re-render listings on auth change to show owner badges
|
||||
@@ -129,6 +129,7 @@ class PageHome extends HTMLElement {
|
||||
searchBox?.addEventListener('filter-change', (e) => {
|
||||
const hadLocation = this.hasUserLocation()
|
||||
this.category = e.detail.category || ''
|
||||
this.subcategory = e.detail.subcategory || ''
|
||||
this.query = e.detail.query || ''
|
||||
this.updateUserLocation(e.detail)
|
||||
this.updateUrl()
|
||||
@@ -147,6 +148,7 @@ class PageHome extends HTMLElement {
|
||||
e.stopPropagation()
|
||||
this.query = e.detail.query || ''
|
||||
this.category = e.detail.category || ''
|
||||
this.subcategory = e.detail.subcategory || ''
|
||||
this.updateUserLocation(e.detail)
|
||||
this.updateUrl()
|
||||
this.resetAndSearch()
|
||||
@@ -184,6 +186,7 @@ class PageHome extends HTMLElement {
|
||||
this.querySelector('#clear-filters')?.addEventListener('click', () => {
|
||||
this.query = ''
|
||||
this.category = ''
|
||||
this.subcategory = ''
|
||||
this.sort = 'newest'
|
||||
this.minPrice = null
|
||||
this.maxPrice = null
|
||||
@@ -210,6 +213,7 @@ class PageHome extends HTMLElement {
|
||||
const filters = {
|
||||
search: this.query || undefined,
|
||||
category: this.category || undefined,
|
||||
subcategory: this.subcategory || undefined,
|
||||
sort: isDistanceSort ? 'newest' : this.sort, // Backend can't sort by distance
|
||||
minPrice: this.minPrice,
|
||||
maxPrice: this.maxPrice,
|
||||
@@ -348,6 +352,28 @@ class PageHome extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
updateTextContent() {
|
||||
const titleEl = this.querySelector('.listings-title')
|
||||
if (titleEl) titleEl.textContent = this.getListingsTitle()
|
||||
|
||||
const sortSelect = this.querySelector('#sort-select')
|
||||
if (sortSelect) {
|
||||
sortSelect.querySelectorAll('option').forEach(opt => {
|
||||
const key = {
|
||||
distance: 'search.sortDistance',
|
||||
newest: 'search.sortNewest',
|
||||
oldest: 'search.sortOldest',
|
||||
price_asc: 'search.sortPriceAsc',
|
||||
price_desc: 'search.sortPriceDesc'
|
||||
}[opt.value]
|
||||
if (key) opt.textContent = t(key)
|
||||
})
|
||||
}
|
||||
|
||||
const searchBox = this.querySelector('search-box')
|
||||
if (searchBox) searchBox.loadAndRender()
|
||||
}
|
||||
|
||||
getListingsTitle() {
|
||||
if (this.hasActiveFilters()) {
|
||||
const count = this.listings.length
|
||||
|
||||
@@ -7,6 +7,7 @@ import { escapeHTML } from '../../utils/helpers.js'
|
||||
import '../chat-widget.js'
|
||||
import '../location-map.js'
|
||||
import '../listing-card.js'
|
||||
import { categoriesService } from '../../services/categories.js'
|
||||
|
||||
class PageListing extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -150,7 +151,9 @@ class PageListing extends HTMLElement {
|
||||
const firstImage = hasImages ? this.getImageUrl(images[0]) : null
|
||||
this.allImages = images
|
||||
|
||||
const categoryName = this.listing.category?.name || ''
|
||||
const categoryName = this.listing.category
|
||||
? categoriesService.getTranslatedName(this.listing.category)
|
||||
: ''
|
||||
const priceInfo = this.getFormattedPrice()
|
||||
const createdDate = this.listing.date_created
|
||||
? new Date(this.listing.date_created).toLocaleDateString()
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { t, i18n } from '../i18n.js'
|
||||
import { escapeHTML } from '../utils/helpers.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']
|
||||
}
|
||||
import { categoriesService } from '../services/categories.js'
|
||||
|
||||
const COUNTRIES = ['ch', 'de', 'at', 'fr', 'it', 'li']
|
||||
const RADIUS_OPTIONS = [5, 10, 20, 50, 100, 200]
|
||||
@@ -51,6 +41,7 @@ class SearchBox extends HTMLElement {
|
||||
this.selectedRadius = 50
|
||||
this.useCurrentLocation = false
|
||||
this.searchQuery = ''
|
||||
this.categoryTree = []
|
||||
this.geoLoading = false
|
||||
this.currentLat = null
|
||||
this.currentLng = null
|
||||
@@ -83,12 +74,18 @@ class SearchBox extends HTMLElement {
|
||||
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()
|
||||
this.unsubscribe = i18n.subscribe(() => {
|
||||
this.render()
|
||||
this.setupEventListeners()
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -151,6 +148,18 @@ class SearchBox extends HTMLElement {
|
||||
`
|
||||
}
|
||||
|
||||
getCategoryLabel() {
|
||||
const cat = this.categoryTree.find(c => c.slug === this.selectedCategory)
|
||||
if (!cat) return this.selectedCategory
|
||||
|
||||
const catName = categoriesService.getTranslatedName(cat)
|
||||
if (this.selectedSubcategory) {
|
||||
const sub = (cat.children || []).find(s => s.slug === this.selectedSubcategory)
|
||||
if (sub) return `${catName} › ${categoriesService.getTranslatedName(sub)}`
|
||||
}
|
||||
return catName
|
||||
}
|
||||
|
||||
hasActiveFilters() {
|
||||
return this.searchQuery || this.selectedCategory || this.useCurrentLocation
|
||||
}
|
||||
@@ -183,9 +192,7 @@ class SearchBox extends HTMLElement {
|
||||
|
||||
// Category badge
|
||||
if (this.selectedCategory) {
|
||||
const categoryLabel = this.selectedSubcategory
|
||||
? `${t(`categories.${this.selectedCategory}`)} › ${t(`subcategories.${this.selectedSubcategory}`)}`
|
||||
: t(`categories.${this.selectedCategory}`)
|
||||
const categoryLabel = this.getCategoryLabel()
|
||||
|
||||
badges.push(/* html */`
|
||||
<button type="button" class="filter-badge" data-filter="category">
|
||||
@@ -242,9 +249,7 @@ class SearchBox extends HTMLElement {
|
||||
<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}`))
|
||||
? this.getCategoryLabel()
|
||||
: 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">
|
||||
@@ -256,21 +261,21 @@ class SearchBox extends HTMLElement {
|
||||
<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>
|
||||
${(this.categoryTree || []).map(cat => `
|
||||
<div class="category-accordion ${this._expandedCategory === cat.slug ? 'expanded' : ''}">
|
||||
<button type="button" class="category-item ${this.selectedCategory === cat.slug ? 'active' : ''}" data-category="${cat.slug}">
|
||||
<span>${categoriesService.getTranslatedName(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 type="button" class="subcategory-item ${this.selectedCategory === cat.slug && !this.selectedSubcategory ? 'active' : ''}" data-category="${cat.slug}" data-subcategory="">
|
||||
${t('search.allIn')} ${categoriesService.getTranslatedName(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}`)}
|
||||
${(cat.children || []).map(sub => `
|
||||
<button type="button" class="subcategory-item ${this.selectedCategory === cat.slug && this.selectedSubcategory === sub.slug ? 'active' : ''}" data-category="${cat.slug}" data-subcategory="${sub.slug}">
|
||||
${categoriesService.getTranslatedName(sub)}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
@@ -696,15 +701,16 @@ class SearchBox extends HTMLElement {
|
||||
if (filters.useCurrentLocation !== undefined) this.useCurrentLocation = filters.useCurrentLocation
|
||||
|
||||
this.saveFiltersToStorage()
|
||||
this.render()
|
||||
this.setupEventListeners()
|
||||
if (this.categoryTree && this.categoryTree.length > 0) {
|
||||
this.render()
|
||||
this.setupEventListeners()
|
||||
}
|
||||
}
|
||||
|
||||
clearFilters() {
|
||||
this.resetFilters()
|
||||
localStorage.removeItem('searchFilters')
|
||||
this.render()
|
||||
this.setupEventListeners()
|
||||
this.loadAndRender()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1174,4 +1180,4 @@ style.textContent = /* css */`
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
|
||||
export { SearchBox, CATEGORIES, COUNTRIES, RADIUS_OPTIONS }
|
||||
export { SearchBox, COUNTRIES, RADIUS_OPTIONS }
|
||||
|
||||
@@ -10,6 +10,7 @@ class CategoriesService {
|
||||
this.cache = null
|
||||
this.cacheTimestamp = 0
|
||||
this.cacheTimeout = 10 * 60 * 1000 // 10 minutes
|
||||
this._pending = null
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
@@ -17,10 +18,19 @@ class CategoriesService {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
const categories = await directus.getCategories()
|
||||
this.cache = categories
|
||||
this.cacheTimestamp = Date.now()
|
||||
return categories
|
||||
if (this._pending) return this._pending
|
||||
|
||||
this._pending = directus.getCategories().then(categories => {
|
||||
this.cache = categories
|
||||
this.cacheTimestamp = Date.now()
|
||||
this._pending = null
|
||||
return categories
|
||||
}).catch(err => {
|
||||
this._pending = null
|
||||
throw err
|
||||
})
|
||||
|
||||
return this._pending
|
||||
}
|
||||
|
||||
async getById(id) {
|
||||
@@ -32,7 +42,17 @@ class CategoriesService {
|
||||
}
|
||||
|
||||
async getTree() {
|
||||
return directus.getCategoryTree()
|
||||
const all = await this.getAll()
|
||||
return this.buildTree(all)
|
||||
}
|
||||
|
||||
buildTree(categories, parentId = null) {
|
||||
return categories
|
||||
.filter(cat => (cat.parent?.id || cat.parent) === parentId)
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
children: this.buildTree(categories, cat.id)
|
||||
}))
|
||||
}
|
||||
|
||||
async getSubcategories(parentId) {
|
||||
|
||||
@@ -134,7 +134,7 @@ class DirectusService {
|
||||
* @returns {Promise<Object|null>} Response data or null for 204
|
||||
* @throws {DirectusError} On request failure
|
||||
*/
|
||||
async request(endpoint, options = {}) {
|
||||
async request(endpoint, options = {}, _retryCount = 0) {
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
|
||||
const headers = {
|
||||
@@ -163,6 +163,12 @@ class DirectusService {
|
||||
this.clearTokens()
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 429 && _retryCount < 3) {
|
||||
const retryAfter = parseInt(response.headers.get('Retry-After') || '3', 10)
|
||||
await new Promise(r => setTimeout(r, retryAfter * 1000))
|
||||
return this.request(endpoint, options, _retryCount + 1)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
@@ -405,6 +411,7 @@ class DirectusService {
|
||||
'category.id',
|
||||
'category.name',
|
||||
'category.slug',
|
||||
'category.translations.*',
|
||||
'location.id',
|
||||
'location.name',
|
||||
'location.postal_code',
|
||||
|
||||
@@ -4,13 +4,27 @@
|
||||
*/
|
||||
|
||||
import { directus } from './directus.js'
|
||||
import { categoriesService } from './categories.js'
|
||||
|
||||
class ListingsService {
|
||||
async getListingsWithFilters(filters = {}) {
|
||||
const directusFilter = { status: { _eq: 'published' } }
|
||||
|
||||
if (filters.category) {
|
||||
directusFilter.category = { slug: { _eq: filters.category } }
|
||||
if (filters.subcategory) {
|
||||
directusFilter.category = { slug: { _eq: filters.subcategory } }
|
||||
} else {
|
||||
const all = await categoriesService.getAll()
|
||||
const parent = all.find(c => c.slug === filters.category && !c.parent)
|
||||
if (parent) {
|
||||
const childSlugs = all
|
||||
.filter(c => (c.parent?.id || c.parent) === parent.id)
|
||||
.map(c => c.slug)
|
||||
directusFilter.category = { slug: { _in: [filters.category, ...childSlugs] } }
|
||||
} else {
|
||||
directusFilter.category = { slug: { _eq: filters.category } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.location) {
|
||||
|
||||
Reference in New Issue
Block a user