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:
@@ -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">← Inicio</a>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle">
|
||||
<span id="themeIcon">☾</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>© 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' ? '☾' : '☀';
|
||||
}
|
||||
// Init icon
|
||||
(function(){
|
||||
var theme = document.documentElement.getAttribute('data-theme');
|
||||
var icon = document.getElementById('themeIcon');
|
||||
if (icon) icon.innerHTML = theme === 'industrial' ? '☾' : '☀';
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="/catalog-public.js"></script>
|
||||
|
||||
<!-- AI Chat Widget -->
|
||||
|
||||
@@ -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">★</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"> · 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
394
dashboard/landing.js
Normal 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' ? '☾' : '☀';
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user