commit aa5e85ae940b2b2aa6fba27e50f0c5aa802f2769 Author: Juan Pablo Restrepo Date: Fri Jun 5 22:06:52 2026 -0500 Solo items y usuarios diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..68d394a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# Contexto del proyecto — Catálogo web + +Eres un agente de desarrollo trabajando en este proyecto. Lee este archivo completo antes de responder o tocar cualquier archivo.SIEMPRE QUE VAYAS A MODFIICAR PRIMERO CONFIRMAR EL CAMBIO, NO CAMBIAR NADA SIN CONFIRMACION. Aquí está todo lo que se ha decidido hasta ahora. + +--- + +## ¿Qué es este proyecto? + +Una página web tipo **catálogo** para una empresa. No es una tienda online: no hay pagos, no hay checkout. El objetivo es mostrar productos visualmente para que los clientes los vean en línea. + +El cliente (la empresa) debe poder subir fotos y gestionar productos por su cuenta desde una interfaz web, sin necesitar acceso técnico al servidor ni al VPS. + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02e33e6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:alpine +COPY frontend /usr/share/nginx/html +COPY data /usr/share/nginx/html/data +COPY Imagenes /usr/share/nginx/html/Imagenes diff --git a/Imagenes/agotados/AF100-124.webp b/Imagenes/agotados/AF100-124.webp new file mode 100644 index 0000000..254c2cc Binary files /dev/null and b/Imagenes/agotados/AF100-124.webp differ diff --git a/Imagenes/bolsos/AF100-128.webp b/Imagenes/bolsos/AF100-128.webp new file mode 100644 index 0000000..88c0a54 Binary files /dev/null and b/Imagenes/bolsos/AF100-128.webp differ diff --git a/Imagenes/bolsos/AF100-129.webp b/Imagenes/bolsos/AF100-129.webp new file mode 100644 index 0000000..175384f Binary files /dev/null and b/Imagenes/bolsos/AF100-129.webp differ diff --git a/Imagenes/bolsos/AF100-159.webp b/Imagenes/bolsos/AF100-159.webp new file mode 100644 index 0000000..3aac912 Binary files /dev/null and b/Imagenes/bolsos/AF100-159.webp differ diff --git a/Imagenes/bolsos/AF100-171.webp b/Imagenes/bolsos/AF100-171.webp new file mode 100644 index 0000000..fe543c9 Binary files /dev/null and b/Imagenes/bolsos/AF100-171.webp differ diff --git a/Imagenes/bolsos/AF100-174.webp b/Imagenes/bolsos/AF100-174.webp new file mode 100644 index 0000000..0300901 Binary files /dev/null and b/Imagenes/bolsos/AF100-174.webp differ diff --git a/Imagenes/bolsos/AF9173.webp b/Imagenes/bolsos/AF9173.webp new file mode 100644 index 0000000..58c34ca Binary files /dev/null and b/Imagenes/bolsos/AF9173.webp differ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9b3deaa --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM node:18-alpine + +WORKDIR /app + +# Instalar dependencias primero (mejor cache de capas) +COPY backend/package*.json ./ +RUN npm install --production + +# Copiar el código del backend +COPY backend/ ./ + +# Copiar el frontend (servido como estático) +COPY frontend/ ./frontend/ + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..a77efee --- /dev/null +++ b/backend/package.json @@ -0,0 +1,19 @@ +{ + "name": "infinity-backend", + "version": "1.0.0", + "description": "Backend para Infinity - catálogo web con panel admin", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "cookie-parser": "^1.4.6", + "express": "^4.19.2", + "multer": "^1.4.5-lts.1", + "sharp": "^0.33.5" + }, + "engines": { + "node": ">=18" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..ac58b21 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,939 @@ +/* ════════════════════════════════ + INFINITY — BACKEND + Express + sesiones + JSON storage + ════════════════════════════════ */ + +const express = require('express'); +const cookieParser = require('cookie-parser'); +const fs = require('fs').promises; +const fsSync = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const multer = require('multer'); +const sharp = require('sharp'); + +const app = express(); +const PORT = process.env.PORT || 3000; +const SECRET = process.env.SESSION_SECRET || 'infinity-cambia-esta-clave-en-produccion'; + +const DATA_DIR = path.join(__dirname, 'data'); +const FRONTEND_DIR = path.join(__dirname, 'frontend'); +const IMG_DIR = path.join(__dirname, 'Imagenes'); + +// ════════════════════════════════ +// MIDDLEWARES +// ════════════════════════════════ +app.use(express.json()); +app.use(cookieParser(SECRET)); +app.use(express.static(FRONTEND_DIR)); +app.use('/Imagenes', express.static(IMG_DIR, { maxAge: '7d' })); + +// Evita el 404 de favicon.ico +app.get('/favicon.ico', (req, res) => res.status(204).end()); + +// ════════════════════════════════ +// HELPERS +// ════════════════════════════════ +async function readJson(filename) { + const filePath = path.join(DATA_DIR, filename); + const data = await fs.readFile(filePath, 'utf8'); + return JSON.parse(data); +} + +async function writeJson(filename, data) { + const filePath = path.join(DATA_DIR, filename); + await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8'); +} + +async function ensureSesiones() { + try { + await fs.access(path.join(DATA_DIR, 'sesiones.json')); + } catch { + await writeJson('sesiones.json', []); + } +} + +async function getSesiones() { + await ensureSesiones(); + return await readJson('sesiones.json'); +} + +async function saveSesiones(sesiones) { + await writeJson('sesiones.json', sesiones); +} + +async function createSession(usuario) { + const sesiones = await getSesiones(); + const token = crypto.randomBytes(32).toString('hex'); + sesiones.push({ + token, + usuario: usuario.usuario, + rol: usuario.rol, + createdAt: Date.now() + }); + await saveSesiones(sesiones); + return token; +} + +async function destroySession(token) { + const sesiones = await getSesiones(); + const filtradas = sesiones.filter(s => s.token !== token); + await saveSesiones(filtradas); +} + +async function getSessionUser(req) { + const token = req.signedCookies.session; + if (!token) return null; + const sesiones = await getSesiones(); + return sesiones.find(s => s.token === token) || null; +} + +async function getUserData(usuario) { + try { + const data = await readJson('contraseñas.json'); + return (data.usuarios || []).find(u => u.usuario === usuario) || null; + } catch { + return null; + } +} + +// ════════════════════════════════ +// HELPERS — Categorías +// ════════════════════════════════ +const SIN_CATEGORIA_ID = 'sin-categoria'; + +function slugify(text) { + return String(text) + .toLowerCase() + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') // quitar acentos + .replace(/[^a-z0-9\s-]/g, '') // quitar chars especiales + .trim() + .replace(/\s+/g, '-') // espacios → guiones + .replace(/-+/g, '-') // colapsar guiones + .replace(/^-+|-+$/g, ''); // trim guiones +} + +async function getCategorias() { + try { + return await readJson('categorias.json'); + } catch { + return []; + } +} + +async function saveCategorias(categorias) { + await writeJson('categorias.json', categorias); +} + +async function ensureSinCategoria() { + const categorias = await getCategorias(); + if (!categorias.some(c => c.id === SIN_CATEGORIA_ID)) { + categorias.push({ id: SIN_CATEGORIA_ID, nombre: 'Sin categoría', icono: '📦' }); + await saveCategorias(categorias); + } + // Asegurar carpeta física + const dir = path.join(IMG_DIR, SIN_CATEGORIA_ID); + try { + await fs.access(dir); + } catch { + await fs.mkdir(dir, { recursive: true }); + } +} + +async function getProductos() { + try { + return await readJson('productos.json'); + } catch { + return []; + } +} + +async function saveProductos(productos) { + await writeJson('productos.json', productos); +} + +// Helper: mover la imagen de un producto entre carpetas. +// - oldImg: ruta actual relativa a /Imagenes/ (ej: "bolsos/AF100-124.webp") +// - newCat: id de la categoría destino (o "agotados") +// - ref: referencia del producto (la imagen destino será ".webp") +// Devuelve la nueva ruta relativa ("/.webp"). +// Si la imagen origen no existe, no falla: igual devuelve la nueva ruta +// (útil para entradas huérfanas en el JSON). +async function moveProductImage(oldImg, newCat, ref) { + const newName = `${ref}.webp`; + const newPath = path.join(IMG_DIR, newCat, newName); + + // Crear carpeta destino si no existe + await fs.mkdir(path.join(IMG_DIR, newCat), { recursive: true }); + + if (oldImg) { + const oldPath = path.join(IMG_DIR, oldImg); + try { + await fs.rename(oldPath, newPath); + } catch (err) { + if (err.code === 'ENOENT') { + // Imagen origen no existe: no es error (entrada huérfana). + // Igual devolvemos la nueva ruta; el JSON quedará coherente. + } else { + // Cross-device u otro error: copiar y borrar. + try { + await fs.copyFile(oldPath, newPath); + await fs.unlink(oldPath); + } catch (err2) { + if (err2.code !== 'ENOENT') throw err2; + } + } + } + } + + return `${newCat}/${newName}`; +} + +// Helper: borrar la imagen de un producto (best-effort). +async function deleteProductImage(img) { + if (!img) return; + const imgPath = path.join(IMG_DIR, img); + try { + await fs.unlink(imgPath); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } +} + +// ════════════════════════════════ +// MIDDLEWARES DE AUTENTICACIÓN +// ════════════════════════════════ +async function requireAuth(req, res, next) { + const session = await getSessionUser(req); + if (!session) return res.status(401).json({ error: 'No autorizado' }); + req.user = session; + next(); +} + +async function requireAdmin(req, res, next) { + const session = await getSessionUser(req); + if (!session) return res.status(401).json({ error: 'No autorizado' }); + + const user = await getUserData(session.usuario); + if (!user) return res.status(401).json({ error: 'No autorizado' }); + + if (user.rol !== 'admin') { + return res.status(403).json({ error: 'Se requiere rol de administrador' }); + } + req.user = user; + next(); +} + +// Permite acceder si es admin O tiene el permiso 'usuarios' +async function requireUserManagement(req, res, next) { + const session = await getSessionUser(req); + if (!session) return res.status(401).json({ error: 'No autorizado' }); + + const user = await getUserData(session.usuario); + if (!user) return res.status(401).json({ error: 'No autorizado' }); + + const esAdmin = user.rol === 'admin'; + const tienePermiso = (user.permisos || []).includes('usuarios'); + + if (!esAdmin && !tienePermiso) { + return res.status(403).json({ error: 'No tienes permiso para gestionar usuarios' }); + } + req.user = user; + next(); +} + +// Permite acceder si es admin O tiene el permiso 'categorias' +async function requireCategoriasManagement(req, res, next) { + const session = await getSessionUser(req); + if (!session) return res.status(401).json({ error: 'No autorizado' }); + + const user = await getUserData(session.usuario); + if (!user) return res.status(401).json({ error: 'No autorizado' }); + + const esAdmin = user.rol === 'admin'; + const tienePermiso = (user.permisos || []).includes('categorias'); + + if (!esAdmin && !tienePermiso) { + return res.status(403).json({ error: 'No tienes permiso para gestionar categorías' }); + } + req.user = user; + next(); +} + +// Permite acceder si es admin O tiene el permiso 'productos' +async function requireProductosManagement(req, res, next) { + const session = await getSessionUser(req); + if (!session) return res.status(401).json({ error: 'No autorizado' }); + + const user = await getUserData(session.usuario); + if (!user) return res.status(401).json({ error: 'No autorizado' }); + + const esAdmin = user.rol === 'admin'; + const tienePermiso = (user.permisos || []).includes('productos'); + + if (!esAdmin && !tienePermiso) { + return res.status(403).json({ error: 'No tienes permiso para gestionar productos' }); + } + req.user = user; + next(); +} + +// ════════════════════════════════ +// RUTAS DE AUTENTICACIÓN +// ════════════════════════════════ +app.post('/api/auth/login', async (req, res) => { + const { usuario, contraseña } = req.body || {}; + if (!usuario || !contraseña) { + return res.status(400).json({ error: 'Usuario y contraseña requeridos' }); + } + + try { + const data = await readJson('contraseñas.json'); + const usuarios = data.usuarios || []; + const match = usuarios.find(u => u.usuario === usuario && u.contraseña === contraseña); + + if (!match) return res.status(401).json({ error: 'Credenciales inválidas' }); + + const token = await createSession(match); + res.cookie('session', token, { + signed: true, + httpOnly: true, + sameSite: 'lax', + secure: false, // ⚠️ cambiar a true cuando se use HTTPS + maxAge: 24 * 60 * 60 * 1000 + }); + res.json({ ok: true, usuario: match.usuario, rol: match.rol }); + } catch (err) { + console.error('Login error:', err); + res.status(500).json({ error: 'Error en el servidor' }); + } +}); + +app.post('/api/auth/logout', async (req, res) => { + const token = req.signedCookies.session; + if (token) await destroySession(token); + res.clearCookie('session'); + res.json({ ok: true }); +}); + +app.get('/api/auth/me', async (req, res) => { + const session = await getSessionUser(req); + if (!session) return res.status(401).json({ error: 'No autenticado' }); + + const user = await getUserData(session.usuario); + if (!user) return res.status(401).json({ error: 'Usuario no encontrado' }); + + res.json({ + usuario: user.usuario, + rol: user.rol, + permisos: user.permisos || [] + }); +}); + +app.put('/api/auth/password', requireAdmin, async (req, res) => { + const { actual, nueva } = req.body || {}; + if (!actual || !nueva) { + return res.status(400).json({ error: 'Contraseña actual y nueva requeridas' }); + } + if (nueva.length < 6) { + return res.status(400).json({ error: 'La nueva contraseña debe tener al menos 6 caracteres' }); + } + + try { + const data = await readJson('contraseñas.json'); + const usuarios = data.usuarios || []; + const idx = usuarios.findIndex(u => u.usuario === req.user.usuario); + + if (idx === -1) return res.status(404).json({ error: 'Usuario no encontrado' }); + if (usuarios[idx].contraseña !== actual) { + return res.status(401).json({ error: 'La contraseña actual es incorrecta' }); + } + + usuarios[idx].contraseña = nueva; + await writeJson('contraseñas.json', { usuarios }); + res.json({ ok: true }); + } catch (err) { + console.error('Change password error:', err); + res.status(500).json({ error: 'Error al cambiar contraseña' }); + } +}); + +// ════════════════════════════════ +// RUTAS PÚBLICAS (catálogo) +// ════════════════════════════════ +app.get('/api/categorias', async (req, res) => { + try { + await ensureSinCategoria(); + const categorias = await getCategorias(); + res.json(categorias); + } catch { + res.json([]); + } +}); + +app.get('/api/productos', async (req, res) => { + try { + const data = await readJson('productos.json'); + res.json(data); + } catch { + res.json([]); + } +}); + +// ════════════════════════════════ +// GESTIÓN DE CATEGORÍAS (admin o permiso 'categorias') +// ════════════════════════════════ +app.post('/api/categorias', requireCategoriasManagement, async (req, res) => { + const { nombre, icono } = req.body || {}; + + if (!nombre || !nombre.trim()) { + return res.status(400).json({ error: 'El nombre es obligatorio' }); + } + + const id = slugify(nombre); + if (!id) { + return res.status(400).json({ error: 'El nombre genera un ID inválido' }); + } + + try { + const categorias = await getCategorias(); + const nombreNorm = nombre.trim().toLowerCase(); + + // No permitir duplicados por id NI por nombre + if (categorias.some(c => c.id === id)) { + return res.status(409).json({ error: 'Ya existe una categoría con ese nombre' }); + } + if (categorias.some(c => c.nombre.toLowerCase() === nombreNorm)) { + return res.status(409).json({ error: 'Ya existe una categoría con ese nombre' }); + } + + // Crear carpeta física + const dir = path.join(IMG_DIR, id); + try { + await fs.access(dir); + // Si ya existe la carpeta, no es error — solo se agrega al JSON + } catch { + await fs.mkdir(dir, { recursive: true }); + } + + const nuevaCat = { id, nombre: nombre.trim(), icono: icono || '📦' }; + categorias.push(nuevaCat); + await saveCategorias(categorias); + + res.json({ ok: true, categoria: nuevaCat }); + } catch (err) { + console.error('Create category error:', err); + res.status(500).json({ error: 'Error al crear la categoría' }); + } +}); + +app.put('/api/categorias/:id', requireCategoriasManagement, async (req, res) => { + const targetId = req.params.id; + const { nombre, icono } = req.body || {}; + + if (!nombre || !nombre.trim()) { + return res.status(400).json({ error: 'El nombre es obligatorio' }); + } + + try { + const categorias = await getCategorias(); + const idx = categorias.findIndex(c => c.id === targetId); + + if (idx === -1) return res.status(404).json({ error: 'Categoría no encontrada' }); + + if (targetId === SIN_CATEGORIA_ID) { + return res.status(400).json({ error: 'No se puede editar la categoría del sistema' }); + } + + const nombreNorm = nombre.trim().toLowerCase(); + // No permitir que el nuevo nombre coincida con OTRA categoría + if (categorias.some((c, i) => i !== idx && c.nombre.toLowerCase() === nombreNorm)) { + return res.status(409).json({ error: 'Ya existe otra categoría con ese nombre' }); + } + + // El id NO se modifica (es inmutable). Solo nombre e icono. + categorias[idx].nombre = nombre.trim(); + if (icono) categorias[idx].icono = icono; + + await saveCategorias(categorias); + res.json({ ok: true, categoria: categorias[idx] }); + } catch (err) { + console.error('Update category error:', err); + res.status(500).json({ error: 'Error al actualizar la categoría' }); + } +}); + +app.delete('/api/categorias/:id', requireCategoriasManagement, async (req, res) => { + const targetId = req.params.id; + + if (targetId === SIN_CATEGORIA_ID) { + return res.status(400).json({ error: 'No se puede eliminar la categoría del sistema' }); + } + + try { + const categorias = await getCategorias(); + const idx = categorias.findIndex(c => c.id === targetId); + + if (idx === -1) return res.status(404).json({ error: 'Categoría no encontrada' }); + + // Asegurar "sin-categoria" existe + await ensureSinCategoria(); + + // Contar productos en esta categoría + const productos = await getProductos(); + const itemsAfectados = productos.filter(p => p.cat === targetId); + + if (itemsAfectados.length > 0) { + // Mover imágenes de Imagenes// → Imagenes/sin-categoria/ + const srcDir = path.join(IMG_DIR, targetId); + const dstDir = path.join(IMG_DIR, SIN_CATEGORIA_ID); + + try { + const files = await fs.readdir(srcDir); + for (const file of files) { + const srcFile = path.join(srcDir, file); + const dstFile = path.join(dstDir, file); + // Mover (rename). Si existe destino, sobreescribir (cada producto tiene img único). + try { + await fs.rename(srcFile, dstFile); + } catch (err) { + // Si falla el rename (p.ej. cross-device), copiar y borrar + await fs.copyFile(srcFile, dstFile); + await fs.unlink(srcFile); + } + } + // Borrar carpeta origen (vacía) + try { + await fs.rmdir(srcDir); + } catch { + // Si quedan subcarpetas u otros archivos, no fallar + } + } catch (err) { + // Si la carpeta no existe, no es error + if (err.code !== 'ENOENT') throw err; + } + + // Actualizar productos: cat → sin-categoria, img → sin-categoria/... + for (const p of productos) { + if (p.cat === targetId) { + p.cat = SIN_CATEGORIA_ID; + if (p.img && p.img.startsWith(targetId + '/')) { + p.img = SIN_CATEGORIA_ID + '/' + p.img.slice(targetId.length + 1); + } + } + } + await saveProductos(productos); + } else { + // Sin items: solo borrar carpeta y entrada del JSON + const srcDir = path.join(IMG_DIR, targetId); + try { + const files = await fs.readdir(srcDir); + for (const file of files) { + await fs.unlink(path.join(srcDir, file)); + } + await fs.rmdir(srcDir); + } catch (err) { + if (err.code !== 'ENOENT') console.error('Error borrando carpeta:', err); + } + } + + // Quitar del JSON + categorias.splice(idx, 1); + await saveCategorias(categorias); + + res.json({ ok: true, itemsMovidos: itemsAfectados.length }); + } catch (err) { + console.error('Delete category error:', err); + res.status(500).json({ error: 'Error al eliminar la categoría' }); + } +}); + +// ════════════════════════════════ +// GESTIÓN DE USUARIOS (admin o permiso 'usuarios') +// ════════════════════════════════ +app.get('/api/usuarios', requireUserManagement, async (req, res) => { + try { + const data = await readJson('contraseñas.json'); + const usuarios = (data.usuarios || []).map(u => ({ + usuario: u.usuario, + rol: u.rol, + permisos: u.permisos || [] + })); + res.json(usuarios); + } catch (err) { + console.error('List users error:', err); + res.status(500).json({ error: 'Error al cargar usuarios' }); + } +}); + +app.post('/api/usuarios', requireUserManagement, async (req, res) => { + const { usuario, contraseña, rol, permisos } = req.body || {}; + + if (!usuario || !contraseña || !rol) { + return res.status(400).json({ error: 'Todos los campos son obligatorios' }); + } + if (contraseña.length < 6) { + return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' }); + } + if (!['admin', 'editor'].includes(rol)) { + return res.status(400).json({ error: 'Rol inválido' }); + } + + try { + const data = await readJson('contraseñas.json'); + const usuarios = data.usuarios || []; + + if (usuarios.some(u => u.usuario === usuario)) { + return res.status(409).json({ error: 'Ya existe un usuario con ese nombre' }); + } + + // Admin siempre tiene todos los permisos; editor recibe la lista enviada + const permisosFinal = rol === 'admin' + ? [] + : (Array.isArray(permisos) ? permisos : []); + + usuarios.push({ usuario, contraseña, rol, permisos: permisosFinal }); + await writeJson('contraseñas.json', { usuarios }); + res.json({ ok: true }); + } catch (err) { + console.error('Create user error:', err); + res.status(500).json({ error: 'Error al crear usuario' }); + } +}); + +app.put('/api/usuarios/:usuario', requireUserManagement, async (req, res) => { + const targetUsuario = req.params.usuario; + const { contraseña, rol, permisos } = req.body || {}; + + if (rol && !['admin', 'editor'].includes(rol)) { + return res.status(400).json({ error: 'Rol inválido' }); + } + if (contraseña && contraseña.length < 6) { + return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' }); + } + + try { + const data = await readJson('contraseñas.json'); + const usuarios = data.usuarios || []; + const idx = usuarios.findIndex(u => u.usuario === targetUsuario); + + if (idx === -1) return res.status(404).json({ error: 'Usuario no encontrado' }); + + if (rol && rol !== 'admin' && usuarios[idx].rol === 'admin') { + const admins = usuarios.filter(u => u.rol === 'admin'); + if (admins.length === 1) { + return res.status(400).json({ error: 'No puedes degradar al único administrador' }); + } + } + + if (contraseña) usuarios[idx].contraseña = contraseña; + if (rol) usuarios[idx].rol = rol; + + // Si cambia a admin, limpiamos permisos (admin siempre tiene todos) + if (rol === 'admin') { + usuarios[idx].permisos = []; + } else if (Array.isArray(permisos)) { + usuarios[idx].permisos = permisos; + } + + await writeJson('contraseñas.json', { usuarios }); + res.json({ ok: true }); + } catch (err) { + console.error('Update user error:', err); + res.status(500).json({ error: 'Error al actualizar usuario' }); + } +}); + +app.delete('/api/usuarios/:usuario', requireUserManagement, async (req, res) => { + const targetUsuario = req.params.usuario; + + if (targetUsuario === req.user.usuario) { + return res.status(400).json({ error: 'No puedes eliminarte a ti mismo' }); + } + + try { + const data = await readJson('contraseñas.json'); + const usuarios = data.usuarios || []; + const target = usuarios.find(u => u.usuario === targetUsuario); + + if (!target) return res.status(404).json({ error: 'Usuario no encontrado' }); + + if (target.rol === 'admin') { + const admins = usuarios.filter(u => u.rol === 'admin'); + if (admins.length === 1) { + return res.status(400).json({ error: 'No puedes eliminar al único administrador' }); + } + } + + const filtrados = usuarios.filter(u => u.usuario !== targetUsuario); + await writeJson('contraseñas.json', { usuarios: filtrados }); + res.json({ ok: true }); + } catch (err) { + console.error('Delete user error:', err); + res.status(500).json({ error: 'Error al eliminar usuario' }); + } +}); + +// ════════════════════════════════ +// SUBIDA MASIVA DE PRODUCTOS (admin o permiso 'productos') +// ════════════════════════════════ + +// Multer config: usa memoria (luego sharp lo procesa y guarda en disco). +const MAX_FILES = 50; +const MAX_SIZE = 20 * 1024 * 1024; // 20MB por archivo +const MAX_WIDTH = 1200; // ancho (o alto) máx del webp resultante +const WEBP_QUALITY = 80; + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_SIZE, files: MAX_FILES } +}); + +// Convención de nombres de archivo: +// La REFERENCIA es TODO el nombre antes de la extensión, sin importar +// qué tan complejo sea (puede tener guiones, guiones bajos, números, etc.). +// Cada archivo = 1 producto con 1 foto. +// +// "bill-001-1.jpg" → ref=bill-001-1 +// "bill-001-2.jpg" → ref=bill-001-2 (producto DISTINTO al anterior) +// "GG-500505.jpg" → ref=GG-500505 +// "mi-prod_loco.jpg" → ref=mi-prod_loco +// +// La referencia es el IDENTIFICATIVO del producto (como un SKU). +// Más adelante, cuando se suba el Excel/CSV, se matcheará por referencia +// para asignar la descripción (nombre visible) y el precio. +const REF_VALID_RE = /^[a-zA-Z0-9]+(?:[-_a-zA-Z0-9]*[a-zA-Z0-9])?$/; + +function parseFilename(name) { + // Quitar extensión (todo lo que está después del último punto) + const base = name.replace(/\.[^.]+$/, '').trim(); + if (!base) return null; + + // Validar que la referencia tenga un formato aceptable + if (!REF_VALID_RE.test(base)) return null; + + return { ref: base, num: 1 }; +} + +app.post('/api/productos/upload', requireProductosManagement, upload.array('fotos', MAX_FILES), async (req, res) => { + const categoria = (req.body.categoria || '').trim(); + const files = req.files || []; + + if (!categoria) { + return res.status(400).json({ error: 'Selecciona una categoría' }); + } + if (files.length === 0) { + return res.status(400).json({ error: 'No se enviaron fotos' }); + } + + try { + // Verificar que la categoría existe y NO es sin-categoria + const categorias = await getCategorias(); + const cat = categorias.find(c => c.id === categoria); + if (!cat) { + return res.status(400).json({ error: 'La categoría no existe' }); + } + if (categoria === SIN_CATEGORIA_ID) { + return res.status(400).json({ error: 'No se puede subir productos a la categoría del sistema' }); + } + + // Crear carpeta destino si no existe + const dstDir = path.join(IMG_DIR, categoria); + await fs.mkdir(dstDir, { recursive: true }); + + // Cargar productos existentes + const productos = await getProductos(); + const existentes = new Set(productos.map(p => p.ref || p.id)); + + const creados = []; + const saltados = []; + const ignorados = []; + const errores = []; + + // Cada archivo = 1 producto con 1 foto. Procesamos secuencialmente. + for (const f of files) { + const parsed = parseFilename(f.originalname); + if (!parsed) { + ignorados.push({ archivo: f.originalname, motivo: 'Nombre de archivo inválido' }); + continue; + } + + const { ref } = parsed; + + if (existentes.has(ref)) { + saltados.push({ ref, motivo: 'Ya existe un producto con esa referencia' }); + continue; + } + + try { + const outName = `${ref}.webp`; + const outPath = path.join(dstDir, outName); + + await sharp(f.buffer) + .rotate() // respeta EXIF orientation + .resize({ + width: MAX_WIDTH, + height: MAX_WIDTH, + fit: 'inside', + withoutEnlargement: true + }) + .webp({ quality: WEBP_QUALITY }) + .toFile(outPath); + + productos.push({ + id: ref, + ref: ref, + cat: categoria, + img: `${categoria}/${outName}` + }); + creados.push({ ref }); + } catch (err) { + console.error(`Error procesando ${ref}:`, err.message); + errores.push({ ref, motivo: 'Error al procesar la imagen' }); + } + } + + await saveProductos(productos); + + res.json({ + ok: true, + resumen: { + creados: creados.length, + creadosList: creados, + saltados: saltados.length, + saltadosList: saltados, + ignorados: ignorados.length, + ignoradosList: ignorados, + errores: errores.length, + erroresList: errores + } + }); + } catch (err) { + console.error('Upload productos error:', err); + res.status(500).json({ error: 'Error al procesar la subida' }); + } +}); + +// Marcar como AGOTADO (admin o permiso 'productos') +// Mueve la imagen a Imagenes/agotados/.webp y actualiza el JSON. +// El producto sigue en el JSON (con su categoría original), pero su `img` +// pasa a apuntar a "agotados/.webp", que es la señal de "agotado". +app.post('/api/productos/:ref/agotar', requireProductosManagement, async (req, res) => { + const ref = req.params.ref; + try { + const productos = await getProductos(); + const idx = productos.findIndex(p => p.ref === ref); + if (idx === -1) return res.status(404).json({ error: 'Producto no encontrado' }); + + if (productos[idx].img && productos[idx].img.startsWith('agotados/')) { + return res.status(400).json({ error: 'El producto ya está agotado' }); + } + + const oldImg = productos[idx].img; + const newImg = await moveProductImage(oldImg, 'agotados', ref); + productos[idx].img = newImg; + await saveProductos(productos); + + res.json({ ok: true, ref, img: newImg }); + } catch (err) { + console.error('Agotar error:', err); + res.status(500).json({ error: 'Error al marcar como agotado' }); + } +}); + +// REACTIVAR un producto agotado (admin o permiso 'productos') +// Mueve la imagen de vuelta a su carpeta de categoría original +// (o a "sin-categoria" si la categoría ya no existe) y actualiza el JSON. +app.post('/api/productos/:ref/reactivar', requireProductosManagement, async (req, res) => { + const ref = req.params.ref; + try { + const productos = await getProductos(); + const idx = productos.findIndex(p => p.ref === ref); + if (idx === -1) return res.status(404).json({ error: 'Producto no encontrado' }); + + if (!productos[idx].img || !productos[idx].img.startsWith('agotados/')) { + return res.status(400).json({ error: 'El producto no está agotado' }); + } + + // Categoría original (la guardamos en el JSON al subir) + const targetCat = productos[idx].cat; + if (!targetCat) { + return res.status(400).json({ error: 'El producto no tiene categoría original' }); + } + + // Si la categoría original ya no existe, fallback a "sin-categoria" + const categorias = await getCategorias(); + let destCat = targetCat; + if (!categorias.some(c => c.id === targetCat)) { + await ensureSinCategoria(); + destCat = SIN_CATEGORIA_ID; + productos[idx].cat = SIN_CATEGORIA_ID; + } + + const newImg = await moveProductImage(productos[idx].img, destCat, ref); + productos[idx].img = newImg; + await saveProductos(productos); + + res.json({ ok: true, ref, img: newImg, cat: destCat }); + } catch (err) { + console.error('Reactivar error:', err); + res.status(500).json({ error: 'Error al reactivar el producto' }); + } +}); + +// ELIMINAR DEFINITIVAMENTE un producto (admin o permiso 'productos') +// Borra la imagen del disco y la entrada del JSON. +// Funciona tanto para productos disponibles como para agotados. +app.delete('/api/productos/:ref', requireProductosManagement, async (req, res) => { + const ref = req.params.ref; + try { + const productos = await getProductos(); + const idx = productos.findIndex(p => p.ref === ref); + if (idx === -1) return res.status(404).json({ error: 'Producto no encontrado' }); + + const img = productos[idx].img; + await deleteProductImage(img); + + productos.splice(idx, 1); + await saveProductos(productos); + + res.json({ ok: true, ref }); + } catch (err) { + console.error('Delete producto error:', err); + res.status(500).json({ error: 'Error al eliminar el producto' }); + } +}); + +// Multer error handler (tamaño excedido, etc.) +app.use((err, req, res, next) => { + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ error: `Cada archivo no puede superar ${MAX_SIZE / 1024 / 1024}MB` }); + } + if (err.code === 'LIMIT_FILE_COUNT') { + return res.status(400).json({ error: `Máximo ${MAX_FILES} archivos por subida` }); + } + if (err.code === 'LIMIT_UNEXPECTED_FILE') { + return res.status(400).json({ error: 'Campo de archivo inesperado' }); + } + return res.status(400).json({ error: err.message }); + } + next(err); +}); + +// ════════════════════════════════ +// FALLBACK para SPA-like del admin +// ════════════════════════════════ +app.get('/administrativo', (req, res) => { + res.sendFile(path.join(FRONTEND_DIR, 'administrativo', 'index.html')); +}); +app.get('/administrativo/', (req, res) => { + res.sendFile(path.join(FRONTEND_DIR, 'administrativo', 'index.html')); +}); + +// ════════════════════════════════ +// START +// ════════════════════════════════ +app.listen(PORT, () => { + console.log(`∞ Infinity server corriendo en http://localhost:${PORT}`); +}); diff --git a/data/categorias.json b/data/categorias.json new file mode 100644 index 0000000..831c42e --- /dev/null +++ b/data/categorias.json @@ -0,0 +1,12 @@ +[ + { + "id": "sin-categoria", + "nombre": "Sin categoría", + "icono": "📦" + }, + { + "id": "bolsos", + "nombre": "BOLSOS", + "icono": "👜" + } +] diff --git a/data/contraseñas.json b/data/contraseñas.json new file mode 100644 index 0000000..87e35d2 --- /dev/null +++ b/data/contraseñas.json @@ -0,0 +1,17 @@ +{ + "usuarios": [ + { + "usuario": "admin", + "contraseña": "123", + "rol": "admin" + }, + { + "usuario": "juan", + "contraseña": "123456", + "rol": "editor", + "permisos": [ + "productos" + ] + } + ] +} diff --git a/data/productos.json b/data/productos.json new file mode 100644 index 0000000..27594f1 --- /dev/null +++ b/data/productos.json @@ -0,0 +1,44 @@ +[ + { + "id": "AF100-124", + "ref": "AF100-124", + "cat": "bolsos", + "img": "agotados/AF100-124.webp" + }, + { + "id": "AF100-128", + "ref": "AF100-128", + "cat": "bolsos", + "img": "bolsos/AF100-128.webp" + }, + { + "id": "AF100-129", + "ref": "AF100-129", + "cat": "bolsos", + "img": "bolsos/AF100-129.webp" + }, + { + "id": "AF100-159", + "ref": "AF100-159", + "cat": "bolsos", + "img": "bolsos/AF100-159.webp" + }, + { + "id": "AF100-171", + "ref": "AF100-171", + "cat": "bolsos", + "img": "bolsos/AF100-171.webp" + }, + { + "id": "AF100-174", + "ref": "AF100-174", + "cat": "bolsos", + "img": "bolsos/AF100-174.webp" + }, + { + "id": "AF9173", + "ref": "AF9173", + "cat": "bolsos", + "img": "bolsos/AF9173.webp" + } +] diff --git a/data/sesiones.json b/data/sesiones.json new file mode 100644 index 0000000..1f55d6c --- /dev/null +++ b/data/sesiones.json @@ -0,0 +1,104 @@ +[ + { + "token": "ccf9611f3e8e338992b1d0428049640aef7c89e6c47efd2d1d5fc94cb299aba5", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780505178562 + }, + { + "token": "1066042a594d4fc5560fef8a52c3166ac204b6edaee5beffe9103af6b0aa536b", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780506861676 + }, + { + "token": "5685227e864f97fa81079ee2f6f105c97889c6d9555de06bfd3934f401bda2e1", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780506937778 + }, + { + "token": "567193982f539690ded8267e45524509eb178189e50ec31791008c87e78e62d0", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780512239821 + }, + { + "token": "88a9ec8328afaf51d197c09e20c6fd2b76b79c038197a15fcdb0ee8a069bd6d3", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780512593175 + }, + { + "token": "a50369058a82a4303d4d47ce06662ec6b957dfbdc48a5e7032876c0d2d7739bb", + "usuario": "editor_noprod", + "rol": "editor", + "createdAt": 1780512670584 + }, + { + "token": "2161f0b1957b4ca09d214f2e4e2cb925a34a734d1410e86cc75d2d144b8b4878", + "usuario": "editor_prod", + "rol": "editor", + "createdAt": 1780512670625 + }, + { + "token": "6106cd70b5dffa613e9acd5bbd26c1359187ba51138a969712cdb9e7cf3e6b97", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780512723692 + }, + { + "token": "765b16fc212ec002679a84ae78e12185e60df7882e46cdfa16ed1629736ee1f1", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780513577239 + }, + { + "token": "e9e68a32b6b0f0a4a701428184ea55a5653ffaab35aed0a4778507db79883caa", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780513919289 + }, + { + "token": "268c41d4b2993e01fe7f1264464e61eef9c4f7fb987c7d36ae202341cf0e0eaf", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780513939678 + }, + { + "token": "981705570c9fd6a956418d6d38f733e267938e99b85f09ac37aa14331cfa8a63", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780514125713 + }, + { + "token": "0e36ecd59edd1cddc4b7078b2f3fdad2d40a6220060b2778a8340bcfdef425b6", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780514195748 + }, + { + "token": "334527a1d0c63bbd73185952f89e962c90a3f37fb93ea3cd76ac528ed12573f5", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780516032214 + }, + { + "token": "afc0f28ed9e2a73685d2f3bd3845a92184b2cc0c2b34743f8f7675e972cb2010", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780516109693 + }, + { + "token": "a07b896abdd2869d699b224d52a1e885317cdeb65ad4d136038966a4c7a1f4f8", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780521156938 + }, + { + "token": "2599baf2a3ddd4e3d0b5839c165fd73722ea4581d8d136ce7fab8f3e216b5158", + "usuario": "admin", + "rol": "admin", + "createdAt": 1780522237750 + } +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..70904ab --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + web: + build: + context: . + dockerfile: backend/Dockerfile + container_name: infinity-web + ports: + - "6060:3000" + volumes: + # Persistencia: los cambios en JSON se guardan en el host + - ./data:/app/data + - ./Imagenes:/app/Imagenes + environment: + - NODE_ENV=production + - PORT=3000 + # ⚠️ Cambiar esta clave en producción por algo aleatorio y secreto + - SESSION_SECRET=cambia-esta-clave-en-produccion-12345 + restart: unless-stopped diff --git a/frontend/administrativo/admin.css b/frontend/administrativo/admin.css new file mode 100644 index 0000000..a377226 --- /dev/null +++ b/frontend/administrativo/admin.css @@ -0,0 +1,1818 @@ +/* ════════════════════════════════ + INFINITY — PANEL ADMIN + Layout: saludo + topbar + vistas + Mismo lenguaje visual Soft Aurora + ════════════════════════════════ */ + +:root { + --bg: #fafaff; + --bg-soft: #f5f3ff; + --white: #ffffff; + --ink: #1a1a2e; + --ink-soft: #4a4a68; + --ink-mute: #8b8ba7; + --line: rgba(139, 92, 246, 0.4); + --line-strong: rgba(139, 92, 246, 0.6); + --danger: #b91c1c; + --danger-soft: rgba(220, 38, 38, 0.08); + + --violet: #a78bfa; + --violet-deep: #8b5cf6; + --pink: #f0abfc; + --cyan: #67e8f9; + + --grad-primary: linear-gradient(135deg, #a78bfa 0%, #f0abfc 50%, #67e8f9 100%); + --grad-text: linear-gradient(135deg, #8b5cf6 0%, #e879f9 50%, #22d3ee 100%); + + --shadow-sm: 0 4px 14px rgba(167, 139, 250, 0.16); + --shadow-md: 0 10px 40px rgba(167, 139, 250, 0.22); + --shadow-lg: 0 20px 60px rgba(167, 139, 250, 0.28); + + --radius-sm: 12px; + --radius-md: 20px; + --radius-lg: 28px; + --radius-pill: 999px; + + --transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { min-height: 100vh; } + +body { + font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--ink); + -webkit-font-smoothing: antialiased; + line-height: 1.6; + overflow-x: hidden; +} + +a { color: inherit; text-decoration: none; } +button { font-family: inherit; cursor: pointer; } + +/* ════════════════════════════════ + MESH BACKGROUND +════════════════════════════════ */ +.mesh-bg { + position: fixed; + inset: 0; + z-index: -1; + overflow: hidden; + background: var(--bg); + pointer-events: none; +} + +.mesh-blob { + position: absolute; + border-radius: 50%; + filter: blur(90px); + opacity: 0.55; + animation: float 22s ease-in-out infinite; + will-change: transform; +} + +.mesh-blob-1 { + width: 520px; height: 520px; + background: var(--violet); + top: -120px; left: -120px; +} + +.mesh-blob-2 { + width: 420px; height: 420px; + background: var(--pink); + top: 30%; right: -120px; + animation-delay: -7s; +} + +.mesh-blob-3 { + width: 480px; height: 480px; + background: var(--cyan); + bottom: -140px; left: 25%; + animation-delay: -14s; +} + +@keyframes float { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(60px, -50px) scale(1.1); } + 66% { transform: translate(-40px, 40px) scale(0.92); } +} + +/* ════════════════════════════════ + LOGO + TAG +════════════════════════════════ */ +.logo { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 800; + font-size: 1.1rem; + letter-spacing: -0.01em; + color: var(--ink); +} + +.logo-symbol { + font-size: 1.7rem; + line-height: 1; + background: var(--grad-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.admin-tag { + background: var(--grad-primary); + color: var(--white); + font-size: 0.7rem; + font-weight: 700; + padding: 3px 10px; + border-radius: var(--radius-pill); + margin-left: 4px; + letter-spacing: 0.05em; +} + +/* ════════════════════════════════ + BOTONES +════════════════════════════════ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 14px 30px; + border-radius: var(--radius-pill); + font-family: inherit; + font-weight: 600; + font-size: 0.95rem; + border: none; + transition: transform var(--transition), box-shadow var(--transition), background var(--transition), color var(--transition), border-color var(--transition); + white-space: nowrap; + line-height: 1; + gap: 6px; +} + +.btn-primary { + background: var(--grad-primary); + color: var(--white); + box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.25); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.3); +} + +.btn-ghost { + background: var(--white); + color: var(--ink); + border: 1.5px solid var(--line-strong); + box-shadow: 0 2px 8px rgba(167, 139, 250, 0.08); +} + +.btn-ghost:hover { + background: var(--bg-soft); + border-color: var(--violet); + color: var(--violet-deep); + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(167, 139, 250, 0.18); +} + +.btn-sm { + padding: 10px 18px; + font-size: 0.85rem; +} + +.btn-full { + width: 100%; +} + +/* ════════════════════════════════ + LOGIN VIEW +════════════════════════════════ */ +.login-view { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; +} + +.login-card { + background: rgba(255, 255, 255, 0.78); + backdrop-filter: blur(24px) saturate(180%); + -webkit-backdrop-filter: blur(24px) saturate(180%); + border: 1.5px solid var(--line-strong); + border-radius: var(--radius-lg); + padding: 40px; + max-width: 440px; + width: 100%; + box-shadow: var(--shadow-lg); + animation: fadeInUp 0.6s ease-out; +} + +.login-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + margin-bottom: 28px; +} + +.login-badge { + background: var(--bg-soft); + color: var(--violet-deep); + font-size: 0.72rem; + font-weight: 700; + padding: 5px 14px; + border-radius: var(--radius-pill); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.login-title { + font-size: 1.9rem; + font-weight: 800; + letter-spacing: -0.025em; + text-align: center; + margin-bottom: 8px; + color: var(--ink); + line-height: 1.1; +} + +.login-title em { + font-family: 'DM Serif Display', serif; + font-style: italic; + font-weight: 400; + background: var(--grad-text); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.login-subtitle { + text-align: center; + color: var(--ink-soft); + font-size: 0.95rem; + margin-bottom: 28px; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-size: 0.85rem; + font-weight: 600; + color: var(--ink-soft); + padding-left: 4px; +} + +.form-group input, +.form-group select { + padding: 14px 18px; + background: var(--white); + border: 1.5px solid var(--line-strong); + border-radius: var(--radius-md); + font-family: inherit; + font-size: 0.95rem; + color: var(--ink); + outline: none; + transition: border-color var(--transition), box-shadow var(--transition); +} + +.form-group input:focus, +.form-group select:focus { + border-color: var(--violet); + box-shadow: 0 0 0 4px rgba(167, 139, 250, 0.18); +} + +.form-group input::placeholder { + color: var(--ink-mute); +} + +.form-error { + background: var(--danger-soft); + border: 1px solid rgba(220, 38, 38, 0.2); + color: var(--danger); + padding: 10px 14px; + border-radius: var(--radius-md); + font-size: 0.85rem; + display: none; + animation: shake 0.4s ease; +} + +.form-error.show { + display: block; +} + +.form-hint { + font-size: 0.78rem; + color: var(--ink-mute); + padding-left: 4px; +} + +.back-link { + display: block; + text-align: center; + margin-top: 20px; + color: var(--ink-mute); + font-size: 0.88rem; + transition: color var(--transition); +} + +.back-link:hover { + color: var(--violet-deep); +} + +select { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b5cf6' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 18px center; + padding-right: 44px; +} + +/* ════════════════════════════════ + ADMIN APP LAYOUT + saludo + topbar + main (vertical) + ════════════════════════════════ */ +.admin-app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* ════════════════════════════════ + SALUDO SUPERIOR + ════════════════════════════════ */ +.admin-greeting { + text-align: center; + padding: 56px 20px 20px; + animation: fadeInUp 0.5s ease-out; +} + +.greeting-title { + font-size: clamp(1.8rem, 4vw, 2.6rem); + font-weight: 800; + letter-spacing: -0.025em; + line-height: 1.1; + margin-bottom: 8px; + color: var(--ink); +} + +.greeting-title em { + font-family: 'DM Serif Display', serif; + font-style: italic; + font-weight: 400; + background: var(--grad-text); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.greeting-subtitle { + color: var(--ink-soft); + font-size: 0.98rem; +} + +/* ════════════════════════════════ + TOPBAR PILL (sticky) + ════════════════════════════════ */ +.admin-topbar { + position: sticky; + top: 16px; + z-index: 50; + padding: 0 20px; + margin-bottom: 36px; + animation: fadeInUp 0.5s 0.1s ease-out backwards; +} + +.topbar-container { + max-width: 1100px; + margin: 0 auto; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1.5px solid var(--line-strong); + border-radius: var(--radius-pill); + padding: 8px; + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + justify-content: center; + box-shadow: 0 6px 24px rgba(167, 139, 250, 0.18); +} + +.topbar-divider { + width: 1px; + height: 24px; + background: var(--line); + margin: 0 6px; + flex-shrink: 0; + align-self: center; +} + +.nav-item { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + background: transparent; + border: none; + border-radius: var(--radius-pill); + font-family: inherit; + font-size: 0.92rem; + font-weight: 500; + color: var(--ink-soft); + white-space: nowrap; + transition: background var(--transition), color var(--transition), transform var(--transition), box-shadow var(--transition); +} + +.nav-item:hover { + background: var(--bg-soft); + color: var(--violet-deep); +} + +.nav-item.active { + background: var(--grad-primary); + color: var(--white); + font-weight: 600; + box-shadow: 0 4px 12px rgba(167, 139, 250, 0.3); +} + +.nav-icon { + font-size: 1.05rem; + line-height: 1; + flex-shrink: 0; +} + +.nav-label { + flex: 1; +} + +/* Visibilidad de nav-items y secciones admin-only se controla por JS (aplicarPermisos) */ + +.nav-item-logout { + margin-left: auto; +} + +.nav-item-logout:hover { + background: var(--danger-soft); + color: var(--danger); +} + +/* ════════════════════════════════ + MAIN + VIEWS + ════════════════════════════════ */ +.admin-main { + padding: 0 40px 60px; + max-width: 1200px; + width: 100%; + margin: 0 auto; + flex: 1; + min-width: 0; + animation: fadeIn 0.4s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.view { + display: none; + animation: fadeInUp 0.4s ease-out; +} + +.view.active { + display: block; +} + +.view-header { + margin-bottom: 32px; +} + +.view-header-with-action { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.view-title { + font-size: clamp(1.6rem, 3.5vw, 2.2rem); + font-weight: 800; + letter-spacing: -0.025em; + margin-bottom: 6px; + color: var(--ink); + line-height: 1.15; +} + +.view-title em { + font-family: 'DM Serif Display', serif; + font-style: italic; + font-weight: 400; + background: var(--grad-text); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.view-subtitle { + color: var(--ink-soft); + font-size: 0.98rem; +} + +.view-content-card { + background: var(--white); + border: 1.5px solid var(--line-strong); + border-radius: var(--radius-md); + padding: 28px; + box-shadow: var(--shadow-sm); +} + +/* Sub-secciones dentro de una vista (ej: Mi contraseña + Gestión de usuarios) */ +.view-subsection { + margin-bottom: 36px; +} + +.view-subsection:last-child { + margin-bottom: 0; +} + +.subsection-title { + font-size: 1.1rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--ink); + margin-bottom: 14px; + display: flex; + align-items: center; + gap: 8px; +} + +.subsection-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + flex-wrap: wrap; +} + +.subsection-header .subsection-title { + margin-bottom: 0; +} + +.view-placeholder { + background: var(--white); + border: 1.5px dashed var(--line-strong); + border-radius: var(--radius-md); + padding: 80px 24px; + text-align: center; + color: var(--ink-soft); +} + +.placeholder-icon { + font-size: 3rem; + margin-bottom: 12px; + display: inline-block; + animation: float 4s ease-in-out infinite; +} + +.view-placeholder h2 { + font-size: 1.3rem; + font-weight: 700; + color: var(--ink); + margin-bottom: 6px; +} + +.view-placeholder p { + font-size: 0.95rem; + color: var(--ink-mute); +} + +/* ════════════════════════════════ + STATS +════════════════════════════════ */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 18px; +} + +.stat-card { + background: var(--white); + border: 1.5px solid var(--line-strong); + border-radius: var(--radius-md); + padding: 24px; + display: flex; + align-items: center; + gap: 18px; + box-shadow: var(--shadow-sm); + animation: fadeInUp 0.6s ease-out backwards; + transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition); +} + +.stat-card-clickable { + cursor: pointer; +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-md); + border-color: var(--violet); +} + +.stat-icon { + font-size: 1.8rem; + line-height: 1; + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-soft); + border-radius: var(--radius-md); + flex-shrink: 0; +} + +.stat-info { + flex: 1; + min-width: 0; +} + +.stat-value { + font-size: 1.8rem; + font-weight: 800; + letter-spacing: -0.02em; + background: var(--grad-text); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + line-height: 1; +} + +.stat-label { + font-size: 0.85rem; + color: var(--ink-mute); + font-weight: 500; + margin-top: 4px; +} + +/* ════════════════════════════════ + FORM (mi contraseña inline) +════════════════════════════════ */ +.password-form { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 480px; +} + +.form-actions { + display: flex; + justify-content: flex-end; + margin-top: 8px; +} + +/* ════════════════════════════════ + CATEGORÍAS + ════════════════════════════════ */ +.categorias-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; +} + +.categoria-card { + background: var(--white); + border: 1.5px solid var(--line-strong); + border-radius: var(--radius-md); + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 8px; + position: relative; + transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition); +} + +.categoria-card:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-md); + border-color: var(--violet); +} + +.categoria-card.categoria-sistema { + background: linear-gradient(135deg, rgba(167,139,250,0.08) 0%, rgba(240,171,252,0.08) 100%); + border-style: dashed; +} + +.categoria-icon { + font-size: 2.4rem; + line-height: 1; + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-soft); + border-radius: 50%; + margin-bottom: 4px; +} + +.categoria-nombre { + font-size: 1.05rem; + font-weight: 700; + color: var(--ink); + letter-spacing: -0.01em; + line-height: 1.2; +} + +.categoria-id { + font-size: 0.75rem; + color: var(--ink-mute); + font-family: monospace; + background: var(--bg-soft); + padding: 2px 8px; + border-radius: var(--radius-pill); +} + +.categoria-count { + font-size: 0.78rem; + color: var(--violet-deep); + font-weight: 600; + margin-top: 2px; +} + +.categoria-actions { + display: flex; + gap: 6px; + margin-top: 8px; + width: 100%; + justify-content: center; +} + +.categoria-actions .btn-icon { + width: 34px; + height: 34px; + font-size: 0.9rem; +} + +.categoria-sistema-badge { + position: absolute; + top: 10px; + right: 10px; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--violet-deep); + background: rgba(167, 139, 250, 0.15); + padding: 3px 8px; + border-radius: var(--radius-pill); +} + +.categorias-empty { + text-align: center; + padding: 40px 20px; + color: var(--ink-mute); +} + +/* Icon picker */ +.icon-picker { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); + gap: 6px; + background: var(--bg-soft); + border: 1.5px solid var(--line); + border-radius: var(--radius-md); + padding: 10px; +} + +.icon-option { + aspect-ratio: 1; + background: var(--white); + border: 1.5px solid transparent; + border-radius: var(--radius-sm); + font-size: 1.4rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background var(--transition), border-color var(--transition), transform var(--transition); + line-height: 1; +} + +.icon-option:hover { + background: rgba(167, 139, 250, 0.10); + transform: scale(1.08); +} + +.icon-option.selected { + background: var(--grad-primary); + border-color: var(--violet-deep); + transform: scale(1.05); + box-shadow: 0 2px 8px rgba(167, 139, 250, 0.3); +} + +/* Alert warning (modal) */ +.alert-warning { + background: rgba(245, 158, 11, 0.10); + border: 1.5px solid rgba(245, 158, 11, 0.3); + border-radius: var(--radius-md); + padding: 14px 16px; + display: flex; + gap: 12px; + align-items: flex-start; + margin: 12px 0; + color: #92400e; +} + +.alert-warning > span { + font-size: 1.4rem; + line-height: 1; + flex-shrink: 0; +} + +.alert-warning strong { + display: block; + margin-bottom: 4px; + color: #78350f; + font-size: 0.95rem; +} + +.alert-warning p { + margin: 0; + font-size: 0.88rem; + color: #92400e; + line-height: 1.4; +} + +/* ════════════════════════════════ + USER MANAGEMENT + ════════════════════════════════ */ +.permissions-grid { + display: flex; + flex-direction: column; + gap: 6px; + background: var(--bg-soft); + border: 1.5px solid var(--line); + border-radius: var(--radius-md); + padding: 10px; +} + +.permission-check { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.92rem; + color: var(--ink); + font-weight: 500; + user-select: none; + transition: background var(--transition); +} + +.permission-check:hover { + background: rgba(167, 139, 250, 0.10); +} + +.permission-check input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--violet-deep); + cursor: pointer; + flex-shrink: 0; + margin: 0; +} + +.permission-icon { + font-size: 1.1rem; + line-height: 1; +} + +.permission-label { + flex: 1; +} + +.permission-note { + background: var(--bg-soft); + border: 1.5px dashed var(--line-strong); + border-radius: var(--radius-md); + padding: 14px 16px; + color: var(--ink-soft); + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 10px; + line-height: 1.4; +} + +.permission-note span:first-child { + font-size: 1.2rem; +} +.users-table-wrap { + overflow-x: auto; +} + +.users-table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; +} + +.users-table thead { + background: var(--bg-soft); +} + +.users-table th { + text-align: left; + padding: 14px 20px; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-mute); +} + +.users-table td { + padding: 16px 20px; + border-top: 1.5px solid var(--line); + vertical-align: middle; +} + +.users-table tbody tr { + transition: background var(--transition); +} + +.users-table tbody tr:hover { + background: var(--bg-soft); +} + +.users-actions-col { + width: 120px; + text-align: right; +} + +.users-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.btn-icon { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--bg-soft); + border: none; + cursor: pointer; + font-size: 0.95rem; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background var(--transition), transform var(--transition); +} + +.btn-icon:hover { + background: var(--violet); + transform: scale(1.1); +} + +.btn-icon-danger:hover { + background: var(--danger); +} + +.btn-icon-spacer { + display: inline-block; + width: 36px; + height: 36px; +} + +.rol-badge { + display: inline-block; + padding: 4px 12px; + border-radius: var(--radius-pill); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.rol-admin { + background: linear-gradient(135deg, rgba(167,139,250,0.18) 0%, rgba(240,171,252,0.18) 100%); + color: var(--violet-deep); +} + +.rol-editor { + background: rgba(103, 232, 249, 0.18); + color: #0e7490; +} + +.you-badge { + display: inline-block; + margin-left: 6px; + padding: 2px 8px; + background: var(--bg-soft); + color: var(--violet-deep); + font-size: 0.68rem; + font-weight: 700; + border-radius: var(--radius-pill); + text-transform: uppercase; + letter-spacing: 0.05em; + vertical-align: middle; +} + +/* ════════════════════════════════ + MODALES +════════════════════════════════ */ +.modal { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeInUp 0.2s ease-out; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(26, 26, 46, 0.5); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + cursor: pointer; +} + +.modal-content { + position: relative; + background: var(--white); + border-radius: var(--radius-lg); + max-width: 480px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow-lg); + animation: modalIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.modal-sm .modal-content { + max-width: 400px; +} + +@keyframes modalIn { + from { opacity: 0; transform: scale(0.95) translateY(20px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px 28px 16px; + border-bottom: 1.5px solid var(--line); +} + +.modal-header h3 { + font-size: 1.2rem; + font-weight: 800; + letter-spacing: -0.02em; + color: var(--ink); +} + +.modal-close { + background: var(--bg-soft); + border: none; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + color: var(--ink-soft); + font-size: 0.95rem; + display: flex; + align-items: center; + justify-content: center; + transition: background var(--transition), color var(--transition); +} + +.modal-close:hover { + background: var(--violet); + color: var(--white); +} + +.modal-body { + padding: 20px 28px; +} + +.modal-body p { + color: var(--ink-soft); + font-size: 0.95rem; + line-height: 1.5; + margin-bottom: 8px; +} + +.modal-warning { + color: var(--danger); + font-size: 0.85rem; + font-weight: 500; +} + +.modal-form { + padding: 20px 28px 24px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 28px 24px; + border-top: 1.5px solid var(--line); + background: var(--bg-soft); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); +} + +.modal-form .modal-actions { + padding: 0; + border-top: none; + background: none; + border-radius: 0; + margin-top: 6px; +} + +.btn-danger { + background: var(--danger); + color: var(--white); + border: none; + padding: 14px 30px; + border-radius: var(--radius-pill); + font-family: inherit; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: background var(--transition), transform var(--transition), box-shadow var(--transition); + box-shadow: 0 4px 12px rgba(185, 28, 28, 0.2); +} + +.btn-danger:hover { + background: #991b1b; + transform: translateY(-2px); + box-shadow: 0 10px 40px rgba(185, 28, 28, 0.3); +} + +/* ════════════════════════════════ + ANIMATIONS +════════════════════════════════ */ +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-6px); } + 75% { transform: translateX(6px); } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } + .mesh-blob { animation: none; } +} + +/* ════════════════════════════════ + RESPONSIVE — topbar wrap + ════════════════════════════════ */ +@media (max-width: 760px) { + .admin-greeting { + padding: 40px 16px 16px; + } + + .greeting-title { + font-size: clamp(1.5rem, 6vw, 1.9rem); + } + + .admin-topbar { + top: 10px; + padding: 0 12px; + margin-bottom: 24px; + } + + .topbar-container { + border-radius: var(--radius-md); + padding: 6px; + gap: 4px; + } + + .nav-item { + padding: 9px 14px; + font-size: 0.85rem; + gap: 6px; + } + + .nav-icon { + font-size: 1rem; + } + + .topbar-divider { + display: none; + } + + .nav-item-logout { + margin-left: 0; + } +} + +@media (max-width: 480px) { + .nav-item { + padding: 8px 12px; + font-size: 0.82rem; + } + + .nav-icon { + font-size: 0.95rem; + } + + .admin-main { + padding: 0 16px 40px; + } + + .view-content-card { + padding: 20px; + } + + .stat-card { + padding: 18px; + } + + .stat-value { + font-size: 1.5rem; + } + + .users-table th, + .users-table td { + padding: 12px 14px; + } + + .modal-header, + .modal-body, + .modal-form, + .modal-actions { + padding-left: 20px; + padding-right: 20px; + } +} + +/* ════════════════════════════════ + SUBIDA DE PRODUCTOS + ════════════════════════════════ */ +.upload-form { + display: flex; + flex-direction: column; + gap: 24px; +} + +.dropzone { + position: relative; + border: 2px dashed var(--line-strong); + border-radius: var(--radius-md); + background: linear-gradient(135deg, rgba(167, 139, 250, 0.04), rgba(240, 171, 252, 0.04), rgba(103, 232, 249, 0.04)); + padding: 40px 24px; + text-align: center; + cursor: pointer; + transition: var(--transition); +} +.dropzone:hover, +.dropzone.is-dragover { + border-color: var(--violet-deep); + background: linear-gradient(135deg, rgba(167, 139, 250, 0.10), rgba(240, 171, 252, 0.08), rgba(103, 232, 249, 0.08)); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} +.dropzone-icon { + font-size: 48px; + margin-bottom: 12px; + filter: drop-shadow(0 4px 12px rgba(167, 139, 250, 0.3)); +} +.dropzone-title { + font-size: 16px; + font-weight: 600; + color: var(--ink); + margin-bottom: 6px; +} +.dropzone-hint { + font-size: 13px; + color: var(--ink-mute); +} + +.upload-preview-section { + border-top: 1px solid var(--line); + padding-top: 24px; +} +.upload-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + gap: 12px; +} +.upload-counter { + font-size: 14px; + color: var(--ink-soft); +} +.upload-counter strong { + color: var(--violet-deep); + font-weight: 700; + font-size: 16px; +} + +.upload-preview-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 12px; +} +.preview-item { + position: relative; + aspect-ratio: 1 / 1; + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--bg-soft); + border: 1px solid var(--line); +} +.preview-item img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.preview-item .preview-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(to top, rgba(0,0,0,0.78), transparent); + color: white; + font-size: 11px; + padding: 18px 8px 6px; + line-height: 1.3; +} +.preview-item .preview-info strong { + display: block; + font-weight: 600; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.preview-item .preview-info span { + opacity: 0.85; +} +.preview-item .preview-remove { + position: absolute; + top: 6px; + right: 6px; + width: 26px; + height: 26px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.7); + color: white; + border: none; + cursor: pointer; + font-size: 14px; + display: grid; + place-items: center; + transition: var(--transition); +} +.preview-item .preview-remove:hover { + background: var(--danger); + transform: scale(1.08); +} +.preview-item.is-invalid { + border-color: var(--danger); + box-shadow: 0 0 0 2px rgba(185, 28, 28, 0.18); +} +.preview-item .preview-invalid-tag { + position: absolute; + top: 6px; + left: 6px; + background: var(--danger); + color: white; + font-size: 10px; + font-weight: 700; + padding: 3px 8px; + border-radius: var(--radius-pill); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.upload-summary { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px; + background: var(--bg-soft); + border: 1px solid var(--line); + border-radius: var(--radius-md); +} +.upload-summary-row { + display: flex; + align-items: flex-start; + gap: 10px; + font-size: 14px; + line-height: 1.4; +} +.upload-summary-row .icon { + font-size: 18px; + flex-shrink: 0; + line-height: 1.2; +} +.upload-summary-row .text { + flex: 1; +} +.upload-summary-row .text strong { + display: block; + font-weight: 600; + margin-bottom: 2px; +} +.upload-summary-row.is-success .icon { color: #15803d; } +.upload-summary-row.is-warning .icon { color: #c2410c; } +.upload-summary-row.is-error .icon { color: var(--danger); } +.upload-summary-row ul { + margin: 4px 0 0 16px; + font-size: 13px; + color: var(--ink-soft); + list-style: disc; +} +.upload-summary-row code { + background: var(--white); + padding: 1px 6px; + border-radius: 4px; + font-size: 12px; + font-family: 'SF Mono', Menlo, monospace; +} + +.btn[disabled] { + opacity: 0.5; + cursor: not-allowed; +} +.btn-spinner { + display: inline-block; + margin-left: 6px; + animation: spin 1s linear infinite; +} +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 600px) { + .upload-preview-grid { + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 8px; + } + .dropzone { padding: 28px 16px; } + .dropzone-icon { font-size: 36px; } +} + +/* ════════════════════════════════ + PRODUCTOS DISPONIBLES (lista en vista Productos) + ════════════════════════════════ */ +.productos-counter { + background: var(--bg-soft); + color: var(--violet-deep); + font-size: 0.78rem; + font-weight: 700; + padding: 4px 12px; + border-radius: var(--radius-pill); + border: 1.5px solid var(--line); +} + +.productos-search { + position: relative; + display: flex; + align-items: center; + margin-bottom: 14px; +} + +.productos-search-icon { + position: absolute; + left: 16px; + font-size: 0.95rem; + color: var(--ink-mute); + pointer-events: none; + line-height: 1; +} + +.productos-search input { + width: 100%; + padding: 12px 44px 12px 42px; + background: var(--white); + border: 1.5px solid var(--line-strong); + border-radius: var(--radius-pill); + font-family: inherit; + font-size: 0.92rem; + color: var(--ink); + outline: none; + transition: border-color var(--transition), box-shadow var(--transition); +} + +.productos-search input::placeholder { + color: var(--ink-mute); +} + +.productos-search input:focus { + border-color: var(--violet); + box-shadow: 0 0 0 4px rgba(167, 139, 250, 0.18); +} + +.productos-search-clear { + position: absolute; + right: 8px; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--bg-soft); + border: none; + cursor: pointer; + color: var(--ink-soft); + font-size: 0.82rem; + display: flex; + align-items: center; + justify-content: center; + transition: background var(--transition), color var(--transition), transform var(--transition); + flex-shrink: 0; +} + +.productos-search-clear:hover { + background: var(--violet); + color: white; + transform: scale(1.08); +} + +.productos-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +.producto-item { + background: var(--white); + border: 1.5px solid var(--line); + border-radius: var(--radius-md); + padding: 12px; + display: flex; + align-items: center; + gap: 12px; + transition: var(--transition); +} + +.producto-item:hover { + border-color: var(--violet); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.producto-thumb { + width: 56px; + height: 56px; + border-radius: var(--radius-sm); + background: var(--bg-soft); + object-fit: cover; + flex-shrink: 0; + border: 1px solid var(--line); + display: block; +} + +.producto-thumb.is-broken { display: none; } + +.producto-thumb-wrap { + position: relative; + width: 56px; + height: 56px; + flex-shrink: 0; +} + +.producto-thumb-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: var(--radius-sm); + background: var(--bg-soft); + font-size: 1.5rem; + color: var(--ink-mute); + border: 1px solid var(--line); +} + +.producto-info { + flex: 1; + min-width: 0; +} + +.producto-ref { + font-weight: 700; + font-size: 0.92rem; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.01em; +} + +.producto-cat-tag { + font-size: 0.76rem; + color: var(--ink-mute); + margin-top: 2px; + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.producto-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.producto-actions .btn-icon { + width: 32px; + height: 32px; + font-size: 0.85rem; +} + +.productos-empty { + grid-column: 1 / -1; + text-align: center; + padding: 40px 20px; + color: var(--ink-mute); + background: var(--bg-soft); + border: 1.5px dashed var(--line); + border-radius: var(--radius-md); +} + +@media (max-width: 600px) { + .productos-grid { + grid-template-columns: 1fr; + } +} + +/* ════════════════════════════════ + AGOTADOS (vista + cards) + ════════════════════════════════ */ +.agotados-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; +} + +.agotado-card { + background: var(--white); + border: 1.5px solid var(--line); + border-radius: var(--radius-md); + overflow: hidden; + position: relative; + display: flex; + flex-direction: column; + transition: var(--transition); +} + +.agotado-card:hover { + border-color: var(--violet); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.agotado-img-wrap { + position: relative; + aspect-ratio: 1 / 1; + background: var(--bg-soft); + overflow: hidden; +} + +.agotado-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + filter: grayscale(0.35); + opacity: 0.9; +} + +.agotado-badge { + position: absolute; + top: 10px; + left: 10px; + background: var(--danger); + color: white; + font-size: 0.66rem; + font-weight: 700; + padding: 4px 10px; + border-radius: var(--radius-pill); + letter-spacing: 0.05em; + text-transform: uppercase; + box-shadow: 0 2px 6px rgba(185, 28, 28, 0.35); + z-index: 2; +} + +.agotado-fallback { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + font-size: 3rem; + color: var(--ink-mute); + background: var(--bg-soft); +} + +.agotado-img-wrap.img-fallback .agotado-img { display: none; } +.agotado-img-wrap.img-fallback .agotado-fallback { display: flex; } + +.agotado-info { + padding: 14px; + display: flex; + flex-direction: column; + gap: 4px; + border-top: 1.5px solid var(--line); +} + +.agotado-ref { + font-size: 0.95rem; + font-weight: 700; + color: var(--ink); + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.agotado-cat { + font-size: 0.76rem; + color: var(--ink-mute); + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.agotado-actions { + padding: 0 14px 14px; + display: flex; + gap: 8px; +} + +.agotado-actions .btn { + flex: 1; + padding: 9px 12px; + font-size: 0.8rem; +} + +.agotados-empty { + grid-column: 1 / -1; + text-align: center; + padding: 40px 20px; + color: var(--ink-mute); + background: var(--bg-soft); + border: 1.5px dashed var(--line); + border-radius: var(--radius-md); +} + +@media (max-width: 600px) { + .agotados-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; + } + .agotado-actions .btn { + font-size: 0.72rem; + padding: 8px 10px; + } +} diff --git a/frontend/administrativo/admin.js b/frontend/administrativo/admin.js new file mode 100644 index 0000000..89e32ef --- /dev/null +++ b/frontend/administrativo/admin.js @@ -0,0 +1,1493 @@ +/* ════════════════════════════════ + INFINITY — ADMIN.JS + Sistema escalable de permisos por pestañas + ════════════════════════════════ */ + +// ════════════════════════════════ +// TABS — fuente de verdad de permisos +// Para agregar una pestaña nueva: agrégala aquí y al HTML con data-view +// ════════════════════════════════ +const TABS = [ + { id: 'productos', label: 'Productos', icon: '📦' }, + { id: 'agotados', label: 'Agotados', icon: '🚫' }, + { id: 'categorias', label: 'Categorías', icon: '🗂️' }, + { id: 'usuarios', label: 'Usuarios', icon: '👥' } +]; + +const ROLES = { + admin: 'Administrador', + editor: 'Editor' +}; + +// Iconos disponibles para categorías +const CAT_ICONS = [ + '💄','👜','☕','🏠','👓','🧴','🕯️','🖼️', + '💍','🧣','👒','🪞','🎁','🪴','🍵','📿', + '🧸','🛍️','👗','📦','🎀','🕊️','✨','🌸' +]; + +let currentUser = null; // { usuario, rol, permisos: [] } +let editingUser = null; // nombre del usuario que se está editando +let deletingUser = null; // nombre del usuario a eliminar +let editingCategoria = null; // id de la categoría que se está editando +let deletingCategoria = null; // id de la categoría que se va a eliminar +let currentView = 'dashboard'; +let cachedProductos = []; // cache de productos para contar items por categoría + +// ════════════════════════════════ +// INIT +// ════════════════════════════════ +document.addEventListener('DOMContentLoaded', async () => { + setupLoginForm(); + setupLogout(); + setupNavigation(); + setupModals(); + setupPermisosToggles(); + setupCategoriasModals(); + setupProductosUpload(); + setupProductosSearch(); + + await checkSession(); +}); + +// ════════════════════════════════ +// SESIÓN +// ════════════════════════════════ +async function checkSession() { + try { + const res = await fetch('/api/auth/me', { credentials: 'include' }); + if (res.ok) { + const user = await res.json(); + currentUser = user; + mostrarDashboard(user); + } else { + mostrarLogin(); + } + } catch (err) { + console.error('Error verificando sesión:', err); + mostrarLogin(); + } +} + +// ════════════════════════════════ +// VISTAS +// ════════════════════════════════ +function mostrarLogin() { + currentUser = null; + document.body.classList.remove('is-admin'); + document.getElementById('loginView').style.display = 'flex'; + document.getElementById('adminApp').style.display = 'none'; +} + +function mostrarDashboard(user) { + currentUser = user; + document.getElementById('loginView').style.display = 'none'; + document.getElementById('adminApp').style.display = 'block'; + + // Marcar el body según el rol + document.body.classList.toggle('is-admin', user.rol === 'admin'); + + // Saludo con el nombre del usuario + document.getElementById('adminName').textContent = user.usuario; + + // Aplicar permisos (visibilidad de nav, subsección admin-only, etc.) + aplicarPermisos(); + + // Reset a la vista dashboard + cambiarVista('dashboard'); + + cargarEstadisticas(); +} + +function cambiarVista(view) { + // Check defensivo: si el usuario no tiene permiso para esta vista, redirige a dashboard + if (currentUser && view !== 'dashboard') { + const esAdmin = currentUser.rol === 'admin'; + const permisos = currentUser.permisos || []; + if (!esAdmin && !permisos.includes(view)) { + view = 'dashboard'; + } + } + + currentView = view; + + // Ocultar todas las vistas + document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); + + // Mostrar la vista solicitada (si existe) + const target = document.querySelector(`.view[data-view="${view}"]`); + if (target) { + target.classList.add('active'); + } else { + document.querySelector('.view[data-view="dashboard"]').classList.add('active'); + view = 'dashboard'; + } + + // Actualizar nav items (excluir el de logout que no tiene data-view) + document.querySelectorAll('.nav-item[data-view]').forEach(item => { + item.classList.toggle('active', item.dataset.view === view); + }); + + // Cargar datos específicos de la vista + if (view === 'usuarios') { + const esAdmin = currentUser.rol === 'admin'; + const puedeGestionar = esAdmin || (currentUser.permisos || []).includes('usuarios'); + if (puedeGestionar) { + cargarUsuarios(); + } + } + + if (view === 'categorias') { + cargarCategorias(); + } + + if (view === 'productos') { + cargarCategoriasEnUpload(); + cargarProductosDisponibles(); + } + + if (view === 'agotados') { + cargarAgotados(); + } +} + +async function cargarEstadisticas() { + try { + const [resCats, resProds] = await Promise.all([ + fetch('/api/categorias'), + fetch('/api/productos') + ]); + + const categorias = await resCats.json(); + const productos = await resProds.json(); + cachedProductos = productos; + + document.getElementById('statProductos').textContent = productos.length; + document.getElementById('statCategorias').textContent = categorias.length; + } catch (err) { + console.error('Error cargando estadísticas:', err); + } +} + +// ════════════════════════════════ +// PERMISOS — visibilidad según rol + permisos[] +// ════════════════════════════════ +function aplicarPermisos() { + if (!currentUser) return; + + const esAdmin = currentUser.rol === 'admin'; + const permisos = currentUser.permisos || []; + const puedeGestionarUsuarios = esAdmin || permisos.includes('usuarios'); + + // Mostrar/ocultar cada nav-item según permisos + document.querySelectorAll('.nav-item[data-view]').forEach(item => { + const view = item.dataset.view; + let visible = false; + + if (view === 'dashboard') { + visible = true; // Dashboard siempre visible + } else if (esAdmin) { + visible = true; // Admin ve todo + } else { + visible = permisos.includes(view); // Editor: solo sus permisos + } + + item.style.display = visible ? '' : 'none'; + }); + + // Subsección "Mi contraseña" — SOLO admin + const miPassSubsection = document.getElementById('miPasswordSubsection'); + if (miPassSubsection) { + miPassSubsection.style.display = esAdmin ? '' : 'none'; + } + + // Subsección "Gestión de usuarios" — admin O permiso 'usuarios' + const gestionSubsection = document.getElementById('gestionUsuariosSubsection'); + if (gestionSubsection) { + gestionSubsection.style.display = puedeGestionarUsuarios ? '' : 'none'; + } +} + +// ════════════════════════════════ +// NAVEGACIÓN +// ════════════════════════════════ +function setupNavigation() { + document.querySelectorAll('.nav-item[data-view]').forEach(item => { + item.addEventListener('click', () => { + cambiarVista(item.dataset.view); + }); + }); +} + +// ════════════════════════════════ +// PERMISOS EN MODALES — render y lectura +// ════════════════════════════════ +function renderPermisos(containerId, checkedPerms = []) { + const container = document.getElementById(containerId); + if (!container) return; + container.innerHTML = TABS.map(t => ` + + `).join(''); +} + +function collectPermisos(containerId) { + const container = document.getElementById(containerId); + if (!container) return []; + return Array.from(container.querySelectorAll('input[type="checkbox"]:checked')) + .map(cb => cb.value); +} + +// Mostrar/ocultar la sección de permisos según el rol elegido en el + +
+ + +
+ + + + + ← Volver al catálogo + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..47ea1e9 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,211 @@ +/* ════════════════════════════════ + 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 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}

+ +
`; + } +} + +// ════════════════════════════════ +// RENDER CATEGORÍAS +// ════════════════════════════════ +// RENDER TAGS FILTROS +// ════════════════════════════════ +function renderTags() { + const cont = document.getElementById('filtrosTags'); + // Filtrar la categoría del sistema "sin-categoria" de los tags + const visibles = categorias.filter(c => c.id !== 'sin-categoria'); + cont.innerHTML = + `` + + visibles.map(cat => ``).join(''); + + cont.querySelectorAll('.tag').forEach(tag => { + tag.addEventListener('click', () => { + catActiva = tag.dataset.cat; + cont.querySelectorAll('.tag').forEach(t => t.classList.remove('active')); + tag.classList.add('active'); + renderProductos(); + }); + }); +} + +function sincronizarTag(cat) { + 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'); + + // Filtrar productos sin categoría (sin-categoria) y productos AGOTADOS + // (imagen movida a la carpeta "agotados/") — no se muestran en el catálogo público. + let filtrados = productos.filter(p => + p.cat && + p.cat !== 'sin-categoria' && + (!p.img || !p.img.startsWith('agotados/')) + ); + + if (catActiva !== 'todos') { + filtrados = filtrados.filter(p => p.cat === catActiva); + } + + 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); + // Fallbacks: si no hay nombre, usar la referencia; si no hay descripción, ocultar + 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')}` + : '—'; + // La ruta en el JSON es relativa a /Imagenes/ (ej: "bolsos/AF100.webp") + const imgSrc = p.img && p.img.startsWith('/') ? p.img : `/Imagenes/${p.img}`; + return ` +
+
+ ${cat ? cat.nombre : p.cat} + ${escapeHtml(nombre)} + ${cat ? cat.icono : '📦'} +
+
+

${escapeHtml(nombre)}

+ ${desc ? `

${escapeHtml(desc)}

` : ''} +
${precio}
+
+
+ `; + }).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(); +}); diff --git a/frontend/estilos.css b/frontend/estilos.css new file mode 100644 index 0000000..068c319 --- /dev/null +++ b/frontend/estilos.css @@ -0,0 +1,867 @@ +/* ════════════════════════════════ + INFINITY — SOFT AURORA DESIGN + Mesh gradient + glassmorphism + friendly modern + ════════════════════════════════ */ + +:root { + --bg: #fafaff; + --bg-soft: #f5f3ff; + --white: #ffffff; + --ink: #1a1a2e; + --ink-soft: #4a4a68; + --ink-mute: #8b8ba7; + --line: rgba(139, 92, 246, 0.4); + --line-strong: rgba(139, 92, 246, 0.6); + + --violet: #a78bfa; + --violet-deep: #8b5cf6; + --pink: #f0abfc; + --pink-deep: #e879f9; + --cyan: #67e8f9; + --cyan-deep: #22d3ee; + + --grad-primary: linear-gradient(135deg, #a78bfa 0%, #f0abfc 50%, #67e8f9 100%); + --grad-soft: linear-gradient(135deg, rgba(167,139,250,0.12) 0%, rgba(240,171,252,0.12) 50%, rgba(103,232,249,0.12) 100%); + --grad-text: linear-gradient(135deg, #8b5cf6 0%, #e879f9 50%, #22d3ee 100%); + + --shadow-sm: 0 4px 14px rgba(167, 139, 250, 0.16); + --shadow-md: 0 10px 40px rgba(167, 139, 250, 0.22); + --shadow-lg: 0 20px 60px rgba(167, 139, 250, 0.28); + + --radius-sm: 12px; + --radius-md: 20px; + --radius-lg: 28px; + --radius-pill: 999px; + + --transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { scroll-behavior: smooth; } + +body { + font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--ink); + min-height: 100vh; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; + line-height: 1.6; +} + +img { display: block; max-width: 100%; } + +button { font-family: inherit; } + +a { color: inherit; } + +/* ════════════════════════════════ + MESH GRADIENT BACKGROUND +════════════════════════════════ */ +.mesh-bg { + position: fixed; + inset: 0; + z-index: -1; + overflow: hidden; + background: var(--bg); + pointer-events: none; +} + +.mesh-blob { + position: absolute; + border-radius: 50%; + filter: blur(90px); + opacity: 0.55; + will-change: transform; + animation: float 22s ease-in-out infinite; +} + +.mesh-blob-1 { + width: 520px; height: 520px; + background: var(--violet); + top: -120px; left: -120px; +} + +.mesh-blob-2 { + width: 420px; height: 420px; + background: var(--pink); + top: 30%; right: -120px; + animation-delay: -7s; +} + +.mesh-blob-3 { + width: 480px; height: 480px; + background: var(--cyan); + bottom: -140px; left: 25%; + animation-delay: -14s; +} + +@keyframes float { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(60px, -50px) scale(1.1); } + 66% { transform: translate(-40px, 40px) scale(0.92); } +} + +/* ════════════════════════════════ + NAVBAR +════════════════════════════════ */ +.navbar { + position: sticky; + top: 18px; + z-index: 100; + padding: 0 20px; +} + +.nav-container { + max-width: 1200px; + margin: 0 auto; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1.5px solid var(--line-strong); + border-radius: var(--radius-pill); + padding: 10px 12px 10px 24px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 6px 24px rgba(167, 139, 250, 0.18); +} + +.logo { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: var(--ink); + font-weight: 800; + font-size: 1.1rem; + letter-spacing: -0.01em; +} + +.logo-symbol { + font-size: 1.7rem; + line-height: 1; + background: var(--grad-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-weight: 800; +} + +.logo-text { + letter-spacing: -0.02em; +} + +.nav-links { + display: flex; + gap: 30px; +} + +.nav-links a { + text-decoration: none; + color: var(--ink-soft); + font-weight: 500; + font-size: 0.92rem; + transition: color var(--transition); + position: relative; +} + +.nav-links a::after { + content: ''; + position: absolute; + left: 0; right: 0; bottom: -6px; + height: 2px; + background: var(--grad-primary); + border-radius: 2px; + transform: scaleX(0); + transform-origin: left; + transition: transform var(--transition); +} + +.nav-links a:hover { + color: var(--violet-deep); +} + +.nav-links a:hover::after { + transform: scaleX(1); +} + +.nav-toggle { + display: none; + flex-direction: column; + gap: 4px; + background: none; + border: none; + cursor: pointer; + padding: 10px; + border-radius: 50%; + transition: background var(--transition); +} + +.nav-toggle:hover { + background: var(--bg-soft); +} + +.nav-toggle span { + width: 22px; + height: 2px; + background: var(--ink); + border-radius: 2px; + transition: transform var(--transition), opacity var(--transition); +} + +/* ════════════════════════════════ + HERO +════════════════════════════════ */ +.hero { + min-height: 78vh; + display: flex; + align-items: center; + justify-content: center; + padding: 80px 20px 60px; + text-align: center; + position: relative; +} + +.hero-content { + max-width: 820px; + animation: fadeInUp 0.8s ease-out both; +} + +.hero-badge { + display: inline-block; + padding: 8px 18px; + background: var(--white); + border: 1px solid rgba(167, 139, 250, 0.3); + border-radius: var(--radius-pill); + font-size: 0.85rem; + font-weight: 600; + color: var(--violet-deep); + margin-bottom: 24px; + box-shadow: var(--shadow-sm); +} + +.hero-title { + font-size: clamp(2.5rem, 7vw, 5rem); + font-weight: 800; + line-height: 1.05; + letter-spacing: -0.035em; + margin-bottom: 24px; + color: var(--ink); +} + +.hero-title em { + font-family: 'DM Serif Display', serif; + font-style: italic; + font-weight: 400; + color: var(--violet-deep); +} + +.hero-gradient-text { + background: var(--grad-text); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero-subtitle { + font-size: clamp(1rem, 1.8vw, 1.2rem); + color: var(--ink-soft); + max-width: 580px; + margin: 0 auto 36px; + line-height: 1.55; +} + +.hero-cta { + display: flex; + gap: 14px; + justify-content: center; + flex-wrap: wrap; +} + +/* ════════════════════════════════ + BUTTONS +════════════════════════════════ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 14px 30px; + border-radius: var(--radius-pill); + font-family: inherit; + font-weight: 600; + font-size: 0.95rem; + text-decoration: none; + cursor: pointer; + border: none; + transition: transform var(--transition), box-shadow var(--transition), background var(--transition), color var(--transition), border-color var(--transition); + white-space: nowrap; + line-height: 1; +} + +.btn-primary { + background: var(--grad-primary); + color: var(--white); + box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.25); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.3); +} + +.btn-ghost { + background: var(--white); + color: var(--ink); + border: 1.5px solid var(--line-strong); + box-shadow: 0 2px 8px rgba(167, 139, 250, 0.08); +} + +.btn-ghost:hover { + background: var(--bg-soft); + border-color: var(--violet); + color: var(--violet-deep); + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(167, 139, 250, 0.18); +} + +/* ════════════════════════════════ + SECTIONS / TYPOGRAPHY +════════════════════════════════ */ +section { + padding: 60px 20px; + max-width: 1200px; + margin: 0 auto; +} + +.section-header { + margin-bottom: 40px; + text-align: center; +} + +.section-title { + font-size: clamp(1.8rem, 4vw, 2.8rem); + font-weight: 800; + letter-spacing: -0.025em; + margin-bottom: 12px; + color: var(--ink); +} + +.section-title em { + font-family: 'DM Serif Display', serif; + font-style: italic; + font-weight: 400; + background: var(--grad-text); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.section-subtitle { + color: var(--ink-soft); + font-size: 1.02rem; +} + +/* ════════════════════════════════ + CATEGORÍAS +════════════════════════════════ */ +.categorias-section { + padding-top: 40px; +} + +.categorias-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; +} + +.cat-card { + background: var(--white); + border: 1.5px solid var(--line-strong); + border-radius: var(--radius-md); + padding: 30px 20px; + text-align: center; + cursor: pointer; + transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition); + box-shadow: var(--shadow-sm); + animation: fadeInUp 0.6s ease-out backwards; +} + +.cat-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-md); + border-color: var(--violet); +} + +.cat-icon { + font-size: 2.4rem; + margin-bottom: 10px; + display: block; + line-height: 1; +} + +.cat-name { + font-weight: 600; + font-size: 0.95rem; + color: var(--ink); +} + +/* ════════════════════════════════ + SEARCH +════════════════════════════════ */ +.search-wrap { + position: relative; + max-width: 600px; + margin: 0 auto 24px; + background: var(--white); + border-radius: var(--radius-pill); + padding: 6px 6px 6px 22px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: var(--shadow-md); + border: 1.5px solid var(--line-strong); + transition: box-shadow var(--transition), border-color var(--transition); +} + +.search-wrap:focus-within { + box-shadow: var(--shadow-lg); + border-color: var(--violet); +} + +.search-icon { + color: var(--ink-mute); + flex-shrink: 0; +} + +.search-input { + flex: 1; + border: none; + background: transparent; + font-family: inherit; + font-size: 0.95rem; + color: var(--ink); + outline: none; + padding: 12px 0; + min-width: 0; +} + +.search-input::placeholder { + color: var(--ink-mute); +} + +.search-clear { + background: var(--bg-soft); + border: none; + width: 34px; + height: 34px; + border-radius: 50%; + cursor: pointer; + color: var(--ink-soft); + display: none; + align-items: center; + justify-content: center; + font-size: 0.85rem; + transition: background var(--transition), color var(--transition); + flex-shrink: 0; +} + +.search-clear:hover { + background: var(--violet); + color: var(--white); +} + +/* ════════════════════════════════ + FILTROS TAGS +════════════════════════════════ */ +.filtros-tags { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; + margin-bottom: 32px; +} + +.tag { + padding: 8px 18px; + background: var(--white); + border: 1.5px solid var(--line-strong); + border-radius: var(--radius-pill); + font-size: 0.88rem; + font-weight: 500; + color: var(--ink-soft); + cursor: pointer; + transition: all var(--transition); + box-shadow: 0 2px 6px rgba(167, 139, 250, 0.06); + display: inline-flex; + align-items: center; + gap: 6px; +} +.tag-icon { + font-size: 1rem; + line-height: 1; +} + +.tag:hover { + border-color: var(--violet); + color: var(--violet-deep); + transform: translateY(-1px); +} + +.tag.active { + background: var(--grad-primary); + color: var(--white); + border-color: transparent; + box-shadow: var(--shadow-sm); +} + +/* ════════════════════════════════ + CONTADOR + PRODUCTOS +════════════════════════════════ */ +.contador { + text-align: center; + color: var(--ink-mute); + font-size: 0.9rem; + margin-bottom: 22px; + font-weight: 500; +} + +.productos-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +.producto-card { + background: var(--white); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition); + cursor: pointer; + animation: fadeInUp 0.6s ease-out backwards; + border: 1.5px solid var(--line-strong); + display: flex; + flex-direction: column; +} + +.producto-card:hover { + transform: translateY(-6px); + box-shadow: var(--shadow-lg); + border-color: var(--violet); +} + +.producto-img { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + background: var(--grad-soft); + overflow: hidden; +} + +.producto-img img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform var(--transition-slow); +} + +.producto-card:hover .producto-img img { + transform: scale(1.06); +} + +.producto-cat { + position: absolute; + top: 12px; + left: 12px; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + padding: 5px 12px; + border-radius: var(--radius-pill); + font-size: 0.7rem; + font-weight: 700; + color: var(--violet-deep); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.producto-info { + padding: 18px 20px 20px; + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} + +.producto-nombre { + font-size: 1.02rem; + font-weight: 600; + color: var(--ink); + letter-spacing: -0.01em; + line-height: 1.3; +} + +.producto-desc { + font-size: 0.85rem; + color: var(--ink-mute); + line-height: 1.5; + flex: 1; +} + +.producto-precio { + margin-top: 6px; + font-size: 1.3rem; + font-weight: 800; + background: var(--grad-text); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.02em; +} + +/* ════════════════════════════════ + EMPTY STATE +════════════════════════════════ */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--ink-soft); +} + +.empty-state p { + font-size: 1.1rem; + margin-bottom: 18px; +} + +.error-state { + grid-column: 1 / -1; + text-align: center; + padding: 60px 20px; + color: var(--ink-soft); +} + +.error-state p { + font-size: 1.1rem; + margin-bottom: 8px; +} + +.error-detalle { + color: var(--ink-mute); + font-size: 0.85rem; + margin-bottom: 20px; + font-family: monospace; +} + +/* ════════════════════════════════ + FALLBACK DE IMAGEN + Se muestra cuando la imagen + del producto aún no existe +════════════════════════════════ */ +.producto-fallback { + display: none; + font-size: 4rem; + line-height: 1; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.1)); + animation: floatFallback 4s ease-in-out infinite; +} + +.producto-img.img-fallback { + display: flex; + align-items: center; + justify-content: center; + background: var(--grad-soft); + position: relative; +} + +.producto-img.img-fallback::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 30% 30%, rgba(255,255,255,0.6), transparent 50%), + radial-gradient(circle at 70% 70%, rgba(255,255,255,0.4), transparent 50%); +} + +.producto-img.img-fallback .producto-fallback { + display: block; + position: relative; + z-index: 1; +} + +@keyframes floatFallback { + 0%, 100% { transform: translateY(0) scale(1); } + 50% { transform: translateY(-8px) scale(1.05); } +} + +/* ════════════════════════════════ + FOOTER +════════════════════════════════ */ +.footer { + background: rgba(255, 255, 255, 0.55); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border-top: 1px solid var(--line); + padding: 50px 20px 30px; + margin-top: 80px; + max-width: none; +} + +.footer-content { + max-width: 1200px; + margin: 0 auto; + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 40px; + align-items: start; +} + +.footer-brand { + display: flex; + flex-direction: column; + gap: 10px; +} + +.footer-brand .logo { + font-size: 1.2rem; +} + +.footer-brand p { + color: var(--ink-soft); + font-size: 0.92rem; + max-width: 320px; +} + +.footer-links { + display: flex; + flex-direction: column; + gap: 10px; +} + +.footer-links a { + color: var(--ink-soft); + text-decoration: none; + font-size: 0.92rem; + transition: color var(--transition); + width: fit-content; +} + +.footer-links a:hover { + color: var(--violet-deep); +} + +.footer-copy p { + color: var(--ink-mute); + font-size: 0.85rem; + line-height: 1.6; +} + +/* ════════════════════════════════ + ANIMATIONS +════════════════════════════════ */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + .mesh-blob { animation: none; } +} + +/* ════════════════════════════════ + RESPONSIVE +════════════════════════════════ */ +@media (max-width: 768px) { + .nav-links { + display: none; + position: absolute; + top: 70px; + left: 0; + right: 0; + flex-direction: column; + background: var(--white); + padding: 22px; + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + gap: 18px; + border: 1px solid var(--line); + } + + .nav-links.open { + display: flex; + } + + .nav-toggle { + display: flex; + } + + .footer-content { + grid-template-columns: 1fr; + gap: 30px; + text-align: center; + } + + .footer-brand, + .footer-links { + align-items: center; + } + + .footer-brand p { + margin: 0 auto; + } +} + +@media (max-width: 900px) { + .productos-grid { + grid-template-columns: repeat(2, 1fr); + gap: 18px; + } +} + +@media (max-width: 480px) { + .nav-container { + padding: 10px 10px 10px 20px; + } + + .productos-grid { + grid-template-columns: 1fr 1fr; + gap: 14px; + } + + .producto-info { + padding: 14px; + } + + .producto-nombre { + font-size: 0.9rem; + } + + .producto-desc { + font-size: 0.78rem; + } + + .producto-precio { + font-size: 1.05rem; + } + + .cat-card { + padding: 22px 14px; + } + + .cat-icon { + font-size: 2rem; + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f6316bc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,107 @@ + + + + + + + Infinity — Catálogo + + + + + + + + + + + + + + + +
+
+ ✨ Nueva colección disponible +

+ Descubre tu estilo
+ sin límites +

+

+ Belleza, accesorios, bolsos y mucho más. Productos curados para acompañarte en tu día a día. +

+ +
+
+ + + + + +
+ +
+ + + +