feat(catalog): full vehicle selector flow in brand catalog

Brand catalog now follows the same navigation as the regular catalog:
1. Brands -> 2. Models -> 3. Years -> 4. Engines -> 5. Categories -> 6. Parts

Backend:
- Add /mye-parts endpoint for MYE-specific parts with category filter
- Uses existing /models, /years, /engines, /categories endpoints

Frontend:
- Complete rewrite of brand-catalog.js with breadcrumb navigation
- State machine: brands -> models -> years -> engines -> categories -> parts
- Search and pagination preserved at parts level
- Breadcrumb allows jumping back to any previous step
This commit is contained in:
2026-05-14 22:35:01 +00:00
parent 79fa7984a1
commit da362e32a6
3 changed files with 292 additions and 47 deletions

View File

@@ -1,14 +1,26 @@
(function() {
const BrandCatalog = {
currentBrand: null,
currentCategory: null,
state: 'brands',
_lastItems: [],
_allBrands: [],
_lastItems: [],
_offset: 0,
_limit: 50,
_total: 0,
// Navigation state
nav: {
brand: null,
brandId: null,
model: null,
modelId: null,
year: null,
yearId: null,
engine: null,
myeId: null,
category: null,
categoryId: null
},
el: function(id) { return document.getElementById(id); },
_getToken: function() {
@@ -49,11 +61,10 @@
},
reset: function() {
this.currentBrand = null;
this.currentCategory = null;
this.state = 'brands';
this._lastItems = [];
this.nav = { brand: null, brandId: null, model: null, modelId: null, year: null, yearId: null, engine: null, myeId: null, category: null, categoryId: null };
this._allBrands = [];
this._lastItems = [];
this._offset = 0;
this._total = 0;
this.el('brandCatalogSearch').innerHTML = '';
@@ -75,10 +86,32 @@
this.el('brandCatalogBreadcrumb').innerHTML = html;
},
buildBreadcrumb: function() {
var parts = [];
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.loadBrands()" style="color:var(--color-primary);text-decoration:none;">Marcas</a>');
if (this.nav.brand) {
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectBrand(' + JSON.stringify(this.nav.brand) + ',' + this.nav.brandId + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(this.nav.brand) + '</a>');
}
if (this.nav.model) {
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectModel(' + this.nav.modelId + ',' + JSON.stringify(this.nav.model) + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(this.nav.model) + '</a>');
}
if (this.nav.year) {
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectYear(' + this.nav.yearId + ',' + this.nav.year + ')" style="color:var(--color-primary);text-decoration:none;">' + this.nav.year + '</a>');
}
if (this.nav.engine) {
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectEngine(' + this.nav.myeId + ',' + JSON.stringify(this.nav.engine) + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(this.nav.engine) + '</a>');
}
if (this.nav.category) {
parts.push('<strong>' + escapeHtml(this.nav.category) + '</strong>');
}
this.setBreadcrumb(parts.join(' &rsaquo; '));
},
// ---------- BRANDS ----------
loadBrands: function() {
this.loading(true);
this.state = 'brands';
this.reset();
this.setBreadcrumb('<strong>Marcas de vehiculo</strong>');
this.setSearch(
'<input type="text" id="brandSearchInput" placeholder="Buscar marca..." ' +
@@ -111,9 +144,8 @@
renderBrandList: function(brands) {
var html = '';
brands.forEach(function(b) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectBrand(' + JSON.stringify(b.name) + ')">' +
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectBrand(' + JSON.stringify(b.name) + ',' + b.id + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(b.name) + '</div>' +
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (b.part_count || 0) + ' refacciones</div>' +
'</div>';
});
this.setContent(html);
@@ -131,21 +163,20 @@
this.renderBrandList(filtered);
},
selectBrand: function(brandName) {
this.currentBrand = brandName;
this.loadCategories(brandName);
selectBrand: function(brandName, brandId) {
this.nav.brand = brandName;
this.nav.brandId = brandId;
this.loadModels(brandId);
},
// ---------- CATEGORIES ----------
loadCategories: function(brandName) {
// ---------- MODELS ----------
loadModels: function(brandId) {
this.loading(true);
this.state = 'categories';
this.state = 'models';
this.setSearch('');
this.setBreadcrumb(
'<a href="javascript:void(0)" onclick="BrandCatalog.loadBrands()" style="color:var(--color-primary);text-decoration:none;">Marcas</a> &rsaquo; <strong>' + escapeHtml(brandName) + '</strong>'
);
this.buildBreadcrumb();
var self = this;
fetch('/pos/api/catalog/brand-categories?brand=' + encodeURIComponent(brandName), { headers: this._headers() })
fetch('/pos/api/catalog/models?brand_id=' + encodeURIComponent(brandId), { headers: this._headers() })
.then(function(r) {
if (!self._checkAuth(r)) return null;
return r.json();
@@ -153,20 +184,137 @@
.then(function(data) {
if (!data) return;
self.loading(false);
if (!data.categories || !data.categories.length) {
self.setContent(
'<div style="grid-column:1/-1;text-align:center;padding:var(--space-8);">' +
'<p style="color:var(--color-text-muted);font-size:var(--text-body-lg);">No se encontraron categorias para <strong>' + escapeHtml(brandName) + '</strong>.</p>' +
'<button class="btn btn--primary" style="margin-top:var(--space-3);" onclick="BrandCatalog.loadBrands()">Volver a marcas</button>' +
'</div>'
);
var models = data.data || [];
if (!models.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron modelos.</p>');
return;
}
var html = '';
data.categories.forEach(function(c) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectCategory(' + c.id + ', ' + JSON.stringify(c.name) + ')">' +
models.forEach(function(m) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectModel(' + m.id_model + ',' + JSON.stringify(m.display_name || m.name_model) + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(m.display_name || m.name_model) + '</div>' +
'</div>';
});
self.setContent(html);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar modelos: ' + escapeHtml(err.message) + '</p>');
});
},
selectModel: function(modelId, modelName) {
this.nav.model = modelName;
this.nav.modelId = modelId;
this.loadYears(modelId);
},
// ---------- YEARS ----------
loadYears: function(modelId) {
this.loading(true);
this.state = 'years';
this.setSearch('');
this.buildBreadcrumb();
var self = this;
fetch('/pos/api/catalog/years?model_id=' + encodeURIComponent(modelId), { headers: this._headers() })
.then(function(r) {
if (!self._checkAuth(r)) return null;
return r.json();
})
.then(function(data) {
if (!data) return;
self.loading(false);
var years = data.data || [];
if (!years.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron años.</p>');
return;
}
var html = '';
years.forEach(function(y) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectYear(' + y.id_year + ',' + y.year_car + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + y.year_car + '</div>' +
'</div>';
});
self.setContent(html);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar años: ' + escapeHtml(err.message) + '</p>');
});
},
selectYear: function(yearId, yearCar) {
this.nav.year = yearCar;
this.nav.yearId = yearId;
this.loadEngines(this.nav.modelId, yearId);
},
// ---------- ENGINES ----------
loadEngines: function(modelId, yearId) {
this.loading(true);
this.state = 'engines';
this.setSearch('');
this.buildBreadcrumb();
var self = this;
fetch('/pos/api/catalog/engines?model_id=' + encodeURIComponent(modelId) + '&year_id=' + encodeURIComponent(yearId), { headers: this._headers() })
.then(function(r) {
if (!self._checkAuth(r)) return null;
return r.json();
})
.then(function(data) {
if (!data) return;
self.loading(false);
var engines = data.data || [];
if (!engines.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron motores.</p>');
return;
}
var html = '';
engines.forEach(function(e) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectEngine(' + e.id_mye + ',' + JSON.stringify(e.name_engine) + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(e.name_engine) + '</div>' +
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + escapeHtml(e.trim_level || '') + '</div>' +
'</div>';
});
self.setContent(html);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar motores: ' + escapeHtml(err.message) + '</p>');
});
},
selectEngine: function(myeId, engineName) {
this.nav.engine = engineName;
this.nav.myeId = myeId;
this.loadCategories(myeId);
},
// ---------- CATEGORIES ----------
loadCategories: function(myeId) {
this.loading(true);
this.state = 'categories';
this.setSearch('');
this.buildBreadcrumb();
var self = this;
fetch('/pos/api/catalog/categories?mye_id=' + encodeURIComponent(myeId) + '&mode=oem', { headers: this._headers() })
.then(function(r) {
if (!self._checkAuth(r)) return null;
return r.json();
})
.then(function(data) {
if (!data) return;
self.loading(false);
var categories = data.data || [];
if (!categories.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron categorias.</p>');
return;
}
var html = '';
categories.forEach(function(c) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectCategory(' + c.id_part_category + ',' + JSON.stringify(c.name) + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(c.name) + '</div>' +
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + c.part_count + ' refacciones</div>' +
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (c.part_count || 0) + ' refacciones</div>' +
'</div>';
});
self.setContent(html);
@@ -178,20 +326,17 @@
},
selectCategory: function(catId, catName) {
this.currentCategory = { id: catId, name: catName };
this.nav.category = catName;
this.nav.categoryId = catId;
this._offset = 0;
this.loadParts(this.currentBrand, catId, '');
this.loadParts(this.nav.myeId, catId, '');
},
// ---------- PARTS ----------
loadParts: function(brandName, categoryId, searchTerm) {
loadParts: function(myeId, categoryId, searchTerm) {
this.loading(true);
this.state = 'parts';
this.setBreadcrumb(
'<a href="javascript:void(0)" onclick="BrandCatalog.loadBrands()" style="color:var(--color-primary);text-decoration:none;">Marcas</a> &rsaquo; ' +
'<a href="javascript:void(0)" onclick="BrandCatalog.selectBrand(' + JSON.stringify(brandName) + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(brandName) + '</a> &rsaquo; ' +
'<strong>' + escapeHtml(this.currentCategory.name) + '</strong>'
);
this.buildBreadcrumb();
this.setSearch(
'<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;align-items:center;">' +
'<input type="text" id="partsSearchInput" placeholder="Buscar refaccion..." value="' + escapeHtml(searchTerm || '') + '" ' +
@@ -202,7 +347,7 @@
'<button class="btn btn--secondary btn--sm" onclick="BrandCatalog.clearPartsSearch()">Limpiar</button>' +
'</div>'
);
var url = '/pos/api/catalog/brand-parts?brand=' + encodeURIComponent(brandName) + '&category_id=' + encodeURIComponent(categoryId) +
var url = '/pos/api/catalog/mye-parts?mye_id=' + encodeURIComponent(myeId) + '&category_id=' + encodeURIComponent(categoryId) +
'&limit=' + this._limit + '&offset=' + this._offset;
if (searchTerm) {
url += '&search=' + encodeURIComponent(searchTerm);
@@ -219,11 +364,7 @@
self._lastItems = data.items || [];
self._total = data.total || 0;
self._offset = data.offset || 0;
if (!data.items || !data.items.length) {
self.renderPartsList([], searchTerm);
return;
}
self.renderPartsList(data.items, searchTerm);
self.renderPartsList(data.items || [], searchTerm);
})
.catch(function(err) {
self.loading(false);
@@ -236,7 +377,7 @@
if (!items.length) {
html += '<div style="grid-column:1/-1;text-align:center;padding:var(--space-8);">' +
'<p style="color:var(--color-text-muted);font-size:var(--text-body-lg);">No se encontraron refacciones.</p>' +
'<button class="btn btn--primary" style="margin-top:var(--space-3);" onclick="BrandCatalog.loadCategories(' + JSON.stringify(this.currentBrand) + ')">Volver a categorias</button>' +
'<button class="btn btn--primary" style="margin-top:var(--space-3);" onclick="BrandCatalog.loadCategories(' + this.nav.myeId + ')">Volver a categorias</button>' +
'</div>';
this.setContent(html);
return;
@@ -286,12 +427,12 @@
searchParts: function(term) {
this._offset = 0;
this.loadParts(this.currentBrand, this.currentCategory.id, term);
this.loadParts(this.nav.myeId, this.nav.categoryId, term);
},
clearPartsSearch: function() {
this._offset = 0;
this.loadParts(this.currentBrand, this.currentCategory.id, '');
this.loadParts(this.nav.myeId, this.nav.categoryId, '');
},
goToPage: function(newOffset) {
@@ -299,7 +440,7 @@
this._offset = newOffset;
var searchInput = document.getElementById('partsSearchInput');
var term = searchInput ? searchInput.value : '';
this.loadParts(this.currentBrand, this.currentCategory.id, term);
this.loadParts(this.nav.myeId, this.nav.categoryId, term);
},
addToCart: function(partId, event) {