/* ════════════════════════════════
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, ''');
}
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 = `
😕 No pudimos cargar el catálogo
${err.message}
`;
}
}
// ════════════════════════════════
// 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 =
`` +
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 ``;
}).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 =
`${escapeHtml(nodo.nombre || '')}` +
`` +
subsVisibles.map(s => {
const esActivo = subActiva[nivel - 1] === s.id;
return ``;
}).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 `
${cat ? cat.nombre : p.cat}
${cat ? cat.icono : '📦'}
${escapeHtml(nombre)}
${desc ? `
${escapeHtml(desc)}
` : ''}
${precio}
`;
}).join('');
wireAddToCart();
}
// ════════════════════════════════
// 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'));
});
}
// ════════════════════════════════
// CARRITO DE COMPRAS
// ════════════════════════════════
let carrito = []; // { ref, nombre, precio, cantidad }
function actualizarBadge() {
const total = carrito.reduce((s, i) => s + i.cantidad, 0);
const badge = document.getElementById('cartBadge');
badge.textContent = total;
badge.style.display = total > 0 ? 'flex' : 'none';
}
function actualizarTotal() {
const total = carrito.reduce((s, i) => s + i.precio * i.cantidad, 0);
document.querySelector('#cartTotal strong').textContent =
total > 0 ? `$${total.toLocaleString('es-CO')}` : '$0';
document.getElementById('cartPdfBtn').disabled = carrito.length === 0;
}
function renderCarritoModal() {
const body = document.getElementById('cartBody');
if (carrito.length === 0) {
body.innerHTML = 'El carrito está vacío.
';
actualizarTotal();
return;
}
const totalGeneral = carrito.reduce((s, i) => s + i.precio * i.cantidad, 0);
body.innerHTML = `
${carrito.map((item, idx) => {
const subtotal = item.precio * item.cantidad;
return `
${escapeHtml(item.nombre)}
${escapeHtml(item.ref)} — $${(item.precio || 0).toLocaleString('es-CO')} c/u
${item.cantidad}
$${subtotal.toLocaleString('es-CO')}
`;
}).join('')}
`;
body.querySelectorAll('[data-cart-inc]').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.cartInc, 10);
carrito[idx].cantidad++;
renderCarritoModal();
actualizarBadge();
});
});
body.querySelectorAll('[data-cart-dec]').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.cartDec, 10);
if (carrito[idx].cantidad > 1) {
carrito[idx].cantidad--;
} else {
carrito.splice(idx, 1);
}
renderCarritoModal();
actualizarBadge();
});
});
body.querySelectorAll('[data-cart-remove]').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.cartRemove, 10);
carrito.splice(idx, 1);
renderCarritoModal();
actualizarBadge();
});
});
actualizarTotal();
}
// Modales
function abrirModal(id) {
document.getElementById(id).classList.add('open');
document.body.style.overflow = 'hidden';
}
function cerrarModal(id) {
document.getElementById(id).classList.remove('open');
document.body.style.overflow = '';
}
function setupCartModals() {
// FAB → abrir carrito
document.getElementById('cartFab').addEventListener('click', () => {
renderCarritoModal();
abrirModal('cartModal');
});
// Cerrar carrito
document.querySelectorAll('[data-cart-close]').forEach(el => {
el.addEventListener('click', () => cerrarModal('cartModal'));
});
// Cerrar pedido
document.querySelectorAll('[data-order-close]').forEach(el => {
el.addEventListener('click', () => cerrarModal('orderModal'));
});
// Vaciar carrito
document.getElementById('cartClearBtn').addEventListener('click', () => {
carrito = [];
renderCarritoModal();
actualizarBadge();
});
// Generar PDF → abrir formulario
document.getElementById('cartPdfBtn').addEventListener('click', async () => {
cerrarModal('cartModal');
document.getElementById('orderForm').reset();
document.getElementById('orderError').classList.remove('show');
// Cargar vendedores en el select
const sel = document.getElementById('orderVendedor');
sel.innerHTML = '';
try {
const res = await fetch('/api/vendedores');
if (res.ok) {
const vendedores = await res.json();
vendedores.forEach(v => {
const opt = document.createElement('option');
opt.value = v.nombre;
opt.textContent = v.nombre;
sel.appendChild(opt);
});
}
} catch {}
abrirModal('orderModal');
});
// Enviar pedido
document.getElementById('orderSubmitBtn').addEventListener('click', enviarPedido);
// Enter en el formulario también envía
document.getElementById('orderForm').addEventListener('submit', e => {
e.preventDefault();
enviarPedido();
});
}
async function enviarPedido() {
const nombre = document.getElementById('orderNombre').value.trim();
const ciudad = document.getElementById('orderCiudad').value.trim();
const telefono = document.getElementById('orderTelefono').value.trim();
const direccion = document.getElementById('orderDireccion').value.trim();
const vendedor = document.getElementById('orderVendedor').value;
const errEl = document.getElementById('orderError');
if (!nombre || !ciudad || !telefono || !direccion || !vendedor) {
errEl.textContent = 'Todos los campos son obligatorios';
errEl.classList.add('show');
return;
}
errEl.classList.remove('show');
const btn = document.getElementById('orderSubmitBtn');
const textEl = btn.querySelector('.btn-text');
const spin = btn.querySelector('.btn-spinner');
btn.disabled = true;
textEl.textContent = 'Generando…';
spin.style.display = 'inline-block';
try {
const res = await fetch('/api/pedidos/generar-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: carrito.map(i => ({ ref: i.ref, nombre: i.nombre, precio: i.precio, cantidad: i.cantidad })),
cliente: { nombre, ciudad, telefono, direccion },
vendedor
})
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Error al generar el pedido');
}
// Descargar PDF
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const dispo = res.headers.get('Content-Disposition') || '';
const match = dispo.match(/filename="(.+)"/);
a.download = match ? match[1] : `pedido-${Date.now()}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
// Limpiar carrito y cerrar modal
carrito = [];
actualizarBadge();
cerrarModal('orderModal');
} catch (err) {
errEl.textContent = err.message;
errEl.classList.add('show');
} finally {
btn.disabled = false;
textEl.textContent = '✅ Generar pedido';
spin.style.display = 'none';
}
}
// Wire add-to-cart buttons después de renderizar productos
function wireAddToCart() {
document.querySelectorAll('.producto-card-add-btn').forEach(btn => {
if (btn.dataset.cartWired) return;
btn.dataset.cartWired = '1';
btn.addEventListener('click', () => {
const ref = btn.dataset.ref;
const nombre = btn.dataset.nombre;
const precio = parseFloat(btn.dataset.precio);
const qtyInput = document.querySelector(`.producto-card-add-qty[data-ref="${ref}"]`);
const cantidad = qtyInput ? parseInt(qtyInput.value, 10) : 1;
if (!cantidad || cantidad < 1) return;
// Mostrar feedback si el producto no tiene precio
if (!precio || isNaN(precio)) {
const orig = btn.textContent;
btn.textContent = '⚠ Sin precio';
btn.style.opacity = '0.7';
setTimeout(() => { btn.textContent = orig; btn.style.opacity = '1'; }, 1500);
return;
}
const existente = carrito.find(i => i.ref === ref);
if (existente) {
existente.cantidad += cantidad;
} else {
carrito.push({ ref, nombre, precio, cantidad });
}
if (qtyInput) qtyInput.value = 1;
actualizarBadge();
const original = btn.textContent;
btn.textContent = '✓ Agregado';
setTimeout(() => { btn.textContent = original; }, 1200);
});
});
}
// ════════════════════════════════
// INIT
// ════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
setupSearch();
setupNav();
setupCartModals();
cargarDatos();
});