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:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user