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