feat: complete session — catalog, marketplace, WhatsApp, peer-to-peer, install scripts

Major features:
- Pixel-Perfect glassmorphism design (landing + POS + public catalog)
- OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types)
- Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications)
- Peer-to-peer inventory (multi-instance, LAN discovery)
- WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations
- Smart unified search (VIN/plate/part_number/keyword auto-detect)
- Shop Supplies tab (vehicle-independent parts)
- Chatbot AI fallback chain (5 models) + response cache
- CSV inventory import tool + setup_instance.sh installer
- Tablet-responsive CSS + sidebar toggle
- Filters, export CSV, employee edit, business data save
- Quotation system (WA→POS) with auto-print on confirmation
- Live stats on landing page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 05:35:53 +00:00
parent 6b097614a0
commit e95f7cf684
54 changed files with 11226 additions and 1422 deletions

View File

@@ -49,15 +49,77 @@
// ─── Navigation State ───
var nav = {
level: 'brands', // brands|models|years|engines|categories|groups|parts
level: 'brands', // brands|models|years|engines|categories|groups|part_types|parts
brand: null, // {id, name}
model: null, // {id, name}
year: null, // {id, year}
engine: null, // {id_mye, name}
// OEM mode (TecDoc) navigation state — integer IDs
category: null, // {id, name}
group: null, // {id, name}
partType: null, // {slug, name} ← 3rd-level subcategory (Nexpart-style)
// Local mode (Nexpart) navigation state — string slugs.
// These live in parallel with category/group/partType so transitioning
// between modes doesn't trash the other branch's state.
nxGroup: null, // {slug, name} ← top-level Nexpart group (14 total)
nxSubgroup: null, // {slug, name} ← Nexpart subgroup
nxPartType: null, // {slug, name} ← Nexpart part type (3rd level)
};
// ─── Catalog mode (OEM / Local) ───
var catalogMode = (localStorage.getItem('catalog_mode') === 'local' ? 'local' : 'oem');
function updateModeToggleUI() {
var btns = document.querySelectorAll('#modeToggle button');
btns.forEach(function (b) {
if (b.getAttribute('data-mode') === catalogMode) {
b.classList.add('is-active');
} else {
b.classList.remove('is-active');
}
});
}
function setCatalogMode(mode) {
if (mode !== 'oem' && mode !== 'local' && mode !== 'supplies') return;
if (mode === catalogMode) return;
catalogMode = mode;
localStorage.setItem('catalog_mode', mode);
updateModeToggleUI();
// Clear category-and-below state regardless of mode
nav.category = nav.group = nav.partType = null;
nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null;
currentPage = 1;
if (mode === 'supplies') {
// Supplies mode skips the vehicle chain entirely.
// Clear the vehicle state for visual clarity and go directly
// to the Shop Supplies top-level group list.
try { vsClearAll(); } catch (e) {}
nav.brand = nav.model = nav.year = nav.engine = null;
nav.level = 'categories';
loadShopSuppliesGroups();
return;
}
// OEM/Local: smart reset — if the user already picked a vehicle,
// stay at the categories level. Otherwise reset to brand selection.
var hasVehicle = !!(nav.engine && nav.engine.id_mye);
if (hasVehicle) {
nav.level = 'categories';
loadCategoriesForMode();
return;
}
try { vsClearAll(); } catch (e) {}
nav.level = 'brands';
nav.brand = nav.model = nav.year = nav.engine = null;
loadBrands();
}
var currentPage = 1;
var currentDetailPart = null;
var detailQty = 1;
@@ -82,6 +144,10 @@
nav.engine = e.state.engine;
nav.category = e.state.category;
nav.group = e.state.group;
nav.partType = e.state.partType || null;
nav.nxGroup = e.state.nxGroup || null;
nav.nxSubgroup = e.state.nxSubgroup || null;
nav.nxPartType = e.state.nxPartType || null;
currentPage = e.state.page || 1;
// Reload the correct level
@@ -89,8 +155,16 @@
else if (nav.level === 'models') loadModels();
else if (nav.level === 'years') loadYears();
else if (nav.level === 'engines') loadEngines();
else if (nav.level === 'categories') loadCategories();
else if (nav.level === 'groups') loadGroups();
// When restoring from history, dispatch between OEM and Nexpart
// based on which branch of state is populated — this survives
// toggle changes made mid-session.
else if (nav.level === 'categories') loadCategoriesForMode();
else if (nav.level === 'groups') {
if (nav.nxGroup) loadNexpartSubgroups(); else loadGroups();
}
else if (nav.level === 'part_types') {
if (nav.nxSubgroup) loadNexpartPartTypes(); else loadPartTypes();
}
else if (nav.level === 'parts') loadParts(currentPage);
else loadBrands();
@@ -151,8 +225,19 @@
if (nav.model) parts.push({ label: nav.model.name, action: 'loadYears' });
if (nav.year) parts.push({ label: String(nav.year.year), action: 'loadEngines' });
if (nav.engine) parts.push({ label: nav.engine.name, action: 'loadCategories' });
if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' });
if (nav.group) parts.push({ label: nav.group.name, action: null });
// The category/group/part_type trio is rendered from EITHER the Nexpart
// branch (nxGroup/nxSubgroup/nxPartType) or the OEM branch (category/
// group/partType), depending on which is populated. Only one branch
// should be active at a time after a navigation reset.
if (nav.nxGroup) parts.push({ label: nav.nxGroup.name, action: 'loadNxSubgroups' });
else if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' });
if (nav.nxSubgroup) parts.push({ label: nav.nxSubgroup.name, action: 'loadNxPartTypes' });
else if (nav.group) parts.push({ label: nav.group.name, action: 'loadPartTypes' });
if (nav.nxPartType) parts.push({ label: nav.nxPartType.name, action: null });
else if (nav.partType) parts.push({ label: nav.partType.name, action: null });
var html = '';
for (var i = 0; i < parts.length; i++) {
@@ -173,8 +258,12 @@
else if (action === 'loadModels') { resetNavFrom('models'); loadModels(); }
else if (action === 'loadYears') { resetNavFrom('years'); loadYears(); }
else if (action === 'loadEngines') { resetNavFrom('engines'); loadEngines(); }
else if (action === 'loadCategories') { resetNavFrom('categories'); loadCategories(); }
else if (action === 'loadCategories') { resetNavFrom('categories'); loadCategoriesForMode(); }
else if (action === 'loadGroups') { resetNavFrom('groups'); loadGroups(); }
else if (action === 'loadPartTypes') { resetNavFrom('part_types'); loadPartTypes(); }
// Nexpart-branch breadcrumb actions
else if (action === 'loadNxSubgroups') { resetNavFrom('groups'); loadNexpartSubgroups(); }
else if (action === 'loadNxPartTypes') { resetNavFrom('part_types'); loadNexpartPartTypes(); }
});
});
}
@@ -182,17 +271,33 @@
function resetNav() {
nav.level = 'brands';
pushNavState();
nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = null;
nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = nav.partType = null;
nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null;
}
function resetNavFrom(level) {
var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'parts'];
var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'part_types', 'parts'];
var idx = levels.indexOf(level);
if (idx <= 0) { resetNav(); return; }
nav.level = level;
var keys = [null, 'model', 'year', 'engine', 'category', 'group', null];
// For each level, the corresponding state key(s) to clear.
// In Local mode, 'categories' clears nxGroup, 'groups' clears nxSubgroup, etc.
// We clear BOTH mode-specific keys at each level so a mode switch mid-navigation
// is always clean.
var keys = [
null, // brands (nothing to clear above)
['model'], // models
['year'], // years
['engine'], // engines
['category', 'nxGroup'], // categories ← both OEM + Nexpart
['group', 'nxSubgroup'], // groups ← both OEM + Nexpart
['partType', 'nxPartType'], // part_types ← both OEM + Nexpart
null, // parts
];
for (var i = idx; i < keys.length; i++) {
if (keys[i]) nav[keys[i]] = null;
if (!keys[i]) continue;
var ks = keys[i];
for (var j = 0; j < ks.length; j++) nav[ks[j]] = null;
}
}
@@ -221,7 +326,7 @@
setupLevelFilter(true);
showLoading();
apiFetch(API + '/brands').then(function (data) {
apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
if (!data) {
@@ -317,7 +422,7 @@
if (data.data.length === 1) {
var e = data.data[0];
nav.engine = { id_mye: e.id_mye, name: e.name_engine + (e.trim_level ? ' ' + e.trim_level : '') };
loadCategories();
loadCategoriesForMode();
return;
}
@@ -333,7 +438,7 @@
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.engine = { id_mye: parseInt(this.dataset.myeId), name: this.dataset.name };
loadCategories();
loadCategoriesForMode();
});
});
});
@@ -389,32 +494,345 @@
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.group = { id: parseInt(this.dataset.groupId), name: this.dataset.name };
nav.partType = null; // reset deeper levels
loadPartTypes();
});
});
});
}
// ─── Part Types (3rd subcategory level — Nexpart-style) ───
function loadPartTypes() {
nav.level = 'part_types';
nav.partType = null;
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.group.name;
setupLevelFilter(true);
showLoading();
apiFetch(API + '/part-types?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
// No part types? Skip directly to all parts in the group.
loadParts(1);
return;
}
// Single part type? Skip the picker — go straight to parts.
if (data.data.length === 1) {
var only = data.data[0];
nav.partType = { slug: only.slug, name: only.name };
loadParts(1);
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (pt) {
var img = pt.sample_image
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
return '<div class="nav-card" role="listitem" data-pt-slug="' + esc(pt.slug) + '" data-pt-name="' + esc(pt.name) + '">' +
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.partType = { slug: this.dataset.ptSlug, name: this.dataset.ptName };
loadParts(1);
});
});
});
}
function loadParts(page) {
// ═══════════════════════════════════════════════════════════════════
// NEXPART (Local mode) — parallel navigation functions
// ═══════════════════════════════════════════════════════════════════
// These run in parallel to loadCategories / loadGroups / loadPartTypes
// and are only invoked when catalogMode === 'local'. They share the
// same DOM hooks (navGrid, breadcrumb, levelTitle) but fetch from the
// Nexpart-filtered endpoints and store state in nav.nxGroup / nxSubgroup
// / nxPartType instead of nav.category / group / partType.
function loadCategoriesForMode() {
// Dispatcher — called by every place that used to call loadCategories()
if (catalogMode === 'local') {
loadNexpartCategories();
} else {
loadCategories();
}
}
function loadNexpartCategories() {
nav.level = 'categories';
pushNavState();
updateBreadcrumb();
levelTitle.textContent = 'Categorias (Local)';
setupLevelFilter(true);
showLoading();
apiFetch(API + '/categories?mode=local&mye_id=' + nav.engine.id_mye).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin categorias Local', 'Ninguna parte de este vehiculo mapea al catalogo Local.');
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (c) {
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(c.slug) + '" data-name="' + esc(c.name) + '">' +
'<div class="nav-card__name">' + esc(c.name) + '</div>' +
'<div class="nav-card__count">' + c.part_count + ' partes</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxGroup = { slug: this.dataset.slug, name: this.dataset.name };
// Reset deeper Nexpart state so a re-click always goes to
// a clean subgroup list.
nav.nxSubgroup = null;
nav.nxPartType = null;
loadNexpartSubgroups();
});
});
});
}
function loadNexpartSubgroups() {
nav.level = 'groups';
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.nxGroup.name;
setupLevelFilter(true);
showLoading();
var url = API + '/groups?mode=local&mye_id=' + nav.engine.id_mye
+ '&category_slug=' + encodeURIComponent(nav.nxGroup.slug);
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin subcategorias', 'No hay subcategorias en ' + nav.nxGroup.name);
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (s) {
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(s.slug) + '" data-name="' + esc(s.name) + '">' +
'<div class="nav-card__name">' + esc(s.name) + '</div>' +
'<div class="nav-card__count">' + s.part_count + ' partes</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxSubgroup = { slug: this.dataset.slug, name: this.dataset.name };
nav.nxPartType = null;
loadNexpartPartTypes();
});
});
});
}
function loadNexpartPartTypes() {
nav.level = 'part_types';
nav.nxPartType = null;
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.nxSubgroup.name;
setupLevelFilter(true);
showLoading();
var url = API + '/part-types?mode=local&mye_id=' + nav.engine.id_mye
+ '&group_slug=' + encodeURIComponent(nav.nxGroup.slug)
+ '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug);
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin tipos de parte', 'No hay tipos de parte en ' + nav.nxSubgroup.name);
return;
}
// Single part type? Auto-drill-down to parts (UX shortcut).
if (data.data.length === 1) {
var only = data.data[0];
nav.nxPartType = { slug: only.slug, name: only.name };
loadParts(1);
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (pt) {
var img = pt.sample_image
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(pt.slug) + '" data-name="' + esc(pt.name) + '">' +
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxPartType = { slug: this.dataset.slug, name: this.dataset.name };
loadParts(1);
});
});
});
}
// ═══════════════════════════════════════════════════════════════════
// SHOP SUPPLIES (Supplies mode) — vehicle-independent
// ═══════════════════════════════════════════════════════════════════
// Uses nav.nxGroup / nav.nxSubgroup / nav.nxPartType for state (reuses
// the Nexpart slot because Supplies is a subset of the Nexpart taxonomy)
// but calls a different set of endpoints (/shop-supplies/*) that don't
// require an mye_id.
function loadShopSuppliesGroups() {
nav.level = 'categories';
pushNavState();
updateBreadcrumb();
levelTitle.textContent = 'Shop Supplies (sin vehiculo)';
setupLevelFilter(true);
showLoading();
apiFetch(API + '/shop-supplies/groups').then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin supplies', 'No hay grupos de Shop Supplies disponibles.');
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (g) {
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(g.slug) + '" data-name="' + esc(g.name) + '">' +
'<div class="nav-card__name">' + esc(g.name) + '</div>' +
'<div class="nav-card__count">' + g.subgroup_count + ' subgrupos</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxGroup = { slug: this.dataset.slug, name: this.dataset.name };
nav.nxSubgroup = null;
nav.nxPartType = null;
loadShopSuppliesSubgroups();
});
});
});
}
function loadShopSuppliesSubgroups() {
nav.level = 'groups';
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.nxGroup.name;
setupLevelFilter(true);
showLoading();
var url = API + '/shop-supplies/subgroups?group_slug=' + encodeURIComponent(nav.nxGroup.slug);
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin subgrupos', 'No hay subgrupos con partes disponibles.');
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (s) {
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(s.slug) + '" data-name="' + esc(s.name) + '">' +
'<div class="nav-card__name">' + esc(s.name) + '</div>' +
'<div class="nav-card__count">' + s.part_count + ' partes</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxSubgroup = { slug: this.dataset.slug, name: this.dataset.name };
nav.nxPartType = null;
loadShopSuppliesPartTypes();
});
});
});
}
function loadShopSuppliesPartTypes() {
nav.level = 'part_types';
nav.nxPartType = null;
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.nxSubgroup.name;
setupLevelFilter(true);
showLoading();
var url = API + '/shop-supplies/part-types'
+ '?group_slug=' + encodeURIComponent(nav.nxGroup.slug)
+ '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug);
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin tipos', 'No hay tipos de parte en este subgrupo.');
return;
}
// Single part type? Skip the picker.
if (data.data.length === 1) {
var only = data.data[0];
nav.nxPartType = { slug: only.slug, name: only.name };
loadShopSuppliesParts(1);
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (pt) {
var img = pt.sample_image
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(pt.slug) + '" data-name="' + esc(pt.name) + '">' +
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxPartType = { slug: this.dataset.slug, name: this.dataset.name };
loadShopSuppliesParts(1);
});
});
});
}
function loadShopSuppliesParts(page) {
nav.level = 'parts';
pushNavState();
currentPage = page || 1;
updateBreadcrumb();
levelTitle.textContent = nav.group.name;
levelTitle.textContent = nav.nxPartType.name;
setupLevelFilter(false);
showLoading();
navGrid.innerHTML = '';
apiFetch(API + '/parts?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id + '&page=' + currentPage + '&per_page=30').then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; }
var url = API + '/shop-supplies/parts'
+ '?group_slug=' + encodeURIComponent(nav.nxGroup.slug)
+ '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug)
+ '&part_type_slug=' + encodeURIComponent(nav.nxPartType.slug)
+ '&page=' + currentPage + '&per_page=30';
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin partes', 'No hay partes en este tipo.');
return;
}
// Reuse the same aftermarket-styled rendering as Local mode.
partsGrid.style.display = '';
partsGrid.innerHTML = data.data.map(function (p) {
var stockBadge;
if (p.local_stock > 0) {
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
} else if (p.bodega_count > 0) {
if (p.in_stock_network || p.bodega_count > 0) {
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
} else {
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
@@ -424,10 +842,123 @@
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>';
return '<article class="part-card" role="listitem" data-part-id="' + p.id_part + '">' +
var tierClass = p.priority_tier === 1 ? ' part-card--tier1' : (p.priority_tier === 2 ? ' part-card--tier2' : '');
var manuBadge = '';
if (p.manufacturer) {
var tierStar = p.priority_tier === 1 ? '<span class="manu-tier">★</span>' : '';
manuBadge = '<div class="part-card__manu"><span class="manu-name">' + esc(p.manufacturer) + '</span>' + tierStar + '</div>';
}
var skuLine = p.part_number
? esc(p.part_number) + '<span class="part-card__oem-sub"> · OEM: ' + esc(p.oem_part_number || '') + '</span>'
: esc(p.oem_part_number || '');
return '<article class="part-card' + tierClass + '" role="listitem" data-part-id="' + p.id_part + '">' +
'<div class="part-card__image">' + imgHtml + '</div>' +
'<div class="part-card__body">' +
'<div class="part-card__oem">' + esc(p.oem_part_number) + '</div>' +
manuBadge +
'<div class="part-card__oem">' + skuLine + '</div>' +
'<div class="part-card__name">' + esc(p.name) + '</div>' +
'</div>' +
'<div class="part-card__footer">' +
'<span class="part-card__price" style="color:var(--color-text-muted);">Sin precio</span>' +
stockBadge +
'</div>' +
'</article>';
}).join('');
partsGrid.querySelectorAll('.part-card').forEach(function (card) {
card.addEventListener('click', function () {
openPartDetail(parseInt(this.dataset.partId));
});
});
if (data.pagination) renderPagination(data.pagination);
});
}
function loadParts(page) {
nav.level = 'parts';
pushNavState();
currentPage = page || 1;
updateBreadcrumb();
// Title: Nexpart part type > TecDoc part type > TecDoc group
if (nav.nxPartType) {
levelTitle.textContent = nav.nxPartType.name;
} else if (nav.partType) {
levelTitle.textContent = nav.partType.name;
} else if (nav.group) {
levelTitle.textContent = nav.group.name;
} else {
levelTitle.textContent = 'Partes';
}
setupLevelFilter(false);
showLoading();
navGrid.innerHTML = '';
// Build the URL based on which navigation branch the user took.
// Nexpart branch uses slug-based params; OEM branch uses integer ids.
var url;
if (nav.nxGroup && nav.nxSubgroup && nav.nxPartType) {
url = API + '/parts?mode=local'
+ '&mye_id=' + nav.engine.id_mye
+ '&page=' + currentPage + '&per_page=30'
+ '&nexpart_group=' + encodeURIComponent(nav.nxGroup.slug)
+ '&nexpart_subgroup=' + encodeURIComponent(nav.nxSubgroup.slug)
+ '&nexpart_part_type=' + encodeURIComponent(nav.nxPartType.slug);
} else {
var ptParam = nav.partType ? '&part_type=' + encodeURIComponent(nav.partType.slug) : '';
url = API + '/parts?mye_id=' + nav.engine.id_mye
+ '&group_id=' + nav.group.id
+ '&page=' + currentPage + '&per_page=30'
+ '&mode=' + catalogMode
+ ptParam;
}
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; }
var isLocal = (catalogMode === 'local');
partsGrid.style.display = '';
partsGrid.innerHTML = data.data.map(function (p) {
// Stock badge — prefer tenant stock, then warehouse network, else fallback
var stockBadge;
if (p.local_stock > 0) {
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
} else if (p.in_stock_network || p.bodega_count > 0) {
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
} else {
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
}
var imgHtml = p.image_url
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>';
// Local-mode extras: manufacturer badge + priority tier indicator
var manuBadge = '';
var tierClass = '';
if (isLocal && p.manufacturer) {
var tierLabel = '';
if (p.priority_tier === 1) { tierClass = ' part-card--tier1'; tierLabel = '★'; }
else if (p.priority_tier === 2) { tierClass = ' part-card--tier2'; tierLabel = ''; }
manuBadge = '<div class="part-card__manu"><span class="manu-name">' + esc(p.manufacturer) + '</span>' +
(tierLabel ? '<span class="manu-tier">' + tierLabel + '</span>' : '') + '</div>';
}
// SKU to show: aftermarket part_number in local mode, OEM number otherwise
var skuLine = isLocal && p.part_number
? esc(p.part_number) + '<span class="part-card__oem-sub"> · OEM: ' + esc(p.oem_part_number) + '</span>'
: esc(p.oem_part_number);
return '<article class="part-card' + tierClass + '" role="listitem" data-part-id="' + p.id_part + '">' +
'<div class="part-card__image">' + imgHtml + '</div>' +
'<div class="part-card__body">' +
manuBadge +
'<div class="part-card__oem">' + skuLine + '</div>' +
'<div class="part-card__name">' + esc(p.name) + '</div>' +
'</div>' +
'<div class="part-card__footer">' +
@@ -618,11 +1149,148 @@
// ─── SMART SEARCH ───
var searchTimeout = null;
// ═══════════════════════════════════════════════════════════════════
// SMART SEARCH — auto-detect VIN / plate / part number / keyword
// ═══════════════════════════════════════════════════════════════════
// Returns: 'vin' | 'plate' | 'part_number' | 'keyword'
function detectQueryType(raw) {
if (!raw) return 'keyword';
var q = raw.trim();
// Strip common separators for detection (VINs/parts rarely contain spaces)
var compact = q.replace(/[\s\-]/g, '').toUpperCase();
// VIN: exactly 17 chars alphanumeric, no I/O/Q
if (/^[A-HJ-NPR-Z0-9]{17}$/.test(compact)) return 'vin';
// Mexican license plate: 3 letters + 3-4 digits (with/without hyphen)
if (/^[A-Z]{3}[-\s]?\d{3,4}$/.test(q.toUpperCase())) return 'plate';
// Part-number heuristic. Rules designed to avoid false positives on
// natural-language Spanish/English queries:
// 1. Original query must NOT contain lowercase letters. Real part
// numbers are always uppercase ("4G0-857-951-A"); keywords aren't.
// 2. No natural-language words allowed (para, de, con, for, the, etc.)
// 3. Either has a dash/slash separator, or is a solid alphanumeric blob.
var hasLowercase = /[a-z]/.test(q);
if (hasLowercase) return 'keyword';
// Block queries that contain a year-like 4-digit number alongside
// other tokens — those are "PART 2018" style vehicle refs, not parts.
var tokens = q.split(/\s+/);
var hasYear = tokens.some(function (t) { return /^(19|20)\d{2}$/.test(t); });
if (hasYear && tokens.length > 1) return 'keyword';
var qUpper = q.toUpperCase();
// Dashed/slashed part number: "4G0-857-951-A", "BP-1234"
if (/^[A-Z0-9]{2,}[\-\/][A-Z0-9]{2,}([\-\/][A-Z0-9]+)*$/.test(qUpper) && compact.length >= 6) {
return 'part_number';
}
// Space-separated part number (rare but real, e.g. BOSCH "0 986 4B7 013")
if (tokens.length >= 2 && tokens.every(function (t) { return /^[A-Z0-9]{1,}$/.test(t); }) && compact.length >= 6) {
return 'part_number';
}
// Solid alphanumeric blob 8+ chars with both letters+digits
if (/^[A-Z0-9]{8,}$/.test(compact) && /[A-Z]/.test(compact) && /\d/.test(compact)) {
return 'part_number';
}
return 'keyword';
}
// Hint badge shown next to the search input. Injected lazily so we don't
// need to touch the HTML.
var searchHint = null;
function ensureSearchHint() {
if (searchHint) return searchHint;
searchHint = document.createElement('div');
searchHint.id = 'searchHint';
searchHint.style.cssText =
'position:absolute;top:100%;left:0;margin-top:4px;padding:3px 10px;' +
'background:var(--color-primary-muted);color:var(--color-text-accent);' +
'font-size:var(--text-caption);font-weight:var(--font-weight-semibold);' +
'border:1px dashed var(--color-border-accent);border-radius:var(--radius-sm);' +
'white-space:nowrap;pointer-events:none;z-index:10;display:none;';
searchInput.parentElement.appendChild(searchHint);
return searchHint;
}
function updateSearchHint(type) {
var hint = ensureSearchHint();
var labels = {
vin: '🚗 VIN detectado — decodificando',
plate: '🔖 Placa detectada — consultando registro',
part_number: '🔩 Numero de parte detectado',
keyword: null,
};
var label = labels[type];
if (!label) {
hint.style.display = 'none';
} else {
hint.textContent = label;
hint.style.display = '';
}
}
// Smart dispatcher — decides which endpoint to call based on input type.
function runSmartSearch(q) {
var type = detectQueryType(q);
if (type === 'vin') {
// Use the existing VIN decoder flow
try { decodeVinWithValue(q); } catch (e) { runSearch(q); }
return;
}
if (type === 'plate') {
// Use the existing plate lookup flow — assume default state MX
try { lookupPlateWithValue(q); } catch (e) { runSearch(q); }
return;
}
// For part_number and keyword, both go through the existing /search
// endpoint (which supports full-text + OEM number search).
runSearch(q);
}
// Thin wrappers around existing VIN/plate handlers — they usually read
// from their own input fields; these set the field and trigger.
function decodeVinWithValue(vin) {
var vinInput = document.getElementById('vinInput');
if (vinInput) {
vinInput.value = vin;
if (typeof decodeVin === 'function') decodeVin();
else runSearch(vin);
} else {
runSearch(vin); // fallback
}
}
function lookupPlateWithValue(plate) {
var plateInput = document.getElementById('plateInput');
if (plateInput) {
plateInput.value = plate.toUpperCase();
if (typeof lookupPlate === 'function') lookupPlate();
else runSearch(plate);
} else {
runSearch(plate); // fallback
}
}
searchInput.addEventListener('input', function () {
clearTimeout(searchTimeout);
var q = this.value.trim();
// Live type detection for the hint (runs on every keystroke)
updateSearchHint(q.length >= 3 ? detectQueryType(q) : null);
if (q.length < 2) { searchDropdown.classList.remove('is-visible'); return; }
searchTimeout = setTimeout(function () { runSearch(q); }, 350);
// For keyword queries, keep the debounced dropdown preview.
// For VIN/plate/part-number, wait for Enter — they're one-shot lookups.
var type = detectQueryType(q);
if (type === 'keyword') {
searchTimeout = setTimeout(function () { runSearch(q); }, 350);
}
});
searchInput.addEventListener('keydown', function (e) {
@@ -630,10 +1298,11 @@
e.preventDefault();
clearTimeout(searchTimeout);
var q = this.value.trim();
if (q.length >= 2) runSearch(q);
if (q.length >= 2) runSmartSearch(q);
}
if (e.key === 'Escape') {
searchDropdown.classList.remove('is-visible');
updateSearchHint(null);
}
});
@@ -906,7 +1575,7 @@
// Load brands filtered by year
vsBrand.disabled = false;
apiFetch(API + '/brands?year_id=' + yearId).then(function (data) {
apiFetch(API + '/brands?year_id=' + yearId + '&mode=' + catalogMode).then(function (data) {
var brands = data.data || data;
if (!brands) return;
vsBrand.innerHTML = '<option value="">Marca...</option>';
@@ -980,7 +1649,7 @@
nav.level = 'categories';
pushNavState();
loadCategories();
loadCategoriesForMode();
// Scroll to catalog content
setTimeout(function () {
@@ -999,7 +1668,9 @@
vsEngine.disabled = true;
vsClear.style.display = 'none';
nav.level = 'brands'; nav.brand = null; nav.model = null; nav.year = null; nav.engine = null; nav.category = null; nav.group = null; currentPage = 1;
nav.level = 'brands'; nav.brand = null; nav.model = null; nav.year = null; nav.engine = null; nav.category = null; nav.group = null; nav.partType = null;
nav.nxGroup = null; nav.nxSubgroup = null; nav.nxPartType = null;
currentPage = 1;
pushNavState();
loadBrands();
}
@@ -1231,10 +1902,12 @@
decodeVin: decodeVin,
togglePlate: togglePlate,
lookupPlate: lookupPlate,
setMode: setCatalogMode,
};
// ─── INIT ───
renderCart();
updateModeToggleUI();
vsLoadYears();
loadBrands();