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

@@ -12,8 +12,10 @@
</script>
<link rel="stylesheet" href="/static/css/tokens.css">
<style>
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
html { scroll-behavior: smooth; scrollbar-width: none; }
html::-webkit-scrollbar { width: 0; }
body {
font-family: var(--font-body);
@@ -21,15 +23,24 @@
color: var(--color-text-primary);
line-height: var(--leading-body);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
}
a { color: var(--color-text-accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ── Header ── */
/* ==========================================================================
HEADER — Glassmorphism sticky (matches landing)
========================================================================== */
.site-header {
position: sticky; top: 0; z-index: var(--z-sticky);
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border);
position: sticky; top: 0;
z-index: var(--z-sticky);
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border-bottom: 1px solid var(--glass-border);
}
.site-header .inner {
max-width: var(--content-xl); margin: 0 auto;
@@ -42,17 +53,82 @@
font-weight: var(--heading-weight-primary);
color: var(--color-text-accent);
text-transform: uppercase; letter-spacing: var(--tracking-wide);
text-decoration: none;
position: relative;
}
.logo::after {
content: '';
position: absolute;
bottom: -3px; left: 0; right: 0;
height: 2px;
background: var(--gradient-accent);
border-radius: 1px;
opacity: 0.5;
filter: blur(1px);
}
.header-right { display: flex; gap: var(--space-3); align-items: center; }
.theme-toggle {
background: var(--btn-ghost-bg); border: 1px solid var(--btn-ghost-border);
color: var(--btn-ghost-text); padding: var(--space-1) var(--space-3);
border-radius: var(--radius-md); cursor: pointer; font-size: var(--text-caption);
font-family: var(--font-body); transition: var(--transition-fast);
}
.theme-toggle:hover { background: var(--color-primary-muted); color: var(--color-text-accent); }
/* ── Search bar ── */
/* ── Catalog mode toggle (OEM / Local) ── */
.mode-toggle {
display: inline-flex;
padding: 3px;
background: var(--glass-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px dashed var(--glass-border);
border-radius: var(--radius-md);
gap: 2px;
}
.mode-toggle button {
background: transparent;
border: none;
color: var(--color-text-muted);
padding: 4px 12px;
border-radius: calc(var(--radius-md) - 3px);
font-family: var(--font-mono);
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
cursor: pointer;
transition: all 0.2s var(--ease-out);
}
.mode-toggle button:hover {
color: var(--color-text-accent);
}
.mode-toggle button.is-active {
background: var(--color-primary-muted);
color: var(--color-text-accent);
box-shadow: 0 0 12px var(--glow-color-soft);
}
.theme-toggle {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
background: transparent;
border: 1px dashed var(--glass-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
cursor: pointer; font-size: 1rem;
transition: var(--transition-fast);
}
.theme-toggle:hover {
border-color: var(--color-border-accent);
color: var(--color-text-accent);
box-shadow: 0 0 12px var(--glow-color-soft);
}
.header-back {
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
text-decoration: none;
transition: color 0.2s;
}
.header-back:hover { color: var(--color-text-accent); text-decoration: none; }
/* ==========================================================================
SEARCH BAR — Glass style
========================================================================== */
.search-bar {
max-width: var(--content-xl); margin: 0 auto;
padding: var(--space-4) var(--space-6);
@@ -63,34 +139,65 @@
.search-wrapper input {
flex: 1;
padding: var(--space-3) var(--space-4);
background: var(--color-surface-1);
border: 1px solid var(--color-border);
background: var(--glass-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-family: var(--font-body);
font-size: var(--text-body);
outline: none;
transition: var(--transition-fast);
transition: all 0.25s var(--ease-out);
}
.search-wrapper input:focus {
border-color: var(--color-border-focus);
box-shadow: var(--shadow-focus);
box-shadow: 0 0 0 3px var(--glow-color-soft), 0 0 20px var(--glow-color-soft);
}
.search-wrapper input::placeholder { color: var(--color-text-muted); }
/* 3D search button */
.search-wrapper button {
padding: var(--space-3) var(--space-5);
background: var(--btn-primary-bg); color: var(--btn-primary-text);
background: var(--gradient-accent);
color: var(--btn-primary-text);
border: none; border-radius: var(--radius-md);
font-family: var(--font-body); font-size: var(--text-body);
font-weight: var(--font-weight-semibold); cursor: pointer;
transition: var(--transition-fast);
transition: all 0.25s var(--ease-out);
box-shadow: 0 3px 0 var(--color-primary-active),
0 4px 10px var(--glow-color-soft);
position: relative;
overflow: hidden;
}
.search-wrapper button:hover { background: var(--btn-primary-bg-hover); }
.search-wrapper button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 0 var(--color-primary-active),
0 8px 20px var(--glow-color);
}
.search-wrapper button:active {
transform: translateY(1px);
box-shadow: 0 1px 0 var(--color-primary-active);
}
/* Shimmer */
.search-wrapper button::after {
content: '';
position: absolute;
top: 0; left: -100%; width: 60%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transition: left 0.5s ease;
}
.search-wrapper button:hover::after { left: 120%; }
/* ==========================================================================
REGION BAR — Glass pills
========================================================================== */
/* ── Region bar ── */
.region-bar {
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border);
background: var(--glass-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid var(--glass-border);
padding: var(--space-2) 0;
}
.region-inner {
@@ -101,32 +208,52 @@
.region-label {
font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
color: var(--color-text-muted); text-transform: uppercase;
letter-spacing: var(--tracking-wider); margin-right: var(--space-2);
letter-spacing: var(--tracking-widest); margin-right: var(--space-2);
font-family: var(--font-mono);
}
.region-btn {
background: var(--btn-ghost-bg); border: 1px solid var(--btn-ghost-border);
color: var(--btn-ghost-text); padding: var(--space-1) var(--space-3);
border-radius: var(--radius-md); cursor: pointer; font-size: var(--text-caption);
font-family: var(--font-body); transition: var(--transition-fast);
background: transparent;
border: 1px dashed var(--glass-border);
color: var(--color-text-muted);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-md); cursor: pointer;
font-size: var(--text-caption);
font-family: var(--font-body);
transition: all 0.25s ease;
}
.region-btn:hover {
border-color: var(--color-border-accent);
color: var(--color-text-accent);
box-shadow: 0 0 12px var(--glow-color-soft);
}
.region-btn:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
.region-btn.is-active {
background: var(--color-primary-muted); color: var(--color-primary);
border-color: var(--color-primary); font-weight: var(--font-weight-semibold);
background: var(--color-primary-muted);
color: var(--color-text-accent);
border-color: var(--color-border-accent);
border-style: solid;
font-weight: var(--font-weight-semibold);
box-shadow: 0 0 16px var(--glow-color-soft);
}
/* ── Breadcrumb ── */
/* ==========================================================================
BREADCRUMB
========================================================================== */
.breadcrumb {
max-width: var(--content-xl); margin: 0 auto;
padding: var(--space-2) var(--space-6);
display: flex; flex-wrap: wrap; gap: var(--space-1);
font-size: var(--text-body-sm);
color: var(--color-text-muted);
font-family: var(--font-mono);
}
.breadcrumb a { color: var(--color-text-accent); }
.breadcrumb .sep { margin: 0 var(--space-1); color: var(--color-text-muted); }
.breadcrumb .sep { margin: 0 var(--space-1); color: var(--color-text-muted); opacity: 0.4; }
/* ==========================================================================
MAIN CONTENT
========================================================================== */
/* ── Main content ── */
.main {
max-width: var(--content-xl); margin: 0 auto;
padding: var(--space-4) var(--space-6) var(--space-16);
@@ -138,25 +265,44 @@
letter-spacing: var(--heading-tracking-h3);
}
/* ── Grid for brands / models / years / engines / categories / groups ── */
/* ==========================================================================
NAV GRID — Glass cards for brands/models/years/engines/categories/groups
========================================================================== */
.nav-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--space-3);
}
.nav-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-5);
cursor: pointer;
transition: var(--transition-fast);
transition: all 0.3s var(--ease-out);
display: flex; align-items: center; justify-content: space-between;
position: relative;
overflow: hidden;
}
/* Top accent line on hover */
.nav-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: var(--gradient-accent);
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s var(--ease-out);
}
.nav-card:hover::before { transform: scaleX(1); }
.nav-card:hover {
border-color: var(--color-border-accent);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
box-shadow: 0 4px 20px var(--glow-color-soft);
transform: translateY(-2px);
}
.nav-card .name {
font-weight: var(--font-weight-semibold);
@@ -168,19 +314,63 @@
font-family: var(--font-mono);
}
/* ── Parts list ── */
/* ==========================================================================
PARTS LIST — Glass rows with glow
========================================================================== */
.parts-list { display: flex; flex-direction: column; gap: var(--space-3); }
.part-row {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-5);
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-3);
transition: var(--transition-fast);
transition: all 0.3s var(--ease-out);
}
.part-row:hover { border-color: var(--color-border-accent); }
.part-row:hover {
border-color: var(--color-border-accent);
box-shadow: 0 4px 20px var(--glow-color-soft);
}
/* Local-mode priority highlights */
.part-row--tier1 {
border-color: var(--color-border-accent);
box-shadow: 0 0 16px var(--glow-color-soft);
}
.part-manu {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px; margin-bottom: var(--space-2);
background: var(--color-primary-muted);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
font-size: var(--text-caption);
font-weight: var(--font-weight-bold);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-text-accent);
font-family: var(--font-mono);
}
.part-row--tier2 .part-manu {
background: var(--color-surface-2);
color: var(--color-text-secondary);
}
.part-manu .manu-tier { color: var(--color-primary); font-size: 13px; }
.part-oem-sub { color: var(--color-text-muted); font-weight: var(--font-weight-regular); font-size: var(--text-caption); }
.part-stock {
display: inline-block;
margin-top: var(--space-2);
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
}
.part-stock--yes { background: rgba(63,185,80,0.15); color: #3FB950; border: 1px solid rgba(63,185,80,0.3); }
.part-stock--no { background: var(--color-surface-2); color: var(--color-text-muted); border: 1px dashed var(--glass-border); }
.part-name {
font-weight: var(--font-weight-semibold);
font-size: var(--text-body);
@@ -202,18 +392,20 @@
}
.part-alts span {
display: inline-block;
background: var(--color-surface-2);
background: var(--color-primary-muted);
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
margin-right: var(--space-1);
margin-bottom: var(--space-1);
font-family: var(--font-mono);
border: 1px dashed var(--glass-border);
}
.part-img {
width: 80px; height: 80px;
object-fit: contain;
border-radius: var(--radius-sm);
background: var(--color-surface-2);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
}
.part-detail-btn {
font-size: var(--text-caption);
@@ -221,35 +413,51 @@
cursor: pointer;
border: none; background: none; font-family: var(--font-body);
padding: var(--space-1) 0;
transition: color 0.2s;
}
.part-detail-btn:hover { text-decoration: underline; }
/* ── Pagination ── */
/* ==========================================================================
PAGINATION — Glass buttons
========================================================================== */
.pagination {
display: flex; justify-content: center; gap: var(--space-2);
margin-top: var(--space-6);
}
.pagination button {
padding: var(--space-2) var(--space-4);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
background: var(--glass-bg);
backdrop-filter: blur(8px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
cursor: pointer; font-family: var(--font-body);
transition: var(--transition-fast);
transition: all 0.25s ease;
}
.pagination button:hover {
border-color: var(--color-border-accent);
box-shadow: 0 0 12px var(--glow-color-soft);
}
.pagination button:hover { border-color: var(--color-border-accent); }
.pagination button.active {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
background: var(--gradient-accent);
color: var(--btn-primary-text);
border-color: transparent;
box-shadow: 0 3px 0 var(--color-primary-active),
0 4px 12px var(--glow-color-soft);
}
.pagination button:disabled { opacity: .4; cursor: default; }
/* ── Search results ── */
/* ==========================================================================
SEARCH RESULTS
========================================================================== */
.search-results { display: flex; flex-direction: column; gap: var(--space-3); }
/* ── Part detail modal ── */
/* ==========================================================================
PART DETAIL MODAL — Glass overlay
========================================================================== */
.modal-overlay {
display: none;
position: fixed; inset: 0;
@@ -258,22 +466,28 @@
justify-content: center; align-items: flex-start;
padding: var(--space-10) var(--space-4);
overflow-y: auto;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.modal-overlay.open { display: flex; }
.modal-content {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
background: var(--glass-bg-strong);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
max-width: 700px; width: 100%;
padding: var(--space-8);
position: relative;
box-shadow: 0 24px 48px rgba(0,0,0,0.3);
}
.modal-close {
position: absolute; top: var(--space-3); right: var(--space-3);
background: none; border: none; color: var(--color-text-muted);
font-size: 1.5rem; cursor: pointer; line-height: 1;
transition: color 0.2s;
}
.modal-close:hover { color: var(--color-text-primary); }
.modal-close:hover { color: var(--color-text-accent); }
.detail-section { margin-top: var(--space-6); }
.detail-section h4 {
font-family: var(--font-heading);
@@ -290,36 +504,45 @@
.alt-table th, .alt-table td {
padding: var(--space-2) var(--space-3);
text-align: left;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--glass-border);
}
.alt-table th {
color: var(--color-text-muted);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
font-size: var(--text-caption);
letter-spacing: var(--tracking-wider);
letter-spacing: var(--tracking-widest);
font-family: var(--font-mono);
}
/* ── Loading ── */
/* ==========================================================================
LOADING / EMPTY
========================================================================== */
.loading {
text-align: center; padding: var(--space-10);
color: var(--color-text-muted);
font-size: var(--text-body);
}
/* ── Empty state ── */
.empty {
text-align: center; padding: var(--space-10);
color: var(--color-text-muted);
}
/* ── Footer ── */
/* ==========================================================================
FOOTER
========================================================================== */
.site-footer {
padding: var(--space-4) 0; text-align: center;
border-top: 1px solid var(--color-border);
font-size: var(--text-caption); color: var(--color-text-muted);
}
/* ==========================================================================
RESPONSIVE
========================================================================== */
@media (max-width: 640px) {
.nav-grid { grid-template-columns: 1fr; }
.part-row { grid-template-columns: 1fr; }
@@ -332,7 +555,14 @@
<div class="inner">
<a href="/" class="logo">Nexus Autoparts</a>
<div class="header-right">
<button class="theme-toggle" onclick="toggleTheme()">Tema</button>
<div class="mode-toggle" id="modeToggle" title="Cambiar entre catalogo OEM (TecDoc) y marcas locales">
<button data-mode="oem" onclick="setCatalogMode('oem')">OEM</button>
<button data-mode="local" onclick="setCatalogMode('local')">Local</button>
</div>
<a href="/" class="header-back">&larr; Inicio</a>
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle">
<span id="themeIcon">&#9790;</span>
</button>
</div>
</div>
</header>
@@ -340,11 +570,11 @@
<!-- Country / Region selector -->
<div class="region-bar">
<div class="region-inner">
<span class="region-label">Región:</span>
<button class="region-btn is-active" data-region="north-america" onclick="setRegion('north-america')">🇲🇽 México, 🇺🇸 USA, 🇨🇦 Canadá</button>
<button class="region-btn" data-region="europe" onclick="setRegion('europe')">🇪🇺 Europa</button>
<button class="region-btn" data-region="asia" onclick="setRegion('asia')">🇯🇵 Asia</button>
<button class="region-btn" data-region="all" onclick="setRegion('all')">🌐 Todos</button>
<span class="region-label">Region:</span>
<button class="region-btn is-active" data-region="north-america" onclick="setRegion('north-america')">MX / USA / CA</button>
<button class="region-btn" data-region="europe" onclick="setRegion('europe')">Europa</button>
<button class="region-btn" data-region="asia" onclick="setRegion('asia')">Asia</button>
<button class="region-btn" data-region="all" onclick="setRegion('all')">Todos</button>
</div>
</div>
@@ -373,6 +603,25 @@
<div>&copy; 2026 Nexus Autoparts</div>
</footer>
<script>
// Theme toggle (inline for catalog page)
function toggleTheme() {
var html = document.documentElement;
var current = html.getAttribute('data-theme');
var next = current === 'industrial' ? 'modern' : 'industrial';
html.setAttribute('data-theme', next);
localStorage.setItem('nexus-theme', next);
var icon = document.getElementById('themeIcon');
if (icon) icon.innerHTML = next === 'industrial' ? '&#9790;' : '&#9728;';
}
// Init icon
(function(){
var theme = document.documentElement.getAttribute('data-theme');
var icon = document.getElementById('themeIcon');
if (icon) icon.innerHTML = theme === 'industrial' ? '&#9790;' : '&#9728;';
})();
</script>
<script src="/catalog-public.js"></script>
<!-- AI Chat Widget -->

View File

@@ -9,18 +9,63 @@
// ── State ──
var state = {
level: 'brands', // brands | models | years | engines | categories | groups | parts | search
level: 'brands', // brands | models | years | engines | categories | groups | part_types | parts | search
brand: null, // {id, name}
model: null, // {id, name}
year: null, // {id, value}
engine: null, // {id_mye, name, trim}
// OEM mode (TecDoc) state — integer IDs
category: null, // {id, name}
group: null, // {id, name}
partType: null, // {slug, name} ← 3rd subcategory level
// Local mode (Nexpart) state — string slugs. Parallel to the OEM state
// so toggle switching mid-nav doesn't trash either branch.
nxGroup: null, // {slug, name} ← top-level Nexpart group
nxSubgroup: null, // {slug, name} ← Nexpart subgroup
nxPartType: null, // {slug, name} ← Nexpart part type
region: 'north-america',
mode: (localStorage.getItem('catalog_mode') === 'local' ? 'local' : 'oem'),
page: 1,
totalPages: 1,
};
// ── Catalog mode toggle (OEM / Local) ──
function updateModeToggleUI() {
document.querySelectorAll('#modeToggle button').forEach(function (b) {
b.classList.toggle('is-active', b.getAttribute('data-mode') === state.mode);
});
}
window.setCatalogMode = function (mode) {
if (mode !== 'oem' && mode !== 'local') return;
if (mode === state.mode) return;
state.mode = mode;
localStorage.setItem('catalog_mode', mode);
updateModeToggleUI();
// Smart reset: if vehicle already picked, stay at categories in the new mode.
var hasVehicle = !!(state.engine && state.engine.id_mye);
// Clear category-and-below state from BOTH branches
state.category = state.group = state.partType = null;
state.nxGroup = state.nxSubgroup = state.nxPartType = null;
state.page = 1;
if (hasVehicle) {
state.level = 'categories';
loadCategoriesForMode();
return;
}
// No vehicle — full reset back to brand selection
state.brand = state.model = state.year = state.engine = null;
state.level = 'brands';
loadBrands();
};
// ── Region selector (global) ──
window.setRegion = function (region) {
state.region = region;
@@ -28,7 +73,9 @@
b.classList.toggle('is-active', b.dataset.region === region);
});
// Reload brands with new region
state.brand = state.model = state.year = state.engine = state.category = state.group = null;
state.brand = state.model = state.year = state.engine = null;
state.category = state.group = state.partType = null;
state.nxGroup = state.nxSubgroup = state.nxPartType = null;
loadBrands();
};
@@ -42,9 +89,10 @@
var initBrandId = urlParams.get('brand');
// ── Init ──
updateModeToggleUI();
if (initBrandId) {
// Load brands, find the one matching, then navigate
fetch(API + '/brands')
fetch(API + '/brands?mode=' + state.mode)
.then(function (r) { return r.json(); })
.then(function (brands) {
var found = brands.find(function (b) { return b.id_brand == initBrandId; });
@@ -62,6 +110,42 @@
}
// Enter on search
// ── Smart search detector ──
function detectQueryType(raw) {
if (!raw) return 'keyword';
var q = raw.trim();
var compact = q.replace(/[\s\-]/g, '').toUpperCase();
if (/^[A-HJ-NPR-Z0-9]{17}$/.test(compact)) return 'vin';
if (/^[A-Z]{3}[-\s]?\d{3,4}$/.test(q.toUpperCase())) return 'plate';
var hasLowercase = /[a-z]/.test(q);
if (hasLowercase) return 'keyword';
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();
if (/^[A-Z0-9]{2,}[\-\/][A-Z0-9]{2,}([\-\/][A-Z0-9]+)*$/.test(qUpper) && compact.length >= 6) return 'part_number';
if (tokens.length >= 2 && tokens.every(function (t) { return /^[A-Z0-9]{1,}$/.test(t); }) && compact.length >= 6) return 'part_number';
if (/^[A-Z0-9]{8,}$/.test(compact) && /[A-Z]/.test(compact) && /\d/.test(compact)) return 'part_number';
return 'keyword';
}
// Smart search hint
var searchHint = document.createElement('div');
searchHint.style.cssText = 'display:none;padding:3px 10px;font-size:12px;color:var(--color-text-accent);background:var(--color-primary-muted);border:1px dashed var(--color-border-accent);border-radius:4px;margin-top:4px;';
searchInput.parentElement.after(searchHint);
searchInput.addEventListener('input', function () {
var q = this.value.trim();
if (q.length >= 3) {
var type = detectQueryType(q);
var hints = { vin: '🚗 VIN detectado', plate: '🔖 Placa detectada', part_number: '🔩 Numero de parte', keyword: null };
if (hints[type]) { searchHint.textContent = hints[type]; searchHint.style.display = ''; }
else { searchHint.style.display = 'none'; }
} else {
searchHint.style.display = 'none';
}
});
searchInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') doSearch();
});
@@ -131,14 +215,41 @@
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) {
// Category / subgroup / part type — rendered from EITHER the Nexpart
// branch (nxGroup/nxSubgroup/nxPartType) or the OEM branch. Only one
// should be populated at any time after a navigation reset.
if (state.nxGroup) {
parts.push('<span class="sep">/</span>');
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'nx_subgroups\')">' + esc(state.nxGroup.name) + '</a>');
} else 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) {
if (state.nxSubgroup) {
parts.push('<span class="sep">/</span>');
parts.push('<span>' + esc(state.group.name) + '</span>');
if (state.nxPartType) {
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'nx_part_types\')">' + esc(state.nxSubgroup.name) + '</a>');
} else {
parts.push('<span>' + esc(state.nxSubgroup.name) + '</span>');
}
} else if (state.group) {
parts.push('<span class="sep">/</span>');
if (state.partType) {
parts.push('<a href="javascript:void(0)" onclick="catalogNav(\'part_types\')">' + esc(state.group.name) + '</a>');
} else {
parts.push('<span>' + esc(state.group.name) + '</span>');
}
}
if (state.nxPartType) {
parts.push('<span class="sep">/</span>');
parts.push('<span>' + esc(state.nxPartType.name) + '</span>');
} else if (state.partType) {
parts.push('<span class="sep">/</span>');
parts.push('<span>' + esc(state.partType.name) + '</span>');
}
if (state.level === 'search') {
parts.push('<span class="sep">/</span>');
parts.push('<span>Busqueda</span>');
@@ -147,32 +258,58 @@
breadcrumbEl.innerHTML = parts.join('');
}
// Global nav
// Helper: clears every state key at-or-below the category level, for
// BOTH the OEM branch and the Nexpart branch. Used whenever we navigate
// backward to an ancestor and need a clean slate below.
function clearCatSubtree() {
state.category = state.group = state.partType = null;
state.nxGroup = state.nxSubgroup = state.nxPartType = null;
}
// Global nav — jump to any ancestor in the breadcrumb
window.catalogNav = function (level) {
if (level === 'brands') {
state.brand = state.model = state.year = state.engine = state.category = state.group = null;
state.brand = state.model = state.year = state.engine = null;
clearCatSubtree();
state.level = 'brands';
loadBrands();
} else if (level === 'models') {
state.model = state.year = state.engine = state.category = state.group = null;
state.model = state.year = state.engine = null;
clearCatSubtree();
state.level = 'models';
loadModels();
} else if (level === 'years') {
state.year = state.engine = state.category = state.group = null;
state.year = state.engine = null;
clearCatSubtree();
state.level = 'years';
loadYears();
} else if (level === 'engines') {
state.engine = state.category = state.group = null;
state.engine = null;
clearCatSubtree();
state.level = 'engines';
loadEngines();
} else if (level === 'categories') {
state.category = state.group = null;
clearCatSubtree();
state.level = 'categories';
loadCategories();
loadCategoriesForMode();
// OEM branch back-nav
} else if (level === 'groups') {
state.group = null;
state.group = state.partType = null;
state.level = 'groups';
loadGroups();
} else if (level === 'part_types') {
state.partType = null;
state.level = 'part_types';
loadPartTypes();
// Nexpart branch back-nav
} else if (level === 'nx_subgroups') {
state.nxSubgroup = state.nxPartType = null;
state.level = 'groups';
loadNexpartSubgroups();
} else if (level === 'nx_part_types') {
state.nxPartType = null;
state.level = 'part_types';
loadNexpartPartTypes();
}
};
@@ -182,7 +319,7 @@
state.level = 'brands';
renderBreadcrumb();
content.innerHTML = '<div class="loading">Cargando marcas...</div>';
fetch(API + '/brands?region=' + (state.region || 'north-america'))
fetch(API + '/brands?region=' + (state.region || 'north-america') + '&mode=' + state.mode)
.then(function (r) { return r.json(); })
.then(function (brands) {
var html = '<h2>Selecciona una Marca</h2><div class="nav-grid">';
@@ -274,7 +411,136 @@
window.selectEngine = function (id_mye, name, trim) {
state.engine = { id_mye: id_mye, name: name, trim: trim };
state.level = 'categories';
loadCategories();
loadCategoriesForMode();
};
// ── Mode dispatcher (OEM vs Nexpart Local) ──
function loadCategoriesForMode() {
if (state.mode === 'local') {
loadNexpartCategories();
} else {
loadCategories();
}
}
// ══════════════════════════════════════════════════════════════
// NEXPART (Local mode) parallel navigation
// ══════════════════════════════════════════════════════════════
function loadNexpartCategories() {
state.level = 'categories';
renderBreadcrumb();
content.innerHTML = '<div class="loading">Cargando categorias Local...</div>';
fetch(API + '/categories?mode=local&mye_id=' + state.engine.id_mye)
.then(function (r) { return r.json(); })
.then(function (resp) {
var cats = (resp && resp.data) || [];
if (!cats.length) {
content.innerHTML = '<h2>Categorias (Local)</h2><div class="empty">Ninguna parte de este vehiculo mapea al catalogo Local.</div>';
return;
}
var html = '<h2>Categorias <span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">(Local · ' + cats.length + ')</span></h2>';
html += '<div class="nav-grid">';
cats.forEach(function (c) {
html += '<div class="nav-card" onclick="selectNxGroup(\'' + escAttr(c.slug) + '\',\'' + 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 Local.</div>'; });
}
window.selectNxGroup = function (slug, name) {
state.nxGroup = { slug: slug, name: name };
state.nxSubgroup = null;
state.nxPartType = null;
state.level = 'groups';
loadNexpartSubgroups();
};
function loadNexpartSubgroups() {
state.level = 'groups';
renderBreadcrumb();
content.innerHTML = '<div class="loading">Cargando subcategorias...</div>';
var url = API + '/groups?mode=local&mye_id=' + state.engine.id_mye
+ '&category_slug=' + encodeURIComponent(state.nxGroup.slug);
fetch(url)
.then(function (r) { return r.json(); })
.then(function (resp) {
var subs = (resp && resp.data) || [];
if (!subs.length) {
content.innerHTML = '<h2>' + esc(state.nxGroup.name) + '</h2><div class="empty">Sin subcategorias.</div>';
return;
}
var html = '<h2>' + esc(state.nxGroup.name) + ' <span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">(' + subs.length + ' subcategorias)</span></h2>';
html += '<div class="nav-grid">';
subs.forEach(function (s) {
html += '<div class="nav-card" onclick="selectNxSubgroup(\'' + escAttr(s.slug) + '\',\'' + escAttr(s.name) + '\')">';
html += '<span class="name">' + esc(s.name) + '</span>';
html += '<span class="count">' + s.part_count + '</span>';
html += '</div>';
});
html += '</div>';
content.innerHTML = html;
})
.catch(function () { content.innerHTML = '<div class="empty">Error cargando subcategorias.</div>'; });
}
window.selectNxSubgroup = function (slug, name) {
state.nxSubgroup = { slug: slug, name: name };
state.nxPartType = null;
state.level = 'part_types';
loadNexpartPartTypes();
};
function loadNexpartPartTypes() {
state.level = 'part_types';
renderBreadcrumb();
content.innerHTML = '<div class="loading">Cargando tipos de parte...</div>';
var url = API + '/part-types?mode=local&mye_id=' + state.engine.id_mye
+ '&group_slug=' + encodeURIComponent(state.nxGroup.slug)
+ '&subgroup_slug=' + encodeURIComponent(state.nxSubgroup.slug);
fetch(url)
.then(function (r) { return r.json(); })
.then(function (resp) {
var pts = (resp && resp.data) || [];
if (!pts.length) {
content.innerHTML = '<h2>' + esc(state.nxSubgroup.name) + '</h2><div class="empty">Sin tipos de parte.</div>';
return;
}
// Single part type → auto-drill-down
if (pts.length === 1) {
state.nxPartType = { slug: pts[0].slug, name: pts[0].name };
state.level = 'parts';
state.page = 1;
loadParts();
return;
}
var html = '<h2>' + esc(state.nxSubgroup.name) + ' <span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">(' + pts.length + ' tipos)</span></h2>';
html += '<div class="nav-grid">';
pts.forEach(function (t) {
var img = t.sample_image
? '<img src="' + esc(t.sample_image) + '" alt="" style="width:24px;height:24px;object-fit:contain;margin-right:6px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
html += '<div class="nav-card" onclick="selectNxPartType(\'' + escAttr(t.slug) + '\',\'' + escAttr(t.name) + '\')">';
html += '<span class="name">' + img + esc(t.name) + '</span>';
html += '<span class="count">' + t.variant_count + '</span>';
html += '</div>';
});
html += '</div>';
content.innerHTML = html;
})
.catch(function () { content.innerHTML = '<div class="empty">Error cargando tipos de parte.</div>'; });
}
window.selectNxPartType = function (slug, name) {
state.nxPartType = { slug: slug, name: name };
state.level = 'parts';
state.page = 1;
loadParts();
};
function loadCategories() {
@@ -331,6 +597,52 @@
window.selectGroup = function (id, name) {
state.group = { id: id, name: name };
state.partType = null;
state.level = 'part_types';
loadPartTypes();
};
// ── Part Types (3rd subcategory level — Nexpart-style) ──
function loadPartTypes() {
state.level = 'part_types';
renderBreadcrumb();
content.innerHTML = '<div class="loading">Cargando tipos de parte...</div>';
fetch(API + '/part-types?mye_id=' + state.engine.id_mye + '&group_id=' + state.group.id)
.then(function (r) { return r.json(); })
.then(function (resp) {
var types = resp.data || [];
if (!types.length) {
// No types available — fall through to all parts in the group.
state.level = 'parts'; state.page = 1;
loadParts();
return;
}
if (types.length === 1) {
// Single type — auto-select and show parts directly.
state.partType = { slug: types[0].slug, name: types[0].name };
state.level = 'parts'; state.page = 1;
loadParts();
return;
}
var html = '<h2>' + esc(state.group.name) + ' <span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">(' + types.length + ' tipos)</span></h2>';
html += '<div class="nav-grid">';
types.forEach(function (t) {
var img = t.sample_image
? '<img src="' + esc(t.sample_image) + '" alt="" style="width:24px;height:24px;object-fit:contain;margin-right:6px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
html += '<div class="nav-card" onclick="selectPartType(\'' + escAttr(t.slug) + '\',\'' + escAttr(t.name) + '\')">';
html += '<span class="name">' + img + esc(t.name) + '</span>';
html += '<span class="count">' + t.variant_count + '</span>';
html += '</div>';
});
html += '</div>';
content.innerHTML = html;
})
.catch(function () { content.innerHTML = '<div class="empty">Error cargando tipos de parte.</div>'; });
}
window.selectPartType = function (slug, name) {
state.partType = { slug: slug, name: name };
state.level = 'parts';
state.page = 1;
loadParts();
@@ -339,27 +651,83 @@
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;
// Build URL based on which navigation branch the user took.
// Nexpart branch uses slug-based params; OEM branch uses integer ids.
var url;
if (state.nxGroup && state.nxSubgroup && state.nxPartType) {
url = API + '/parts?mode=local'
+ '&mye_id=' + state.engine.id_mye
+ '&page=' + state.page
+ '&nexpart_group=' + encodeURIComponent(state.nxGroup.slug)
+ '&nexpart_subgroup=' + encodeURIComponent(state.nxSubgroup.slug)
+ '&nexpart_part_type=' + encodeURIComponent(state.nxPartType.slug);
} else {
var ptParam = state.partType ? '&part_type=' + encodeURIComponent(state.partType.slug) : '';
url = API + '/parts?mye_id=' + state.engine.id_mye
+ '&group_id=' + state.group.id
+ '&page=' + state.page
+ '&mode=' + state.mode
+ ptParam;
}
// The header title shows the deepest selected node, regardless of branch.
var headerTitle = state.nxPartType ? state.nxPartType.name
: state.nxSubgroup ? state.nxSubgroup.name
: state.partType ? state.partType.name
: state.group ? state.group.name
: 'Partes';
fetch(url)
.then(function (r) { return r.json(); })
.then(function (resp) {
var parts = resp.data;
var pag = resp.pagination;
state.totalPages = pag.total_pages;
var isLocal = (state.mode === 'local');
if (!parts.length) {
content.innerHTML = '<h2>' + esc(state.group.name) + '</h2><div class="empty">No se encontraron partes.</div>';
content.innerHTML = '<h2>' + esc(headerTitle) + '</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>';
var html = '<h2>' + esc(headerTitle) + ' <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">';
var tierClass = '';
if (isLocal) {
if (p.priority_tier === 1) tierClass = ' part-row--tier1';
else if (p.priority_tier === 2) tierClass = ' part-row--tier2';
}
html += '<div class="part-row' + tierClass + '">';
html += '<div>';
html += '<div class="part-oem">' + esc(p.oem_part_number) + '</div>';
// Manufacturer badge (local mode only)
if (isLocal && p.manufacturer) {
var tierStar = p.priority_tier === 1 ? '<span class="manu-tier">&#9733;</span>' : '';
html += '<div class="part-manu"><span class="manu-name">' + esc(p.manufacturer) + '</span>' + tierStar + '</div>';
}
// SKU line
if (isLocal && p.part_number) {
html += '<div class="part-oem">' + esc(p.part_number) + '<span class="part-oem-sub"> &middot; OEM: ' + esc(p.oem_part_number) + '</span></div>';
} else {
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>';
// Stock badge (local mode)
if (isLocal) {
if (p.in_stock_network) {
html += '<div class="part-stock part-stock--yes">En stock en ' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</div>';
} else {
html += '<div class="part-stock part-stock--no">Consultar disponibilidad</div>';
}
}
html += '<button class="part-detail-btn" onclick="openDetail(' + p.id_part + ')">Ver detalle y alternativas</button>';
html += '</div>';
if (p.image_url) {

File diff suppressed because it is too large Load Diff

394
dashboard/landing.js Normal file
View File

@@ -0,0 +1,394 @@
/**
* landing.js — Pixel-Perfect inspired interactions for Nexus Autoparts
*
* Features:
* - Animated canvas grid background (grid lines, stars at intersections, glowing tiles)
* - Scroll-reveal via IntersectionObserver
* - Counter animation on stat cards
* - Typewriter effect
* - Infinite brand marquee loader
* - Theme toggle with icon swap
*/
(function () {
'use strict';
// ═══════════════════════════════════════════════════════════════════════════
// THEME TOGGLE
// ═══════════════════════════════════════════════════════════════════════════
var themeIcon = document.getElementById('themeIcon');
function updateThemeIcon() {
var theme = document.documentElement.getAttribute('data-theme');
if (themeIcon) themeIcon.innerHTML = theme === 'industrial' ? '&#9790;' : '&#9728;';
}
window.toggleTheme = function () {
var html = document.documentElement;
var current = html.getAttribute('data-theme');
var next = current === 'industrial' ? 'modern' : 'industrial';
html.setAttribute('data-theme', next);
localStorage.setItem('nexus-theme', next);
updateThemeIcon();
// Canvas will pick up new CSS vars on next frame
};
updateThemeIcon();
// ═══════════════════════════════════════════════════════════════════════════
// CANVAS ANIMATED GRID
// ═══════════════════════════════════════════════════════════════════════════
var canvas = document.getElementById('heroCanvas');
if (canvas) {
var ctx = canvas.getContext('2d');
var CELL = 80;
var MAX_GLOWS = 6;
var glows = [];
var frame = 0;
function resizeCanvas() {
var hero = canvas.parentElement;
canvas.width = hero.offsetWidth;
canvas.height = hero.offsetHeight;
}
function getColor(varName) {
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
}
function drawStar(x, y, outer, inner, color) {
ctx.beginPath();
for (var i = 0; i < 4; i++) {
var angle = (Math.PI / 2) * i;
ctx.lineTo(x + Math.cos(angle) * outer, y + Math.sin(angle) * outer);
ctx.lineTo(x + Math.cos(angle + Math.PI / 4) * inner, y + Math.sin(angle + Math.PI / 4) * inner);
}
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
function spawnGlow() {
var cols = Math.floor(canvas.width / CELL);
var rows = Math.floor(canvas.height / CELL);
glows.push({
col: Math.floor(Math.random() * cols),
row: Math.floor(Math.random() * rows),
life: 0,
maxLife: 90 + Math.random() * 60, // 1.5-2.5s at 60fps
});
}
function animateGrid() {
frame++;
ctx.clearRect(0, 0, canvas.width, canvas.height);
var gridColor = getColor('--canvas-grid-color') || 'rgba(255,255,255,0.06)';
var starColor = getColor('--canvas-star-color') || 'rgba(245,166,35,0.3)';
var glowColor = getColor('--canvas-glow-color') || 'rgba(245,166,35,0.08)';
var cols = Math.floor(canvas.width / CELL) + 1;
var rows = Math.floor(canvas.height / CELL) + 1;
// Draw grid lines
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
for (var c = 0; c <= cols; c++) {
var x = c * CELL;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
for (var r = 0; r <= rows; r++) {
var y = r * CELL;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
// Draw stars at intersections (every other one for performance)
for (c = 0; c <= cols; c += 2) {
for (r = 0; r <= rows; r += 2) {
drawStar(c * CELL, r * CELL, 6, 3, starColor);
}
}
// Spawn glowing tiles
if (glows.length < MAX_GLOWS && Math.random() < 0.03) {
spawnGlow();
}
// Draw glowing tiles
for (var i = glows.length - 1; i >= 0; i--) {
var g = glows[i];
g.life++;
var progress = g.life / g.maxLife;
var opacity = progress < 0.5 ? progress * 2 : (1 - progress) * 2;
ctx.fillStyle = glowColor;
ctx.globalAlpha = opacity;
ctx.fillRect(g.col * CELL + 1, g.row * CELL + 1, CELL - 2, CELL - 2);
// Subtle shadow glow
ctx.shadowColor = glowColor;
ctx.shadowBlur = 20;
ctx.fillRect(g.col * CELL + 1, g.row * CELL + 1, CELL - 2, CELL - 2);
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
if (g.life >= g.maxLife) glows.splice(i, 1);
}
requestAnimationFrame(animateGrid);
}
resizeCanvas();
animateGrid();
var resizeTimeout;
window.addEventListener('resize', function () {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(resizeCanvas, 150);
});
}
// ═══════════════════════════════════════════════════════════════════════════
// SCROLL REVEAL — IntersectionObserver
// ═══════════════════════════════════════════════════════════════════════════
var revealEls = document.querySelectorAll('.nx-reveal, .nx-reveal-scale');
if ('IntersectionObserver' in window) {
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.15, rootMargin: '0px 0px -40px 0px' });
revealEls.forEach(function (el) {
observer.observe(el);
});
} else {
// Fallback: show all immediately
revealEls.forEach(function (el) { el.classList.add('is-visible'); });
}
// ═══════════════════════════════════════════════════════════════════════════
// COUNTER ANIMATION — Stat cards
// ═══════════════════════════════════════════════════════════════════════════
function animateCounter(el, target, format, suffix, duration) {
var start = 0;
var startTime = null;
function step(timestamp) {
if (!startTime) startTime = timestamp;
var progress = Math.min((timestamp - startTime) / duration, 1);
// Ease out cubic
var eased = 1 - Math.pow(1 - progress, 3);
var current = Math.floor(eased * target);
if (format.indexOf('M') !== -1) {
// e.g. "1.5M" or "15.8M"
var decimals = target >= 10e6 ? 1 : 1;
el.textContent = (current / 1e6).toFixed(decimals) + 'M' + suffix;
} else if (format.indexOf('K') !== -1) {
// e.g. "85K" or "304K"
el.textContent = Math.floor(current / 1e3) + 'K' + suffix;
} else {
el.textContent = current + suffix;
}
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// Trigger counters when stats become visible
var statCards = document.querySelectorAll('.stat-card');
if (statCards.length && 'IntersectionObserver' in window) {
var statsObserver = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
var numEl = entry.target.querySelector('.number');
if (numEl && !numEl._animated) {
numEl._animated = true;
var target = parseInt(numEl.getAttribute('data-target'), 10) || 0;
var suffix = numEl.getAttribute('data-suffix') || '';
var format = numEl.getAttribute('data-format') || 'num';
animateCounter(numEl, target, format, suffix, 2000);
}
statsObserver.unobserve(entry.target);
}
});
}, { threshold: 0.5 });
statCards.forEach(function (card) { statsObserver.observe(card); });
}
// ═══════════════════════════════════════════════════════════════════════════
// TYPEWRITER EFFECT
// ═══════════════════════════════════════════════════════════════════════════
var typewriterEl = document.getElementById('typewriterText');
if (typewriterEl) {
var phrases = [
'POS + Inventario + CFDI 4.0 + Contabilidad',
'Catalogo TecDoc: 1.5M+ partes, 304K aftermarket',
'15.8M cross-references OEM ↔ aftermarket',
'Chatbot IA con voz, foto y diagnostico',
'WhatsApp Business integrado',
'Busca por VIN, placas o numero de parte',
'Marketplace B2B: bodegas ↔ talleres',
'PWA + Android + modo kiosko + offline',
];
var phraseIdx = 0;
var charIdx = 0;
var isDeleting = false;
var typingSpeed = 50;
function typeStep() {
var current = phrases[phraseIdx];
if (!isDeleting) {
typewriterEl.textContent = current.substring(0, charIdx + 1);
charIdx++;
if (charIdx >= current.length) {
isDeleting = true;
setTimeout(typeStep, 2000); // Pause before deleting
return;
}
setTimeout(typeStep, typingSpeed);
} else {
typewriterEl.textContent = current.substring(0, charIdx);
charIdx--;
if (charIdx < 0) {
isDeleting = false;
charIdx = 0;
phraseIdx = (phraseIdx + 1) % phrases.length;
setTimeout(typeStep, 400);
return;
}
setTimeout(typeStep, 30);
}
}
// Start after hero reveals
setTimeout(typeStep, 1200);
}
// ═══════════════════════════════════════════════════════════════════════════
// BRAND MARQUEE — Load from API + duplicate for seamless loop
// ═══════════════════════════════════════════════════════════════════════════
var marqueeContainer = document.getElementById('brandsMarquee');
function buildMarquee(brands) {
var html = '';
brands.forEach(function (b) {
html += '<a href="/catalog?brand=' + b.id_brand + '" class="brand-tag">' + escHtml(b.name_brand) + '</a>';
});
// Duplicate for seamless loop
marqueeContainer.innerHTML = html + html;
}
function escHtml(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
// Fallback brands in case API fails
var fallbackBrands = [
'Toyota', 'Nissan', 'Ford', 'Volkswagen', 'Honda', 'Chevrolet',
'Hyundai', 'Kia', 'Mazda', 'BMW', 'Mercedes-Benz', 'Audi',
'Renault', 'Jeep', 'Dodge', 'Ram', 'Subaru', 'Mitsubishi',
'Suzuki', 'Peugeot', 'Volvo', 'Fiat', 'Chrysler', 'Acura',
'Infiniti', 'Lexus', 'Lincoln', 'Buick', 'GMC', 'Cadillac',
'Porsche', 'Mini', 'Seat', 'Alfa Romeo', 'Land Rover', 'Jaguar'
];
fetch('/api/catalog/brands')
.then(function (r) { return r.json(); })
.then(function (brands) {
if (brands && brands.length > 0) {
buildMarquee(brands);
} else {
buildMarquee(fallbackBrands.map(function (n, i) { return { id_brand: i, name_brand: n }; }));
}
})
.catch(function () {
buildMarquee(fallbackBrands.map(function (n, i) { return { id_brand: i, name_brand: n }; }));
});
// ── Live hero stats from API ──
// The landing has 4 stat cards with data-format tags that identify them.
// The counter animation runs on whatever data-target is set at observe
// time, so we update all 4 targets BEFORE the IntersectionObserver fires.
fetch('/api/catalog/stats')
.then(function (r) { return r.json(); })
.then(function (d) {
// Maps data-format (the card's identifier) → the JSON key to pull.
var statMap = [
{ format: '1.5M', key: 'parts' }, // OEM parts
{ format: '304K', key: 'aftermarket_parts' }, // Aftermarket
{ format: '15.8M', key: 'cross_references' }, // Cross-refs
{ format: 'num', key: 'brands' }, // Brand count
];
statMap.forEach(function (s) {
var el = document.querySelector('[data-format="' + s.format + '"]');
var value = d[s.key];
if (el && typeof value === 'number' && value > 0) {
el.setAttribute('data-target', value);
// If the animation already ran (cached, instant DOM), snap to the
// real value so users see the live number instead of the stale
// hardcoded default.
if (el._animated) {
if (s.format.indexOf('M') !== -1) {
el.textContent = (value / 1e6).toFixed(1) + 'M+';
} else if (s.format.indexOf('K') !== -1) {
el.textContent = Math.floor(value / 1e3) + 'K+';
} else {
el.textContent = String(value);
}
}
}
});
})
.catch(function () { /* fallback: hardcoded data-target values stay */ });
// ═══════════════════════════════════════════════════════════════════════════
// SMOOTH SCROLL for nav links
// ═══════════════════════════════════════════════════════════════════════════
document.querySelectorAll('.header-nav a[href^="#"]').forEach(function (a) {
a.addEventListener('click', function (e) {
e.preventDefault();
var target = document.querySelector(a.getAttribute('href'));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// HEADER — Solid on scroll
// ═══════════════════════════════════════════════════════════════════════════
var header = document.querySelector('.site-header');
if (header) {
window.addEventListener('scroll', function () {
if (window.scrollY > 80) {
header.style.background = 'var(--glass-bg-strong)';
} else {
header.style.background = 'var(--glass-bg)';
}
}, { passive: true });
}
})();

View File

@@ -11,8 +11,9 @@ import uuid
import urllib.request
from datetime import datetime, timedelta
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'pos'))
_base = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_base, '..', 'pos')) # pos/ for auth, services
sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL)
from config import DB_URL
from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth
from services.translations import translate_part_name, translate_category
@@ -225,6 +226,10 @@ def public_catalog():
def catalog_public_js():
return send_from_directory('.', 'catalog-public.js')
@app.route('/landing.js')
def landing_js():
return send_from_directory('.', 'landing.js')
@app.route('/static/<path:path>')
def static_files(path):
return send_from_directory('static', path)
@@ -372,8 +377,10 @@ NORTH_AMERICA_BRANDS = REGION_BRANDS['north-america']
@app.route('/api/catalog/brands')
def api_catalog_brands():
from services.catalog_modes import get_brands_for_mode, normalize_mode
region = request.args.get('region', 'north-america')
year_id = request.args.get('year_id', type=int)
mode = normalize_mode(request.args.get('mode'))
session = Session()
try:
params = {}
@@ -382,7 +389,18 @@ def api_catalog_brands():
year_filter = " AND mye.year_id = :year_id"
params['year_id'] = year_id
if region == 'all':
# 'local' mode overrides the region filter — curated bodega list only.
if mode == 'local':
params['brands'] = list(get_brands_for_mode('local'))
rows = session.execute(text("""
SELECT DISTINCT b.id_brand, b.name_brand
FROM brands b
JOIN models m ON m.brand_id = b.id_brand
JOIN model_year_engine mye ON mye.model_id = m.id_model
WHERE b.name_brand = ANY(:brands)""" + year_filter + """
ORDER BY b.name_brand
"""), params).mappings().all()
elif region == 'all':
rows = session.execute(text("""
SELECT DISTINCT b.id_brand, b.name_brand
FROM brands b
@@ -472,11 +490,39 @@ def api_catalog_engines():
session.close()
def _nexpart_master_conn():
"""Return a raw psycopg2 connection for passing to services.catalog_service
Nexpart functions (they expect .cursor() / .commit() / .close() DBAPI).
Uses SQLAlchemy's connection pool so we don't open a new socket per call.
"""
return engine.raw_connection()
@app.route('/api/catalog/categories')
def api_catalog_categories():
"""Categories for a vehicle.
OEM mode: TecDoc part_categories (integer ids).
Local mode: 14 Nexpart top-level groups filtered by what has parts for
this vehicle (strings as slugs).
"""
from services.catalog_modes import normalize_mode
mye_id = request.args.get('mye_id', type=int)
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
if mode == 'local':
from services.catalog_service import get_nexpart_groups_for_vehicle
conn = _nexpart_master_conn()
try:
data = get_nexpart_groups_for_vehicle(conn, mye_id)
finally:
conn.close()
return jsonify({'data': data, 'mode': 'local'})
# OEM mode (original behavior)
session = Session()
try:
rows = session.execute(text("""
@@ -502,10 +548,33 @@ def api_catalog_categories():
@app.route('/api/catalog/groups')
def api_catalog_groups():
"""Subgroups for a vehicle + parent category.
OEM mode: TecDoc part_groups (integer category_id).
Local mode: Nexpart subgroups within a Nexpart group (category_slug string).
"""
from services.catalog_modes import normalize_mode
mye_id = request.args.get('mye_id', type=int)
category_id = request.args.get('category_id', type=int)
if not mye_id or not category_id:
return jsonify({'error': 'mye_id and category_id required'}), 400
category_slug = request.args.get('category_slug')
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
if mode == 'local':
if not category_slug:
return jsonify({'error': 'category_slug required for local mode'}), 400
from services.catalog_service import get_nexpart_subgroups_for_vehicle
conn = _nexpart_master_conn()
try:
data = get_nexpart_subgroups_for_vehicle(conn, mye_id, category_slug)
finally:
conn.close()
return jsonify({'data': data, 'mode': 'local'})
# OEM mode
if not category_id:
return jsonify({'error': 'category_id required for oem mode'}), 400
session = Session()
try:
rows = session.execute(text("""
@@ -526,33 +595,275 @@ def api_catalog_groups():
session.close()
@app.route('/api/catalog/parts')
def api_catalog_parts():
@app.route('/api/catalog/part-types')
def api_catalog_part_types():
"""Distinct part types within a vehicle+group (3rd subcategory level).
OEM mode: distinct name_part values within a TecDoc part_group_id.
Local mode: Nexpart Part Types within a Nexpart group + subgroup.
"""
from services.translations import translate_part_name
from services.catalog_modes import normalize_mode
mye_id = request.args.get('mye_id', type=int)
group_id = request.args.get('group_id', type=int)
if not mye_id or not group_id:
return jsonify({'error': 'mye_id and group_id required'}), 400
group_slug = request.args.get('group_slug')
subgroup_slug = request.args.get('subgroup_slug')
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
if mode == 'local':
if not group_slug or not subgroup_slug:
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
from services.catalog_service import get_nexpart_part_types_for_vehicle
conn = _nexpart_master_conn()
try:
data = get_nexpart_part_types_for_vehicle(conn, mye_id, group_slug, subgroup_slug)
finally:
conn.close()
return jsonify({'data': data, 'mode': 'local'})
# OEM mode
if not group_id:
return jsonify({'error': 'group_id required for oem mode'}), 400
session = Session()
try:
rows = session.execute(text("""
SELECT
p.name_part AS slug,
COALESCE(p.name_es, p.name_part) AS display_name,
COUNT(*) AS variants,
(ARRAY_AGG(p.image_url) FILTER (WHERE p.image_url IS NOT NULL))[1] AS sample_image
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id
GROUP BY p.name_part, COALESCE(p.name_es, p.name_part)
ORDER BY variants DESC, display_name ASC
"""), {'mye_id': mye_id, 'group_id': group_id}).mappings().all()
return jsonify({'data': [{
'slug': r['slug'],
'name': translate_part_name(r['display_name']),
'variant_count': r['variants'],
'sample_image': r['sample_image'],
} for r in rows]})
finally:
session.close()
@app.route('/api/catalog/shop-supplies/groups')
def api_shop_supplies_groups():
"""Public Shop Supplies tab: vehicle-independent groups."""
from services.catalog_service import get_shop_supplies_groups
return jsonify({'data': get_shop_supplies_groups()})
@app.route('/api/catalog/shop-supplies/subgroups')
def api_shop_supplies_subgroups():
group_slug = request.args.get('group_slug')
if not group_slug:
return jsonify({'error': 'group_slug required'}), 400
from services.catalog_service import get_shop_supplies_subgroups
conn = _nexpart_master_conn()
try:
return jsonify({'data': get_shop_supplies_subgroups(conn, group_slug)})
finally:
conn.close()
@app.route('/api/catalog/shop-supplies/part-types')
def api_shop_supplies_part_types():
group_slug = request.args.get('group_slug')
subgroup_slug = request.args.get('subgroup_slug')
if not group_slug or not subgroup_slug:
return jsonify({'error': 'group_slug and subgroup_slug required'}), 400
from services.catalog_service import get_shop_supplies_part_types
conn = _nexpart_master_conn()
try:
return jsonify({'data': get_shop_supplies_part_types(conn, group_slug, subgroup_slug)})
finally:
conn.close()
@app.route('/api/catalog/shop-supplies/parts')
def api_shop_supplies_parts():
group_slug = request.args.get('group_slug')
subgroup_slug = request.args.get('subgroup_slug')
part_type_slug = request.args.get('part_type_slug')
page = max(1, request.args.get('page', 1, type=int))
per_page = min(request.args.get('per_page', 30, type=int), 100)
if not group_slug or not subgroup_slug or not part_type_slug:
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
from services.catalog_service import get_shop_supplies_parts
conn = _nexpart_master_conn()
try:
result = get_shop_supplies_parts(
conn, group_slug, subgroup_slug, part_type_slug,
tenant_conn=None, branch_id=None,
page=page, per_page=per_page,
)
return jsonify(result)
finally:
conn.close()
@app.route('/api/catalog/parts')
def api_catalog_parts():
from services.catalog_modes import (
normalize_mode,
LOCAL_PRIORITY_MANUFACTURERS_TIER1,
LOCAL_PRIORITY_MANUFACTURERS_TIER2,
)
mye_id = request.args.get('mye_id', type=int)
group_id = request.args.get('group_id', type=int)
part_type = request.args.get('part_type')
# Nexpart navigation slugs (Local mode, chosen via new Nexpart hierarchy)
nexpart_group = request.args.get('nexpart_group')
nexpart_subgroup = request.args.get('nexpart_subgroup')
nexpart_part_type = request.args.get('nexpart_part_type')
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
page = max(1, request.args.get('page', 1, type=int))
per_page = min(request.args.get('per_page', 30, type=int), 100)
offset = (page - 1) * per_page
mode = normalize_mode(request.args.get('mode'))
# ─── Nexpart-nav dispatch (delegates to POS service layer) ───
# Public catalog has no tenant context — pass tenant_conn=None which
# the service gracefully handles (no local stock / price enrichment).
if mode == 'local' and nexpart_group and nexpart_subgroup and nexpart_part_type:
from services.catalog_service import get_parts_for_nexpart_triple
conn = _nexpart_master_conn()
try:
result = get_parts_for_nexpart_triple(
conn, mye_id,
nexpart_group, nexpart_subgroup, nexpart_part_type,
tenant_conn=None, branch_id=None,
page=page, per_page=per_page,
)
return jsonify(result)
finally:
conn.close()
# Below here: legacy group_id based flow (OEM or legacy Local)
if not group_id:
return jsonify({'error': 'group_id required'}), 400
session = Session()
try:
# Optional 3rd-level Part Type filter (applies to both OEM and Local modes)
pt_clause = " AND p.name_part = :part_type" if part_type else ""
if mode == 'local':
# Aftermarket-oriented listing, prioritized + stock-aware.
count_params = {'mye_id': mye_id, 'group_id': group_id}
if part_type:
count_params['part_type'] = part_type
total = session.execute(text("""
SELECT COUNT(*)
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause), count_params).scalar()
fetch_params = {
'mye_id': mye_id,
'group_id': group_id,
'tier1': list(LOCAL_PRIORITY_MANUFACTURERS_TIER1),
'tier2': list(LOCAL_PRIORITY_MANUFACTURERS_TIER2),
'limit': per_page,
'offset': offset,
}
if part_type:
fetch_params['part_type'] = part_type
rows = session.execute(text("""
WITH aftermarket_for_vehicle AS (
SELECT DISTINCT
ap.id_aftermarket_parts,
ap.oem_part_id,
ap.part_number,
COALESCE(ap.name_es, ap.name_aftermarket_parts) AS am_name,
m.name_manufacture,
p.oem_part_number,
COALESCE(p.name_es, p.name_part) AS oem_name,
COALESCE(p.description_es, p.description) AS oem_desc,
p.image_url AS oem_image
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause + """
),
stock_per_oem AS (
SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price, SUM(stock_quantity) AS total_stock
FROM warehouse_inventory
WHERE stock_quantity > 0
GROUP BY part_id
)
SELECT afv.id_aftermarket_parts, afv.oem_part_id, afv.part_number, afv.am_name,
afv.name_manufacture, afv.oem_part_number, afv.oem_name, afv.oem_desc, afv.oem_image,
COALESCE(s.bodega_count, 0) AS bodega_count,
s.min_price AS warehouse_price,
COALESCE(s.total_stock, 0) AS warehouse_stock,
CASE
WHEN UPPER(afv.name_manufacture) = ANY(:tier1) THEN 1
WHEN UPPER(afv.name_manufacture) = ANY(:tier2) THEN 2
ELSE 3
END AS tier
FROM aftermarket_for_vehicle afv
LEFT JOIN stock_per_oem s ON s.part_id = afv.oem_part_id
ORDER BY tier ASC,
(COALESCE(s.bodega_count, 0) > 0) DESC,
afv.name_manufacture ASC,
afv.am_name ASC
LIMIT :limit OFFSET :offset
"""), fetch_params).mappings().all()
items = [{
'id_part': r['oem_part_id'],
'id_aftermarket': r['id_aftermarket_parts'],
'oem_part_number': r['oem_part_number'],
'part_number': r['part_number'],
'name': translate_part_name(r['am_name'] or r['oem_name']),
'description': r['oem_desc'],
'image_url': r['oem_image'],
'manufacturer': r['name_manufacture'],
'priority_tier': r['tier'],
'bodega_count': r['bodega_count'],
'warehouse_stock': r['warehouse_stock'],
'warehouse_price': float(r['warehouse_price']) if r['warehouse_price'] is not None else None,
'in_stock_network': r['bodega_count'] > 0,
} for r in rows]
total_pages = max(1, (total + per_page - 1) // per_page)
return jsonify({'data': items, 'mode': 'local', 'pagination': {
'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages
}})
# OEM mode (original behavior)
oem_count_params = {'mye_id': mye_id, 'group_id': group_id}
oem_fetch_params = {'mye_id': mye_id, 'group_id': group_id, 'limit': per_page, 'offset': offset}
if part_type:
oem_count_params['part_type'] = part_type
oem_fetch_params['part_type'] = part_type
total = session.execute(text("""
SELECT COUNT(*)
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id
"""), {'mye_id': mye_id, 'group_id': group_id}).scalar()
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause), oem_count_params).scalar()
rows = session.execute(text("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.description, p.description_es, p.image_url
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause + """
ORDER BY p.name_part
LIMIT :limit OFFSET :offset
"""), {'mye_id': mye_id, 'group_id': group_id, 'limit': per_page, 'offset': offset}).mappings().all()
"""), oem_fetch_params).mappings().all()
items = [{
'id_part': r['id_part'],
@@ -563,7 +874,7 @@ def api_catalog_parts():
} for r in rows]
total_pages = max(1, (total + per_page - 1) // per_page)
return jsonify({'data': items, 'pagination': {
return jsonify({'data': items, 'mode': 'oem', 'pagination': {
'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages
}})
finally:
@@ -660,6 +971,7 @@ def api_catalog_search():
if is_part_number:
clean_q = q.replace(' ', '').upper()
# Search OEM part numbers first
rows = session.execute(text("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url
@@ -668,6 +980,28 @@ def api_catalog_search():
ORDER BY p.oem_part_number
LIMIT :limit
"""), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all()
# If no OEM match, search aftermarket + cross-reference numbers
if not rows:
rows = session.execute(text("""
SELECT DISTINCT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url
FROM parts p
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
WHERE REPLACE(UPPER(ap.part_number), ' ', '') LIKE :q
ORDER BY p.oem_part_number
LIMIT :limit
"""), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all()
if not rows:
rows = session.execute(text("""
SELECT DISTINCT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url
FROM parts p
JOIN part_cross_references cr ON cr.part_id = p.id_part
WHERE REPLACE(UPPER(cr.cross_reference_number), ' ', '') LIKE :q
ORDER BY p.oem_part_number
LIMIT :limit
"""), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all()
else:
tsquery = ' & '.join(q.split())
rows = session.execute(text("""
@@ -723,19 +1057,46 @@ def api_catalog_search():
@app.route('/api/catalog/stats')
def api_catalog_stats():
"""Public stats endpoint consumed by the landing page hero cards.
Returns live counts from the master catalog so the landing never shows
stale numbers. Counts are fast (pg_class reltuples for the big tables)
and cached in-process for 10 minutes.
"""
# In-process cache — counts barely change and are called on every
# landing pageview; no reason to hit the DB every time.
import time as _t
now = _t.time()
cache = getattr(api_catalog_stats, '_cache', None)
if cache and cache[0] > now:
return jsonify(cache[1])
session = Session()
try:
# Use reltuples for large tables (parts, aftermarket_parts,
# part_cross_references) — exact COUNT(*) on 1M+ row tables is slow
# and the landing doesn't need exact accuracy, just "big numbers".
row = session.execute(text("""
SELECT
(SELECT COUNT(*) FROM brands) AS brands,
(SELECT COUNT(*) FROM models) AS models,
(SELECT COUNT(*) FROM model_year_engine) AS vehicles,
(SELECT COUNT(*) FROM parts) AS parts
(SELECT reltuples::bigint FROM pg_class WHERE relname = 'parts') AS parts,
(SELECT reltuples::bigint FROM pg_class WHERE relname = 'aftermarket_parts') AS aftermarket_parts,
(SELECT reltuples::bigint FROM pg_class WHERE relname = 'part_cross_references') AS cross_references,
(SELECT COUNT(*) FROM manufacturers) AS manufacturers
""")).mappings().first()
return jsonify({
'brands': row['brands'], 'models': row['models'],
'vehicles': row['vehicles'], 'parts': row['parts']
})
data = {
'brands': row['brands'] or 0,
'models': row['models'] or 0,
'vehicles': row['vehicles'] or 0,
'parts': row['parts'] or 0,
'aftermarket_parts': row['aftermarket_parts'] or 0,
'cross_references': row['cross_references'] or 0,
'manufacturers': row['manufacturers'] or 0,
}
api_catalog_stats._cache = (now + 600, data)
return jsonify(data)
finally:
session.close()
@@ -3581,9 +3942,13 @@ def api_pos_search_parts():
# Store Dashboard Endpoints
# ============================================================================
# Old page routes removed (demo, bodega, pitch, login, tienda)
# Old page routes removed (demo, bodega, login, tienda)
# APIs below are kept for backward compatibility
@app.route('/pitch')
def pitch_deck():
return send_from_directory(os.path.join(_base, '..', 'pitch'), 'deck.html')
@app.route('/api/tienda/stats')
def api_tienda_stats():

View File

@@ -558,6 +558,153 @@
}
/* ==========================================================================
GLASSMORPHISM TOKENS
========================================================================== */
[data-theme="industrial"] {
--glass-bg: rgba(26, 26, 26, 0.70);
--glass-bg-strong: rgba(26, 26, 26, 0.85);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: 16px;
--glass-highlight: rgba(245, 166, 35, 0.06);
--glow-color: rgba(245, 166, 35, 0.40);
--glow-color-soft: rgba(245, 166, 35, 0.15);
--glow-color-strong: rgba(245, 166, 35, 0.60);
--gradient-accent: linear-gradient(135deg, #F5A623 0%, #e8951a 50%, #d4850f 100%);
--gradient-text: linear-gradient(135deg, #F5A623 0%, #FFD080 50%, #F5A623 100%);
--canvas-grid-color: rgba(255, 255, 255, 0.06);
--canvas-star-color: rgba(245, 166, 35, 0.30);
--canvas-glow-color: rgba(245, 166, 35, 0.08);
}
[data-theme="modern"] {
--glass-bg: rgba(248, 249, 255, 0.70);
--glass-bg-strong: rgba(248, 249, 255, 0.85);
--glass-border: rgba(26, 26, 46, 0.08);
--glass-blur: 16px;
--glass-highlight: rgba(255, 107, 53, 0.04);
--glow-color: rgba(255, 107, 53, 0.35);
--glow-color-soft: rgba(255, 107, 53, 0.12);
--glow-color-strong: rgba(255, 107, 53, 0.55);
--gradient-accent: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #FF6B35 100%);
--gradient-text: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #e85520 100%);
--canvas-grid-color: rgba(26, 26, 46, 0.05);
--canvas-star-color: rgba(255, 107, 53, 0.20);
--canvas-glow-color: rgba(255, 107, 53, 0.06);
}
/* ==========================================================================
ANIMATION KEYFRAMES
========================================================================== */
@keyframes nx-fade-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes nx-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes nx-scale-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes nx-marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@keyframes nx-glow-pulse {
0%, 100% { box-shadow: 0 0 20px var(--glow-color-soft); }
50% { box-shadow: 0 0 40px var(--glow-color); }
}
@keyframes nx-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes nx-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes nx-border-glow {
0%, 100% { border-color: var(--color-border); }
50% { border-color: var(--color-border-accent); }
}
@keyframes nx-typewriter-cursor {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ==========================================================================
SCROLL REVEAL UTILITIES
========================================================================== */
.nx-reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out);
}
.nx-reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
.nx-reveal-scale {
opacity: 0;
transform: scale(0.95);
transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out);
}
.nx-reveal-scale.is-visible {
opacity: 1;
transform: scale(1);
}
/* Stagger children */
.nx-stagger > .nx-reveal:nth-child(1) { transition-delay: 0ms; }
.nx-stagger > .nx-reveal:nth-child(2) { transition-delay: 80ms; }
.nx-stagger > .nx-reveal:nth-child(3) { transition-delay: 160ms; }
.nx-stagger > .nx-reveal:nth-child(4) { transition-delay: 240ms; }
.nx-stagger > .nx-reveal:nth-child(5) { transition-delay: 320ms; }
.nx-stagger > .nx-reveal:nth-child(6) { transition-delay: 400ms; }
/* ==========================================================================
GLASS UTILITIES
========================================================================== */
.nx-glass {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
}
.nx-glass-strong {
background: var(--glass-bg-strong);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
}
/* ==========================================================================
END OF TOKENS FILE
nexus-autoparts-design/tokens/tokens.css