- Fix YAML files with unquoted strings containing quotes - Export singleton instances in ai/__init__.py - Fix validator scoring prompt to use replace() instead of format() to avoid conflicts with JSON curly braces Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
427 lines
19 KiB
HTML
427 lines
19 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Productos{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="animate-fade-in">
|
||
<!-- Header -->
|
||
<div class="flex items-center justify-between mb-8">
|
||
<div>
|
||
<h1 class="text-3xl font-bold">Productos</h1>
|
||
<p class="text-gray-400 mt-1">Catálogo de productos para generar contenido</p>
|
||
</div>
|
||
<button onclick="openModal('add')" class="btn-primary px-6 py-3 rounded-xl font-medium flex items-center gap-2">
|
||
<span>➕</span>
|
||
<span>Agregar Producto</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Stats -->
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||
<div class="stat-card card rounded-2xl p-6">
|
||
<p class="text-3xl font-bold text-primary" id="stat-total">0</p>
|
||
<p class="text-gray-400 text-sm mt-1">Total Productos</p>
|
||
</div>
|
||
<div class="stat-card card rounded-2xl p-6">
|
||
<p class="text-3xl font-bold text-green-400" id="stat-active">0</p>
|
||
<p class="text-gray-400 text-sm mt-1">Activos</p>
|
||
</div>
|
||
<div class="stat-card card rounded-2xl p-6">
|
||
<p class="text-3xl font-bold text-yellow-400" id="stat-featured">0</p>
|
||
<p class="text-gray-400 text-sm mt-1">Destacados</p>
|
||
</div>
|
||
<div class="stat-card card rounded-2xl p-6">
|
||
<p class="text-3xl font-bold text-blue-400" id="stat-categories">0</p>
|
||
<p class="text-gray-400 text-sm mt-1">Categorías</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filters -->
|
||
<div class="card rounded-2xl p-4 mb-6">
|
||
<div class="flex flex-wrap gap-4 items-center">
|
||
<div>
|
||
<label class="text-sm text-gray-400 block mb-1">Categoría</label>
|
||
<select id="filter-category" onchange="loadProducts()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
|
||
<option value="">Todas</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm text-gray-400 block mb-1">Estado</label>
|
||
<select id="filter-active" onchange="loadProducts()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
|
||
<option value="">Todos</option>
|
||
<option value="true">Activos</option>
|
||
<option value="false">Inactivos</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm text-gray-400 block mb-1">Destacados</label>
|
||
<select id="filter-featured" onchange="loadProducts()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
|
||
<option value="">Todos</option>
|
||
<option value="true">Destacados</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex-1">
|
||
<label class="text-sm text-gray-400 block mb-1">Buscar</label>
|
||
<input type="text" id="filter-search" onkeyup="loadProducts()" placeholder="Nombre del producto..."
|
||
class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white w-full focus:border-primary focus:outline-none">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Products Grid -->
|
||
<div id="products-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
<!-- Products loaded dynamically -->
|
||
</div>
|
||
|
||
<!-- Empty State -->
|
||
<div id="empty-state" class="card rounded-2xl p-12 text-center hidden">
|
||
<div class="text-6xl mb-4">📦</div>
|
||
<h3 class="text-xl font-bold mb-2">No hay productos</h3>
|
||
<p class="text-gray-400 mb-4">Agrega tu primer producto para empezar a generar contenido</p>
|
||
<button onclick="openModal('add')" class="btn-primary px-6 py-3 rounded-xl">
|
||
+ Agregar Producto
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Add/Edit Modal -->
|
||
<div id="product-modal" class="fixed inset-0 bg-black/70 backdrop-blur-sm hidden flex items-center justify-center z-50">
|
||
<div class="card rounded-2xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4">
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h3 class="text-xl font-semibold" id="modal-title">Agregar Producto</h3>
|
||
<button onclick="closeModal()" class="text-gray-400 hover:text-white text-xl">✕</button>
|
||
</div>
|
||
|
||
<form id="product-form" onsubmit="saveProduct(event)">
|
||
<input type="hidden" id="product-id">
|
||
|
||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||
<div class="col-span-2">
|
||
<label class="text-sm text-gray-400 block mb-2">Nombre *</label>
|
||
<input type="text" id="product-name" required
|
||
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 focus:border-primary focus:outline-none">
|
||
</div>
|
||
|
||
<div>
|
||
<label class="text-sm text-gray-400 block mb-2">Categoría *</label>
|
||
<select id="product-category" required class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 focus:border-primary focus:outline-none">
|
||
<option value="laptops">Laptops</option>
|
||
<option value="desktops">Equipos de Escritorio</option>
|
||
<option value="impresoras_3d">Impresoras 3D</option>
|
||
<option value="componentes">Componentes</option>
|
||
<option value="perifericos">Periféricos</option>
|
||
<option value="redes">Redes</option>
|
||
<option value="software">Software</option>
|
||
<option value="accesorios">Accesorios</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="text-sm text-gray-400 block mb-2">Precio (MXN) *</label>
|
||
<input type="number" id="product-price" step="0.01" min="0" required
|
||
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 focus:border-primary focus:outline-none">
|
||
</div>
|
||
|
||
<div class="col-span-2">
|
||
<label class="text-sm text-gray-400 block mb-2">Descripción</label>
|
||
<textarea id="product-description" rows="3"
|
||
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 focus:border-primary focus:outline-none resize-none"></textarea>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="text-sm text-gray-400 block mb-2">Stock</label>
|
||
<input type="number" id="product-stock" min="0" value="0"
|
||
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 focus:border-primary focus:outline-none">
|
||
</div>
|
||
|
||
<div>
|
||
<label class="text-sm text-gray-400 block mb-2">URL Imagen</label>
|
||
<input type="url" id="product-image"
|
||
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 focus:border-primary focus:outline-none">
|
||
</div>
|
||
|
||
<div class="col-span-2">
|
||
<label class="text-sm text-gray-400 block mb-2">Puntos Destacados (uno por línea)</label>
|
||
<textarea id="product-highlights" rows="3" placeholder="Alta velocidad Diseño compacto Garantía 2 años"
|
||
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 focus:border-primary focus:outline-none resize-none"></textarea>
|
||
</div>
|
||
|
||
<div class="col-span-2 flex items-center gap-6">
|
||
<label class="flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="product-active" checked class="w-4 h-4 rounded">
|
||
<span>Activo</span>
|
||
</label>
|
||
<label class="flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="product-featured" class="w-4 h-4 rounded">
|
||
<span>Destacado</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-end gap-3 mt-6">
|
||
<button type="button" onclick="closeModal()" class="btn-secondary px-4 py-2 rounded-xl">
|
||
Cancelar
|
||
</button>
|
||
<button type="submit" class="btn-primary px-4 py-2 rounded-xl">
|
||
Guardar
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_scripts %}
|
||
<script>
|
||
let products = [];
|
||
let categories = [];
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
loadCategories();
|
||
loadProducts();
|
||
});
|
||
|
||
async function loadCategories() {
|
||
try {
|
||
const response = await fetch('/api/products/categories');
|
||
if (response.ok) {
|
||
categories = await response.json();
|
||
const select = document.getElementById('filter-category');
|
||
categories.forEach(cat => {
|
||
const option = document.createElement('option');
|
||
option.value = cat;
|
||
option.textContent = cat.charAt(0).toUpperCase() + cat.slice(1).replace('_', ' ');
|
||
select.appendChild(option);
|
||
});
|
||
document.getElementById('stat-categories').textContent = categories.length;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading categories:', error);
|
||
}
|
||
}
|
||
|
||
async function loadProducts() {
|
||
const category = document.getElementById('filter-category').value;
|
||
const active = document.getElementById('filter-active').value;
|
||
const featured = document.getElementById('filter-featured').value;
|
||
const search = document.getElementById('filter-search').value;
|
||
|
||
let url = '/api/products/?limit=100';
|
||
if (category) url += `&category=${category}`;
|
||
if (active) url += `&is_active=${active}`;
|
||
if (featured === 'true') url += `&is_featured=true`;
|
||
|
||
try {
|
||
const response = await fetch(url);
|
||
if (response.ok) {
|
||
products = await response.json();
|
||
|
||
if (search) {
|
||
products = products.filter(p =>
|
||
p.name.toLowerCase().includes(search.toLowerCase())
|
||
);
|
||
}
|
||
|
||
renderProducts();
|
||
updateStats();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading products:', error);
|
||
}
|
||
}
|
||
|
||
function updateStats() {
|
||
document.getElementById('stat-total').textContent = products.length;
|
||
document.getElementById('stat-active').textContent = products.filter(p => p.is_active).length;
|
||
document.getElementById('stat-featured').textContent = products.filter(p => p.is_featured).length;
|
||
}
|
||
|
||
function renderProducts() {
|
||
const grid = document.getElementById('products-grid');
|
||
const empty = document.getElementById('empty-state');
|
||
|
||
if (products.length === 0) {
|
||
grid.classList.add('hidden');
|
||
empty.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
grid.classList.remove('hidden');
|
||
empty.classList.add('hidden');
|
||
|
||
grid.innerHTML = products.map(product => `
|
||
<div class="card rounded-2xl p-4 ${!product.is_active ? 'opacity-60' : ''}">
|
||
${product.image_url ?
|
||
`<img src="${product.image_url}" alt="${product.name}" class="w-full h-40 object-cover rounded-xl mb-4">` :
|
||
`<div class="w-full h-40 bg-dark-800 rounded-xl mb-4 flex items-center justify-center text-4xl">📦</div>`
|
||
}
|
||
|
||
<div class="flex justify-between items-start mb-2">
|
||
<h4 class="font-bold text-lg">${product.name}</h4>
|
||
${product.is_featured ? '<span class="text-yellow-400">⭐</span>' : ''}
|
||
</div>
|
||
|
||
<div class="text-sm text-gray-400 mb-2">
|
||
${product.category.replace('_', ' ')}
|
||
</div>
|
||
|
||
<div class="text-xl font-bold text-primary mb-3">
|
||
$${product.price.toLocaleString('es-MX')} MXN
|
||
</div>
|
||
|
||
${product.description ?
|
||
`<p class="text-sm text-gray-400 mb-3 line-clamp-2">${product.description}</p>` : ''
|
||
}
|
||
|
||
<div class="flex justify-between items-center text-sm mb-4">
|
||
<span class="${product.stock > 0 ? 'text-green-400' : 'text-red-400'}">
|
||
${product.stock > 0 ? `${product.stock} en stock` : 'Sin stock'}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="flex gap-2">
|
||
<button onclick="generatePost(${product.id})"
|
||
class="flex-1 btn-primary px-3 py-2 rounded-xl text-sm">
|
||
Generar Post
|
||
</button>
|
||
<button onclick="editProduct(${product.id})"
|
||
class="btn-secondary px-3 py-2 rounded-xl text-sm">
|
||
Editar
|
||
</button>
|
||
<button onclick="deleteProduct(${product.id})"
|
||
class="bg-red-500/20 text-red-400 px-3 py-2 rounded-xl text-sm hover:bg-red-500/30 transition-colors">
|
||
🗑
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function openModal(mode, productId = null) {
|
||
document.getElementById('modal-title').textContent =
|
||
mode === 'add' ? 'Agregar Producto' : 'Editar Producto';
|
||
document.getElementById('product-form').reset();
|
||
document.getElementById('product-id').value = '';
|
||
document.getElementById('product-active').checked = true;
|
||
document.getElementById('product-modal').classList.remove('hidden');
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('product-modal').classList.add('hidden');
|
||
}
|
||
|
||
function editProduct(id) {
|
||
const product = products.find(p => p.id === id);
|
||
if (!product) return;
|
||
|
||
document.getElementById('modal-title').textContent = 'Editar Producto';
|
||
document.getElementById('product-id').value = product.id;
|
||
document.getElementById('product-name').value = product.name;
|
||
document.getElementById('product-category').value = product.category;
|
||
document.getElementById('product-price').value = product.price;
|
||
document.getElementById('product-description').value = product.description || '';
|
||
document.getElementById('product-stock').value = product.stock || 0;
|
||
document.getElementById('product-image').value = product.image_url || '';
|
||
document.getElementById('product-highlights').value = (product.highlights || []).join('\n');
|
||
document.getElementById('product-active').checked = product.is_active;
|
||
document.getElementById('product-featured').checked = product.is_featured;
|
||
|
||
document.getElementById('product-modal').classList.remove('hidden');
|
||
}
|
||
|
||
async function saveProduct(event) {
|
||
event.preventDefault();
|
||
|
||
const id = document.getElementById('product-id').value;
|
||
const highlightsText = document.getElementById('product-highlights').value;
|
||
|
||
const data = {
|
||
name: document.getElementById('product-name').value,
|
||
category: document.getElementById('product-category').value,
|
||
price: parseFloat(document.getElementById('product-price').value),
|
||
description: document.getElementById('product-description').value,
|
||
stock: parseInt(document.getElementById('product-stock').value) || 0,
|
||
image_url: document.getElementById('product-image').value || null,
|
||
highlights: highlightsText ? highlightsText.split('\n').filter(h => h.trim()) : [],
|
||
is_active: document.getElementById('product-active').checked,
|
||
is_featured: document.getElementById('product-featured').checked
|
||
};
|
||
|
||
try {
|
||
const url = id ? `/api/products/${id}` : '/api/products/';
|
||
const method = id ? 'PUT' : 'POST';
|
||
|
||
const response = await fetch(url, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data)
|
||
});
|
||
|
||
if (response.ok) {
|
||
closeModal();
|
||
loadProducts();
|
||
showModal('<div class="text-center"><span class="text-4xl mb-4 block">✅</span><p>Producto guardado</p></div>');
|
||
} else {
|
||
const error = await response.json();
|
||
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>' + (error.detail || 'Error al guardar') + '</p></div>');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error);
|
||
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error de conexión</p></div>');
|
||
}
|
||
}
|
||
|
||
async function deleteProduct(id) {
|
||
if (!confirm('¿Eliminar este producto?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/products/${id}`, { method: 'DELETE' });
|
||
if (response.ok) {
|
||
loadProducts();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error);
|
||
}
|
||
}
|
||
|
||
async function generatePost(productId) {
|
||
const product = products.find(p => p.id === productId);
|
||
if (!product) return;
|
||
|
||
const platform = prompt('¿Para qué plataforma? (x, threads, instagram, facebook)', 'x');
|
||
if (!platform) return;
|
||
|
||
showModal('Generando post con IA...', true);
|
||
|
||
try {
|
||
const response = await fetch('/api/generate/product', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
product_id: productId,
|
||
platform: platform
|
||
})
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
showModal(`<div class="text-left"><p class="font-semibold mb-2">Post generado:</p><p class="bg-dark-800 p-4 rounded-xl text-sm whitespace-pre-wrap">${data.content}</p></div>`);
|
||
} else {
|
||
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error al generar post</p></div>');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error);
|
||
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error de conexión</p></div>');
|
||
}
|
||
}
|
||
</script>
|
||
<style>
|
||
.line-clamp-2 {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
</style>
|
||
{% endblock %}
|