feat: rebuild web — landing page + catalogo publico con navegacion por vehiculo
Removidas paginas viejas (demo, tienda, admin, pos, captura, cuentas). Nueva landing page con estilo del design system (tokens.css, 2 temas). Catalogo publico sin auth con navegacion Marca>Modelo>Ano>Motor>Categoria>Partes. Endpoints /api/catalog/* publicos con filtro NORTH_AMERICA_BRANDS (36 marcas). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
469
dashboard/catalog-public.js
Normal file
469
dashboard/catalog-public.js
Normal file
@@ -0,0 +1,469 @@
|
||||
/* =========================================================================
|
||||
Nexus Autoparts — Public Catalog (catalog-public.js)
|
||||
Vehicle hierarchy navigation: Brand > Model > Year > Engine > Category > Group > Parts
|
||||
No auth, no cart, no prices — public browsing only.
|
||||
========================================================================= */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── State ──
|
||||
var state = {
|
||||
level: 'brands', // brands | models | years | engines | categories | groups | parts | search
|
||||
brand: null, // {id, name}
|
||||
model: null, // {id, name}
|
||||
year: null, // {id, value}
|
||||
engine: null, // {id_mye, name, trim}
|
||||
category: null, // {id, name}
|
||||
group: null, // {id, name}
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
var API = '/api/catalog';
|
||||
var content = document.getElementById('content');
|
||||
var breadcrumbEl = document.getElementById('breadcrumb');
|
||||
var searchInput = document.getElementById('searchInput');
|
||||
|
||||
// Check URL for brand param
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
var initBrandId = urlParams.get('brand');
|
||||
|
||||
// ── Init ──
|
||||
if (initBrandId) {
|
||||
// Load brands, find the one matching, then navigate
|
||||
fetch(API + '/brands')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (brands) {
|
||||
var found = brands.find(function (b) { return b.id_brand == initBrandId; });
|
||||
if (found) {
|
||||
state.brand = { id: found.id_brand, name: found.name_brand };
|
||||
state.level = 'models';
|
||||
loadModels();
|
||||
} else {
|
||||
loadBrands();
|
||||
}
|
||||
})
|
||||
.catch(function () { loadBrands(); });
|
||||
} else {
|
||||
loadBrands();
|
||||
}
|
||||
|
||||
// Enter on search
|
||||
searchInput.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') doSearch();
|
||||
});
|
||||
|
||||
// ── Theme toggle (global) ──
|
||||
window.toggleTheme = function () {
|
||||
var html = document.documentElement;
|
||||
var cur = html.getAttribute('data-theme');
|
||||
var next = cur === 'industrial' ? 'modern' : 'industrial';
|
||||
html.setAttribute('data-theme', next);
|
||||
localStorage.setItem('nexus-theme', next);
|
||||
};
|
||||
|
||||
// ── Search (global) ──
|
||||
window.doSearch = function () {
|
||||
var q = searchInput.value.trim();
|
||||
if (!q || q.length < 2) return;
|
||||
state.level = 'search';
|
||||
renderBreadcrumb();
|
||||
content.innerHTML = '<div class="loading">Buscando...</div>';
|
||||
fetch(API + '/search?q=' + encodeURIComponent(q))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) { renderSearchResults(data); })
|
||||
.catch(function () { content.innerHTML = '<div class="empty">Error en la busqueda.</div>'; });
|
||||
};
|
||||
|
||||
// ── Detail modal (global) ──
|
||||
window.openDetail = function (partId) {
|
||||
var modal = document.getElementById('detailModal');
|
||||
var body = document.getElementById('detailBody');
|
||||
body.innerHTML = '<div class="loading">Cargando detalle...</div>';
|
||||
modal.classList.add('open');
|
||||
fetch(API + '/part/' + partId)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { renderDetail(d, body); })
|
||||
.catch(function () { body.innerHTML = '<div class="empty">Error cargando detalle.</div>'; });
|
||||
};
|
||||
|
||||
window.closeDetail = function () {
|
||||
document.getElementById('detailModal').classList.remove('open');
|
||||
};
|
||||
|
||||
// Close modal on backdrop click
|
||||
document.getElementById('detailModal').addEventListener('click', function (e) {
|
||||
if (e.target === this) closeDetail();
|
||||
});
|
||||
|
||||
// ── Breadcrumb ──
|
||||
function renderBreadcrumb() {
|
||||
var parts = [];
|
||||
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'brands\')">Catalogo</a>');
|
||||
|
||||
if (state.brand) {
|
||||
parts.push('<span class="sep">/</span>');
|
||||
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'models\')">' + esc(state.brand.name) + '</a>');
|
||||
}
|
||||
if (state.model) {
|
||||
parts.push('<span class="sep">/</span>');
|
||||
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'years\')">' + esc(state.model.name) + '</a>');
|
||||
}
|
||||
if (state.year) {
|
||||
parts.push('<span class="sep">/</span>');
|
||||
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'engines\')">' + esc(String(state.year.value)) + '</a>');
|
||||
}
|
||||
if (state.engine) {
|
||||
parts.push('<span class="sep">/</span>');
|
||||
var engineLabel = state.engine.name + (state.engine.trim ? ' (' + state.engine.trim + ')' : '');
|
||||
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'categories\')">' + esc(engineLabel) + '</a>');
|
||||
}
|
||||
if (state.category) {
|
||||
parts.push('<span class="sep">/</span>');
|
||||
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'groups\')">' + esc(state.category.name) + '</a>');
|
||||
}
|
||||
if (state.group) {
|
||||
parts.push('<span class="sep">/</span>');
|
||||
parts.push('<span>' + esc(state.group.name) + '</span>');
|
||||
}
|
||||
if (state.level === 'search') {
|
||||
parts.push('<span class="sep">/</span>');
|
||||
parts.push('<span>Busqueda</span>');
|
||||
}
|
||||
|
||||
breadcrumbEl.innerHTML = parts.join('');
|
||||
}
|
||||
|
||||
// Global nav
|
||||
window.catalogNav = function (level) {
|
||||
if (level === 'brands') {
|
||||
state.brand = state.model = state.year = state.engine = state.category = state.group = null;
|
||||
state.level = 'brands';
|
||||
loadBrands();
|
||||
} else if (level === 'models') {
|
||||
state.model = state.year = state.engine = state.category = state.group = null;
|
||||
state.level = 'models';
|
||||
loadModels();
|
||||
} else if (level === 'years') {
|
||||
state.year = state.engine = state.category = state.group = null;
|
||||
state.level = 'years';
|
||||
loadYears();
|
||||
} else if (level === 'engines') {
|
||||
state.engine = state.category = state.group = null;
|
||||
state.level = 'engines';
|
||||
loadEngines();
|
||||
} else if (level === 'categories') {
|
||||
state.category = state.group = null;
|
||||
state.level = 'categories';
|
||||
loadCategories();
|
||||
} else if (level === 'groups') {
|
||||
state.group = null;
|
||||
state.level = 'groups';
|
||||
loadGroups();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Data loaders ──
|
||||
|
||||
function loadBrands() {
|
||||
state.level = 'brands';
|
||||
renderBreadcrumb();
|
||||
content.innerHTML = '<div class="loading">Cargando marcas...</div>';
|
||||
fetch(API + '/brands')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (brands) {
|
||||
var html = '<h2>Selecciona una Marca</h2><div class="nav-grid">';
|
||||
brands.forEach(function (b) {
|
||||
html += '<div class="nav-card" onclick="selectBrand(' + b.id_brand + ',\'' + escAttr(b.name_brand) + '\')">';
|
||||
html += '<span class="name">' + esc(b.name_brand) + '</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
})
|
||||
.catch(function () { content.innerHTML = '<div class="empty">Error cargando marcas.</div>'; });
|
||||
}
|
||||
|
||||
window.selectBrand = function (id, name) {
|
||||
state.brand = { id: id, name: name };
|
||||
state.level = 'models';
|
||||
loadModels();
|
||||
};
|
||||
|
||||
function loadModels() {
|
||||
renderBreadcrumb();
|
||||
content.innerHTML = '<div class="loading">Cargando modelos...</div>';
|
||||
fetch(API + '/models?brand_id=' + state.brand.id)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (models) {
|
||||
var html = '<h2>' + esc(state.brand.name) + ' — Modelos</h2><div class="nav-grid">';
|
||||
models.forEach(function (m) {
|
||||
html += '<div class="nav-card" onclick="selectModel(' + m.id_model + ',\'' + escAttr(m.name_model) + '\')">';
|
||||
html += '<span class="name">' + esc(m.name_model) + '</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
})
|
||||
.catch(function () { content.innerHTML = '<div class="empty">Error cargando modelos.</div>'; });
|
||||
}
|
||||
|
||||
window.selectModel = function (id, name) {
|
||||
state.model = { id: id, name: name };
|
||||
state.level = 'years';
|
||||
loadYears();
|
||||
};
|
||||
|
||||
function loadYears() {
|
||||
renderBreadcrumb();
|
||||
content.innerHTML = '<div class="loading">Cargando anos...</div>';
|
||||
fetch(API + '/years?model_id=' + state.model.id)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (years) {
|
||||
var html = '<h2>' + esc(state.brand.name) + ' ' + esc(state.model.name) + ' — Anos</h2><div class="nav-grid">';
|
||||
years.forEach(function (y) {
|
||||
html += '<div class="nav-card" onclick="selectYear(' + y.id_year + ',' + y.year_car + ')">';
|
||||
html += '<span class="name">' + y.year_car + '</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
})
|
||||
.catch(function () { content.innerHTML = '<div class="empty">Error cargando anos.</div>'; });
|
||||
}
|
||||
|
||||
window.selectYear = function (id, value) {
|
||||
state.year = { id: id, value: value };
|
||||
state.level = 'engines';
|
||||
loadEngines();
|
||||
};
|
||||
|
||||
function loadEngines() {
|
||||
renderBreadcrumb();
|
||||
content.innerHTML = '<div class="loading">Cargando motores...</div>';
|
||||
fetch(API + '/engines?model_id=' + state.model.id + '&year_id=' + state.year.id)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (engines) {
|
||||
var html = '<h2>' + esc(state.brand.name) + ' ' + esc(state.model.name) + ' ' + state.year.value + ' — Motor</h2>';
|
||||
html += '<div class="nav-grid">';
|
||||
engines.forEach(function (e) {
|
||||
var label = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : '');
|
||||
html += '<div class="nav-card" onclick="selectEngine(' + e.id_mye + ',\'' + escAttr(e.name_engine) + '\',\'' + escAttr(e.trim_level || '') + '\')">';
|
||||
html += '<span class="name">' + esc(label) + '</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
})
|
||||
.catch(function () { content.innerHTML = '<div class="empty">Error cargando motores.</div>'; });
|
||||
}
|
||||
|
||||
window.selectEngine = function (id_mye, name, trim) {
|
||||
state.engine = { id_mye: id_mye, name: name, trim: trim };
|
||||
state.level = 'categories';
|
||||
loadCategories();
|
||||
};
|
||||
|
||||
function loadCategories() {
|
||||
renderBreadcrumb();
|
||||
content.innerHTML = '<div class="loading">Cargando categorias...</div>';
|
||||
fetch(API + '/categories?mye_id=' + state.engine.id_mye)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (cats) {
|
||||
if (!cats.length) {
|
||||
content.innerHTML = '<h2>Categorias</h2><div class="empty">No se encontraron categorias con partes para este vehiculo.</div>';
|
||||
return;
|
||||
}
|
||||
var html = '<h2>Categorias</h2><div class="nav-grid">';
|
||||
cats.forEach(function (c) {
|
||||
html += '<div class="nav-card" onclick="selectCategory(' + c.id_part_category + ',\'' + escAttr(c.name) + '\')">';
|
||||
html += '<span class="name">' + esc(c.name) + '</span>';
|
||||
html += '<span class="count">' + c.part_count + '</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
})
|
||||
.catch(function () { content.innerHTML = '<div class="empty">Error cargando categorias.</div>'; });
|
||||
}
|
||||
|
||||
window.selectCategory = function (id, name) {
|
||||
state.category = { id: id, name: name };
|
||||
state.level = 'groups';
|
||||
loadGroups();
|
||||
};
|
||||
|
||||
function loadGroups() {
|
||||
renderBreadcrumb();
|
||||
content.innerHTML = '<div class="loading">Cargando grupos...</div>';
|
||||
fetch(API + '/groups?mye_id=' + state.engine.id_mye + '&category_id=' + state.category.id)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (groups) {
|
||||
if (!groups.length) {
|
||||
content.innerHTML = '<h2>' + esc(state.category.name) + '</h2><div class="empty">No se encontraron sub-grupos.</div>';
|
||||
return;
|
||||
}
|
||||
var html = '<h2>' + esc(state.category.name) + '</h2><div class="nav-grid">';
|
||||
groups.forEach(function (g) {
|
||||
html += '<div class="nav-card" onclick="selectGroup(' + g.id_part_group + ',\'' + escAttr(g.name) + '\')">';
|
||||
html += '<span class="name">' + esc(g.name) + '</span>';
|
||||
html += '<span class="count">' + g.part_count + '</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
})
|
||||
.catch(function () { content.innerHTML = '<div class="empty">Error cargando grupos.</div>'; });
|
||||
}
|
||||
|
||||
window.selectGroup = function (id, name) {
|
||||
state.group = { id: id, name: name };
|
||||
state.level = 'parts';
|
||||
state.page = 1;
|
||||
loadParts();
|
||||
};
|
||||
|
||||
function loadParts() {
|
||||
renderBreadcrumb();
|
||||
content.innerHTML = '<div class="loading">Cargando partes...</div>';
|
||||
var url = API + '/parts?mye_id=' + state.engine.id_mye + '&group_id=' + state.group.id + '&page=' + state.page;
|
||||
fetch(url)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (resp) {
|
||||
var parts = resp.data;
|
||||
var pag = resp.pagination;
|
||||
state.totalPages = pag.total_pages;
|
||||
|
||||
if (!parts.length) {
|
||||
content.innerHTML = '<h2>' + esc(state.group.name) + '</h2><div class="empty">No se encontraron partes.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<h2>' + esc(state.group.name) + ' <span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">(' + pag.total + ' partes)</span></h2>';
|
||||
html += '<div class="parts-list">';
|
||||
parts.forEach(function (p) {
|
||||
html += '<div class="part-row">';
|
||||
html += '<div>';
|
||||
html += '<div class="part-oem">' + esc(p.oem_part_number) + '</div>';
|
||||
html += '<div class="part-name">' + esc(p.name || '') + '</div>';
|
||||
if (p.description) html += '<div class="part-desc">' + esc(p.description) + '</div>';
|
||||
html += '<button class="part-detail-btn" onclick="openDetail(' + p.id_part + ')">Ver detalle y alternativas</button>';
|
||||
html += '</div>';
|
||||
if (p.image_url) {
|
||||
html += '<img class="part-img" src="' + esc(p.image_url) + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
// Pagination
|
||||
if (pag.total_pages > 1) {
|
||||
html += '<div class="pagination">';
|
||||
html += '<button ' + (state.page <= 1 ? 'disabled' : 'onclick="partsPage(' + (state.page - 1) + ')"') + '>« Anterior</button>';
|
||||
html += '<button disabled>Pagina ' + state.page + ' de ' + pag.total_pages + '</button>';
|
||||
html += '<button ' + (state.page >= pag.total_pages ? 'disabled' : 'onclick="partsPage(' + (state.page + 1) + ')"') + '>Siguiente »</button>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
})
|
||||
.catch(function () { content.innerHTML = '<div class="empty">Error cargando partes.</div>'; });
|
||||
}
|
||||
|
||||
window.partsPage = function (p) {
|
||||
state.page = p;
|
||||
loadParts();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// ── Search results ──
|
||||
function renderSearchResults(results) {
|
||||
renderBreadcrumb();
|
||||
if (!results.length) {
|
||||
content.innerHTML = '<h2>Busqueda</h2><div class="empty">No se encontraron resultados.</div>';
|
||||
return;
|
||||
}
|
||||
var html = '<h2>Resultados (' + results.length + ')</h2><div class="parts-list">';
|
||||
results.forEach(function (p) {
|
||||
html += '<div class="part-row">';
|
||||
html += '<div>';
|
||||
html += '<div class="part-oem">' + esc(p.oem_part_number) + '</div>';
|
||||
html += '<div class="part-name">' + esc(p.name || '') + '</div>';
|
||||
if (p.vehicle_info) html += '<div class="part-desc">' + esc(p.vehicle_info) + '</div>';
|
||||
html += '<button class="part-detail-btn" onclick="openDetail(' + p.id_part + ')">Ver detalle y alternativas</button>';
|
||||
html += '</div>';
|
||||
if (p.image_url) {
|
||||
html += '<img class="part-img" src="' + esc(p.image_url) + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Part detail ──
|
||||
function renderDetail(d, body) {
|
||||
if (!d || !d.part) {
|
||||
body.innerHTML = '<div class="empty">Parte no encontrada.</div>';
|
||||
return;
|
||||
}
|
||||
var p = d.part;
|
||||
var html = '';
|
||||
html += '<div class="part-oem" style="font-size:var(--text-h5)">' + esc(p.oem_part_number) + '</div>';
|
||||
html += '<div class="part-name" style="font-size:var(--text-h4);margin-top:var(--space-2)">' + esc(p.name || '') + '</div>';
|
||||
if (p.category_name) html += '<div class="part-desc">' + esc(p.category_name) + (p.group_name ? ' / ' + esc(p.group_name) : '') + '</div>';
|
||||
if (p.description) html += '<div class="part-desc" style="margin-top:var(--space-3)">' + esc(p.description) + '</div>';
|
||||
if (p.image_url) {
|
||||
html += '<div style="margin-top:var(--space-4);text-align:center">';
|
||||
html += '<img src="' + esc(p.image_url) + '" alt="" style="max-width:300px;border-radius:var(--radius-md)" onerror="this.style.display=\'none\'">';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Alternatives
|
||||
if (d.alternatives && d.alternatives.length) {
|
||||
html += '<div class="detail-section">';
|
||||
html += '<h4>Alternativas y Cross-References (' + d.alternatives.length + ')</h4>';
|
||||
html += '<table class="alt-table"><thead><tr><th>Numero</th><th>Fabricante</th><th>Nombre</th><th>Tipo</th></tr></thead><tbody>';
|
||||
d.alternatives.forEach(function (a) {
|
||||
html += '<tr>';
|
||||
html += '<td style="font-family:var(--font-mono)">' + esc(a.part_number || '') + '</td>';
|
||||
html += '<td>' + esc(a.manufacturer || '') + '</td>';
|
||||
html += '<td>' + esc(a.name || '-') + '</td>';
|
||||
html += '<td>' + esc(a.type === 'aftermarket' ? 'Aftermarket' : 'Cross-Ref') + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
// Bodegas
|
||||
if (d.bodegas && d.bodegas.length) {
|
||||
html += '<div class="detail-section">';
|
||||
html += '<h4>Disponibilidad en Bodegas (' + d.bodegas.length + ')</h4>';
|
||||
html += '<table class="alt-table"><thead><tr><th>Bodega</th><th>Stock</th><th>Ubicacion</th></tr></thead><tbody>';
|
||||
d.bodegas.forEach(function (b) {
|
||||
html += '<tr>';
|
||||
html += '<td>' + esc(b.business_name || '') + '</td>';
|
||||
html += '<td>' + b.stock + '</td>';
|
||||
html += '<td>' + esc(b.location || '-') + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return esc(s).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user