339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
/* ════════════════════════════════
|
|
INFINITY — APP.JS
|
|
Carga datos desde el backend (/api/*)
|
|
════════════════════════════════ */
|
|
|
|
// ════════════════════════════════
|
|
// ESTADO
|
|
// ════════════════════════════════
|
|
let categorias = [];
|
|
let productos = [];
|
|
|
|
function escapeHtml(str) {
|
|
return String(str ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
let catActiva = 'todos';
|
|
let subActiva = []; // [nivel0, nivel1, ...] — null = "Todos" en ese nivel
|
|
let busqueda = '';
|
|
let cargando = true;
|
|
|
|
// ════════════════════════════════
|
|
// CARGA DE DATOS
|
|
// ════════════════════════════════
|
|
async function cargarDatos() {
|
|
try {
|
|
const [resCats, resProds] = await Promise.all([
|
|
fetch('/api/categorias'),
|
|
fetch('/api/productos')
|
|
]);
|
|
|
|
if (!resCats.ok) throw new Error('No se pudieron cargar las categorías');
|
|
if (!resProds.ok) throw new Error('No se pudieron cargar los productos');
|
|
|
|
categorias = await resCats.json();
|
|
productos = await resProds.json();
|
|
|
|
cargando = false;
|
|
renderTags();
|
|
renderProductos();
|
|
} catch (err) {
|
|
console.error('Error cargando datos:', err);
|
|
cargando = false;
|
|
const grid = document.getElementById('productos');
|
|
const cont = document.getElementById('contador');
|
|
cont.textContent = '';
|
|
grid.innerHTML = `
|
|
<div class="error-state">
|
|
<p>😕 No pudimos cargar el catálogo</p>
|
|
<p class="error-detalle">${err.message}</p>
|
|
<button class="btn btn-primary" onclick="location.reload()">Reintentar</button>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════
|
|
// HELPERS DE FILTRADO POR SUBRAMA
|
|
// ════════════════════════════════
|
|
|
|
// Devuelve los productos que matchean la rama actual (cat + sub[niveles anteriores])
|
|
// y cuyo sub[i] === id. Sirve para contar/mostrar subcats con productos.
|
|
function productosEnNivel(catId, nivelesPrevios, idNivel) {
|
|
return productos.filter(p => {
|
|
if (!p || p.cat !== catId) return false;
|
|
if (p.img && p.img.startsWith('agotados/')) return false;
|
|
// El sub del producto debe tener al menos la longitud de nivelesPrevios + 1
|
|
const sub = Array.isArray(p.sub) ? p.sub : [];
|
|
if (sub.length < nivelesPrevios.length + 1) return false;
|
|
// Coincidir con los niveles previos
|
|
for (let i = 0; i < nivelesPrevios.length; i++) {
|
|
if (sub[i] !== nivelesPrevios[i]) return false;
|
|
}
|
|
// El nivel actual debe ser este id
|
|
return sub[nivelesPrevios.length] === idNivel;
|
|
});
|
|
}
|
|
|
|
// Devuelve el nodo del árbol (categoría o subcat) según el path
|
|
function getNodo(catId, niveles) {
|
|
const cat = categorias.find(c => c.id === catId);
|
|
if (!cat) return null;
|
|
let nodo = cat;
|
|
for (const id of niveles) {
|
|
if (!Array.isArray(nodo.subcategorias)) return null;
|
|
const sig = nodo.subcategorias.find(s => s.id === id);
|
|
if (!sig) return null;
|
|
nodo = sig;
|
|
}
|
|
return nodo;
|
|
}
|
|
|
|
// ════════════════════════════════
|
|
// RENDER TAGS FILTROS (multinivel)
|
|
// ════════════════════════════════
|
|
function renderTags() {
|
|
const cont = document.getElementById('filtrosTags');
|
|
cont.innerHTML = '';
|
|
|
|
// ——— Nivel 0: Categorías ———
|
|
const catsVisibles = categorias.filter(c => c.id !== 'sin-categoria');
|
|
const nivel0 = document.createElement('div');
|
|
nivel0.className = 'filtros-tags filtros-nivel';
|
|
nivel0.innerHTML =
|
|
`<button class="tag ${catActiva === 'todos' ? 'active' : ''}" data-nivel="0" data-id="todos">Todos</button>` +
|
|
catsVisibles.map(cat => {
|
|
const tieneSubcats = Array.isArray(cat.subcategorias) && cat.subcategorias.length > 0;
|
|
// Solo mostrar la categoría como tag si tiene productos directos O tiene subcats con productos
|
|
if (tieneSubcats) {
|
|
// No renderizar la categoría como "hoja" — sus subcats la cubren
|
|
// PERO si alguna subcat es hoja con productos, sí tiene sentido mostrarla igual
|
|
// porque al hacer click se entra al nivel 1
|
|
}
|
|
return `<button class="tag ${catActiva === cat.id ? 'active' : ''}" data-nivel="0" data-id="${cat.id}">
|
|
<span class="tag-icon">${cat.icono || '📦'}</span> ${cat.nombre}
|
|
</button>`;
|
|
}).join('');
|
|
cont.appendChild(nivel0);
|
|
|
|
// ——— Niveles 1+ : subcategorías (solo si catActiva está elegida) ———
|
|
if (catActiva !== 'todos') {
|
|
const cat = categorias.find(c => c.id === catActiva);
|
|
if (cat && Array.isArray(cat.subcategorias) && cat.subcategorias.length > 0) {
|
|
renderNivelSub(cont, cat, [], 1);
|
|
}
|
|
}
|
|
|
|
// Wire click handlers
|
|
cont.querySelectorAll('.tag').forEach(tag => {
|
|
tag.addEventListener('click', () => onTagClick(parseInt(tag.dataset.nivel, 10), tag.dataset.id));
|
|
});
|
|
}
|
|
|
|
// Renderiza un nivel de subcategorías. `previos` = niveles ya elegidos arriba.
|
|
function renderNivelSub(cont, nodo, previos, nivel) {
|
|
if (!Array.isArray(nodo.subcategorias) || nodo.subcategorias.length === 0) return;
|
|
|
|
// Subcats que tienen productos en este nivel o cuyas sub-subs tienen productos
|
|
const subsVisibles = nodo.subcategorias.filter(s => {
|
|
const tieneSubSub = Array.isArray(s.subcategorias) && s.subcategorias.length > 0;
|
|
if (tieneSubSub) {
|
|
// Esta subcat NO recibe productos directamente. Mostrar solo si ALGUNA
|
|
// de sus sub-subs tiene productos.
|
|
return s.subcategorias.some(ss =>
|
|
productosEnNivel(catActiva, [...previos, s.id], ss.id).length > 0
|
|
);
|
|
} else {
|
|
// Hoja: recibe productos. Mostrar si tiene al menos uno.
|
|
return productosEnNivel(catActiva, [...previos], s.id).length > 0;
|
|
}
|
|
});
|
|
|
|
if (subsVisibles.length === 0) return;
|
|
|
|
const nivelEl = document.createElement('div');
|
|
nivelEl.className = 'filtros-tags filtros-nivel';
|
|
const idTodos = `__all_${nivel}`;
|
|
nivelEl.innerHTML =
|
|
`<span class="filtros-nivel-label">${escapeHtml(nodo.nombre || '')}</span>` +
|
|
`<button class="tag ${subActiva[nivel - 1] == null ? 'active' : ''}" data-nivel="${nivel}" data-id="${idTodos}">Todos</button>` +
|
|
subsVisibles.map(s => {
|
|
const esActivo = subActiva[nivel - 1] === s.id;
|
|
return `<button class="tag ${esActivo ? 'active' : ''}" data-nivel="${nivel}" data-id="${s.id}">
|
|
<span class="tag-icon">${s.icono || '📂'}</span> ${escapeHtml(s.nombre)}
|
|
</button>`;
|
|
}).join('');
|
|
cont.appendChild(nivelEl);
|
|
|
|
// Si la subcat activa tiene sub-subs, renderizar el siguiente nivel
|
|
if (subActiva[nivel - 1]) {
|
|
const subActivaNodo = nodo.subcategorias.find(s => s.id === subActiva[nivel - 1]);
|
|
if (subActivaNodo && Array.isArray(subActivaNodo.subcategorias) && subActivaNodo.subcategorias.length > 0) {
|
|
renderNivelSub(cont, subActivaNodo, [...previos, subActiva[nivel - 1]], nivel + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function onTagClick(nivel, id) {
|
|
if (nivel === 0) {
|
|
if (id === 'todos') {
|
|
catActiva = 'todos';
|
|
} else {
|
|
catActiva = id;
|
|
}
|
|
subActiva = []; // reset sub al cambiar categoría
|
|
} else {
|
|
if (id.startsWith('__all_')) {
|
|
subActiva[nivel - 1] = null;
|
|
} else {
|
|
subActiva[nivel - 1] = id;
|
|
}
|
|
// Resetear niveles más profundos
|
|
subActiva = subActiva.slice(0, nivel);
|
|
// Rellenar con null hasta el nivel actual
|
|
while (subActiva.length < nivel) subActiva.push(null);
|
|
}
|
|
renderTags();
|
|
renderProductos();
|
|
}
|
|
|
|
function sincronizarTag(cat) {
|
|
// Legacy — mantenido por compatibilidad, pero ya no se usa
|
|
document.querySelectorAll('.tag').forEach(t => {
|
|
t.classList.toggle('active', t.dataset.cat === cat);
|
|
});
|
|
}
|
|
|
|
// ════════════════════════════════
|
|
// RENDER PRODUCTOS
|
|
// ════════════════════════════════
|
|
function renderProductos() {
|
|
const grid = document.getElementById('productos');
|
|
const empty = document.getElementById('emptyState');
|
|
const contador = document.getElementById('contador');
|
|
|
|
let filtrados = productos.filter(p =>
|
|
p.cat &&
|
|
p.cat !== 'sin-categoria' &&
|
|
(!p.img || !p.img.startsWith('agotados/'))
|
|
);
|
|
|
|
if (catActiva !== 'todos') {
|
|
filtrados = filtrados.filter(p => {
|
|
if (p.cat !== catActiva) return false;
|
|
// Validar que la cadena de sub coincida
|
|
const sub = Array.isArray(p.sub) ? p.sub : [];
|
|
for (let i = 0; i < subActiva.length; i++) {
|
|
if (subActiva[i] != null && sub[i] !== subActiva[i]) return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
if (busqueda.trim()) {
|
|
const q = busqueda.toLowerCase();
|
|
filtrados = filtrados.filter(p =>
|
|
(p.nombre || '').toLowerCase().includes(q) ||
|
|
(p.desc || '').toLowerCase().includes(q) ||
|
|
(p.cat || '').toLowerCase().includes(q) ||
|
|
(p.ref || '').toLowerCase().includes(q)
|
|
);
|
|
}
|
|
|
|
contador.textContent = filtrados.length === 1
|
|
? 'Mostrando 1 producto'
|
|
: `Mostrando ${filtrados.length} productos`;
|
|
|
|
if (filtrados.length === 0) {
|
|
grid.innerHTML = '';
|
|
empty.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
empty.style.display = 'none';
|
|
|
|
grid.innerHTML = filtrados.map((p, i) => {
|
|
const cat = categorias.find(c => c.id === p.cat);
|
|
const nombre = p.nombre || p.ref || 'Sin nombre';
|
|
const desc = p.desc || '';
|
|
const precio = (p.precio !== undefined && p.precio !== null && p.precio !== '')
|
|
? `$${Number(p.precio).toLocaleString('es-CO')}`
|
|
: '—';
|
|
const imgSrc = p.img && p.img.startsWith('/') ? p.img : `/Imagenes/${p.img}`;
|
|
return `
|
|
<article class="producto-card" style="animation-delay: ${i * 0.04}s">
|
|
<div class="producto-img">
|
|
<span class="producto-cat">${cat ? cat.nombre : p.cat}</span>
|
|
<img src="${escapeHtml(imgSrc)}" alt="${escapeHtml(nombre)}" loading="lazy"
|
|
onerror="this.remove(); this.parentElement.classList.add('img-fallback');">
|
|
<span class="producto-fallback">${cat ? cat.icono : '📦'}</span>
|
|
</div>
|
|
<div class="producto-info">
|
|
<h3 class="producto-nombre">${escapeHtml(nombre)}</h3>
|
|
${desc ? `<p class="producto-desc">${escapeHtml(desc)}</p>` : ''}
|
|
<div class="producto-precio">${precio}</div>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// ════════════════════════════════
|
|
// BUSCADOR
|
|
// ════════════════════════════════
|
|
function setupSearch() {
|
|
const input = document.getElementById('searchInput');
|
|
const clear = document.getElementById('searchClear');
|
|
|
|
input.addEventListener('input', e => {
|
|
busqueda = e.target.value;
|
|
clear.style.display = busqueda ? 'flex' : 'none';
|
|
renderProductos();
|
|
});
|
|
|
|
clear.addEventListener('click', () => {
|
|
input.value = '';
|
|
busqueda = '';
|
|
clear.style.display = 'none';
|
|
renderProductos();
|
|
input.focus();
|
|
});
|
|
|
|
document.getElementById('btnLimpiar').addEventListener('click', () => {
|
|
input.value = '';
|
|
busqueda = '';
|
|
catActiva = 'todos';
|
|
sincronizarTag('todos');
|
|
clear.style.display = 'none';
|
|
renderProductos();
|
|
});
|
|
}
|
|
|
|
// ════════════════════════════════
|
|
// NAVBAR MOBILE
|
|
// ════════════════════════════════
|
|
function setupNav() {
|
|
const toggle = document.getElementById('navToggle');
|
|
const links = document.getElementById('navLinks');
|
|
|
|
toggle.addEventListener('click', () => {
|
|
links.classList.toggle('open');
|
|
});
|
|
|
|
links.querySelectorAll('a').forEach(a => {
|
|
a.addEventListener('click', () => links.classList.remove('open'));
|
|
});
|
|
}
|
|
|
|
// ════════════════════════════════
|
|
// INIT
|
|
// ════════════════════════════════
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
setupSearch();
|
|
setupNav();
|
|
cargarDatos();
|
|
});
|