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>
395 lines
16 KiB
JavaScript
395 lines
16 KiB
JavaScript
/**
|
|
* 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 });
|
|
}
|
|
|
|
})();
|