fix: Fix YAML syntax errors and validator prompt formatting
- 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>
This commit is contained in:
@@ -27,7 +27,18 @@
|
|||||||
"Bash(tee:*)",
|
"Bash(tee:*)",
|
||||||
"Bash(systemctl start:*)",
|
"Bash(systemctl start:*)",
|
||||||
"Bash(systemctl enable:*)",
|
"Bash(systemctl enable:*)",
|
||||||
"Bash(systemctl is-enabled:*)"
|
"Bash(systemctl is-enabled:*)",
|
||||||
|
"Bash(for f in dashboard/templates/*.html)",
|
||||||
|
"Bash(do)",
|
||||||
|
"Bash(if grep -q \"extends \"\"base.html\"\"\" \"$f\")",
|
||||||
|
"Bash(then)",
|
||||||
|
"Bash(echo:*)",
|
||||||
|
"Bash(else)",
|
||||||
|
"Bash(fi)",
|
||||||
|
"Bash(done)",
|
||||||
|
"Bash(xargs basename:*)",
|
||||||
|
"Bash(docker-compose down:*)",
|
||||||
|
"Bash(for:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ topics_to_cover:
|
|||||||
- Automatización de tareas repetitivas
|
- Automatización de tareas repetitivas
|
||||||
|
|
||||||
avoid:
|
avoid:
|
||||||
- "Levántate a las 5am" sin contexto
|
- '"Levántate a las 5am" sin contexto'
|
||||||
- Hustle culture tóxica
|
- Hustle culture tóxica
|
||||||
- Productividad como fin en sí misma
|
- Productividad como fin en sí misma
|
||||||
- Consejos que solo funcionan para privilegiados
|
- Consejos que solo funcionan para privilegiados
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ avoid:
|
|||||||
- Más de 2 hashtags
|
- Más de 2 hashtags
|
||||||
- Emojis excesivos
|
- Emojis excesivos
|
||||||
- Links en el medio del texto
|
- Links en el medio del texto
|
||||||
- "Hilo:" sin contenido de hilo real
|
- '"Hilo:" sin contenido de hilo real'
|
||||||
|
|
||||||
adaptation_rules: |
|
adaptation_rules: |
|
||||||
Cuando adaptes contenido para X:
|
Cuando adaptes contenido para X:
|
||||||
|
|||||||
@@ -78,5 +78,5 @@ tone_guidelines:
|
|||||||
- "¿Te interesa? Escríbenos para más detalles"
|
- "¿Te interesa? Escríbenos para más detalles"
|
||||||
dont:
|
dont:
|
||||||
- "¡¡¡OFERTA INCREÍBLE!!!"
|
- "¡¡¡OFERTA INCREÍBLE!!!"
|
||||||
- "ÚLTIMAS UNIDADES" (a menos que sea verdad)
|
- '"ÚLTIMAS UNIDADES" (a menos que sea verdad)'
|
||||||
- "Compra ahora antes de que se acabe"
|
- "Compra ahora antes de que se acabe"
|
||||||
|
|||||||
@@ -9,16 +9,23 @@ Este módulo contiene los componentes del motor de generación de contenido:
|
|||||||
- ContentValidator: Validación y scoring con IA
|
- ContentValidator: Validación y scoring con IA
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.services.ai.prompt_library import PromptLibrary
|
from app.services.ai.prompt_library import PromptLibrary, prompt_library
|
||||||
from app.services.ai.context_engine import ContextEngine
|
from app.services.ai.context_engine import ContextEngine, context_engine
|
||||||
from app.services.ai.generator import ContentGeneratorV2
|
from app.services.ai.generator import ContentGeneratorV2, content_generator_v2
|
||||||
from app.services.ai.platform_adapter import PlatformAdapter
|
from app.services.ai.platform_adapter import PlatformAdapter, platform_adapter
|
||||||
from app.services.ai.validator import ContentValidator
|
from app.services.ai.validator import ContentValidator, content_validator
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
# Classes
|
||||||
"PromptLibrary",
|
"PromptLibrary",
|
||||||
"ContextEngine",
|
"ContextEngine",
|
||||||
"ContentGeneratorV2",
|
"ContentGeneratorV2",
|
||||||
"PlatformAdapter",
|
"PlatformAdapter",
|
||||||
"ContentValidator",
|
"ContentValidator",
|
||||||
|
# Singleton instances
|
||||||
|
"prompt_library",
|
||||||
|
"context_engine",
|
||||||
|
"content_generator_v2",
|
||||||
|
"platform_adapter",
|
||||||
|
"content_validator",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -261,11 +261,8 @@ class ContentValidator:
|
|||||||
if not scoring_prompt:
|
if not scoring_prompt:
|
||||||
scoring_prompt = self._default_scoring_prompt()
|
scoring_prompt = self._default_scoring_prompt()
|
||||||
|
|
||||||
# Renderizar prompt
|
# Renderizar prompt usando replace para evitar problemas con JSON
|
||||||
prompt = scoring_prompt.format(
|
prompt = scoring_prompt.replace("{content}", content).replace("{platform}", platform)
|
||||||
content=content,
|
|
||||||
platform=platform
|
|
||||||
)
|
|
||||||
|
|
||||||
# Llamar a DeepSeek
|
# Llamar a DeepSeek
|
||||||
response = self.client.chat.completions.create(
|
response = self.client.chat.completions.create(
|
||||||
@@ -340,7 +337,7 @@ CRITERIOS (suma = 100):
|
|||||||
- CTA (0-10): ¿CTA claro si aplica?
|
- CTA (0-10): ¿CTA claro si aplica?
|
||||||
|
|
||||||
RESPONDE EN JSON:
|
RESPONDE EN JSON:
|
||||||
{{"total": N, "breakdown": {{"hook_strength": N, "clarity": N, "actionability": N, "originality": N, "brand_voice": N, "cta_effectiveness": N}}, "feedback": "sugerencia"}}"""
|
{"total": N, "breakdown": {"hook_strength": N, "clarity": N, "actionability": N, "originality": N, "brand_voice": N, "cta_effectiveness": N}, "feedback": "sugerencia"}"""
|
||||||
|
|
||||||
def _extract_score_from_text(self, text: str) -> Dict:
|
def _extract_score_from_text(self, text: str) -> Dict:
|
||||||
"""Extraer score de texto si falla JSON parsing."""
|
"""Extraer score de texto si falla JSON parsing."""
|
||||||
|
|||||||
@@ -1,446 +1,256 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="es">
|
|
||||||
<head>
|
{% block title %}Analytics{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{% block extra_head %}
|
||||||
<title>Analytics - Social Media Automation</title>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
{% endblock %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
<style>
|
{% block content %}
|
||||||
body { background-color: #1a1a2e; color: #eee; }
|
<div class="animate-fade-in">
|
||||||
.card { background-color: #16213e; border-radius: 12px; }
|
|
||||||
.accent { color: #d4a574; }
|
|
||||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
|
||||||
.btn-primary:hover { background-color: #c49564; }
|
|
||||||
.btn-secondary { background-color: #374151; color: #fff; }
|
|
||||||
.btn-secondary:hover { background-color: #4b5563; }
|
|
||||||
.stat-up { color: #10b981; }
|
|
||||||
.stat-down { color: #ef4444; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen">
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<div>
|
||||||
<h1 class="text-2xl font-bold">
|
<h1 class="text-3xl font-bold">Analytics</h1>
|
||||||
<span class="accent">Consultoría AS</span> - Analytics
|
<p class="text-gray-400 mt-1">Métricas y rendimiento de tus publicaciones</p>
|
||||||
</h1>
|
|
||||||
<nav class="flex gap-4">
|
|
||||||
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
|
||||||
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800 accent">+ Crear Post</a>
|
|
||||||
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
|
||||||
<a href="/dashboard/analytics" class="px-4 py-2 rounded bg-gray-800">Analytics</a>
|
|
||||||
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
|
||||||
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<div class="flex gap-3">
|
||||||
|
<select id="periodSelect" onchange="loadDashboard()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
|
||||||
<main class="container mx-auto px-6 py-8">
|
<option value="7">Últimos 7 días</option>
|
||||||
<!-- Period Selector -->
|
<option value="30" selected>Últimos 30 días</option>
|
||||||
<div class="flex justify-between items-center mb-6">
|
<option value="90">Últimos 90 días</option>
|
||||||
<h2 class="text-xl font-semibold">Dashboard de Analytics</h2>
|
</select>
|
||||||
<div class="flex gap-2">
|
<select id="platformSelect" onchange="loadDashboard()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
|
||||||
<select id="periodSelect" onchange="loadDashboard()"
|
<option value="">Todas las plataformas</option>
|
||||||
class="bg-gray-800 border border-gray-700 rounded px-4 py-2">
|
<option value="x">X (Twitter)</option>
|
||||||
<option value="7">Últimos 7 días</option>
|
<option value="facebook">Facebook</option>
|
||||||
<option value="30" selected>Últimos 30 días</option>
|
<option value="instagram">Instagram</option>
|
||||||
<option value="90">Últimos 90 días</option>
|
<option value="threads">Threads</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="platformSelect" onchange="loadDashboard()"
|
|
||||||
class="bg-gray-800 border border-gray-700 rounded px-4 py-2">
|
|
||||||
<option value="">Todas las plataformas</option>
|
|
||||||
<option value="x">X (Twitter)</option>
|
|
||||||
<option value="threads">Threads</option>
|
|
||||||
<option value="instagram">Instagram</option>
|
|
||||||
<option value="facebook">Facebook</option>
|
|
||||||
</select>
|
|
||||||
<button onclick="generateReport()" class="btn-primary px-4 py-2 rounded">
|
|
||||||
Generar Reporte
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Grid -->
|
||||||
<div class="grid grid-cols-5 gap-4 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
<div class="card p-4 text-center">
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<div class="text-3xl font-bold accent" id="totalPosts">-</div>
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="text-gray-400 text-sm">Posts Publicados</div>
|
<div class="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||||
</div>
|
<span class="text-2xl">📝</span>
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-3xl font-bold text-blue-400" id="totalImpressions">-</div>
|
|
||||||
<div class="text-gray-400 text-sm">Impresiones</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-3xl font-bold text-green-400" id="totalEngagements">-</div>
|
|
||||||
<div class="text-gray-400 text-sm">Interacciones</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-3xl font-bold text-purple-400" id="engagementRate">-</div>
|
|
||||||
<div class="text-gray-400 text-sm">Engagement Rate</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-3xl font-bold text-yellow-400" id="pendingInteractions">-</div>
|
|
||||||
<div class="text-gray-400 text-sm">Por Responder</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Engagement Breakdown -->
|
|
||||||
<div class="grid grid-cols-3 gap-4 mb-8">
|
|
||||||
<div class="card p-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div class="text-gray-400 text-sm">Likes</div>
|
|
||||||
<div class="text-2xl font-bold" id="totalLikes">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-4xl">❤️</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div class="text-gray-400 text-sm">Comentarios</div>
|
|
||||||
<div class="text-2xl font-bold" id="totalComments">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-4xl">💬</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div class="text-gray-400 text-sm">Compartidos</div>
|
|
||||||
<div class="text-2xl font-bold" id="totalShares">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-4xl">🔄</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-6 mb-8">
|
|
||||||
<!-- Engagement Trend Chart -->
|
|
||||||
<div class="card p-6">
|
|
||||||
<h3 class="text-lg font-semibold mb-4">Tendencia de Engagement</h3>
|
|
||||||
<canvas id="engagementChart" height="200"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Platform Breakdown -->
|
|
||||||
<div class="card p-6">
|
|
||||||
<h3 class="text-lg font-semibold mb-4">Por Plataforma</h3>
|
|
||||||
<div id="platformBreakdown" class="space-y-3">
|
|
||||||
<!-- Populated by JS -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-3xl font-bold" id="stat-posts">-</p>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">Posts Totales</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-6 mb-8">
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<!-- Top Posts -->
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="card p-6">
|
<div class="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
|
||||||
<h3 class="text-lg font-semibold mb-4">Top Posts por Engagement</h3>
|
<span class="text-2xl">👁️</span>
|
||||||
<div id="topPosts" class="space-y-3">
|
|
||||||
<!-- Populated by JS -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-3xl font-bold" id="stat-impressions">-</p>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">Impresiones</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Optimal Times Heatmap -->
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<div class="card p-6">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold mb-4">Mejores Horarios</h3>
|
<div class="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||||
<div id="optimalTimes" class="space-y-2">
|
<span class="text-2xl">❤️</span>
|
||||||
<!-- Populated by JS -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-3xl font-bold" id="stat-engagements">-</p>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">Interacciones</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content Type Performance -->
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<div class="card p-6 mb-8">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold mb-4">Rendimiento por Tipo de Contenido</h3>
|
<div class="w-12 h-12 rounded-xl bg-yellow-500/20 flex items-center justify-center">
|
||||||
<div id="contentBreakdown" class="grid grid-cols-4 gap-4">
|
<span class="text-2xl">📊</span>
|
||||||
<!-- Populated by JS -->
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-3xl font-bold" id="stat-rate">-</p>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">Engagement Rate</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Reports History -->
|
<!-- Charts -->
|
||||||
<div class="card p-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="card rounded-2xl p-6">
|
||||||
<h3 class="text-lg font-semibold">Reportes Anteriores</h3>
|
<h3 class="font-semibold mb-4">Engagement por Plataforma</h3>
|
||||||
<button onclick="sendReportTelegram()" class="btn-secondary px-4 py-2 rounded text-sm">
|
<canvas id="platformChart" height="200"></canvas>
|
||||||
Enviar a Telegram
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="reportsList" class="space-y-2">
|
|
||||||
<!-- Populated by JS -->
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div class="card rounded-2xl p-6">
|
||||||
|
<h3 class="font-semibold mb-4">Rendimiento por Tipo de Contenido</h3>
|
||||||
|
<canvas id="contentChart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- Top Posts -->
|
||||||
let engagementChart = null;
|
<div class="card rounded-2xl p-6 mb-8">
|
||||||
|
<h3 class="font-semibold mb-4">Mejores Posts</h3>
|
||||||
|
<div id="top-posts" class="space-y-4">
|
||||||
|
<div class="text-center py-8 text-gray-500">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
async function loadDashboard() {
|
<!-- Optimal Times -->
|
||||||
const days = document.getElementById('periodSelect').value;
|
<div class="card rounded-2xl p-6">
|
||||||
const platform = document.getElementById('platformSelect').value;
|
<h3 class="font-semibold mb-4">Mejores Horarios para Publicar</h3>
|
||||||
|
<div id="optimal-times" class="grid grid-cols-7 gap-2">
|
||||||
|
<div class="text-center py-8 text-gray-500 col-span-7">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
try {
|
{% block extra_scripts %}
|
||||||
// Load dashboard stats
|
<script>
|
||||||
const params = new URLSearchParams({ days });
|
let platformChart, contentChart;
|
||||||
if (platform) params.append('platform', platform);
|
|
||||||
|
|
||||||
const statsRes = await fetch(`/api/analytics/dashboard?${params}`);
|
async function loadDashboard() {
|
||||||
const stats = await statsRes.json();
|
const period = document.getElementById('periodSelect').value;
|
||||||
|
const platform = document.getElementById('platformSelect').value;
|
||||||
|
|
||||||
// Update stat cards
|
const params = new URLSearchParams({ days: period });
|
||||||
document.getElementById('totalPosts').textContent = stats.total_posts;
|
if (platform) params.append('platform', platform);
|
||||||
document.getElementById('totalImpressions').textContent = formatNumber(stats.total_impressions);
|
|
||||||
document.getElementById('totalEngagements').textContent = formatNumber(stats.total_engagements);
|
|
||||||
document.getElementById('engagementRate').textContent = stats.avg_engagement_rate + '%';
|
|
||||||
document.getElementById('pendingInteractions').textContent = stats.pending_interactions;
|
|
||||||
document.getElementById('totalLikes').textContent = formatNumber(stats.total_likes);
|
|
||||||
document.getElementById('totalComments').textContent = formatNumber(stats.total_comments);
|
|
||||||
document.getElementById('totalShares').textContent = formatNumber(stats.total_shares);
|
|
||||||
|
|
||||||
// Platform breakdown
|
try {
|
||||||
renderPlatformBreakdown(stats.platform_breakdown);
|
const statsRes = await fetch(`/api/analytics/dashboard?${params}`);
|
||||||
|
const stats = await statsRes.json();
|
||||||
|
|
||||||
// Content breakdown
|
document.getElementById('stat-posts').textContent = stats.total_posts || 0;
|
||||||
renderContentBreakdown(stats.content_breakdown);
|
document.getElementById('stat-impressions').textContent = formatNumber(stats.total_impressions || 0);
|
||||||
|
document.getElementById('stat-engagements').textContent = formatNumber(stats.total_engagements || 0);
|
||||||
|
document.getElementById('stat-rate').textContent = (stats.avg_engagement_rate || 0).toFixed(2) + '%';
|
||||||
|
|
||||||
// Load engagement trend
|
updatePlatformChart(stats.platform_breakdown || {});
|
||||||
const trendRes = await fetch(`/api/analytics/engagement-trend?${params}`);
|
updateContentChart(stats.content_breakdown || {});
|
||||||
const trendData = await trendRes.json();
|
|
||||||
renderEngagementChart(trendData.trend);
|
|
||||||
|
|
||||||
// Load top posts
|
loadTopPosts(period, platform);
|
||||||
const topRes = await fetch(`/api/analytics/top-posts?${params}&limit=5`);
|
loadOptimalTimes(platform);
|
||||||
const topData = await topRes.json();
|
} catch (error) {
|
||||||
renderTopPosts(topData.posts);
|
console.error('Error loading dashboard:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load optimal times
|
function formatNumber(num) {
|
||||||
const timesRes = await fetch(`/api/analytics/optimal-times?days=${days}${platform ? '&platform=' + platform : ''}`);
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||||
const timesData = await timesRes.json();
|
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
||||||
renderOptimalTimes(timesData.optimal_times);
|
return num.toString();
|
||||||
|
}
|
||||||
|
|
||||||
// Load reports
|
function updatePlatformChart(data) {
|
||||||
const reportsRes = await fetch('/api/analytics/reports?limit=5');
|
const ctx = document.getElementById('platformChart').getContext('2d');
|
||||||
const reportsData = await reportsRes.json();
|
const labels = Object.keys(data).length ? Object.keys(data) : ['Sin datos'];
|
||||||
renderReports(reportsData.reports);
|
const values = Object.keys(data).length ? Object.values(data) : [0];
|
||||||
|
|
||||||
} catch (error) {
|
if (platformChart) platformChart.destroy();
|
||||||
console.error('Error loading dashboard:', error);
|
|
||||||
|
platformChart = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
data: values,
|
||||||
|
backgroundColor: ['#6366f1', '#8b5cf6', '#ec4899', '#f59e0b'],
|
||||||
|
borderWidth: 0
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom', labels: { color: '#9ca3af' } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContentChart(data) {
|
||||||
|
const ctx = document.getElementById('contentChart').getContext('2d');
|
||||||
|
const labels = Object.keys(data).length ? Object.keys(data) : ['Sin datos'];
|
||||||
|
const values = Object.keys(data).length ? Object.values(data) : [0];
|
||||||
|
|
||||||
|
if (contentChart) contentChart.destroy();
|
||||||
|
|
||||||
|
contentChart = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Posts',
|
||||||
|
data: values,
|
||||||
|
backgroundColor: '#6366f1',
|
||||||
|
borderRadius: 8
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
y: { grid: { color: '#334155' }, ticks: { color: '#9ca3af' } },
|
||||||
|
x: { grid: { display: false }, ticks: { color: '#9ca3af' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTopPosts(period, platform) {
|
||||||
|
const container = document.getElementById('top-posts');
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ days: period, limit: 5 });
|
||||||
|
if (platform) params.append('platform', platform);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/analytics/top-posts?${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.posts && data.posts.length) {
|
||||||
|
container.innerHTML = data.posts.map(post => `
|
||||||
|
<div class="bg-dark-800/50 rounded-xl p-4">
|
||||||
|
<p class="text-sm text-gray-300 mb-2">${post.content?.substring(0, 100) || 'Sin contenido'}...</p>
|
||||||
|
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<span>${post.platforms?.join(', ') || '-'}</span>
|
||||||
|
<span>Engagement: ${(post.engagement_rate || 0).toFixed(2)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="text-center py-8 text-gray-500">No hay datos suficientes</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<div class="text-center py-8 text-red-400">Error cargando datos</div>';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatNumber(num) {
|
async function loadOptimalTimes(platform) {
|
||||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
const container = document.getElementById('optimal-times');
|
||||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
try {
|
||||||
return num.toString();
|
const params = platform ? `?platform=${platform}` : '';
|
||||||
}
|
const response = await fetch(`/api/analytics/optimal-times${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
function renderPlatformBreakdown(breakdown) {
|
const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
||||||
const container = document.getElementById('platformBreakdown');
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
const platformIcons = {
|
if (data.optimal_times && Object.keys(data.optimal_times).length) {
|
||||||
'x': '𝕏',
|
container.innerHTML = days.map((day, i) => {
|
||||||
'threads': '🧵',
|
const times = data.optimal_times[i] || [];
|
||||||
'instagram': '📷',
|
return `
|
||||||
'facebook': '📘'
|
<div class="bg-dark-800/50 rounded-xl p-3 text-center">
|
||||||
};
|
<p class="font-medium text-sm mb-2">${day}</p>
|
||||||
|
<div class="space-y-1">
|
||||||
for (const [platform, data] of Object.entries(breakdown)) {
|
${times.length ? times.slice(0, 3).map(t => `<span class="text-xs bg-primary/20 text-primary px-2 py-1 rounded-full block">${t}:00</span>`).join('') : '<span class="text-xs text-gray-500">-</span>'}
|
||||||
container.innerHTML += `
|
|
||||||
<div class="flex items-center justify-between bg-gray-800 rounded p-3">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-2xl">${platformIcons[platform] || '📱'}</span>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium capitalize">${platform}</div>
|
|
||||||
<div class="text-sm text-gray-400">${data.posts} posts</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
`;
|
||||||
<div class="text-green-400 font-bold">${formatNumber(data.engagements)}</div>
|
}).join('');
|
||||||
<div class="text-sm text-gray-400">interacciones</div>
|
} else {
|
||||||
</div>
|
container.innerHTML = '<div class="text-center py-8 text-gray-500 col-span-7">No hay datos suficientes para calcular horarios óptimos</div>';
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(breakdown).length === 0) {
|
|
||||||
container.innerHTML = '<p class="text-gray-500">No hay datos disponibles</p>';
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<div class="text-center py-8 text-red-400 col-span-7">Error cargando datos</div>';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderContentBreakdown(breakdown) {
|
// Load on page load
|
||||||
const container = document.getElementById('contentBreakdown');
|
loadDashboard();
|
||||||
container.innerHTML = '';
|
</script>
|
||||||
|
{% endblock %}
|
||||||
for (const [type, data] of Object.entries(breakdown)) {
|
|
||||||
container.innerHTML += `
|
|
||||||
<div class="bg-gray-800 rounded p-4 text-center">
|
|
||||||
<div class="text-lg font-bold accent">${data.posts}</div>
|
|
||||||
<div class="text-sm text-gray-400 capitalize">${type.replace('_', ' ')}</div>
|
|
||||||
<div class="text-xs text-green-400 mt-1">${formatNumber(data.engagements)} eng.</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(breakdown).length === 0) {
|
|
||||||
container.innerHTML = '<p class="text-gray-500 col-span-4">No hay datos disponibles</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEngagementChart(trend) {
|
|
||||||
const ctx = document.getElementById('engagementChart').getContext('2d');
|
|
||||||
|
|
||||||
if (engagementChart) {
|
|
||||||
engagementChart.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
engagementChart = new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: trend.map(d => d.date),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: 'Impresiones',
|
|
||||||
data: trend.map(d => d.impressions),
|
|
||||||
borderColor: '#60a5fa',
|
|
||||||
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
fill: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Interacciones',
|
|
||||||
data: trend.map(d => d.engagements),
|
|
||||||
borderColor: '#34d399',
|
|
||||||
backgroundColor: 'rgba(52, 211, 153, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
fill: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
labels: { color: '#9ca3af' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
ticks: { color: '#9ca3af' },
|
|
||||||
grid: { color: '#374151' }
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
ticks: { color: '#9ca3af' },
|
|
||||||
grid: { color: '#374151' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTopPosts(posts) {
|
|
||||||
const container = document.getElementById('topPosts');
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
posts.forEach((post, i) => {
|
|
||||||
container.innerHTML += `
|
|
||||||
<div class="bg-gray-800 rounded p-3">
|
|
||||||
<div class="flex justify-between items-start mb-2">
|
|
||||||
<span class="text-accent font-bold">#${i + 1}</span>
|
|
||||||
<span class="text-green-400 text-sm">${post.engagement_rate}% eng.</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-300 mb-2">${post.content}</p>
|
|
||||||
<div class="flex gap-4 text-xs text-gray-500">
|
|
||||||
<span>❤️ ${post.likes}</span>
|
|
||||||
<span>💬 ${post.comments}</span>
|
|
||||||
<span>🔄 ${post.shares}</span>
|
|
||||||
<span class="ml-auto">${post.platforms.join(', ')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (posts.length === 0) {
|
|
||||||
container.innerHTML = '<p class="text-gray-500">No hay posts con métricas</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOptimalTimes(times) {
|
|
||||||
const container = document.getElementById('optimalTimes');
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
// Show top 10 times
|
|
||||||
times.slice(0, 10).forEach(time => {
|
|
||||||
const barWidth = Math.min(time.avg_engagement_rate * 10, 100);
|
|
||||||
container.innerHTML += `
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="w-20 text-sm text-gray-400">${time.day_name} ${time.hour_formatted}</span>
|
|
||||||
<div class="flex-1 bg-gray-800 rounded h-4 overflow-hidden">
|
|
||||||
<div class="bg-accent h-full" style="width: ${barWidth}%"></div>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-green-400 w-16 text-right">${time.avg_engagement_rate}%</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (times.length === 0) {
|
|
||||||
container.innerHTML = '<p class="text-gray-500">No hay suficientes datos</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReports(reports) {
|
|
||||||
const container = document.getElementById('reportsList');
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
reports.forEach(report => {
|
|
||||||
container.innerHTML += `
|
|
||||||
<div class="bg-gray-800 rounded p-3 flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium capitalize">${report.report_type}</span>
|
|
||||||
<span class="text-gray-500 text-sm ml-2">
|
|
||||||
${report.period_start} - ${report.period_end}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-4 text-sm">
|
|
||||||
<span>${report.total_posts} posts</span>
|
|
||||||
<span class="text-green-400">${report.avg_engagement_rate}% eng.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (reports.length === 0) {
|
|
||||||
container.innerHTML = '<p class="text-gray-500">No hay reportes generados</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateReport() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/analytics/reports/generate?report_type=weekly', {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
alert('Reporte generado exitosamente');
|
|
||||||
loadDashboard();
|
|
||||||
} else {
|
|
||||||
alert('Error: ' + data.detail);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error generando reporte');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendReportTelegram() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/analytics/reports/send-telegram', {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
alert(data.message);
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error enviando reporte');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', loadDashboard);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
194
dashboard/templates/base.html
Normal file
194
dashboard/templates/base.html
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Dashboard{% endblock %} - Social Media Automation</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#6366f1',
|
||||||
|
secondary: '#8b5cf6',
|
||||||
|
accent: '#d4a574',
|
||||||
|
dark: {
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
950: '#020617'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'sans-serif']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Inter', sans-serif; }
|
||||||
|
.gradient-bg { background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); }
|
||||||
|
.card { background: rgba(30, 41, 59, 0.8); backdrop-filter: blur(10px); border: 1px solid rgba(99, 102, 241, 0.1); }
|
||||||
|
.card:hover { border-color: rgba(99, 102, 241, 0.3); }
|
||||||
|
.btn-primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); }
|
||||||
|
.btn-primary:hover { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); transform: translateY(-1px); }
|
||||||
|
.btn-secondary { background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); }
|
||||||
|
.btn-secondary:hover { background: rgba(99, 102, 241, 0.2); }
|
||||||
|
.sidebar-link { transition: all 0.2s ease; }
|
||||||
|
.sidebar-link:hover { background: rgba(99, 102, 241, 0.1); transform: translateX(4px); }
|
||||||
|
.sidebar-link.active { background: rgba(99, 102, 241, 0.2); border-left: 3px solid #6366f1; }
|
||||||
|
.stat-card { transition: all 0.3s ease; }
|
||||||
|
.stat-card:hover { transform: translateY(-4px); box-shadow: 0 10px 40px rgba(99, 102, 241, 0.2); }
|
||||||
|
.glow { box-shadow: 0 0 20px rgba(99, 102, 241, 0.3); }
|
||||||
|
::-webkit-scrollbar { width: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: #1e293b; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #6366f1; }
|
||||||
|
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
</style>
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="gradient-bg min-h-screen text-gray-100">
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="w-64 min-h-screen bg-dark-900/50 backdrop-blur-xl border-r border-dark-700/50 fixed">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-8">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center">
|
||||||
|
<span class="text-xl">📱</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-lg">Social Media</h1>
|
||||||
|
<p class="text-xs text-gray-500">Automation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="space-y-1">
|
||||||
|
<a href="/" class="sidebar-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:text-white {% if request.url.path == '/' %}active{% endif %}">
|
||||||
|
<span class="text-xl">🏠</span>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a href="/compose" class="sidebar-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:text-white {% if request.url.path == '/compose' %}active{% endif %}">
|
||||||
|
<span class="text-xl">✍️</span>
|
||||||
|
<span>Crear Post</span>
|
||||||
|
</a>
|
||||||
|
<a href="/posts" class="sidebar-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:text-white {% if request.url.path == '/posts' %}active{% endif %}">
|
||||||
|
<span class="text-xl">📝</span>
|
||||||
|
<span>Posts</span>
|
||||||
|
</a>
|
||||||
|
<a href="/calendar" class="sidebar-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:text-white {% if request.url.path == '/calendar' %}active{% endif %}">
|
||||||
|
<span class="text-xl">📅</span>
|
||||||
|
<span>Calendario</span>
|
||||||
|
</a>
|
||||||
|
<a href="/interactions" class="sidebar-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:text-white {% if request.url.path == '/interactions' %}active{% endif %}">
|
||||||
|
<span class="text-xl">💬</span>
|
||||||
|
<span>Interacciones</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="pt-4 mt-4 border-t border-dark-700/50">
|
||||||
|
<p class="px-4 text-xs text-gray-500 uppercase tracking-wider mb-2">Catálogo</p>
|
||||||
|
</div>
|
||||||
|
<a href="/products" class="sidebar-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:text-white {% if request.url.path == '/products' %}active{% endif %}">
|
||||||
|
<span class="text-xl">📦</span>
|
||||||
|
<span>Productos</span>
|
||||||
|
</a>
|
||||||
|
<a href="/services" class="sidebar-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:text-white {% if request.url.path == '/services' %}active{% endif %}">
|
||||||
|
<span class="text-xl">🛠️</span>
|
||||||
|
<span>Servicios</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="pt-4 mt-4 border-t border-dark-700/50">
|
||||||
|
<p class="px-4 text-xs text-gray-500 uppercase tracking-wider mb-2">Analytics</p>
|
||||||
|
</div>
|
||||||
|
<a href="/analytics" class="sidebar-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:text-white {% if request.url.path == '/analytics' %}active{% endif %}">
|
||||||
|
<span class="text-xl">📊</span>
|
||||||
|
<span>Analytics</span>
|
||||||
|
</a>
|
||||||
|
<a href="/leads" class="sidebar-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:text-white {% if request.url.path == '/leads' %}active{% endif %}">
|
||||||
|
<span class="text-xl">🎯</span>
|
||||||
|
<span>Leads</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="pt-4 mt-4 border-t border-dark-700/50">
|
||||||
|
<p class="px-4 text-xs text-gray-500 uppercase tracking-wider mb-2">Sistema</p>
|
||||||
|
</div>
|
||||||
|
<a href="/settings" class="sidebar-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:text-white {% if request.url.path == '/settings' %}active{% endif %}">
|
||||||
|
<span class="text-xl">⚙️</span>
|
||||||
|
<span>Configuración</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Section -->
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 p-4 border-t border-dark-700/50 bg-dark-900/50">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center text-sm">
|
||||||
|
{{ user.username[0]|upper if user else '?' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="font-medium">{{ user.username if user else 'Usuario' }}</p>
|
||||||
|
<p class="text-xs text-gray-500">Admin</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/logout" class="text-gray-400 hover:text-red-400 transition-colors" title="Cerrar sesión">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 ml-64 p-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div id="modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
|
||||||
|
<div class="card rounded-2xl p-6 max-w-md w-full mx-4 animate-fade-in">
|
||||||
|
<div id="modal-content"></div>
|
||||||
|
<button onclick="closeModal()" class="mt-4 w-full btn-secondary px-4 py-2 rounded-lg">Cerrar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showModal(content, loading = false) {
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
const modalContent = document.getElementById('modal-content');
|
||||||
|
modalContent.innerHTML = loading
|
||||||
|
? `<div class="flex items-center gap-3"><div class="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full"></div><span>${content}</span></div>`
|
||||||
|
: content;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on outside click
|
||||||
|
document.getElementById('modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeModal();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% block extra_scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,436 +1,395 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Calendario - Social Media Automation</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { background-color: #1a1a2e; color: #eee; }
|
|
||||||
.card { background-color: #16213e; border-radius: 12px; }
|
|
||||||
.accent { color: #d4a574; }
|
|
||||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
|
||||||
.btn-primary:hover { background-color: #c49564; }
|
|
||||||
.btn-secondary { background-color: #374151; }
|
|
||||||
.btn-secondary:hover { background-color: #4b5563; }
|
|
||||||
.calendar-day { min-height: 120px; }
|
|
||||||
.calendar-day:hover { background-color: #1e3a5f; }
|
|
||||||
.calendar-day.today { border: 2px solid #d4a574; }
|
|
||||||
.calendar-day.other-month { opacity: 0.4; }
|
|
||||||
.post-dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
||||||
.post-item { font-size: 11px; padding: 2px 4px; border-radius: 4px; margin-bottom: 2px; cursor: pointer; }
|
|
||||||
.post-item:hover { opacity: 0.8; }
|
|
||||||
.status-published { background-color: #065f46; }
|
|
||||||
.status-scheduled { background-color: #1e40af; }
|
|
||||||
.status-pending { background-color: #92400e; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen">
|
|
||||||
<!-- Header -->
|
|
||||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<h1 class="text-2xl font-bold">
|
|
||||||
<span class="accent">Consultoría AS</span> - Social Media
|
|
||||||
</h1>
|
|
||||||
<nav class="flex gap-4">
|
|
||||||
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
|
||||||
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800">+ Crear</a>
|
|
||||||
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
|
||||||
<a href="/dashboard/calendar" class="px-4 py-2 rounded bg-gray-800 accent">Calendario</a>
|
|
||||||
<a href="/dashboard/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
|
|
||||||
<a href="/dashboard/settings" class="px-4 py-2 rounded hover:bg-gray-800">Config</a>
|
|
||||||
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="container mx-auto px-6 py-8">
|
{% block title %}Calendario{% endblock %}
|
||||||
<!-- Calendar Controls -->
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
{% block content %}
|
||||||
<div class="flex items-center gap-4">
|
<div class="animate-fade-in">
|
||||||
<button onclick="prevMonth()" class="btn-secondary px-3 py-2 rounded">
|
<!-- Header -->
|
||||||
← Anterior
|
<div class="flex items-center justify-between mb-8">
|
||||||
</button>
|
<div>
|
||||||
<h2 class="text-2xl font-bold" id="current-month">Enero 2025</h2>
|
<h1 class="text-3xl font-bold">Calendario</h1>
|
||||||
<button onclick="nextMonth()" class="btn-secondary px-3 py-2 rounded">
|
<p class="text-gray-400 mt-1">Visualiza y programa tus publicaciones</p>
|
||||||
Siguiente →
|
</div>
|
||||||
</button>
|
<div class="flex gap-3">
|
||||||
</div>
|
<button onclick="prevMonth()" class="btn-secondary px-4 py-2 rounded-xl flex items-center gap-2">
|
||||||
|
<span>←</span> Anterior
|
||||||
|
</button>
|
||||||
|
<h2 class="text-xl font-bold px-4 py-2" id="current-month">Enero 2025</h2>
|
||||||
|
<button onclick="nextMonth()" class="btn-secondary px-4 py-2 rounded-xl flex items-center gap-2">
|
||||||
|
Siguiente <span>→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Controls -->
|
||||||
|
<div class="card rounded-2xl p-4 mb-6">
|
||||||
|
<div class="flex flex-wrap gap-4 items-center justify-between">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button onclick="setView('month')" id="btn-month" class="btn-primary px-4 py-2 rounded">
|
<button onclick="setView('month')" id="btn-month" class="btn-primary px-4 py-2 rounded-xl">
|
||||||
Mes
|
Mes
|
||||||
</button>
|
</button>
|
||||||
<button onclick="setView('week')" id="btn-week" class="btn-secondary px-4 py-2 rounded">
|
<button onclick="setView('week')" id="btn-week" class="btn-secondary px-4 py-2 rounded-xl">
|
||||||
Semana
|
Semana
|
||||||
</button>
|
</button>
|
||||||
<button onclick="goToToday()" class="btn-secondary px-4 py-2 rounded">
|
<button onclick="goToToday()" class="btn-secondary px-4 py-2 rounded-xl">
|
||||||
Hoy
|
Hoy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Platform Filters -->
|
|
||||||
<div class="card p-4 mb-6">
|
|
||||||
<div class="flex gap-4 items-center">
|
<div class="flex gap-4 items-center">
|
||||||
<span class="text-gray-400">Filtrar:</span>
|
<span class="text-gray-400 text-sm">Filtrar:</span>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" checked class="platform-filter" value="x" onchange="filterCalendar()">
|
<input type="checkbox" checked class="platform-filter w-4 h-4 rounded" value="x" onchange="filterCalendar()">
|
||||||
<span class="bg-gray-800 px-2 py-1 rounded text-sm">X</span>
|
<span class="text-sm bg-dark-700 px-2 py-1 rounded-lg">X</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" checked class="platform-filter" value="threads" onchange="filterCalendar()">
|
<input type="checkbox" checked class="platform-filter w-4 h-4 rounded" value="facebook" onchange="filterCalendar()">
|
||||||
<span class="bg-gray-800 px-2 py-1 rounded text-sm">Threads</span>
|
<span class="text-sm bg-blue-600/30 text-blue-400 px-2 py-1 rounded-lg">Facebook</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" checked class="platform-filter" value="instagram" onchange="filterCalendar()">
|
<input type="checkbox" checked class="platform-filter w-4 h-4 rounded" value="instagram" onchange="filterCalendar()">
|
||||||
<span class="bg-pink-900 px-2 py-1 rounded text-sm">Instagram</span>
|
<span class="text-sm bg-pink-600/30 text-pink-400 px-2 py-1 rounded-lg">Instagram</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" checked class="platform-filter" value="facebook" onchange="filterCalendar()">
|
<input type="checkbox" checked class="platform-filter w-4 h-4 rounded" value="threads" onchange="filterCalendar()">
|
||||||
<span class="bg-blue-900 px-2 py-1 rounded text-sm">Facebook</span>
|
<span class="text-sm bg-dark-700 px-2 py-1 rounded-lg">Threads</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex-1"></div>
|
|
||||||
<div class="flex gap-2 text-xs">
|
|
||||||
<span class="flex items-center gap-1"><span class="post-dot status-scheduled"></span> Programado</span>
|
|
||||||
<span class="flex items-center gap-1"><span class="post-dot status-pending"></span> Pendiente</span>
|
|
||||||
<span class="flex items-center gap-1"><span class="post-dot status-published"></span> Publicado</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex gap-3 text-xs">
|
||||||
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-blue-500"></span> Programado</span>
|
||||||
<!-- Calendar Grid -->
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-yellow-500"></span> Pendiente</span>
|
||||||
<div class="card overflow-hidden">
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-green-500"></span> Publicado</span>
|
||||||
<!-- Day Headers -->
|
|
||||||
<div class="grid grid-cols-7 bg-gray-800">
|
|
||||||
<div class="p-3 text-center font-semibold border-r border-gray-700">Lun</div>
|
|
||||||
<div class="p-3 text-center font-semibold border-r border-gray-700">Mar</div>
|
|
||||||
<div class="p-3 text-center font-semibold border-r border-gray-700">Mié</div>
|
|
||||||
<div class="p-3 text-center font-semibold border-r border-gray-700">Jue</div>
|
|
||||||
<div class="p-3 text-center font-semibold border-r border-gray-700">Vie</div>
|
|
||||||
<div class="p-3 text-center font-semibold border-r border-gray-700 text-blue-400">Sáb</div>
|
|
||||||
<div class="p-3 text-center font-semibold text-blue-400">Dom</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Calendar Days -->
|
|
||||||
<div id="calendar-grid" class="grid grid-cols-7">
|
|
||||||
<!-- Days filled dynamically -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Week View (hidden by default) -->
|
|
||||||
<div id="week-view" class="card hidden mt-6">
|
|
||||||
<div id="week-grid" class="divide-y divide-gray-700">
|
|
||||||
<!-- Week days filled dynamically -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Post Detail Modal -->
|
|
||||||
<div id="post-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
|
||||||
<div class="card p-6 max-w-lg w-full mx-4">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="font-semibold text-lg">Detalle del Post</h3>
|
|
||||||
<button onclick="closePostModal()" class="text-gray-400 hover:text-white">✕</button>
|
|
||||||
</div>
|
|
||||||
<div id="post-modal-content">
|
|
||||||
<!-- Content loaded dynamically -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- Calendar Grid -->
|
||||||
let currentDate = new Date();
|
<div id="month-view" class="card rounded-2xl overflow-hidden">
|
||||||
let currentView = 'month';
|
<!-- Day Headers -->
|
||||||
let calendarData = {};
|
<div class="grid grid-cols-7 bg-dark-800">
|
||||||
let allPosts = [];
|
<div class="p-3 text-center font-semibold border-r border-dark-600">Lun</div>
|
||||||
|
<div class="p-3 text-center font-semibold border-r border-dark-600">Mar</div>
|
||||||
|
<div class="p-3 text-center font-semibold border-r border-dark-600">Mié</div>
|
||||||
|
<div class="p-3 text-center font-semibold border-r border-dark-600">Jue</div>
|
||||||
|
<div class="p-3 text-center font-semibold border-r border-dark-600">Vie</div>
|
||||||
|
<div class="p-3 text-center font-semibold border-r border-dark-600 text-primary">Sáb</div>
|
||||||
|
<div class="p-3 text-center font-semibold text-primary">Dom</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
// Initialize
|
<!-- Calendar Days -->
|
||||||
window.addEventListener('load', () => {
|
<div id="calendar-grid" class="grid grid-cols-7">
|
||||||
loadCalendarData();
|
<!-- Days filled dynamically -->
|
||||||
});
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
async function loadCalendarData() {
|
<!-- Week View (hidden by default) -->
|
||||||
const year = currentDate.getFullYear();
|
<div id="week-view" class="card rounded-2xl hidden">
|
||||||
const month = currentDate.getMonth();
|
<div id="week-grid" class="divide-y divide-dark-600">
|
||||||
|
<!-- Week days filled dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
// Get first and last day of visible calendar (including overflow days)
|
<!-- Post Detail Modal -->
|
||||||
const firstDay = new Date(year, month, 1);
|
<div id="post-modal" class="fixed inset-0 bg-black/70 backdrop-blur-sm hidden items-center justify-center z-50">
|
||||||
const lastDay = new Date(year, month + 1, 0);
|
<div class="card rounded-2xl p-6 max-w-lg w-full mx-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="font-semibold text-lg">Detalle del Post</h3>
|
||||||
|
<button onclick="closePostModal()" class="text-gray-400 hover:text-white text-xl">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="post-modal-content">
|
||||||
|
<!-- Content loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
// Adjust for Monday start
|
{% block extra_scripts %}
|
||||||
const startDate = new Date(firstDay);
|
<style>
|
||||||
startDate.setDate(startDate.getDate() - (startDate.getDay() || 7) + 1);
|
.calendar-day { min-height: 120px; }
|
||||||
|
.calendar-day:hover { background-color: rgba(99, 102, 241, 0.1); }
|
||||||
|
.calendar-day.today { border: 2px solid #6366f1; }
|
||||||
|
.calendar-day.other-month { opacity: 0.4; }
|
||||||
|
.post-item { font-size: 11px; padding: 2px 6px; border-radius: 6px; margin-bottom: 2px; cursor: pointer; }
|
||||||
|
.post-item:hover { opacity: 0.8; }
|
||||||
|
.status-published { background-color: rgba(16, 185, 129, 0.3); color: #34d399; }
|
||||||
|
.status-scheduled { background-color: rgba(59, 130, 246, 0.3); color: #60a5fa; }
|
||||||
|
.status-pending_approval { background-color: rgba(245, 158, 11, 0.3); color: #fbbf24; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
let currentDate = new Date();
|
||||||
|
let currentView = 'month';
|
||||||
|
let calendarData = {};
|
||||||
|
|
||||||
const endDate = new Date(lastDay);
|
window.addEventListener('load', () => {
|
||||||
endDate.setDate(endDate.getDate() + (7 - endDate.getDay()) % 7);
|
loadCalendarData();
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
async function loadCalendarData() {
|
||||||
const response = await fetch(
|
const year = currentDate.getFullYear();
|
||||||
`/api/calendar/posts/view?start_date=${formatDateISO(startDate)}&days=42`
|
const month = currentDate.getMonth();
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
calendarData = data.calendar || {};
|
|
||||||
renderCalendar();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading calendar:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCalendar() {
|
const firstDay = new Date(year, month, 1);
|
||||||
if (currentView === 'month') {
|
const startDate = new Date(firstDay);
|
||||||
renderMonthView();
|
startDate.setDate(startDate.getDate() - (startDate.getDay() || 7) + 1);
|
||||||
} else {
|
|
||||||
renderWeekView();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update month title
|
|
||||||
const months = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
|
||||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
|
||||||
document.getElementById('current-month').textContent =
|
|
||||||
`${months[currentDate.getMonth()]} ${currentDate.getFullYear()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMonthView() {
|
|
||||||
const grid = document.getElementById('calendar-grid');
|
|
||||||
const year = currentDate.getFullYear();
|
|
||||||
const month = currentDate.getMonth();
|
|
||||||
|
|
||||||
const firstDay = new Date(year, month, 1);
|
|
||||||
const lastDay = new Date(year, month + 1, 0);
|
|
||||||
|
|
||||||
// Start from Monday
|
|
||||||
const startDate = new Date(firstDay);
|
|
||||||
startDate.setDate(startDate.getDate() - (startDate.getDay() || 7) + 1);
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const platforms = getSelectedPlatforms();
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
for (let i = 0; i < 42; i++) {
|
|
||||||
const date = new Date(startDate);
|
|
||||||
date.setDate(date.getDate() + i);
|
|
||||||
|
|
||||||
const dateKey = formatDateISO(date);
|
|
||||||
const isToday = date.getTime() === today.getTime();
|
|
||||||
const isOtherMonth = date.getMonth() !== month;
|
|
||||||
|
|
||||||
const dayPosts = (calendarData[dateKey] || []).filter(post =>
|
|
||||||
post.platforms && post.platforms.some(p => platforms.includes(p))
|
|
||||||
);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="calendar-day p-2 border-r border-b border-gray-700 ${isToday ? 'today' : ''} ${isOtherMonth ? 'other-month' : ''}"
|
|
||||||
ondrop="dropPost(event, '${dateKey}')" ondragover="allowDrop(event)">
|
|
||||||
<div class="font-semibold text-sm mb-1 ${isToday ? 'accent' : ''}">${date.getDate()}</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
${dayPosts.slice(0, 4).map(post => `
|
|
||||||
<div class="post-item status-${post.status} truncate"
|
|
||||||
draggable="true"
|
|
||||||
ondragstart="dragPost(event, ${post.id})"
|
|
||||||
onclick="showPostDetail(${post.id})">
|
|
||||||
${post.platforms ? post.platforms[0].charAt(0).toUpperCase() : '?'}:
|
|
||||||
${escapeHtml(post.content || '').substring(0, 20)}
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
${dayPosts.length > 4 ? `<div class="text-xs text-gray-400">+${dayPosts.length - 4} más</div>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
grid.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWeekView() {
|
|
||||||
const container = document.getElementById('week-grid');
|
|
||||||
const weekStart = new Date(currentDate);
|
|
||||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay() + 1); // Monday
|
|
||||||
|
|
||||||
const platforms = getSelectedPlatforms();
|
|
||||||
const days = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
const date = new Date(weekStart);
|
|
||||||
date.setDate(date.getDate() + i);
|
|
||||||
const dateKey = formatDateISO(date);
|
|
||||||
const isToday = date.getTime() === today.getTime();
|
|
||||||
|
|
||||||
const dayPosts = (calendarData[dateKey] || []).filter(post =>
|
|
||||||
post.platforms && post.platforms.some(p => platforms.includes(p))
|
|
||||||
);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="p-4 ${isToday ? 'bg-gray-800' : ''}" ondrop="dropPost(event, '${dateKey}')" ondragover="allowDrop(event)">
|
|
||||||
<div class="flex items-center gap-4 mb-3">
|
|
||||||
<div class="font-semibold ${isToday ? 'accent' : ''}">${days[i]}</div>
|
|
||||||
<div class="text-gray-400">${date.getDate()}/${date.getMonth() + 1}</div>
|
|
||||||
${isToday ? '<span class="bg-amber-600 text-xs px-2 py-1 rounded">Hoy</span>' : ''}
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-4 gap-2">
|
|
||||||
${dayPosts.map(post => `
|
|
||||||
<div class="post-item status-${post.status} p-2"
|
|
||||||
draggable="true"
|
|
||||||
ondragstart="dragPost(event, ${post.id})"
|
|
||||||
onclick="showPostDetail(${post.id})">
|
|
||||||
<div class="flex gap-1 mb-1">
|
|
||||||
${post.platforms ? post.platforms.map(p => `
|
|
||||||
<span class="text-xs opacity-75">${p}</span>
|
|
||||||
`).join('') : ''}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs truncate">${escapeHtml(post.content || '').substring(0, 50)}</div>
|
|
||||||
<div class="text-xs text-gray-400 mt-1">
|
|
||||||
${post.scheduled_at ? new Date(post.scheduled_at).toLocaleTimeString('es-MX', {hour: '2-digit', minute: '2-digit'}) : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('') || '<div class="text-gray-500 text-sm">Sin posts</div>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setView(view) {
|
|
||||||
currentView = view;
|
|
||||||
document.getElementById('btn-month').className = view === 'month' ? 'btn-primary px-4 py-2 rounded' : 'btn-secondary px-4 py-2 rounded';
|
|
||||||
document.getElementById('btn-week').className = view === 'week' ? 'btn-primary px-4 py-2 rounded' : 'btn-secondary px-4 py-2 rounded';
|
|
||||||
|
|
||||||
document.getElementById('calendar-grid').parentElement.classList.toggle('hidden', view !== 'month');
|
|
||||||
document.getElementById('week-view').classList.toggle('hidden', view !== 'week');
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/calendar/posts/view?start_date=${formatDateISO(startDate)}&days=42`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
calendarData = data.calendar || {};
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading calendar:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
if (currentView === 'month') {
|
||||||
|
renderMonthView();
|
||||||
|
} else {
|
||||||
|
renderWeekView();
|
||||||
}
|
}
|
||||||
|
|
||||||
function prevMonth() {
|
const months = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||||
if (currentView === 'month') {
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||||
currentDate.setMonth(currentDate.getMonth() - 1);
|
document.getElementById('current-month').textContent =
|
||||||
} else {
|
`${months[currentDate.getMonth()]} ${currentDate.getFullYear()}`;
|
||||||
currentDate.setDate(currentDate.getDate() - 7);
|
}
|
||||||
}
|
|
||||||
loadCalendarData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextMonth() {
|
function renderMonthView() {
|
||||||
if (currentView === 'month') {
|
const grid = document.getElementById('calendar-grid');
|
||||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
const year = currentDate.getFullYear();
|
||||||
} else {
|
const month = currentDate.getMonth();
|
||||||
currentDate.setDate(currentDate.getDate() + 7);
|
|
||||||
}
|
|
||||||
loadCalendarData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToToday() {
|
const firstDay = new Date(year, month, 1);
|
||||||
currentDate = new Date();
|
const startDate = new Date(firstDay);
|
||||||
loadCalendarData();
|
startDate.setDate(startDate.getDate() - (startDate.getDay() || 7) + 1);
|
||||||
}
|
|
||||||
|
|
||||||
function getSelectedPlatforms() {
|
const today = new Date();
|
||||||
return Array.from(document.querySelectorAll('.platform-filter:checked')).map(el => el.value);
|
today.setHours(0, 0, 0, 0);
|
||||||
}
|
|
||||||
|
|
||||||
function filterCalendar() {
|
const platforms = getSelectedPlatforms();
|
||||||
renderCalendar();
|
let html = '';
|
||||||
}
|
|
||||||
|
|
||||||
// Drag and drop
|
for (let i = 0; i < 42; i++) {
|
||||||
let draggedPostId = null;
|
const date = new Date(startDate);
|
||||||
|
date.setDate(date.getDate() + i);
|
||||||
|
|
||||||
function dragPost(event, postId) {
|
const dateKey = formatDateISO(date);
|
||||||
draggedPostId = postId;
|
const isToday = date.getTime() === today.getTime();
|
||||||
event.dataTransfer.setData('text/plain', postId);
|
const isOtherMonth = date.getMonth() !== month;
|
||||||
}
|
|
||||||
|
|
||||||
function allowDrop(event) {
|
const dayPosts = (calendarData[dateKey] || []).filter(post =>
|
||||||
event.preventDefault();
|
post.platforms && post.platforms.some(p => platforms.includes(p))
|
||||||
}
|
);
|
||||||
|
|
||||||
async function dropPost(event, dateKey) {
|
html += `
|
||||||
event.preventDefault();
|
<div class="calendar-day p-2 border-r border-b border-dark-600 ${isToday ? 'today' : ''} ${isOtherMonth ? 'other-month' : ''}"
|
||||||
|
ondrop="dropPost(event, '${dateKey}')" ondragover="allowDrop(event)">
|
||||||
if (!draggedPostId) return;
|
<div class="font-semibold text-sm mb-1 ${isToday ? 'text-primary' : ''}">${date.getDate()}</div>
|
||||||
|
<div class="space-y-1">
|
||||||
const newDate = new Date(dateKey + 'T12:00:00');
|
${dayPosts.slice(0, 4).map(post => `
|
||||||
|
<div class="post-item status-${post.status} truncate"
|
||||||
try {
|
draggable="true"
|
||||||
await fetch(`/api/calendar/posts/${draggedPostId}/reschedule?scheduled_at=${newDate.toISOString()}`, {
|
ondragstart="dragPost(event, ${post.id})"
|
||||||
method: 'POST'
|
onclick="showPostDetail(${post.id})">
|
||||||
});
|
${post.platforms ? post.platforms[0].charAt(0).toUpperCase() : '?'}:
|
||||||
loadCalendarData();
|
${escapeHtml(post.content || '').substring(0, 20)}
|
||||||
} catch (error) {
|
</div>
|
||||||
alert('Error al reprogramar: ' + error.message);
|
`).join('')}
|
||||||
}
|
${dayPosts.length > 4 ? `<div class="text-xs text-gray-400">+${dayPosts.length - 4} más</div>` : ''}
|
||||||
|
|
||||||
draggedPostId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Post detail modal
|
|
||||||
function showPostDetail(postId) {
|
|
||||||
// Find post in calendar data
|
|
||||||
let post = null;
|
|
||||||
for (const dayPosts of Object.values(calendarData)) {
|
|
||||||
post = dayPosts.find(p => p.id === postId);
|
|
||||||
if (post) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!post) return;
|
|
||||||
|
|
||||||
const content = `
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<span class="status-${post.status} px-2 py-1 rounded text-xs">${post.status}</span>
|
|
||||||
${post.platforms ? post.platforms.map(p => `<span class="bg-gray-700 px-2 py-1 rounded text-xs ml-1">${p}</span>`).join('') : ''}
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-300">${escapeHtml(post.content || '')}</p>
|
|
||||||
<div class="text-gray-400 text-sm">
|
|
||||||
${post.scheduled_at ? `Programado: ${new Date(post.scheduled_at).toLocaleString('es-MX')}` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 pt-4 border-t border-gray-700">
|
|
||||||
<a href="/dashboard/posts" class="btn-secondary px-4 py-2 rounded text-sm">Ver en Posts</a>
|
|
||||||
${post.status === 'scheduled' ? `
|
|
||||||
<button onclick="publishNowFromModal(${post.id})" class="btn-primary px-4 py-2 rounded text-sm">Publicar Ahora</button>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('post-modal-content').innerHTML = content;
|
|
||||||
document.getElementById('post-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('post-modal').classList.add('flex');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePostModal() {
|
grid.innerHTML = html;
|
||||||
document.getElementById('post-modal').classList.add('hidden');
|
}
|
||||||
document.getElementById('post-modal').classList.remove('flex');
|
|
||||||
|
function renderWeekView() {
|
||||||
|
const container = document.getElementById('week-grid');
|
||||||
|
const weekStart = new Date(currentDate);
|
||||||
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay() + 1);
|
||||||
|
|
||||||
|
const platforms = getSelectedPlatforms();
|
||||||
|
const days = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const date = new Date(weekStart);
|
||||||
|
date.setDate(date.getDate() + i);
|
||||||
|
const dateKey = formatDateISO(date);
|
||||||
|
const isToday = date.getTime() === today.getTime();
|
||||||
|
|
||||||
|
const dayPosts = (calendarData[dateKey] || []).filter(post =>
|
||||||
|
post.platforms && post.platforms.some(p => platforms.includes(p))
|
||||||
|
);
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="p-4 ${isToday ? 'bg-primary/10' : ''}" ondrop="dropPost(event, '${dateKey}')" ondragover="allowDrop(event)">
|
||||||
|
<div class="flex items-center gap-4 mb-3">
|
||||||
|
<div class="font-semibold ${isToday ? 'text-primary' : ''}">${days[i]}</div>
|
||||||
|
<div class="text-gray-400">${date.getDate()}/${date.getMonth() + 1}</div>
|
||||||
|
${isToday ? '<span class="bg-primary text-white text-xs px-2 py-1 rounded-full">Hoy</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-4 gap-2">
|
||||||
|
${dayPosts.map(post => `
|
||||||
|
<div class="post-item status-${post.status} p-2 rounded-lg"
|
||||||
|
draggable="true"
|
||||||
|
ondragstart="dragPost(event, ${post.id})"
|
||||||
|
onclick="showPostDetail(${post.id})">
|
||||||
|
<div class="flex gap-1 mb-1">
|
||||||
|
${post.platforms ? post.platforms.map(p => `
|
||||||
|
<span class="text-xs opacity-75">${p}</span>
|
||||||
|
`).join('') : ''}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs truncate">${escapeHtml(post.content || '').substring(0, 50)}</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">
|
||||||
|
${post.scheduled_at ? new Date(post.scheduled_at).toLocaleTimeString('es-MX', {hour: '2-digit', minute: '2-digit'}) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('') || '<div class="text-gray-500 text-sm">Sin posts</div>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishNowFromModal(postId) {
|
container.innerHTML = html;
|
||||||
try {
|
}
|
||||||
await fetch(`/api/calendar/posts/${postId}/publish-now`, { method: 'POST' });
|
|
||||||
closePostModal();
|
function setView(view) {
|
||||||
loadCalendarData();
|
currentView = view;
|
||||||
alert('Post enviado a publicación');
|
document.getElementById('btn-month').className = view === 'month' ? 'btn-primary px-4 py-2 rounded-xl' : 'btn-secondary px-4 py-2 rounded-xl';
|
||||||
} catch (error) {
|
document.getElementById('btn-week').className = view === 'week' ? 'btn-primary px-4 py-2 rounded-xl' : 'btn-secondary px-4 py-2 rounded-xl';
|
||||||
alert('Error: ' + error.message);
|
|
||||||
}
|
document.getElementById('month-view').classList.toggle('hidden', view !== 'month');
|
||||||
|
document.getElementById('week-view').classList.toggle('hidden', view !== 'week');
|
||||||
|
|
||||||
|
renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
if (currentView === 'month') {
|
||||||
|
currentDate.setMonth(currentDate.getMonth() - 1);
|
||||||
|
} else {
|
||||||
|
currentDate.setDate(currentDate.getDate() - 7);
|
||||||
|
}
|
||||||
|
loadCalendarData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth() {
|
||||||
|
if (currentView === 'month') {
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||||
|
} else {
|
||||||
|
currentDate.setDate(currentDate.getDate() + 7);
|
||||||
|
}
|
||||||
|
loadCalendarData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToToday() {
|
||||||
|
currentDate = new Date();
|
||||||
|
loadCalendarData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedPlatforms() {
|
||||||
|
return Array.from(document.querySelectorAll('.platform-filter:checked')).map(el => el.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterCalendar() {
|
||||||
|
renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
let draggedPostId = null;
|
||||||
|
|
||||||
|
function dragPost(event, postId) {
|
||||||
|
draggedPostId = postId;
|
||||||
|
event.dataTransfer.setData('text/plain', postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowDrop(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropPost(event, dateKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!draggedPostId) return;
|
||||||
|
|
||||||
|
const newDate = new Date(dateKey + 'T12:00:00');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/calendar/posts/${draggedPostId}/reschedule?scheduled_at=${newDate.toISOString()}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
loadCalendarData();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al reprogramar: ' + error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utilities
|
draggedPostId = null;
|
||||||
function formatDateISO(date) {
|
}
|
||||||
return date.toISOString().split('T')[0];
|
|
||||||
|
function showPostDetail(postId) {
|
||||||
|
let post = null;
|
||||||
|
for (const dayPosts of Object.values(calendarData)) {
|
||||||
|
post = dayPosts.find(p => p.id === postId);
|
||||||
|
if (post) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
if (!post) return;
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
const content = `
|
||||||
return div.innerHTML;
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<span class="status-${post.status} px-2 py-1 rounded-lg text-xs">${post.status}</span>
|
||||||
|
${post.platforms ? post.platforms.map(p => `<span class="bg-dark-700 px-2 py-1 rounded-lg text-xs ml-1">${p}</span>`).join('') : ''}
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-300">${escapeHtml(post.content || '')}</p>
|
||||||
|
<div class="text-gray-400 text-sm">
|
||||||
|
${post.scheduled_at ? `Programado: ${new Date(post.scheduled_at).toLocaleString('es-MX')}` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 pt-4 border-t border-dark-600">
|
||||||
|
<a href="/posts" class="btn-secondary px-4 py-2 rounded-xl text-sm">Ver en Posts</a>
|
||||||
|
${post.status === 'scheduled' ? `
|
||||||
|
<button onclick="publishNowFromModal(${post.id})" class="btn-primary px-4 py-2 rounded-xl text-sm">Publicar Ahora</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('post-modal-content').innerHTML = content;
|
||||||
|
document.getElementById('post-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('post-modal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePostModal() {
|
||||||
|
document.getElementById('post-modal').classList.add('hidden');
|
||||||
|
document.getElementById('post-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishNowFromModal(postId) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/calendar/posts/${postId}/publish-now`, { method: 'POST' });
|
||||||
|
closePostModal();
|
||||||
|
loadCalendarData();
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">🎉</span><p>Post enviado a publicación</p></div>');
|
||||||
|
} catch (error) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error: ' + error.message + '</p></div>');
|
||||||
}
|
}
|
||||||
</script>
|
}
|
||||||
</body>
|
|
||||||
</html>
|
function formatDateISO(date) {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,626 +1,348 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="es">
|
|
||||||
<head>
|
{% block title %}Crear Post{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{% block content %}
|
||||||
<title>Crear Post - Social Media Automation</title>
|
<div class="animate-fade-in">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { background-color: #1a1a2e; color: #eee; }
|
|
||||||
.card { background-color: #16213e; border-radius: 12px; }
|
|
||||||
.accent { color: #d4a574; }
|
|
||||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
|
||||||
.btn-primary:hover { background-color: #c49564; }
|
|
||||||
.btn-secondary { background-color: #374151; color: #fff; }
|
|
||||||
.btn-secondary:hover { background-color: #4b5563; }
|
|
||||||
textarea { background-color: #0f172a; border-color: #334155; }
|
|
||||||
textarea:focus { border-color: #d4a574; outline: none; }
|
|
||||||
.platform-btn { transition: all 0.2s; }
|
|
||||||
.platform-btn.selected { ring: 2px; ring-color: #d4a574; }
|
|
||||||
.char-warning { color: #fbbf24; }
|
|
||||||
.char-error { color: #ef4444; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen">
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
<div class="mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<h1 class="text-3xl font-bold">Crear Publicación</h1>
|
||||||
<h1 class="text-2xl font-bold">
|
<p class="text-gray-400 mt-1">Genera y programa contenido para redes sociales</p>
|
||||||
<span class="accent">Consultoría AS</span> - Social Media
|
</div>
|
||||||
</h1>
|
|
||||||
<nav class="flex gap-4">
|
|
||||||
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
|
||||||
<a href="/dashboard/compose" class="px-4 py-2 rounded bg-gray-800 accent">Crear Post</a>
|
|
||||||
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
|
||||||
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
|
||||||
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="container mx-auto px-6 py-8 max-w-4xl">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<h2 class="text-2xl font-bold mb-6">Crear Nueva Publicación</h2>
|
<!-- Main Form -->
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
<form id="compose-form" class="space-y-6">
|
|
||||||
<!-- Platform Selection -->
|
<!-- Platform Selection -->
|
||||||
<div class="card p-6">
|
<div class="card rounded-2xl p-6">
|
||||||
<h3 class="font-semibold mb-4">Plataformas</h3>
|
<h3 class="font-semibold mb-4 flex items-center gap-2">
|
||||||
<div class="flex gap-4">
|
<span>📱</span>
|
||||||
<button type="button" onclick="togglePlatform('x')"
|
<span>Plataformas</span>
|
||||||
class="platform-btn px-6 py-3 rounded-lg bg-gray-800 hover:bg-gray-700 flex items-center gap-2"
|
</h3>
|
||||||
id="btn-x">
|
<div class="flex flex-wrap gap-3">
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
<button type="button" onclick="togglePlatform('x')" id="btn-x"
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
class="platform-btn px-5 py-3 rounded-xl bg-dark-800 hover:bg-dark-700 flex items-center gap-2 transition-all border-2 border-transparent">
|
||||||
</svg>
|
<span class="text-xl">𝕏</span>
|
||||||
<span>X</span>
|
<span>Twitter/X</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="togglePlatform('threads')"
|
<button type="button" onclick="togglePlatform('facebook')" id="btn-facebook"
|
||||||
class="platform-btn px-6 py-3 rounded-lg bg-gray-800 hover:bg-gray-700 flex items-center gap-2"
|
class="platform-btn px-5 py-3 rounded-xl bg-dark-800 hover:bg-dark-700 flex items-center gap-2 transition-all border-2 border-transparent">
|
||||||
id="btn-threads">
|
<span class="text-xl">📘</span>
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.96-.065-1.17.408-2.168 1.332-2.81.88-.612 2.104-.9 3.64-.857 1.016.028 1.96.132 2.828.31-.055-.792-.283-1.392-.683-1.786-.468-.46-1.166-.682-2.135-.682h-.07c-.834.019-1.548.26-2.063.7-.472.4-.768.945-.859 1.576l-2.028-.283c.142-.981.58-1.838 1.265-2.477.891-.829 2.092-1.27 3.474-1.274h.096c1.492.013 2.706.46 3.607 1.328.857.825 1.348 2.007 1.461 3.517.636.174 1.227.398 1.768.67 1.327.666 2.358 1.634 2.982 2.8.818 1.524.876 3.916-.935 5.686-1.818 1.779-4.16 2.606-7.378 2.606zm-.39-5.086c1.075-.055 1.834-.467 2.254-1.22.396-.71.583-1.745.558-3.078-.842-.156-1.73-.242-2.66-.258-1.927-.048-3.085.484-3.181 1.461-.064.648.222 1.27.806 1.753.618.512 1.42.785 2.223.785z"/>
|
|
||||||
</svg>
|
|
||||||
<span>Threads</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="togglePlatform('facebook')"
|
|
||||||
class="platform-btn px-6 py-3 rounded-lg bg-gray-800 hover:bg-gray-700 flex items-center gap-2"
|
|
||||||
id="btn-facebook">
|
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
|
||||||
</svg>
|
|
||||||
<span>Facebook</span>
|
<span>Facebook</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="togglePlatform('instagram')"
|
<button type="button" onclick="togglePlatform('instagram')" id="btn-instagram"
|
||||||
class="platform-btn px-6 py-3 rounded-lg bg-gray-800 hover:bg-gray-700 flex items-center gap-2"
|
class="platform-btn px-5 py-3 rounded-xl bg-dark-800 hover:bg-dark-700 flex items-center gap-2 transition-all border-2 border-transparent">
|
||||||
id="btn-instagram">
|
<span class="text-xl">📸</span>
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
|
|
||||||
</svg>
|
|
||||||
<span>Instagram</span>
|
<span>Instagram</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onclick="togglePlatform('threads')" id="btn-threads"
|
||||||
|
class="platform-btn px-5 py-3 rounded-xl bg-dark-800 hover:bg-dark-700 flex items-center gap-2 transition-all border-2 border-transparent">
|
||||||
|
<span class="text-xl">🧵</span>
|
||||||
|
<span>Threads</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-500 text-sm mt-2">Selecciona una o más plataformas</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="card p-6">
|
<div class="card rounded-2xl p-6">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<h3 class="font-semibold mb-4 flex items-center gap-2">
|
||||||
<h3 class="font-semibold">Contenido</h3>
|
<span>✍️</span>
|
||||||
<div id="char-counter" class="text-sm">
|
<span>Contenido</span>
|
||||||
<span id="char-count">0</span> caracteres
|
</h3>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="content"
|
id="content"
|
||||||
name="content"
|
|
||||||
rows="6"
|
rows="6"
|
||||||
class="w-full px-4 py-3 rounded-lg border resize-none"
|
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:border-primary focus:outline-none resize-none transition-colors"
|
||||||
placeholder="Escribe tu publicación aquí..."
|
placeholder="Escribe tu publicación aquí..."
|
||||||
oninput="updateCharCount()"
|
oninput="updateCharCount()"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<div class="flex justify-between items-center mt-3">
|
||||||
<!-- Platform limits -->
|
<div id="char-count" class="text-sm text-gray-500">0 / 280</div>
|
||||||
<div id="platform-limits" class="mt-3 space-y-1 text-sm">
|
<div class="flex gap-2">
|
||||||
<!-- Se llena dinámicamente -->
|
<button type="button" onclick="generateWithAI('tip')" class="btn-secondary px-3 py-1 rounded-lg text-sm flex items-center gap-1">
|
||||||
|
<span>💡</span> Generar Tip
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="generateWithAI('promo')" class="btn-secondary px-3 py-1 rounded-lg text-sm flex items-center gap-1">
|
||||||
|
<span>📢</span> Generar Promo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI Assist -->
|
<!-- Hashtags -->
|
||||||
<div class="card p-6">
|
<div class="card rounded-2xl p-6">
|
||||||
<h3 class="font-semibold mb-4">Asistente IA (DeepSeek)</h3>
|
<h3 class="font-semibold mb-4 flex items-center gap-2">
|
||||||
<div class="flex gap-2 flex-wrap">
|
<span>#️⃣</span>
|
||||||
<button type="button" onclick="generateTip()"
|
<span>Hashtags</span>
|
||||||
class="ai-btn btn-secondary px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
</h3>
|
||||||
<span>Generar Tip Tech</span>
|
<input
|
||||||
</button>
|
type="text"
|
||||||
<button type="button" onclick="improveContent()"
|
id="hashtags"
|
||||||
class="ai-btn btn-secondary px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:border-primary focus:outline-none transition-colors"
|
||||||
<span>Mejorar Texto</span>
|
placeholder="#Tecnología #Automatización #Tijuana"
|
||||||
</button>
|
>
|
||||||
<button type="button" onclick="adaptContent()"
|
<div class="flex flex-wrap gap-2 mt-3">
|
||||||
class="ai-btn btn-secondary px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
<button type="button" onclick="addHashtag('#ConsultoríaAS')" class="text-xs bg-dark-700 px-2 py-1 rounded-full hover:bg-dark-600 transition-colors">#ConsultoríaAS</button>
|
||||||
<span>Adaptar por Plataforma</span>
|
<button type="button" onclick="addHashtag('#Tijuana')" class="text-xs bg-dark-700 px-2 py-1 rounded-full hover:bg-dark-600 transition-colors">#Tijuana</button>
|
||||||
</button>
|
<button type="button" onclick="addHashtag('#Tecnología')" class="text-xs bg-dark-700 px-2 py-1 rounded-full hover:bg-dark-600 transition-colors">#Tecnología</button>
|
||||||
|
<button type="button" onclick="addHashtag('#Automatización')" class="text-xs bg-dark-700 px-2 py-1 rounded-full hover:bg-dark-600 transition-colors">#Automatización</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="ai-status" class="text-gray-500 text-sm mt-2">
|
|
||||||
Verificando estado de IA...
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Schedule -->
|
<!-- Schedule -->
|
||||||
<div class="card p-6">
|
<div class="card rounded-2xl p-6">
|
||||||
<h3 class="font-semibold mb-4">Programación</h3>
|
<h3 class="font-semibold mb-4 flex items-center gap-2">
|
||||||
<div class="flex gap-4 items-center">
|
<span>📅</span>
|
||||||
<label class="flex items-center gap-2">
|
<span>Programación</span>
|
||||||
<input type="radio" name="schedule" value="now" checked
|
</h3>
|
||||||
class="text-amber-500" onchange="toggleSchedule()">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<span>Publicar ahora</span>
|
<div>
|
||||||
</label>
|
<label class="block text-sm text-gray-400 mb-2">Fecha</label>
|
||||||
<label class="flex items-center gap-2">
|
<input
|
||||||
<input type="radio" name="schedule" value="later"
|
type="date"
|
||||||
class="text-amber-500" onchange="toggleSchedule()">
|
id="schedule-date"
|
||||||
<span>Programar</span>
|
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 text-white focus:border-primary focus:outline-none transition-colors"
|
||||||
</label>
|
>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-2">Hora</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
id="schedule-time"
|
||||||
|
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 text-white focus:border-primary focus:outline-none transition-colors"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="schedule-picker" class="mt-4 hidden">
|
</div>
|
||||||
<input type="datetime-local" id="scheduled_at" name="scheduled_at"
|
</div>
|
||||||
class="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2">
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Preview -->
|
||||||
|
<div class="card rounded-2xl p-6">
|
||||||
|
<h3 class="font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<span>👁️</span>
|
||||||
|
<span>Vista Previa</span>
|
||||||
|
</h3>
|
||||||
|
<div id="preview" class="bg-dark-800 rounded-xl p-4 min-h-32">
|
||||||
|
<p class="text-gray-500 text-sm italic">El contenido aparecerá aquí...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex gap-4 justify-end">
|
<div class="card rounded-2xl p-6">
|
||||||
<button type="button" onclick="saveDraft()"
|
<h3 class="font-semibold mb-4 flex items-center gap-2">
|
||||||
class="btn-secondary px-6 py-3 rounded-lg">
|
<span>⚡</span>
|
||||||
Guardar Borrador
|
<span>Acciones</span>
|
||||||
</button>
|
</h3>
|
||||||
<button type="button" onclick="previewPost()"
|
<div class="space-y-3">
|
||||||
class="btn-secondary px-6 py-3 rounded-lg">
|
<button onclick="saveAsDraft()" class="w-full btn-secondary px-4 py-3 rounded-xl flex items-center justify-center gap-2">
|
||||||
Vista Previa
|
<span>💾</span>
|
||||||
</button>
|
<span>Guardar Borrador</span>
|
||||||
<button type="submit" class="btn-primary px-8 py-3 rounded-lg font-semibold">
|
</button>
|
||||||
Publicar
|
<button onclick="schedulePost()" class="w-full btn-secondary px-4 py-3 rounded-xl flex items-center justify-center gap-2">
|
||||||
</button>
|
<span>📅</span>
|
||||||
</div>
|
<span>Programar</span>
|
||||||
</form>
|
</button>
|
||||||
|
<button onclick="publishNow()" class="w-full btn-primary px-4 py-3 rounded-xl flex items-center justify-center gap-2 font-medium">
|
||||||
<!-- Preview Modal -->
|
<span>🚀</span>
|
||||||
<div id="preview-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<span>Publicar Ahora</span>
|
||||||
<div class="card p-6 max-w-2xl w-full mx-4 max-h-screen overflow-y-auto">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="font-semibold text-lg">Vista Previa</h3>
|
|
||||||
<button onclick="closePreview()" class="text-gray-400 hover:text-white">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="preview-content" class="space-y-4">
|
</div>
|
||||||
<!-- Se llena dinámicamente -->
|
|
||||||
|
<!-- AI Status -->
|
||||||
|
<div class="card rounded-2xl p-6">
|
||||||
|
<h3 class="font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<span>🤖</span>
|
||||||
|
<span>IA</span>
|
||||||
|
</h3>
|
||||||
|
<div id="ai-status" class="text-sm text-gray-400">
|
||||||
|
Verificando...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<!-- Result Modal -->
|
{% block extra_scripts %}
|
||||||
<div id="result-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<script>
|
||||||
<div class="card p-6 max-w-md w-full mx-4">
|
let selectedPlatforms = [];
|
||||||
<div id="result-content">
|
const charLimits = { x: 280, facebook: 63206, instagram: 2200, threads: 500 };
|
||||||
<!-- Se llena dinámicamente -->
|
|
||||||
</div>
|
|
||||||
<button onclick="closeResult()" class="btn-primary w-full mt-4 py-2 rounded-lg">
|
|
||||||
Cerrar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
function togglePlatform(platform) {
|
||||||
// State
|
const btn = document.getElementById(`btn-${platform}`);
|
||||||
let selectedPlatforms = [];
|
const index = selectedPlatforms.indexOf(platform);
|
||||||
const charLimits = {
|
|
||||||
x: 280,
|
|
||||||
threads: 500,
|
|
||||||
instagram: 2200,
|
|
||||||
facebook: 63206
|
|
||||||
};
|
|
||||||
|
|
||||||
// Platform selection
|
if (index > -1) {
|
||||||
function togglePlatform(platform) {
|
selectedPlatforms.splice(index, 1);
|
||||||
const btn = document.getElementById(`btn-${platform}`);
|
btn.classList.remove('border-primary', 'bg-primary/20');
|
||||||
const index = selectedPlatforms.indexOf(platform);
|
btn.classList.add('border-transparent');
|
||||||
|
} else {
|
||||||
|
selectedPlatforms.push(platform);
|
||||||
|
btn.classList.add('border-primary', 'bg-primary/20');
|
||||||
|
btn.classList.remove('border-transparent');
|
||||||
|
}
|
||||||
|
updateCharCount();
|
||||||
|
}
|
||||||
|
|
||||||
if (index > -1) {
|
function updateCharCount() {
|
||||||
selectedPlatforms.splice(index, 1);
|
const content = document.getElementById('content').value;
|
||||||
btn.classList.remove('ring-2', 'ring-amber-500');
|
const countEl = document.getElementById('char-count');
|
||||||
|
const previewEl = document.getElementById('preview');
|
||||||
|
|
||||||
|
const limit = selectedPlatforms.includes('x') ? 280 :
|
||||||
|
selectedPlatforms.includes('threads') ? 500 : 2200;
|
||||||
|
|
||||||
|
countEl.textContent = `${content.length} / ${limit}`;
|
||||||
|
|
||||||
|
if (content.length > limit) {
|
||||||
|
countEl.classList.add('text-red-400');
|
||||||
|
countEl.classList.remove('text-yellow-400', 'text-gray-500');
|
||||||
|
} else if (content.length > limit * 0.9) {
|
||||||
|
countEl.classList.add('text-yellow-400');
|
||||||
|
countEl.classList.remove('text-red-400', 'text-gray-500');
|
||||||
|
} else {
|
||||||
|
countEl.classList.add('text-gray-500');
|
||||||
|
countEl.classList.remove('text-red-400', 'text-yellow-400');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update preview
|
||||||
|
if (content) {
|
||||||
|
previewEl.innerHTML = `<p class="text-white whitespace-pre-wrap">${content}</p>`;
|
||||||
|
} else {
|
||||||
|
previewEl.innerHTML = `<p class="text-gray-500 text-sm italic">El contenido aparecerá aquí...</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHashtag(tag) {
|
||||||
|
const input = document.getElementById('hashtags');
|
||||||
|
if (!input.value.includes(tag)) {
|
||||||
|
input.value = input.value ? `${input.value} ${tag}` : tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateWithAI(type) {
|
||||||
|
showModal('Generando contenido con IA...', true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/generate/${type}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ platforms: selectedPlatforms.length ? selectedPlatforms : ['x'] })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.content) {
|
||||||
|
document.getElementById('content').value = data.content;
|
||||||
|
updateCharCount();
|
||||||
|
closeModal();
|
||||||
} else {
|
} else {
|
||||||
selectedPlatforms.push(platform);
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error generando contenido</p></div>`);
|
||||||
btn.classList.add('ring-2', 'ring-amber-500');
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error de conexión</p></div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateCharCount();
|
async function saveAsDraft() {
|
||||||
|
const content = document.getElementById('content').value;
|
||||||
|
if (!content) {
|
||||||
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">⚠️</span><p>Escribe algo primero</p></div>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await savePost('draft');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schedulePost() {
|
||||||
|
const content = document.getElementById('content').value;
|
||||||
|
const date = document.getElementById('schedule-date').value;
|
||||||
|
const time = document.getElementById('schedule-time').value;
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">⚠️</span><p>Escribe algo primero</p></div>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!date || !time) {
|
||||||
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">⚠️</span><p>Selecciona fecha y hora</p></div>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await savePost('scheduled', `${date}T${time}:00`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishNow() {
|
||||||
|
const content = document.getElementById('content').value;
|
||||||
|
if (!content) {
|
||||||
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">⚠️</span><p>Escribe algo primero</p></div>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedPlatforms.length) {
|
||||||
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">⚠️</span><p>Selecciona al menos una plataforma</p></div>`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Character counter
|
showModal('Publicando...', true);
|
||||||
function updateCharCount() {
|
try {
|
||||||
const content = document.getElementById('content').value;
|
const response = await fetch('/api/publish/single', {
|
||||||
const count = content.length;
|
method: 'POST',
|
||||||
const counter = document.getElementById('char-count');
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const limitsDiv = document.getElementById('platform-limits');
|
body: JSON.stringify({
|
||||||
|
content: content,
|
||||||
counter.textContent = count;
|
platforms: selectedPlatforms,
|
||||||
|
hashtags: document.getElementById('hashtags').value.split(' ').filter(h => h)
|
||||||
// Update limits display
|
})
|
||||||
let limitsHtml = '';
|
|
||||||
selectedPlatforms.forEach(platform => {
|
|
||||||
const limit = charLimits[platform];
|
|
||||||
const remaining = limit - count;
|
|
||||||
let statusClass = '';
|
|
||||||
let icon = '✓';
|
|
||||||
|
|
||||||
if (remaining < 0) {
|
|
||||||
statusClass = 'char-error';
|
|
||||||
icon = '✗';
|
|
||||||
} else if (remaining < 50) {
|
|
||||||
statusClass = 'char-warning';
|
|
||||||
icon = '⚠';
|
|
||||||
}
|
|
||||||
|
|
||||||
limitsHtml += `
|
|
||||||
<div class="${statusClass}">
|
|
||||||
${icon} ${platform.charAt(0).toUpperCase() + platform.slice(1)}:
|
|
||||||
${remaining >= 0 ? remaining + ' restantes' : Math.abs(remaining) + ' excedidos'}
|
|
||||||
(máx ${limit})
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
limitsDiv.innerHTML = limitsHtml;
|
if (data.success) {
|
||||||
}
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">🎉</span><p class="text-lg">¡Publicado!</p><p class="text-gray-400 mt-2">${data.message || 'Post publicado correctamente'}</p></div>`);
|
||||||
|
document.getElementById('content').value = '';
|
||||||
// Schedule toggle
|
updateCharCount();
|
||||||
function toggleSchedule() {
|
} else {
|
||||||
const scheduleValue = document.querySelector('input[name="schedule"]:checked').value;
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error: ${data.detail || data.error || 'Error al publicar'}</p></div>`);
|
||||||
const picker = document.getElementById('schedule-picker');
|
|
||||||
picker.classList.toggle('hidden', scheduleValue === 'now');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preview
|
|
||||||
function previewPost() {
|
|
||||||
if (selectedPlatforms.length === 0) {
|
|
||||||
alert('Selecciona al menos una plataforma');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error de conexión</p></div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const content = document.getElementById('content').value;
|
async function savePost(status, scheduledAt = null) {
|
||||||
if (!content.trim()) {
|
showModal('Guardando...', true);
|
||||||
alert('Escribe el contenido del post');
|
try {
|
||||||
return;
|
const body = {
|
||||||
}
|
content: document.getElementById('content').value,
|
||||||
|
platforms: selectedPlatforms.length ? selectedPlatforms : ['x'],
|
||||||
|
hashtags: document.getElementById('hashtags').value.split(' ').filter(h => h),
|
||||||
|
status: status
|
||||||
|
};
|
||||||
|
if (scheduledAt) body.scheduled_at = scheduledAt;
|
||||||
|
|
||||||
const previewDiv = document.getElementById('preview-content');
|
const response = await fetch('/api/posts/', {
|
||||||
let html = '';
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
selectedPlatforms.forEach(platform => {
|
body: JSON.stringify(body)
|
||||||
const limit = charLimits[platform];
|
|
||||||
const truncated = content.length > limit;
|
|
||||||
const displayContent = truncated ? content.substring(0, limit) + '...' : content;
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="bg-gray-800 rounded-lg p-4">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<span class="font-semibold">${platform.charAt(0).toUpperCase() + platform.slice(1)}</span>
|
|
||||||
${truncated ? '<span class="text-red-400 text-xs">(truncado)</span>' : ''}
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-300 whitespace-pre-wrap">${displayContent}</p>
|
|
||||||
<div class="text-gray-500 text-xs mt-2">
|
|
||||||
${content.length}/${limit} caracteres
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
previewDiv.innerHTML = html;
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">✅</span><p class="text-lg">Guardado</p><p class="text-gray-400 mt-2">Post ${status === 'scheduled' ? 'programado' : 'guardado como borrador'}</p></div>`);
|
||||||
document.getElementById('preview-modal').classList.remove('hidden');
|
} catch (error) {
|
||||||
document.getElementById('preview-modal').classList.add('flex');
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error guardando</p></div>`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closePreview() {
|
// Check AI status
|
||||||
document.getElementById('preview-modal').classList.add('hidden');
|
async function checkAIStatus() {
|
||||||
document.getElementById('preview-modal').classList.remove('flex');
|
try {
|
||||||
|
const response = await fetch('/api/generate/status');
|
||||||
|
const data = await response.json();
|
||||||
|
document.getElementById('ai-status').innerHTML = data.configured
|
||||||
|
? `<span class="text-green-400">✓</span> ${data.provider || 'DeepSeek'} configurado`
|
||||||
|
: `<span class="text-red-400">✗</span> No configurado`;
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('ai-status').innerHTML = `<span class="text-red-400">✗</span> Error`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Result modal
|
checkAIStatus();
|
||||||
function showResult(success, message, details = null) {
|
</script>
|
||||||
const resultDiv = document.getElementById('result-content');
|
{% endblock %}
|
||||||
const icon = success ? '✅' : '❌';
|
|
||||||
|
|
||||||
let html = `
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-4xl mb-4">${icon}</div>
|
|
||||||
<h3 class="text-xl font-semibold mb-2">${success ? 'Publicado' : 'Error'}</h3>
|
|
||||||
<p class="text-gray-400">${message}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (details) {
|
|
||||||
html += `
|
|
||||||
<div class="mt-4 space-y-2">
|
|
||||||
${Object.entries(details).map(([platform, result]) => `
|
|
||||||
<div class="flex justify-between items-center bg-gray-800 rounded p-2">
|
|
||||||
<span>${platform}</span>
|
|
||||||
<span class="${result.success ? 'text-green-400' : 'text-red-400'}">
|
|
||||||
${result.success ? '✓ OK' : '✗ ' + (result.error || 'Error')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
resultDiv.innerHTML = html;
|
|
||||||
document.getElementById('result-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('result-modal').classList.add('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeResult() {
|
|
||||||
document.getElementById('result-modal').classList.add('hidden');
|
|
||||||
document.getElementById('result-modal').classList.remove('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form submission
|
|
||||||
document.getElementById('compose-form').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (selectedPlatforms.length === 0) {
|
|
||||||
alert('Selecciona al menos una plataforma');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = document.getElementById('content').value;
|
|
||||||
if (!content.trim()) {
|
|
||||||
alert('Escribe el contenido del post');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare content per platform
|
|
||||||
const platformContent = {};
|
|
||||||
selectedPlatforms.forEach(p => {
|
|
||||||
platformContent[p] = content;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/publish/multiple', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
platforms: selectedPlatforms,
|
|
||||||
content: platformContent
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
showResult(true,
|
|
||||||
`Publicado en ${data.successful_platforms.length} plataforma(s)`,
|
|
||||||
data.results
|
|
||||||
);
|
|
||||||
// Clear form
|
|
||||||
document.getElementById('content').value = '';
|
|
||||||
selectedPlatforms.forEach(p => togglePlatform(p));
|
|
||||||
selectedPlatforms = [];
|
|
||||||
} else {
|
|
||||||
showResult(false,
|
|
||||||
'Algunas publicaciones fallaron',
|
|
||||||
data.results
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showResult(false, error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// AI functions
|
|
||||||
let aiLoading = false;
|
|
||||||
|
|
||||||
function setAiLoading(loading) {
|
|
||||||
aiLoading = loading;
|
|
||||||
document.querySelectorAll('.ai-btn').forEach(btn => {
|
|
||||||
btn.disabled = loading;
|
|
||||||
if (loading) {
|
|
||||||
btn.classList.add('opacity-50', 'cursor-wait');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('opacity-50', 'cursor-wait');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkAiStatus() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/generate/status');
|
|
||||||
const data = await response.json();
|
|
||||||
return data.configured && data.status === 'connected';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateTip() {
|
|
||||||
if (aiLoading) return;
|
|
||||||
|
|
||||||
// Show category selector
|
|
||||||
const categories = [
|
|
||||||
'productividad', 'seguridad', 'ia', 'programacion',
|
|
||||||
'hardware', 'redes', 'cloud', 'automatizacion', 'impresion3d', 'general'
|
|
||||||
];
|
|
||||||
|
|
||||||
const category = prompt(
|
|
||||||
'Categoría del tip:\n\n' + categories.join(', ') + '\n\n(Enter para "general")'
|
|
||||||
) || 'general';
|
|
||||||
|
|
||||||
const platform = selectedPlatforms[0] || 'x';
|
|
||||||
|
|
||||||
setAiLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/generate/tip', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ category, platform })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
document.getElementById('content').value = data.content;
|
|
||||||
updateCharCount();
|
|
||||||
} else {
|
|
||||||
alert('Error: ' + (data.error || 'No se pudo generar'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error de conexión: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
setAiLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function improveContent() {
|
|
||||||
if (aiLoading) return;
|
|
||||||
|
|
||||||
const content = document.getElementById('content').value;
|
|
||||||
if (!content.trim()) {
|
|
||||||
alert('Primero escribe algo de contenido para mejorar');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = ['engaging', 'professional', 'casual', 'educational'];
|
|
||||||
const style = prompt(
|
|
||||||
'Estilo de mejora:\n\n' +
|
|
||||||
'- engaging: Más atractivo\n' +
|
|
||||||
'- professional: Más formal\n' +
|
|
||||||
'- casual: Más cercano\n' +
|
|
||||||
'- educational: Más didáctico\n\n' +
|
|
||||||
'(Enter para "engaging")'
|
|
||||||
) || 'engaging';
|
|
||||||
|
|
||||||
const platform = selectedPlatforms[0] || 'x';
|
|
||||||
|
|
||||||
setAiLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/generate/improve', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ content, platform, style })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
document.getElementById('content').value = data.content;
|
|
||||||
updateCharCount();
|
|
||||||
} else {
|
|
||||||
alert('Error: ' + (data.error || 'No se pudo mejorar'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error de conexión: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
setAiLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function adaptContent() {
|
|
||||||
if (aiLoading) return;
|
|
||||||
|
|
||||||
const content = document.getElementById('content').value;
|
|
||||||
if (!content.trim()) {
|
|
||||||
alert('Primero escribe algo de contenido para adaptar');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedPlatforms.length < 2) {
|
|
||||||
alert('Selecciona al menos 2 plataformas para adaptar el contenido');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAiLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Adaptar a cada plataforma excepto la primera (que es el contenido original)
|
|
||||||
const adaptedContent = { [selectedPlatforms[0]]: content };
|
|
||||||
|
|
||||||
for (let i = 1; i < selectedPlatforms.length; i++) {
|
|
||||||
const platform = selectedPlatforms[i];
|
|
||||||
const response = await fetch('/api/generate/adapt', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ content, target_platform: platform })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
|
||||||
adaptedContent[platform] = data.content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mostrar resultados en preview
|
|
||||||
const previewDiv = document.getElementById('preview-content');
|
|
||||||
let html = '<p class="text-gray-400 mb-4">Contenido adaptado por plataforma:</p>';
|
|
||||||
|
|
||||||
for (const [platform, text] of Object.entries(adaptedContent)) {
|
|
||||||
html += `
|
|
||||||
<div class="bg-gray-800 rounded-lg p-4 mb-2">
|
|
||||||
<div class="font-semibold mb-2">${platform.toUpperCase()}</div>
|
|
||||||
<p class="text-gray-300 whitespace-pre-wrap text-sm">${text}</p>
|
|
||||||
<button onclick="useAdaptedContent('${platform}', this)"
|
|
||||||
class="mt-2 text-amber-500 text-sm hover:underline"
|
|
||||||
data-content="${encodeURIComponent(text)}">
|
|
||||||
Usar este
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
previewDiv.innerHTML = html;
|
|
||||||
document.getElementById('preview-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('preview-modal').classList.add('flex');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error de conexión: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
setAiLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useAdaptedContent(platform, btn) {
|
|
||||||
const content = decodeURIComponent(btn.dataset.content);
|
|
||||||
document.getElementById('content').value = content;
|
|
||||||
updateCharCount();
|
|
||||||
closePreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDraft() {
|
|
||||||
const content = document.getElementById('content').value;
|
|
||||||
localStorage.setItem('draft_content', content);
|
|
||||||
localStorage.setItem('draft_platforms', JSON.stringify(selectedPlatforms));
|
|
||||||
alert('Borrador guardado localmente');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load draft on page load
|
|
||||||
window.addEventListener('load', async () => {
|
|
||||||
const draftContent = localStorage.getItem('draft_content');
|
|
||||||
const draftPlatforms = localStorage.getItem('draft_platforms');
|
|
||||||
|
|
||||||
if (draftContent) {
|
|
||||||
document.getElementById('content').value = draftContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (draftPlatforms) {
|
|
||||||
const platforms = JSON.parse(draftPlatforms);
|
|
||||||
platforms.forEach(p => togglePlatform(p));
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCharCount();
|
|
||||||
|
|
||||||
// Check AI status
|
|
||||||
const aiStatus = document.getElementById('ai-status');
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/generate/status');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.configured && data.status === 'connected') {
|
|
||||||
aiStatus.innerHTML = '<span class="text-green-400">IA conectada y lista</span>';
|
|
||||||
} else if (data.configured) {
|
|
||||||
aiStatus.innerHTML = '<span class="text-yellow-400">IA configurada pero con error: ' + (data.error || 'desconocido') + '</span>';
|
|
||||||
} else {
|
|
||||||
aiStatus.innerHTML = '<span class="text-red-400">IA no configurada. Agrega DEEPSEEK_API_KEY en .env</span>';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
aiStatus.innerHTML = '<span class="text-red-400">No se pudo verificar estado de IA</span>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,182 +1,250 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="es">
|
|
||||||
<head>
|
{% block title %}Dashboard{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{% block content %}
|
||||||
<title>Dashboard - Social Media Automation</title>
|
<div class="animate-fade-in">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { background-color: #1a1a2e; color: #eee; }
|
|
||||||
.card { background-color: #16213e; border-radius: 12px; }
|
|
||||||
.accent { color: #d4a574; }
|
|
||||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
|
||||||
.btn-primary:hover { background-color: #c49564; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen">
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<div>
|
||||||
<h1 class="text-2xl font-bold">
|
<h1 class="text-3xl font-bold">Dashboard</h1>
|
||||||
<span class="accent">Consultoría AS</span> - Social Media
|
<p class="text-gray-400 mt-1">Bienvenido, {{ user.username }}</p>
|
||||||
</h1>
|
|
||||||
<nav class="flex gap-4">
|
|
||||||
<a href="/dashboard" class="px-4 py-2 rounded bg-gray-800">Home</a>
|
|
||||||
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800 accent">+ Crear Post</a>
|
|
||||||
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
|
||||||
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
|
||||||
<a href="/dashboard/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
|
|
||||||
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<a href="/compose" class="btn-primary px-6 py-3 rounded-xl font-medium flex items-center gap-2 transition-all">
|
||||||
|
<span>✍️</span>
|
||||||
|
<span>Crear Post</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="container mx-auto px-6 py-8">
|
<!-- Stats Grid -->
|
||||||
<!-- Stats -->
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
<div class="grid grid-cols-5 gap-4 mb-8">
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<div class="card p-4 text-center">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="text-3xl font-bold accent">{{ stats.posts_today }}</div>
|
<div class="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||||
<div class="text-gray-400 text-sm">Posts Hoy</div>
|
<span class="text-2xl">📝</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4 text-center">
|
<span class="text-xs text-gray-500 bg-dark-700 px-2 py-1 rounded-full">Hoy</span>
|
||||||
<div class="text-3xl font-bold accent">{{ stats.posts_week }}</div>
|
|
||||||
<div class="text-gray-400 text-sm">Posts Semana</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-3xl font-bold text-yellow-500">{{ stats.pending_approval }}</div>
|
|
||||||
<div class="text-gray-400 text-sm">Pendientes</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-3xl font-bold text-blue-500">{{ stats.scheduled }}</div>
|
|
||||||
<div class="text-gray-400 text-sm">Programados</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-3xl font-bold text-red-500">{{ stats.interactions_pending }}</div>
|
|
||||||
<div class="text-gray-400 text-sm">Interacciones</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-3xl font-bold">{{ stats.posts_today }}</p>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">Posts publicados</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-6">
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<!-- Pending Approval -->
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="card p-6">
|
<div class="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||||
<h2 class="text-xl font-semibold mb-4">Pendientes de Aprobación</h2>
|
<span class="text-2xl">📅</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500 bg-dark-700 px-2 py-1 rounded-full">Semana</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-3xl font-bold">{{ stats.posts_week }}</p>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">Posts esta semana</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-yellow-500/20 flex items-center justify-center">
|
||||||
|
<span class="text-2xl">⏳</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-yellow-400 bg-yellow-500/20 px-2 py-1 rounded-full">Pendiente</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-3xl font-bold">{{ stats.pending_approval }}</p>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">Por aprobar</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
|
||||||
|
<span class="text-2xl">🗓️</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-1 rounded-full">Programado</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-3xl font-bold">{{ stats.scheduled }}</p>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">Posts programados</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Pending Posts -->
|
||||||
|
<div class="card rounded-2xl p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<span>⏳</span>
|
||||||
|
<span>Pendientes de Aprobación</span>
|
||||||
|
</h2>
|
||||||
|
<a href="/posts?status=pending" class="text-primary text-sm hover:underline">Ver todos</a>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
{% if pending_posts %}
|
{% if pending_posts %}
|
||||||
{% for post in pending_posts %}
|
{% for post in pending_posts %}
|
||||||
<div class="border-b border-gray-700 py-4 last:border-0">
|
<div class="bg-dark-800/50 rounded-xl p-4 hover:bg-dark-700/50 transition-colors">
|
||||||
<div class="flex justify-between items-start mb-2">
|
<p class="text-sm text-gray-300 line-clamp-2">{{ post.content[:100] }}{% if post.content|length > 100 %}...{% endif %}</p>
|
||||||
<span class="bg-blue-900 text-blue-300 px-2 py-1 rounded text-xs">
|
<div class="flex items-center justify-between mt-3">
|
||||||
{{ post.content_type }}
|
<div class="flex items-center gap-2">
|
||||||
</span>
|
{% for platform in post.platforms %}
|
||||||
<span class="text-gray-500 text-sm">
|
<span class="text-xs bg-dark-700 px-2 py-1 rounded-full">{{ platform }}</span>
|
||||||
{{ post.scheduled_at }}
|
{% endfor %}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<span class="text-xs text-gray-500">{{ post.created_at[:10] if post.created_at else '-' }}</span>
|
||||||
<p class="text-gray-300 mb-3">{{ post.content[:200] }}...</p>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button onclick="approvePost({{ post.id }})"
|
|
||||||
class="btn-primary px-3 py-1 rounded text-sm">
|
|
||||||
Aprobar
|
|
||||||
</button>
|
|
||||||
<button onclick="rejectPost({{ post.id }})"
|
|
||||||
class="bg-red-900 text-red-300 px-3 py-1 rounded text-sm">
|
|
||||||
Rechazar
|
|
||||||
</button>
|
|
||||||
<button onclick="editPost({{ post.id }})"
|
|
||||||
class="bg-gray-700 px-3 py-1 rounded text-sm">
|
|
||||||
Editar
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-gray-500">No hay posts pendientes</p>
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<span class="text-4xl mb-2 block">✅</span>
|
||||||
|
<p>No hay posts pendientes</p>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Scheduled -->
|
<!-- Scheduled Posts -->
|
||||||
<div class="card p-6">
|
<div class="card rounded-2xl p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Próximas Publicaciones</h2>
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<span>🗓️</span>
|
||||||
|
<span>Próximas Publicaciones</span>
|
||||||
|
</h2>
|
||||||
|
<a href="/calendar" class="text-primary text-sm hover:underline">Ver calendario</a>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
{% if scheduled_posts %}
|
{% if scheduled_posts %}
|
||||||
{% for post in scheduled_posts %}
|
{% for post in scheduled_posts %}
|
||||||
<div class="border-b border-gray-700 py-3 last:border-0 flex items-center gap-4">
|
<div class="bg-dark-800/50 rounded-xl p-4 hover:bg-dark-700/50 transition-colors">
|
||||||
<div class="text-center">
|
<p class="text-sm text-gray-300 line-clamp-2">{{ post.content[:100] }}{% if post.content|length > 100 %}...{% endif %}</p>
|
||||||
<div class="text-accent font-bold">{{ post.scheduled_at }}</div>
|
<div class="flex items-center justify-between mt-3">
|
||||||
</div>
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex-1">
|
{% for platform in post.platforms %}
|
||||||
<span class="text-xs bg-gray-700 px-2 py-1 rounded">
|
<span class="text-xs bg-dark-700 px-2 py-1 rounded-full">{{ platform }}</span>
|
||||||
{{ post.content_type }}
|
{% endfor %}
|
||||||
</span>
|
</div>
|
||||||
<p class="text-gray-400 text-sm mt-1">
|
<span class="text-xs text-green-400">{{ post.scheduled_at[:16] if post.scheduled_at else '-' }}</span>
|
||||||
{{ post.content[:100] }}...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
{% for platform in post.platforms %}
|
|
||||||
<span class="text-xs bg-gray-800 px-2 py-1 rounded">
|
|
||||||
{{ platform }}
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-gray-500">No hay posts programados</p>
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<span class="text-4xl mb-2 block">📭</span>
|
||||||
|
<p>No hay posts programados</p>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Interactions -->
|
<!-- Recent Interactions -->
|
||||||
<div class="card p-6 mt-6">
|
<div class="card rounded-2xl p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Interacciones Recientes</h2>
|
<div class="flex items-center justify-between mb-6">
|
||||||
{% if recent_interactions %}
|
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||||
<div class="grid gap-4">
|
<span>💬</span>
|
||||||
|
<span>Interacciones Recientes</span>
|
||||||
|
</h2>
|
||||||
|
<a href="/interactions" class="text-primary text-sm hover:underline">Ver todas</a>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% if recent_interactions %}
|
||||||
{% for interaction in recent_interactions %}
|
{% for interaction in recent_interactions %}
|
||||||
<div class="bg-gray-800 rounded-lg p-4">
|
<div class="bg-dark-800/50 rounded-xl p-4 hover:bg-dark-700/50 transition-colors">
|
||||||
<div class="flex justify-between items-start mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
<div>
|
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-sm">
|
||||||
<span class="font-semibold">@{{ interaction.author_username }}</span>
|
{{ interaction.author_username[0]|upper if interaction.author_username else '?' }}
|
||||||
<span class="text-gray-500 text-sm ml-2">{{ interaction.platform }}</span>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">@{{ interaction.author_username or 'Usuario' }}</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ interaction.interaction_type }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-gray-500 text-xs">{{ interaction.interaction_at }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-300">{{ interaction.content }}</p>
|
|
||||||
<div class="mt-3 flex gap-2">
|
|
||||||
<button class="bg-accent text-gray-900 px-3 py-1 rounded text-sm">
|
|
||||||
Responder
|
|
||||||
</button>
|
|
||||||
<button class="bg-gray-700 px-3 py-1 rounded text-sm">
|
|
||||||
Marcar como Lead
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm text-gray-400 line-clamp-2">{{ interaction.content[:80] }}{% if interaction.content|length > 80 %}...{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
{% else %}
|
||||||
{% else %}
|
<div class="text-center py-8 text-gray-500">
|
||||||
<p class="text-gray-500">No hay interacciones pendientes</p>
|
<span class="text-4xl mb-2 block">💤</span>
|
||||||
{% endif %}
|
<p>No hay interacciones pendientes</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- Quick Actions -->
|
||||||
async function approvePost(postId) {
|
<div class="mt-8">
|
||||||
const response = await fetch(`/api/posts/${postId}/approve`, { method: 'POST' });
|
<h2 class="text-lg font-semibold mb-4">Acciones Rápidas</h2>
|
||||||
if (response.ok) {
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
location.reload();
|
<a href="/compose" class="card rounded-xl p-4 text-center hover:bg-dark-700/50 transition-all group">
|
||||||
|
<span class="text-3xl mb-2 block group-hover:scale-110 transition-transform">✍️</span>
|
||||||
|
<span class="text-sm text-gray-300">Nuevo Post</span>
|
||||||
|
</a>
|
||||||
|
<a href="/compose?type=tip" class="card rounded-xl p-4 text-center hover:bg-dark-700/50 transition-all group">
|
||||||
|
<span class="text-3xl mb-2 block group-hover:scale-110 transition-transform">💡</span>
|
||||||
|
<span class="text-sm text-gray-300">Generar Tip</span>
|
||||||
|
</a>
|
||||||
|
<a href="/analytics" class="card rounded-xl p-4 text-center hover:bg-dark-700/50 transition-all group">
|
||||||
|
<span class="text-3xl mb-2 block group-hover:scale-110 transition-transform">📊</span>
|
||||||
|
<span class="text-sm text-gray-300">Ver Analytics</span>
|
||||||
|
</a>
|
||||||
|
<a href="/settings" class="card rounded-xl p-4 text-center hover:bg-dark-700/50 transition-all group">
|
||||||
|
<span class="text-3xl mb-2 block group-hover:scale-110 transition-transform">⚙️</span>
|
||||||
|
<span class="text-sm text-gray-300">Configuración</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Platform Status -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Estado de Plataformas</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4" id="platform-status">
|
||||||
|
<!-- Loaded via JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
async function loadPlatformStatus() {
|
||||||
|
const container = document.getElementById('platform-status');
|
||||||
|
const platforms = ['x', 'facebook', 'instagram', 'threads'];
|
||||||
|
|
||||||
|
container.innerHTML = platforms.map(p => `
|
||||||
|
<div class="card rounded-xl p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xl">${getPlatformIcon(p)}</span>
|
||||||
|
<span class="text-xs bg-dark-700 px-2 py-1 rounded-full">Verificando...</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm mt-2 capitalize">${p}</p>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/publish/test');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (const [platform, status] of Object.entries(data)) {
|
||||||
|
const isConnected = status.configured && status.connected;
|
||||||
|
container.innerHTML += `
|
||||||
|
<div class="card rounded-xl p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xl">${getPlatformIcon(platform)}</span>
|
||||||
|
<span class="text-xs ${isConnected ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'} px-2 py-1 rounded-full">
|
||||||
|
${isConnected ? 'Conectado' : 'Desconectado'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm mt-2 capitalize">${platform}</p>
|
||||||
|
${status.details?.username ? `<p class="text-xs text-gray-500">@${status.details.username}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<div class="col-span-4 text-center text-gray-500 py-4">Error cargando estado</div>';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function rejectPost(postId) {
|
function getPlatformIcon(platform) {
|
||||||
if (confirm('¿Seguro que quieres rechazar este post?')) {
|
const icons = { x: '𝕏', facebook: '📘', instagram: '📸', threads: '🧵' };
|
||||||
const response = await fetch(`/api/posts/${postId}/reject`, { method: 'POST' });
|
return icons[platform] || '📱';
|
||||||
if (response.ok) {
|
}
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function editPost(postId) {
|
loadPlatformStatus();
|
||||||
window.location.href = `/posts/${postId}/edit`;
|
</script>
|
||||||
}
|
{% endblock %}
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,424 +1,400 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="es">
|
|
||||||
<head>
|
{% block title %}Interacciones{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{% block content %}
|
||||||
<title>Interacciones - Social Media Automation</title>
|
<div class="animate-fade-in">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { background-color: #1a1a2e; color: #eee; }
|
|
||||||
.card { background-color: #16213e; border-radius: 12px; }
|
|
||||||
.accent { color: #d4a574; }
|
|
||||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
|
||||||
.btn-primary:hover { background-color: #c49564; }
|
|
||||||
.btn-secondary { background-color: #374151; }
|
|
||||||
.btn-secondary:hover { background-color: #4b5563; }
|
|
||||||
.interaction-item { transition: all 0.2s; }
|
|
||||||
.interaction-item:hover { background-color: #1e3a5f; }
|
|
||||||
.interaction-item.selected { border-left: 3px solid #d4a574; }
|
|
||||||
.platform-x { color: #1d9bf0; }
|
|
||||||
.platform-threads { color: #fff; }
|
|
||||||
.platform-instagram { color: #e1306c; }
|
|
||||||
.platform-facebook { color: #1877f2; }
|
|
||||||
.type-comment { background-color: #1e40af; }
|
|
||||||
.type-mention { background-color: #7c3aed; }
|
|
||||||
.type-dm { background-color: #059669; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen">
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<div>
|
||||||
<h1 class="text-2xl font-bold">
|
<h1 class="text-3xl font-bold">Interacciones</h1>
|
||||||
<span class="accent">Consultoría AS</span> - Social Media
|
<p class="text-gray-400 mt-1">Gestiona comentarios, menciones y mensajes</p>
|
||||||
</h1>
|
|
||||||
<nav class="flex gap-4">
|
|
||||||
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
|
||||||
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800">+ Crear</a>
|
|
||||||
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
|
||||||
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
|
||||||
<a href="/dashboard/interactions" class="px-4 py-2 rounded bg-gray-800 accent">Interacciones</a>
|
|
||||||
<a href="/dashboard/settings" class="px-4 py-2 rounded hover:bg-gray-800">Config</a>
|
|
||||||
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<button onclick="syncInteractions()" class="btn-secondary px-6 py-3 rounded-xl font-medium flex items-center gap-2">
|
||||||
|
<span id="sync-icon">🔄</span>
|
||||||
|
<span>Sincronizar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="container mx-auto px-6 py-8">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div class="grid grid-cols-3 gap-6">
|
<!-- Interactions List -->
|
||||||
<!-- Interactions List -->
|
<div class="lg:col-span-2 space-y-6">
|
||||||
<div class="col-span-2">
|
<!-- Filters -->
|
||||||
<!-- Filters -->
|
<div class="card rounded-2xl p-4">
|
||||||
<div class="card p-4 mb-4">
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
<div class="flex gap-4 items-center">
|
<select id="filter-platform" onchange="filterInteractions()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
|
||||||
<select id="filter-platform" onchange="filterInteractions()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
<option value="">Todas las plataformas</option>
|
||||||
<option value="">Todas las plataformas</option>
|
<option value="x">X (Twitter)</option>
|
||||||
<option value="x">X (Twitter)</option>
|
<option value="threads">Threads</option>
|
||||||
<option value="threads">Threads</option>
|
<option value="instagram">Instagram</option>
|
||||||
<option value="instagram">Instagram</option>
|
<option value="facebook">Facebook</option>
|
||||||
<option value="facebook">Facebook</option>
|
</select>
|
||||||
</select>
|
<select id="filter-type" onchange="filterInteractions()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
|
||||||
<select id="filter-type" onchange="filterInteractions()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
<option value="">Todos los tipos</option>
|
||||||
<option value="">Todos los tipos</option>
|
<option value="comment">Comentarios</option>
|
||||||
<option value="comment">Comentarios</option>
|
<option value="mention">Menciones</option>
|
||||||
<option value="mention">Menciones</option>
|
<option value="dm">Mensajes</option>
|
||||||
<option value="dm">Mensajes</option>
|
</select>
|
||||||
</select>
|
<select id="filter-status" onchange="filterInteractions()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
|
||||||
<select id="filter-status" onchange="filterInteractions()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
<option value="pending">Sin responder</option>
|
||||||
<option value="pending">Sin responder</option>
|
<option value="responded">Respondidos</option>
|
||||||
<option value="responded">Respondidos</option>
|
<option value="all">Todos</option>
|
||||||
<option value="all">Todos</option>
|
</select>
|
||||||
</select>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button onclick="syncInteractions()" class="btn-secondary px-4 py-2 rounded flex items-center gap-2">
|
|
||||||
<span id="sync-icon">🔄</span> Sincronizar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats -->
|
|
||||||
<div class="grid grid-cols-4 gap-4 mb-4">
|
|
||||||
<div class="card p-3 text-center">
|
|
||||||
<div class="text-xl font-bold text-red-400" id="stat-pending">0</div>
|
|
||||||
<div class="text-xs text-gray-400">Sin responder</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-3 text-center">
|
|
||||||
<div class="text-xl font-bold text-green-400" id="stat-responded">0</div>
|
|
||||||
<div class="text-xs text-gray-400">Respondidos</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-3 text-center">
|
|
||||||
<div class="text-xl font-bold text-purple-400" id="stat-leads">0</div>
|
|
||||||
<div class="text-xs text-gray-400">Leads</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-3 text-center">
|
|
||||||
<div class="text-xl font-bold text-gray-400" id="stat-total">0</div>
|
|
||||||
<div class="text-xs text-gray-400">Total</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- List -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="p-4 border-b border-gray-700">
|
|
||||||
<h2 class="font-semibold">Interacciones</h2>
|
|
||||||
</div>
|
|
||||||
<div id="interactions-list" class="divide-y divide-gray-700 max-h-screen overflow-y-auto">
|
|
||||||
<div class="p-8 text-center text-gray-500">Cargando...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Detail Panel -->
|
<!-- Stats -->
|
||||||
<div class="col-span-1">
|
<div class="grid grid-cols-4 gap-4">
|
||||||
<div class="card p-6 sticky top-6" id="detail-panel">
|
<div class="card rounded-2xl p-4 text-center">
|
||||||
<div class="text-center text-gray-500 py-8">
|
<div class="text-2xl font-bold text-red-400" id="stat-pending">0</div>
|
||||||
<p>Selecciona una interacción</p>
|
<div class="text-xs text-gray-400">Sin responder</div>
|
||||||
<p class="text-sm mt-2">para ver detalles y responder</p>
|
</div>
|
||||||
</div>
|
<div class="card rounded-2xl p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-green-400" id="stat-responded">0</div>
|
||||||
|
<div class="text-xs text-gray-400">Respondidos</div>
|
||||||
|
</div>
|
||||||
|
<div class="card rounded-2xl p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-purple-400" id="stat-leads">0</div>
|
||||||
|
<div class="text-xs text-gray-400">Leads</div>
|
||||||
|
</div>
|
||||||
|
<div class="card rounded-2xl p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-gray-400" id="stat-total">0</div>
|
||||||
|
<div class="text-xs text-gray-400">Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<div class="card rounded-2xl overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-dark-600">
|
||||||
|
<h2 class="font-semibold">Interacciones</h2>
|
||||||
|
</div>
|
||||||
|
<div id="interactions-list" class="divide-y divide-dark-600 max-h-[600px] overflow-y-auto">
|
||||||
|
<div class="p-8 text-center text-gray-500">Cargando...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
<!-- Detail Panel -->
|
||||||
let interactions = [];
|
<div class="lg:col-span-1">
|
||||||
let selectedId = null;
|
<div class="card rounded-2xl p-6 sticky top-6" id="detail-panel">
|
||||||
|
<div class="text-center text-gray-500 py-8">
|
||||||
|
<span class="text-4xl block mb-4">💬</span>
|
||||||
|
<p>Selecciona una interacción</p>
|
||||||
|
<p class="text-sm mt-2">para ver detalles y responder</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
window.addEventListener('load', loadInteractions);
|
{% block extra_scripts %}
|
||||||
|
<style>
|
||||||
|
.interaction-item { transition: all 0.2s; }
|
||||||
|
.interaction-item:hover { background-color: rgba(99, 102, 241, 0.1); }
|
||||||
|
.interaction-item.selected { border-left: 3px solid #6366f1; background-color: rgba(99, 102, 241, 0.1); }
|
||||||
|
.platform-x { color: #1d9bf0; }
|
||||||
|
.platform-threads { color: #fff; }
|
||||||
|
.platform-instagram { color: #e1306c; }
|
||||||
|
.platform-facebook { color: #1877f2; }
|
||||||
|
.type-comment { background-color: rgba(59, 130, 246, 0.3); color: #60a5fa; }
|
||||||
|
.type-mention { background-color: rgba(139, 92, 246, 0.3); color: #a78bfa; }
|
||||||
|
.type-dm { background-color: rgba(16, 185, 129, 0.3); color: #34d399; }
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
let interactions = [];
|
||||||
|
let selectedId = null;
|
||||||
|
|
||||||
async function loadInteractions() {
|
window.addEventListener('load', loadInteractions);
|
||||||
try {
|
|
||||||
const response = await fetch('/api/interactions/');
|
async function loadInteractions() {
|
||||||
interactions = await response.json();
|
try {
|
||||||
updateStats();
|
const response = await fetch('/api/interactions/');
|
||||||
filterInteractions();
|
interactions = await response.json();
|
||||||
} catch (error) {
|
updateStats();
|
||||||
console.error('Error:', error);
|
filterInteractions();
|
||||||
document.getElementById('interactions-list').innerHTML =
|
} catch (error) {
|
||||||
'<div class="p-8 text-center text-red-400">Error cargando interacciones</div>';
|
console.error('Error:', error);
|
||||||
}
|
document.getElementById('interactions-list').innerHTML =
|
||||||
|
'<div class="p-8 text-center text-red-400">Error cargando interacciones</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
const pending = interactions.filter(i => !i.responded && !i.is_archived).length;
|
||||||
|
const responded = interactions.filter(i => i.responded).length;
|
||||||
|
const leads = interactions.filter(i => i.is_potential_lead).length;
|
||||||
|
|
||||||
|
document.getElementById('stat-pending').textContent = pending;
|
||||||
|
document.getElementById('stat-responded').textContent = responded;
|
||||||
|
document.getElementById('stat-leads').textContent = leads;
|
||||||
|
document.getElementById('stat-total').textContent = interactions.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterInteractions() {
|
||||||
|
const platform = document.getElementById('filter-platform').value;
|
||||||
|
const type = document.getElementById('filter-type').value;
|
||||||
|
const status = document.getElementById('filter-status').value;
|
||||||
|
|
||||||
|
let filtered = interactions;
|
||||||
|
|
||||||
|
if (platform) {
|
||||||
|
filtered = filtered.filter(i => i.platform === platform);
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
filtered = filtered.filter(i => i.interaction_type === type);
|
||||||
|
}
|
||||||
|
if (status === 'pending') {
|
||||||
|
filtered = filtered.filter(i => !i.responded && !i.is_archived);
|
||||||
|
} else if (status === 'responded') {
|
||||||
|
filtered = filtered.filter(i => i.responded);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStats() {
|
renderInteractions(filtered);
|
||||||
const pending = interactions.filter(i => !i.responded && !i.is_archived).length;
|
}
|
||||||
const responded = interactions.filter(i => i.responded).length;
|
|
||||||
const leads = interactions.filter(i => i.is_potential_lead).length;
|
|
||||||
|
|
||||||
document.getElementById('stat-pending').textContent = pending;
|
function renderInteractions(items) {
|
||||||
document.getElementById('stat-responded').textContent = responded;
|
const container = document.getElementById('interactions-list');
|
||||||
document.getElementById('stat-leads').textContent = leads;
|
|
||||||
document.getElementById('stat-total').textContent = interactions.length;
|
if (items.length === 0) {
|
||||||
|
container.innerHTML = '<div class="p-8 text-center text-gray-500">No hay interacciones</div>';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterInteractions() {
|
container.innerHTML = items.map(item => `
|
||||||
const platform = document.getElementById('filter-platform').value;
|
<div class="interaction-item p-4 cursor-pointer ${selectedId === item.id ? 'selected' : ''}"
|
||||||
const type = document.getElementById('filter-type').value;
|
onclick="selectInteraction(${item.id}, this)">
|
||||||
const status = document.getElementById('filter-status').value;
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
let filtered = interactions;
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="platform-${item.platform} font-medium">@${item.author_username || 'usuario'}</span>
|
||||||
if (platform) {
|
<span class="type-${item.interaction_type} text-xs px-2 py-0.5 rounded-full">${item.interaction_type}</span>
|
||||||
filtered = filtered.filter(i => i.platform === platform);
|
<span class="text-gray-500 text-xs">${item.platform}</span>
|
||||||
}
|
${item.is_potential_lead ? '<span class="bg-purple-600 text-xs px-2 py-0.5 rounded-full">Lead</span>' : ''}
|
||||||
if (type) {
|
|
||||||
filtered = filtered.filter(i => i.interaction_type === type);
|
|
||||||
}
|
|
||||||
if (status === 'pending') {
|
|
||||||
filtered = filtered.filter(i => !i.responded && !i.is_archived);
|
|
||||||
} else if (status === 'responded') {
|
|
||||||
filtered = filtered.filter(i => i.responded);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderInteractions(filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInteractions(items) {
|
|
||||||
const container = document.getElementById('interactions-list');
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
container.innerHTML = '<div class="p-8 text-center text-gray-500">No hay interacciones</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = items.map(item => `
|
|
||||||
<div class="interaction-item p-4 cursor-pointer ${selectedId === item.id ? 'selected' : ''}"
|
|
||||||
onclick="selectInteraction(${item.id})">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<span class="platform-${item.platform} font-medium">@${item.author_username || 'usuario'}</span>
|
|
||||||
<span class="type-${item.interaction_type} text-xs px-2 py-0.5 rounded">${item.interaction_type}</span>
|
|
||||||
<span class="text-gray-500 text-xs">${item.platform}</span>
|
|
||||||
${item.is_potential_lead ? '<span class="bg-purple-600 text-xs px-2 py-0.5 rounded">Lead</span>' : ''}
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-300 text-sm line-clamp-2">${escapeHtml(item.content || '')}</p>
|
|
||||||
<div class="text-gray-500 text-xs mt-1">
|
|
||||||
${formatDate(item.interaction_at)}
|
|
||||||
${item.responded ? '<span class="text-green-400 ml-2">✓ Respondido</span>' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
${!item.responded ? '<span class="w-2 h-2 bg-red-500 rounded-full"></span>' : ''}
|
<p class="text-gray-300 text-sm line-clamp-2">${escapeHtml(item.content || '')}</p>
|
||||||
|
<div class="text-gray-500 text-xs mt-1">
|
||||||
|
${formatDate(item.interaction_at)}
|
||||||
|
${item.responded ? '<span class="text-green-400 ml-2">✓ Respondido</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${!item.responded ? '<span class="w-2 h-2 bg-red-500 rounded-full flex-shrink-0"></span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectInteraction(id, element) {
|
||||||
|
selectedId = id;
|
||||||
|
const item = interactions.find(i => i.id === id);
|
||||||
|
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.interaction-item').forEach(el => el.classList.remove('selected'));
|
||||||
|
element?.classList.add('selected');
|
||||||
|
|
||||||
|
const panel = document.getElementById('detail-panel');
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="platform-${item.platform} font-semibold text-lg">@${item.author_username || 'usuario'}</span>
|
||||||
|
${item.is_potential_lead ? '<span class="bg-purple-600 text-xs px-2 py-1 rounded-full">Lead</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<span class="type-${item.interaction_type} text-xs px-2 py-1 rounded-full">${item.interaction_type}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-dark-800 rounded-xl p-4">
|
||||||
|
<p class="text-gray-200">${escapeHtml(item.content || '')}</p>
|
||||||
|
<div class="text-gray-500 text-xs mt-2">
|
||||||
|
${formatDate(item.interaction_at)} • ${item.platform}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectInteraction(id) {
|
${item.responded && item.response_content ? `
|
||||||
selectedId = id;
|
<div class="bg-green-900/30 rounded-xl p-4">
|
||||||
const item = interactions.find(i => i.id === id);
|
<div class="text-xs text-green-400 mb-1">Tu respuesta:</div>
|
||||||
|
<p class="text-gray-200">${escapeHtml(item.response_content)}</p>
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
// Update selection visual
|
|
||||||
document.querySelectorAll('.interaction-item').forEach(el => el.classList.remove('selected'));
|
|
||||||
event.currentTarget?.classList.add('selected');
|
|
||||||
|
|
||||||
// Render detail panel
|
|
||||||
const panel = document.getElementById('detail-panel');
|
|
||||||
panel.innerHTML = `
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="platform-${item.platform} font-semibold text-lg">@${item.author_username || 'usuario'}</span>
|
|
||||||
${item.is_potential_lead ? '<span class="bg-purple-600 text-xs px-2 py-1 rounded">Lead</span>' : ''}
|
|
||||||
</div>
|
|
||||||
<span class="type-${item.interaction_type} text-xs px-2 py-1 rounded">${item.interaction_type}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<div class="bg-gray-800 rounded-lg p-4">
|
${!item.responded ? `
|
||||||
<p class="text-gray-200">${escapeHtml(item.content || '')}</p>
|
<div>
|
||||||
<div class="text-gray-500 text-xs mt-2">
|
<label class="block text-sm text-gray-400 mb-2">Responder</label>
|
||||||
${formatDate(item.interaction_at)} • ${item.platform}
|
<textarea id="response-text" rows="3"
|
||||||
|
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 text-sm focus:border-primary focus:outline-none"
|
||||||
|
placeholder="Escribe tu respuesta..."></textarea>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<button onclick="generateSuggestions(${item.id})" class="btn-secondary px-3 py-2 rounded-xl text-sm flex-1">
|
||||||
|
🤖 Sugerir IA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="suggestions-${item.id}" class="hidden mt-3 space-y-2">
|
||||||
|
<!-- AI suggestions -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${item.responded && item.response_content ? `
|
<div class="flex gap-2">
|
||||||
<div class="bg-green-900 bg-opacity-30 rounded-lg p-4">
|
<button onclick="sendResponse(${item.id})" class="btn-primary flex-1 py-3 rounded-xl">
|
||||||
<div class="text-xs text-green-400 mb-1">Tu respuesta:</div>
|
Responder
|
||||||
<p class="text-gray-200">${escapeHtml(item.response_content)}</p>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
${!item.responded ? `
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-gray-400 mb-2">Responder</label>
|
|
||||||
<textarea id="response-text" rows="3"
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm"
|
|
||||||
placeholder="Escribe tu respuesta..."></textarea>
|
|
||||||
|
|
||||||
<div class="flex gap-2 mt-2">
|
|
||||||
<button onclick="generateSuggestions(${item.id})" class="btn-secondary px-3 py-1 rounded text-sm flex-1">
|
|
||||||
🤖 Sugerir IA
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="suggestions-${item.id}" class="hidden mt-3 space-y-2">
|
|
||||||
<!-- AI suggestions -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button onclick="sendResponse(${item.id})" class="btn-primary flex-1 py-2 rounded">
|
|
||||||
Responder
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
<div class="flex gap-2 pt-4 border-t border-gray-700">
|
|
||||||
${!item.is_potential_lead ? `
|
|
||||||
<button onclick="markAsLead(${item.id})" class="btn-secondary px-3 py-2 rounded text-sm flex-1">
|
|
||||||
⭐ Marcar Lead
|
|
||||||
</button>
|
|
||||||
` : `
|
|
||||||
<button onclick="unmarkLead(${item.id})" class="btn-secondary px-3 py-2 rounded text-sm flex-1">
|
|
||||||
Quitar Lead
|
|
||||||
</button>
|
|
||||||
`}
|
|
||||||
<button onclick="archiveInteraction(${item.id})" class="text-gray-400 hover:text-red-400 px-3 py-2 text-sm">
|
|
||||||
Archivar
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-4 border-t border-dark-600">
|
||||||
|
${!item.is_potential_lead ? `
|
||||||
|
<button onclick="markAsLead(${item.id})" class="btn-secondary px-3 py-2 rounded-xl text-sm flex-1">
|
||||||
|
⭐ Marcar Lead
|
||||||
|
</button>
|
||||||
|
` : `
|
||||||
|
<button onclick="unmarkLead(${item.id})" class="btn-secondary px-3 py-2 rounded-xl text-sm flex-1">
|
||||||
|
Quitar Lead
|
||||||
|
</button>
|
||||||
|
`}
|
||||||
|
<button onclick="archiveInteraction(${item.id})" class="text-gray-400 hover:text-red-400 px-3 py-2 text-sm">
|
||||||
|
Archivar
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
</div>
|
||||||
}
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
async function generateSuggestions(id) {
|
async function generateSuggestions(id) {
|
||||||
const item = interactions.find(i => i.id === id);
|
const item = interactions.find(i => i.id === id);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
const suggestionsDiv = document.getElementById(`suggestions-${id}`);
|
const suggestionsDiv = document.getElementById(`suggestions-${id}`);
|
||||||
suggestionsDiv.classList.remove('hidden');
|
suggestionsDiv.classList.remove('hidden');
|
||||||
suggestionsDiv.innerHTML = '<div class="text-gray-400 text-sm">Generando sugerencias...</div>';
|
suggestionsDiv.innerHTML = '<div class="text-gray-400 text-sm">Generando sugerencias...</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/generate/response', {
|
const response = await fetch('/api/generate/response', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
interaction_content: item.content,
|
interaction_content: item.content,
|
||||||
interaction_type: item.interaction_type
|
interaction_type: item.interaction_type
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success && data.contents) {
|
if (data.success && data.contents) {
|
||||||
suggestionsDiv.innerHTML = data.contents.map((suggestion, i) => `
|
suggestionsDiv.innerHTML = data.contents.map((suggestion, i) => `
|
||||||
<div class="bg-gray-800 rounded p-2 text-sm cursor-pointer hover:bg-gray-700"
|
<div class="bg-dark-800 rounded-xl p-3 text-sm cursor-pointer hover:bg-dark-700 transition-colors"
|
||||||
onclick="useSuggestion('${escapeHtml(suggestion).replace(/'/g, "\\'")}')">
|
onclick="useSuggestion('${escapeHtml(suggestion).replace(/'/g, "\\'")}')">
|
||||||
${i + 1}. ${escapeHtml(suggestion)}
|
${i + 1}. ${escapeHtml(suggestion)}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
} else {
|
} else {
|
||||||
suggestionsDiv.innerHTML = '<div class="text-red-400 text-sm">Error: ' + (data.error || 'No se pudo generar') + '</div>';
|
suggestionsDiv.innerHTML = '<div class="text-red-400 text-sm">Error: ' + (data.error || 'No se pudo generar') + '</div>';
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
suggestionsDiv.innerHTML = '<div class="text-red-400 text-sm">Error de conexión</div>';
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
suggestionsDiv.innerHTML = '<div class="text-red-400 text-sm">Error de conexión</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSuggestion(text) {
|
||||||
|
document.getElementById('response-text').value = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendResponse(id) {
|
||||||
|
const responseText = document.getElementById('response-text').value.trim();
|
||||||
|
if (!responseText) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">⚠️</span><p>Escribe una respuesta</p></div>');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSuggestion(text) {
|
try {
|
||||||
document.getElementById('response-text').value = text;
|
await fetch(`/api/interactions/${id}/respond`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ response: responseText })
|
||||||
|
});
|
||||||
|
|
||||||
|
loadInteractions();
|
||||||
|
} catch (error) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error al enviar</p></div>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAsLead(id) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/interactions/${id}/mark-lead`, { method: 'POST' });
|
||||||
|
loadInteractions();
|
||||||
|
} catch (error) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error</p></div>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unmarkLead(id) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/interactions/${id}/unmark-lead`, { method: 'POST' });
|
||||||
|
loadInteractions();
|
||||||
|
} catch (error) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error</p></div>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveInteraction(id) {
|
||||||
|
if (!confirm('¿Archivar esta interacción?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/interactions/${id}/archive`, { method: 'POST' });
|
||||||
|
loadInteractions();
|
||||||
|
document.getElementById('detail-panel').innerHTML =
|
||||||
|
'<div class="text-center text-gray-500 py-8"><span class="text-4xl block mb-4">📦</span><p>Interacción archivada</p></div>';
|
||||||
|
} catch (error) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error</p></div>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncInteractions() {
|
||||||
|
const icon = document.getElementById('sync-icon');
|
||||||
|
icon.style.animation = 'spin 1s linear infinite';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/interactions/sync', { method: 'POST' });
|
||||||
|
await loadInteractions();
|
||||||
|
} catch (error) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error al sincronizar</p></div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendResponse(id) {
|
icon.style.animation = '';
|
||||||
const responseText = document.getElementById('response-text').value.trim();
|
}
|
||||||
if (!responseText) {
|
|
||||||
alert('Escribe una respuesta');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
function formatDate(dateStr) {
|
||||||
await fetch(`/api/interactions/${id}/respond`, {
|
if (!dateStr) return '';
|
||||||
method: 'POST',
|
const date = new Date(dateStr);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const now = new Date();
|
||||||
body: JSON.stringify({ response: responseText })
|
const diffMs = now - date;
|
||||||
});
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
loadInteractions();
|
if (diffMins < 60) return `hace ${diffMins}m`;
|
||||||
} catch (error) {
|
if (diffHours < 24) return `hace ${diffHours}h`;
|
||||||
alert('Error al enviar: ' + error.message);
|
if (diffDays < 7) return `hace ${diffDays}d`;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markAsLead(id) {
|
return date.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
|
||||||
try {
|
}
|
||||||
await fetch(`/api/interactions/${id}/mark-lead`, { method: 'POST' });
|
|
||||||
loadInteractions();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unmarkLead(id) {
|
function escapeHtml(text) {
|
||||||
try {
|
const div = document.createElement('div');
|
||||||
await fetch(`/api/interactions/${id}/unmark-lead`, { method: 'POST' });
|
div.textContent = text || '';
|
||||||
loadInteractions();
|
return div.innerHTML;
|
||||||
} catch (error) {
|
}
|
||||||
alert('Error: ' + error.message);
|
</script>
|
||||||
}
|
{% endblock %}
|
||||||
}
|
|
||||||
|
|
||||||
async function archiveInteraction(id) {
|
|
||||||
if (!confirm('¿Archivar esta interacción?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch(`/api/interactions/${id}/archive`, { method: 'POST' });
|
|
||||||
loadInteractions();
|
|
||||||
document.getElementById('detail-panel').innerHTML =
|
|
||||||
'<div class="text-center text-gray-500 py-8"><p>Interacción archivada</p></div>';
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncInteractions() {
|
|
||||||
const icon = document.getElementById('sync-icon');
|
|
||||||
icon.style.animation = 'spin 1s linear infinite';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch('/api/interactions/sync', { method: 'POST' });
|
|
||||||
await loadInteractions();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error al sincronizar: ' + error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
icon.style.animation = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now - date;
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
const diffHours = Math.floor(diffMs / 3600000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
|
||||||
|
|
||||||
if (diffMins < 60) return `hace ${diffMins}m`;
|
|
||||||
if (diffHours < 24) return `hace ${diffHours}h`;
|
|
||||||
if (diffDays < 7) return `hace ${diffDays}d`;
|
|
||||||
|
|
||||||
return date.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text || '';
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
.line-clamp-2 {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,108 +4,79 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Login - Social Media Automation</title>
|
<title>Login - Social Media Automation</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body { font-family: 'Inter', sans-serif; }
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
.gradient-bg { background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); }
|
||||||
min-height: 100vh;
|
.card { background: rgba(30, 41, 59, 0.8); backdrop-filter: blur(10px); border: 1px solid rgba(99, 102, 241, 0.1); }
|
||||||
}
|
.btn-primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); }
|
||||||
.card {
|
.btn-primary:hover { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); }
|
||||||
background-color: #16213e;
|
.input-field { background: rgba(15, 23, 42, 0.5); border: 1px solid rgba(99, 102, 241, 0.2); }
|
||||||
border-radius: 12px;
|
.input-field:focus { border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); }
|
||||||
}
|
.animate-float { animation: float 3s ease-in-out infinite; }
|
||||||
.accent { color: #d4a574; }
|
@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
|
||||||
.btn-primary {
|
|
||||||
background-color: #d4a574;
|
|
||||||
color: #1a1a2e;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #c49564;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
.input-field {
|
|
||||||
background-color: #1a1a2e;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
color: #fff;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
}
|
|
||||||
.input-field:focus {
|
|
||||||
border-color: #d4a574;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.input-field::placeholder {
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="flex items-center justify-center p-4">
|
<body class="gradient-bg min-h-screen flex items-center justify-center p-4">
|
||||||
<div class="card p-8 w-full max-w-md">
|
<div class="w-full max-w-md">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-xl bg-gradient-to-br from-yellow-600 to-yellow-800 mb-4">
|
<div class="w-20 h-20 mx-auto rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center mb-4 animate-float">
|
||||||
<span class="text-2xl font-bold text-white">AS</span>
|
<span class="text-4xl">📱</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-bold text-white">Social Media Automation</h1>
|
<h1 class="text-2xl font-bold text-white">Social Media Automation</h1>
|
||||||
<p class="text-gray-400 mt-1">Consultoría AS</p>
|
<p class="text-gray-400 mt-2">Consultoría AS</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error message -->
|
<!-- Login Card -->
|
||||||
{% if error %}
|
<div class="card rounded-2xl p-8">
|
||||||
<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-6">
|
<h2 class="text-xl font-semibold text-white mb-6 text-center">Iniciar Sesión</h2>
|
||||||
{{ error }}
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-500/20 border border-red-500/50 text-red-400 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/login" class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">Usuario o Email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
class="input-field w-full px-4 py-3 rounded-xl text-white placeholder-gray-500 focus:outline-none transition-all"
|
||||||
|
placeholder="tu@email.com"
|
||||||
|
autocomplete="username"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">Contraseña</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
class="input-field w-full px-4 py-3 rounded-xl text-white placeholder-gray-500 focus:outline-none transition-all"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autocomplete="current-password"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary w-full py-3 rounded-xl text-white font-medium transition-all hover:shadow-lg hover:shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
Entrar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Login Form -->
|
|
||||||
<form method="POST" action="/login">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="username" class="block text-gray-400 text-sm mb-2">
|
|
||||||
Usuario o Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
required
|
|
||||||
class="input-field w-full px-4 py-3 rounded-lg"
|
|
||||||
placeholder="tu.usuario"
|
|
||||||
autocomplete="username"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-6">
|
|
||||||
<label for="password" class="block text-gray-400 text-sm mb-2">
|
|
||||||
Contraseña
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
required
|
|
||||||
class="input-field w-full px-4 py-3 rounded-lg"
|
|
||||||
placeholder="••••••••"
|
|
||||||
autocomplete="current-password"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary w-full py-3 rounded-lg font-semibold text-lg"
|
|
||||||
>
|
|
||||||
Iniciar Sesión
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="mt-8 text-center text-gray-500 text-sm">
|
<p class="text-center text-gray-500 text-sm mt-8">
|
||||||
<p>Sistema de automatización de redes sociales</p>
|
© 2025 <a href="https://consultoria-as.com" target="_blank" class="text-indigo-400 hover:underline">Consultoría AS</a> - Tijuana, México
|
||||||
<p class="mt-1">
|
</p>
|
||||||
<a href="https://consultoria-as.com" target="_blank" class="accent hover:underline">
|
|
||||||
consultoria-as.com
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,449 +1,156 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="es">
|
|
||||||
<head>
|
{% block title %}Posts{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{% block content %}
|
||||||
<title>Posts - Social Media Automation</title>
|
<div class="animate-fade-in">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { background-color: #1a1a2e; color: #eee; }
|
|
||||||
.card { background-color: #16213e; border-radius: 12px; }
|
|
||||||
.accent { color: #d4a574; }
|
|
||||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
|
||||||
.btn-primary:hover { background-color: #c49564; }
|
|
||||||
.btn-secondary { background-color: #374151; }
|
|
||||||
.btn-secondary:hover { background-color: #4b5563; }
|
|
||||||
.btn-danger { background-color: #dc2626; }
|
|
||||||
.btn-danger:hover { background-color: #b91c1c; }
|
|
||||||
.status-published { background-color: #065f46; color: #6ee7b7; }
|
|
||||||
.status-scheduled { background-color: #1e40af; color: #93c5fd; }
|
|
||||||
.status-pending { background-color: #92400e; color: #fcd34d; }
|
|
||||||
.status-draft { background-color: #374151; color: #9ca3af; }
|
|
||||||
.status-failed { background-color: #991b1b; color: #fca5a5; }
|
|
||||||
.platform-x { background-color: #1d1d1d; }
|
|
||||||
.platform-threads { background-color: #1a1a1a; }
|
|
||||||
.platform-instagram { background: linear-gradient(45deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888); }
|
|
||||||
.platform-facebook { background-color: #1877f2; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen">
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<div>
|
||||||
<h1 class="text-2xl font-bold">
|
<h1 class="text-3xl font-bold">Posts</h1>
|
||||||
<span class="accent">Consultoría AS</span> - Social Media
|
<p class="text-gray-400 mt-1">Gestiona todas tus publicaciones</p>
|
||||||
</h1>
|
|
||||||
<nav class="flex gap-4">
|
|
||||||
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
|
||||||
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800">+ Crear</a>
|
|
||||||
<a href="/dashboard/posts" class="px-4 py-2 rounded bg-gray-800 accent">Posts</a>
|
|
||||||
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
|
||||||
<a href="/dashboard/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
|
|
||||||
<a href="/dashboard/settings" class="px-4 py-2 rounded hover:bg-gray-800">Config</a>
|
|
||||||
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<a href="/compose" class="btn-primary px-6 py-3 rounded-xl font-medium flex items-center gap-2 transition-all">
|
||||||
|
<span>✍️</span>
|
||||||
|
<span>Crear Post</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="container mx-auto px-6 py-8">
|
<!-- Filters -->
|
||||||
<!-- Filters -->
|
<div class="card rounded-2xl p-4 mb-6">
|
||||||
<div class="card p-4 mb-6">
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
<div class="flex flex-wrap gap-4 items-center">
|
<select id="filter-status" onchange="filterPosts()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
|
||||||
<div>
|
<option value="">Todos los estados</option>
|
||||||
<label class="text-sm text-gray-400 block mb-1">Estado</label>
|
<option value="published">Publicados</option>
|
||||||
<select id="filter-status" onchange="filterPosts()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
<option value="scheduled">Programados</option>
|
||||||
<option value="">Todos</option>
|
<option value="pending_approval">Pendientes</option>
|
||||||
<option value="published">Publicados</option>
|
<option value="draft">Borradores</option>
|
||||||
<option value="scheduled">Programados</option>
|
<option value="failed">Fallidos</option>
|
||||||
<option value="pending_approval">Pendientes</option>
|
</select>
|
||||||
<option value="draft">Borradores</option>
|
<select id="filter-platform" onchange="filterPosts()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
|
||||||
<option value="failed">Fallidos</option>
|
<option value="">Todas las plataformas</option>
|
||||||
</select>
|
<option value="x">X (Twitter)</option>
|
||||||
</div>
|
<option value="facebook">Facebook</option>
|
||||||
<div>
|
<option value="instagram">Instagram</option>
|
||||||
<label class="text-sm text-gray-400 block mb-1">Plataforma</label>
|
<option value="threads">Threads</option>
|
||||||
<select id="filter-platform" onchange="filterPosts()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
</select>
|
||||||
<option value="">Todas</option>
|
<input type="text" id="filter-search" placeholder="Buscar..." onkeyup="filterPosts()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white placeholder-gray-500 focus:border-primary focus:outline-none flex-1 min-w-48">
|
||||||
<option value="x">X (Twitter)</option>
|
|
||||||
<option value="threads">Threads</option>
|
|
||||||
<option value="instagram">Instagram</option>
|
|
||||||
<option value="facebook">Facebook</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">Tipo</label>
|
|
||||||
<select id="filter-type" onchange="filterPosts()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
<option value="">Todos</option>
|
|
||||||
<option value="tip">Tips</option>
|
|
||||||
<option value="product">Productos</option>
|
|
||||||
<option value="service">Servicios</option>
|
|
||||||
<option value="engagement">Engagement</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<div>
|
|
||||||
<a href="/dashboard/compose" class="btn-primary px-4 py-2 rounded inline-block">
|
|
||||||
+ Nuevo Post
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats -->
|
|
||||||
<div class="grid grid-cols-5 gap-4 mb-6">
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-green-400" id="stat-published">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Publicados</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-blue-400" id="stat-scheduled">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Programados</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-yellow-400" id="stat-pending">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Pendientes</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-gray-400" id="stat-draft">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Borradores</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-red-400" id="stat-failed">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Fallidos</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Posts List -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="p-4 border-b border-gray-700 flex justify-between items-center">
|
|
||||||
<h2 class="text-xl font-semibold">Posts</h2>
|
|
||||||
<span class="text-gray-400 text-sm" id="posts-count">0 posts</span>
|
|
||||||
</div>
|
|
||||||
<div id="posts-list" class="divide-y divide-gray-700">
|
|
||||||
<!-- Posts loaded dynamically -->
|
|
||||||
<div class="p-8 text-center text-gray-500">
|
|
||||||
Cargando posts...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div class="flex justify-center gap-2 mt-6" id="pagination">
|
|
||||||
<!-- Pagination buttons -->
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Edit Modal -->
|
|
||||||
<div id="edit-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
|
||||||
<div class="card p-6 max-w-2xl w-full mx-4">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="font-semibold text-lg">Editar Post</h3>
|
|
||||||
<button onclick="closeEditModal()" class="text-gray-400 hover:text-white">✕</button>
|
|
||||||
</div>
|
|
||||||
<form id="edit-form">
|
|
||||||
<input type="hidden" id="edit-post-id">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm text-gray-400 mb-1">Contenido</label>
|
|
||||||
<textarea id="edit-content" rows="4" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2"></textarea>
|
|
||||||
<div class="text-right text-sm text-gray-500 mt-1">
|
|
||||||
<span id="edit-char-count">0</span> caracteres
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm text-gray-400 mb-1">Programar para</label>
|
|
||||||
<input type="datetime-local" id="edit-scheduled" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 justify-end">
|
|
||||||
<button type="button" onclick="closeEditModal()" class="btn-secondary px-4 py-2 rounded">Cancelar</button>
|
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded">Guardar</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- Posts List -->
|
||||||
let allPosts = [];
|
<div id="posts-container" class="space-y-4">
|
||||||
let currentPage = 1;
|
{% for post in posts %}
|
||||||
const postsPerPage = 20;
|
<div class="card rounded-2xl p-6 hover:border-primary/30 transition-all post-item"
|
||||||
|
data-status="{{ post.status }}"
|
||||||
// Load posts on page load
|
data-platforms="{{ post.platforms|join(',') if post.platforms else '' }}"
|
||||||
window.addEventListener('load', loadPosts);
|
data-content="{{ post.content|lower }}">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
async function loadPosts() {
|
<div class="flex-1">
|
||||||
try {
|
<p class="text-white mb-3">{{ post.content[:200] }}{% if post.content|length > 200 %}...{% endif %}</p>
|
||||||
const response = await fetch('/api/posts/');
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
allPosts = await response.json();
|
{% for platform in post.platforms %}
|
||||||
updateStats();
|
<span class="text-xs px-2 py-1 rounded-full
|
||||||
filterPosts();
|
{% if platform == 'x' %}bg-gray-700{% endif %}
|
||||||
} catch (error) {
|
{% if platform == 'facebook' %}bg-blue-600/30 text-blue-400{% endif %}
|
||||||
console.error('Error loading posts:', error);
|
{% if platform == 'instagram' %}bg-pink-600/30 text-pink-400{% endif %}
|
||||||
document.getElementById('posts-list').innerHTML =
|
{% if platform == 'threads' %}bg-gray-600{% endif %}
|
||||||
'<div class="p-8 text-center text-red-400">Error cargando posts</div>';
|
">{{ platform }}</span>
|
||||||
}
|
{% endfor %}
|
||||||
}
|
<span class="text-xs px-2 py-1 rounded-full
|
||||||
|
{% if post.status == 'published' %}bg-green-500/20 text-green-400{% endif %}
|
||||||
function updateStats() {
|
{% if post.status == 'scheduled' %}bg-blue-500/20 text-blue-400{% endif %}
|
||||||
const stats = {
|
{% if post.status == 'pending_approval' %}bg-yellow-500/20 text-yellow-400{% endif %}
|
||||||
published: 0,
|
{% if post.status == 'draft' %}bg-gray-500/20 text-gray-400{% endif %}
|
||||||
scheduled: 0,
|
{% if post.status == 'failed' %}bg-red-500/20 text-red-400{% endif %}
|
||||||
pending_approval: 0,
|
">{{ post.status }}</span>
|
||||||
draft: 0,
|
|
||||||
failed: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
allPosts.forEach(post => {
|
|
||||||
if (stats[post.status] !== undefined) {
|
|
||||||
stats[post.status]++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('stat-published').textContent = stats.published;
|
|
||||||
document.getElementById('stat-scheduled').textContent = stats.scheduled;
|
|
||||||
document.getElementById('stat-pending').textContent = stats.pending_approval;
|
|
||||||
document.getElementById('stat-draft').textContent = stats.draft;
|
|
||||||
document.getElementById('stat-failed').textContent = stats.failed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterPosts() {
|
|
||||||
const status = document.getElementById('filter-status').value;
|
|
||||||
const platform = document.getElementById('filter-platform').value;
|
|
||||||
const type = document.getElementById('filter-type').value;
|
|
||||||
|
|
||||||
let filtered = allPosts;
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
filtered = filtered.filter(p => p.status === status);
|
|
||||||
}
|
|
||||||
if (platform) {
|
|
||||||
filtered = filtered.filter(p => p.platforms && p.platforms.includes(platform));
|
|
||||||
}
|
|
||||||
if (type) {
|
|
||||||
filtered = filtered.filter(p => p.content_type === type);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderPosts(filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPosts(posts) {
|
|
||||||
const container = document.getElementById('posts-list');
|
|
||||||
document.getElementById('posts-count').textContent = `${posts.length} posts`;
|
|
||||||
|
|
||||||
if (posts.length === 0) {
|
|
||||||
container.innerHTML = '<div class="p-8 text-center text-gray-500">No hay posts</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const start = (currentPage - 1) * postsPerPage;
|
|
||||||
const pagePosts = posts.slice(start, start + postsPerPage);
|
|
||||||
|
|
||||||
container.innerHTML = pagePosts.map(post => `
|
|
||||||
<div class="p-4 hover:bg-gray-800 transition-colors" data-post-id="${post.id}">
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<span class="status-${post.status} px-2 py-1 rounded text-xs font-medium">
|
|
||||||
${getStatusLabel(post.status)}
|
|
||||||
</span>
|
|
||||||
${post.platforms ? post.platforms.map(p => `
|
|
||||||
<span class="platform-${p} px-2 py-1 rounded text-xs">${p}</span>
|
|
||||||
`).join('') : ''}
|
|
||||||
<span class="text-gray-500 text-xs">${post.content_type || 'general'}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-300 mb-2">${escapeHtml(post.content || '').substring(0, 200)}${post.content && post.content.length > 200 ? '...' : ''}</p>
|
|
||||||
<div class="text-gray-500 text-xs">
|
|
||||||
${post.scheduled_at ? `Programado: ${formatDate(post.scheduled_at)}` : ''}
|
|
||||||
${post.published_at ? `Publicado: ${formatDate(post.published_at)}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
${post.status === 'pending_approval' ? `
|
|
||||||
<button onclick="approvePost(${post.id})" class="btn-primary px-3 py-1 rounded text-sm">Aprobar</button>
|
|
||||||
<button onclick="rejectPost(${post.id})" class="btn-danger px-3 py-1 rounded text-sm">Rechazar</button>
|
|
||||||
` : ''}
|
|
||||||
${post.status === 'scheduled' ? `
|
|
||||||
<button onclick="publishNow(${post.id})" class="btn-primary px-3 py-1 rounded text-sm">Publicar Ya</button>
|
|
||||||
<button onclick="cancelPost(${post.id})" class="btn-secondary px-3 py-1 rounded text-sm">Cancelar</button>
|
|
||||||
` : ''}
|
|
||||||
${post.status === 'draft' ? `
|
|
||||||
<button onclick="schedulePost(${post.id})" class="btn-primary px-3 py-1 rounded text-sm">Programar</button>
|
|
||||||
` : ''}
|
|
||||||
<button onclick="editPost(${post.id})" class="btn-secondary px-3 py-1 rounded text-sm">Editar</button>
|
|
||||||
<button onclick="deletePost(${post.id})" class="text-red-400 hover:text-red-300 text-sm">Eliminar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
<div class="flex flex-col items-end gap-2">
|
||||||
|
<span class="text-xs text-gray-500">{{ post.created_at[:16] if post.created_at else '-' }}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if post.status == 'draft' or post.status == 'pending_approval' %}
|
||||||
|
<button onclick="publishPost({{ post.id }})" class="text-xs bg-green-500/20 text-green-400 px-3 py-1 rounded-lg hover:bg-green-500/30 transition-colors">
|
||||||
|
Publicar
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="deletePost({{ post.id }})" class="text-xs bg-red-500/20 text-red-400 px-3 py-1 rounded-lg hover:bg-red-500/30 transition-colors">
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-16 text-gray-500">
|
||||||
|
<span class="text-6xl mb-4 block">📭</span>
|
||||||
|
<p class="text-xl">No hay posts</p>
|
||||||
|
<p class="mt-2">Crea tu primera publicación</p>
|
||||||
|
<a href="/compose" class="btn-primary px-6 py-3 rounded-xl font-medium inline-flex items-center gap-2 mt-4">
|
||||||
|
<span>✍️</span>
|
||||||
|
<span>Crear Post</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
// Render pagination
|
{% block extra_scripts %}
|
||||||
renderPagination(posts.length);
|
<script>
|
||||||
}
|
function filterPosts() {
|
||||||
|
const status = document.getElementById('filter-status').value;
|
||||||
|
const platform = document.getElementById('filter-platform').value;
|
||||||
|
const search = document.getElementById('filter-search').value.toLowerCase();
|
||||||
|
|
||||||
function renderPagination(totalPosts) {
|
document.querySelectorAll('.post-item').forEach(post => {
|
||||||
const totalPages = Math.ceil(totalPosts / postsPerPage);
|
const postStatus = post.dataset.status;
|
||||||
const container = document.getElementById('pagination');
|
const postPlatforms = post.dataset.platforms;
|
||||||
|
const postContent = post.dataset.content;
|
||||||
|
|
||||||
if (totalPages <= 1) {
|
const matchesStatus = !status || postStatus === status;
|
||||||
container.innerHTML = '';
|
const matchesPlatform = !platform || postPlatforms.includes(platform);
|
||||||
return;
|
const matchesSearch = !search || postContent.includes(search);
|
||||||
}
|
|
||||||
|
|
||||||
let html = '';
|
post.style.display = matchesStatus && matchesPlatform && matchesSearch ? 'block' : 'none';
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
|
||||||
html += `<button onclick="goToPage(${i})" class="${i === currentPage ? 'btn-primary' : 'btn-secondary'} px-3 py-1 rounded">${i}</button>`;
|
|
||||||
}
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToPage(page) {
|
|
||||||
currentPage = page;
|
|
||||||
filterPosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusLabel(status) {
|
|
||||||
const labels = {
|
|
||||||
'published': 'Publicado',
|
|
||||||
'scheduled': 'Programado',
|
|
||||||
'pending_approval': 'Pendiente',
|
|
||||||
'draft': 'Borrador',
|
|
||||||
'failed': 'Fallido'
|
|
||||||
};
|
|
||||||
return labels[status] || status;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleString('es-MX', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: 'short',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
async function approvePost(id) {
|
|
||||||
if (!confirm('¿Aprobar este post para publicación?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch(`/api/posts/${id}/approve`, { method: 'POST' });
|
|
||||||
loadPosts();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error al aprobar: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rejectPost(id) {
|
|
||||||
if (!confirm('¿Rechazar este post?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch(`/api/posts/${id}/reject`, { method: 'POST' });
|
|
||||||
loadPosts();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error al rechazar: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishNow(id) {
|
|
||||||
if (!confirm('¿Publicar inmediatamente?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch(`/api/calendar/posts/${id}/publish-now`, { method: 'POST' });
|
|
||||||
alert('Post enviado a publicación');
|
|
||||||
loadPosts();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancelPost(id) {
|
|
||||||
if (!confirm('¿Cancelar programación?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch(`/api/calendar/posts/${id}/cancel`, { method: 'POST' });
|
|
||||||
loadPosts();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function schedulePost(id) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/calendar/posts/${id}/schedule?auto=true`, { method: 'POST' });
|
|
||||||
const data = await response.json();
|
|
||||||
alert(`Programado para: ${formatDate(data.scheduled_at)}`);
|
|
||||||
loadPosts();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function editPost(id) {
|
|
||||||
const post = allPosts.find(p => p.id === id);
|
|
||||||
if (!post) return;
|
|
||||||
|
|
||||||
document.getElementById('edit-post-id').value = id;
|
|
||||||
document.getElementById('edit-content').value = post.content || '';
|
|
||||||
document.getElementById('edit-char-count').textContent = (post.content || '').length;
|
|
||||||
|
|
||||||
if (post.scheduled_at) {
|
|
||||||
const date = new Date(post.scheduled_at);
|
|
||||||
document.getElementById('edit-scheduled').value = date.toISOString().slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('edit-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('edit-modal').classList.add('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditModal() {
|
|
||||||
document.getElementById('edit-modal').classList.add('hidden');
|
|
||||||
document.getElementById('edit-modal').classList.remove('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('edit-content').addEventListener('input', function() {
|
|
||||||
document.getElementById('edit-char-count').textContent = this.value.length;
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('edit-form').addEventListener('submit', async function(e) {
|
async function publishPost(id) {
|
||||||
e.preventDefault();
|
if (!confirm('¿Publicar este post ahora?')) return;
|
||||||
|
|
||||||
const id = document.getElementById('edit-post-id').value;
|
showModal('Publicando...', true);
|
||||||
const content = document.getElementById('edit-content').value;
|
try {
|
||||||
const scheduled = document.getElementById('edit-scheduled').value;
|
const response = await fetch(`/api/posts/${id}/publish`, { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
try {
|
if (data.success) {
|
||||||
await fetch(`/api/posts/${id}`, {
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">🎉</span><p>¡Publicado!</p></div>');
|
||||||
method: 'PUT',
|
setTimeout(() => location.reload(), 1500);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
} else {
|
||||||
body: JSON.stringify({
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>${data.error || 'Error'}</p></div>`);
|
||||||
content,
|
|
||||||
scheduled_at: scheduled || null
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
closeEditModal();
|
|
||||||
loadPosts();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error al guardar: ' + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function deletePost(id) {
|
|
||||||
if (!confirm('¿Eliminar este post permanentemente?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch(`/api/posts/${id}`, { method: 'DELETE' });
|
|
||||||
loadPosts();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error al eliminar: ' + error.message);
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error de conexión</p></div>');
|
||||||
}
|
}
|
||||||
</script>
|
}
|
||||||
</body>
|
|
||||||
</html>
|
async function deletePost(id) {
|
||||||
|
if (!confirm('¿Eliminar este post?')) return;
|
||||||
|
|
||||||
|
showModal('Eliminando...', true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/posts/${id}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">🗑️</span><p>Eliminado</p></div>');
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
} else {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error eliminando</p></div>');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error de conexión</p></div>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,467 +1,426 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="es">
|
|
||||||
<head>
|
{% block title %}Productos{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{% block content %}
|
||||||
<title>Productos - Social Media Automation</title>
|
<div class="animate-fade-in">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { background-color: #1a1a2e; color: #eee; }
|
|
||||||
.card { background-color: #16213e; border-radius: 12px; }
|
|
||||||
.accent { color: #d4a574; }
|
|
||||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
|
||||||
.btn-primary:hover { background-color: #c49564; }
|
|
||||||
.btn-secondary { background-color: #374151; }
|
|
||||||
.btn-secondary:hover { background-color: #4b5563; }
|
|
||||||
.btn-danger { background-color: #dc2626; }
|
|
||||||
.btn-danger:hover { background-color: #b91c1c; }
|
|
||||||
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 50; }
|
|
||||||
.modal.active { display: flex; justify-content: center; align-items: center; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen">
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<div>
|
||||||
<h1 class="text-2xl font-bold">
|
<h1 class="text-3xl font-bold">Productos</h1>
|
||||||
<span class="accent">Consultoría AS</span> - Social Media
|
<p class="text-gray-400 mt-1">Catálogo de productos para generar contenido</p>
|
||||||
</h1>
|
|
||||||
<nav class="flex gap-4">
|
|
||||||
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
|
||||||
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800">+ Crear</a>
|
|
||||||
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
|
||||||
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
|
||||||
<a href="/dashboard/products" class="px-4 py-2 rounded bg-gray-800 accent">Productos</a>
|
|
||||||
<a href="/dashboard/services" class="px-4 py-2 rounded hover:bg-gray-800">Servicios</a>
|
|
||||||
<a href="/dashboard/settings" class="px-4 py-2 rounded hover:bg-gray-800">Config</a>
|
|
||||||
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<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>
|
||||||
|
|
||||||
<main class="container mx-auto px-6 py-8">
|
<!-- Stats -->
|
||||||
<!-- Header & Actions -->
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<h2 class="text-xl font-bold">Catálogo de Productos</h2>
|
<p class="text-3xl font-bold text-primary" id="stat-total">0</p>
|
||||||
<button onclick="openModal('add')" class="btn-primary px-4 py-2 rounded">
|
<p class="text-gray-400 text-sm mt-1">Total Productos</p>
|
||||||
+ Agregar Producto
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<!-- Filters -->
|
<p class="text-3xl font-bold text-green-400" id="stat-active">0</p>
|
||||||
<div class="card p-4 mb-6">
|
<p class="text-gray-400 text-sm mt-1">Activos</p>
|
||||||
<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-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
<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-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
<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-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
<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-gray-800 border border-gray-700 rounded px-3 py-2 w-full">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<!-- Stats -->
|
<p class="text-3xl font-bold text-yellow-400" id="stat-featured">0</p>
|
||||||
<div class="grid grid-cols-4 gap-4 mb-6">
|
<p class="text-gray-400 text-sm mt-1">Destacados</p>
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold accent" id="stat-total">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Total Productos</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-green-400" id="stat-active">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Activos</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-yellow-400" id="stat-featured">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Destacados</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-blue-400" id="stat-categories">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Categorías</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<!-- Products Grid -->
|
<p class="text-3xl font-bold text-blue-400" id="stat-categories">0</p>
|
||||||
<div id="products-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<p class="text-gray-400 text-sm mt-1">Categorías</p>
|
||||||
<!-- Products loaded dynamically -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div id="empty-state" class="card 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-2 rounded">
|
|
||||||
+ Agregar Producto
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Add/Edit Modal -->
|
|
||||||
<div id="product-modal" class="modal">
|
|
||||||
<div class="card p-6 w-full max-w-2xl max-h-screen overflow-y-auto">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h3 class="text-xl font-bold" id="modal-title">Agregar Producto</h3>
|
|
||||||
<button onclick="closeModal()" class="text-gray-400 hover:text-white text-2xl">×</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-1">Nombre *</label>
|
|
||||||
<input type="text" id="product-name" required
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">Categoría *</label>
|
|
||||||
<select id="product-category" required class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
<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-1">Precio (MXN) *</label>
|
|
||||||
<input type="number" id="product-price" step="0.01" min="0" required
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">Descripción</label>
|
|
||||||
<textarea id="product-description" rows="3"
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">Stock</label>
|
|
||||||
<input type="number" id="product-stock" min="0" value="0"
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">URL Imagen</label>
|
|
||||||
<input type="url" id="product-image"
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">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-gray-800 border border-gray-700 rounded px-3 py-2"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">Especificaciones (JSON)</label>
|
|
||||||
<textarea id="product-specs" rows="3" placeholder='{"procesador": "Intel i5", "ram": "16GB"}'
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 font-mono text-sm"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" id="product-active" checked class="w-4 h-4">
|
|
||||||
<span>Activo</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" id="product-featured" class="w-4 h-4">
|
|
||||||
<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">
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded">
|
|
||||||
Guardar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- Filters -->
|
||||||
let products = [];
|
<div class="card rounded-2xl p-4 mb-6">
|
||||||
let categories = [];
|
<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>
|
||||||
|
|
||||||
// Load products on page load
|
<!-- Products Grid -->
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
<div id="products-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
loadCategories();
|
<!-- Products loaded dynamically -->
|
||||||
loadProducts();
|
</div>
|
||||||
});
|
|
||||||
|
|
||||||
async function loadCategories() {
|
<!-- Empty State -->
|
||||||
try {
|
<div id="empty-state" class="card rounded-2xl p-12 text-center hidden">
|
||||||
const response = await fetch('/api/products/categories');
|
<div class="text-6xl mb-4">📦</div>
|
||||||
if (response.ok) {
|
<h3 class="text-xl font-bold mb-2">No hay productos</h3>
|
||||||
categories = await response.json();
|
<p class="text-gray-400 mb-4">Agrega tu primer producto para empezar a generar contenido</p>
|
||||||
const select = document.getElementById('filter-category');
|
<button onclick="openModal('add')" class="btn-primary px-6 py-3 rounded-xl">
|
||||||
categories.forEach(cat => {
|
+ Agregar Producto
|
||||||
const option = document.createElement('option');
|
</button>
|
||||||
option.value = cat;
|
</div>
|
||||||
option.textContent = cat.charAt(0).toUpperCase() + cat.slice(1).replace('_', ' ');
|
</div>
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
document.getElementById('stat-categories').textContent = categories.length;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading categories:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProducts() {
|
<!-- Add/Edit Modal -->
|
||||||
const category = document.getElementById('filter-category').value;
|
<div id="product-modal" class="fixed inset-0 bg-black/70 backdrop-blur-sm hidden flex items-center justify-center z-50">
|
||||||
const active = document.getElementById('filter-active').value;
|
<div class="card rounded-2xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4">
|
||||||
const featured = document.getElementById('filter-featured').value;
|
<div class="flex justify-between items-center mb-6">
|
||||||
const search = document.getElementById('filter-search').value;
|
<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>
|
||||||
|
|
||||||
let url = '/api/products/?limit=100';
|
<form id="product-form" onsubmit="saveProduct(event)">
|
||||||
if (category) url += `&category=${category}`;
|
<input type="hidden" id="product-id">
|
||||||
if (active) url += `&is_active=${active}`;
|
|
||||||
if (featured === 'true') url += `&is_featured=true`;
|
|
||||||
|
|
||||||
try {
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
const response = await fetch(url);
|
<div class="col-span-2">
|
||||||
if (response.ok) {
|
<label class="text-sm text-gray-400 block mb-2">Nombre *</label>
|
||||||
products = await response.json();
|
<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">
|
||||||
// Client-side search filter
|
|
||||||
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 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 mb-4">` :
|
|
||||||
`<div class="w-full h-40 bg-gray-800 rounded 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 accent 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">
|
|
||||||
<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 mt-4">
|
|
||||||
<button onclick="generatePost(${product.id})"
|
|
||||||
class="flex-1 btn-primary px-3 py-2 rounded text-sm">
|
|
||||||
Generar Post
|
|
||||||
</button>
|
|
||||||
<button onclick="editProduct(${product.id})"
|
|
||||||
class="btn-secondary px-3 py-2 rounded text-sm">
|
|
||||||
Editar
|
|
||||||
</button>
|
|
||||||
<button onclick="deleteProduct(${product.id})"
|
|
||||||
class="btn-danger px-3 py-2 rounded text-sm">
|
|
||||||
🗑
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openModal(mode, productId = null) {
|
<div>
|
||||||
document.getElementById('modal-title').textContent =
|
<label class="text-sm text-gray-400 block mb-2">Categoría *</label>
|
||||||
mode === 'add' ? 'Agregar Producto' : 'Editar Producto';
|
<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">
|
||||||
document.getElementById('product-form').reset();
|
<option value="laptops">Laptops</option>
|
||||||
document.getElementById('product-id').value = '';
|
<option value="desktops">Equipos de Escritorio</option>
|
||||||
document.getElementById('product-active').checked = true;
|
<option value="impresoras_3d">Impresoras 3D</option>
|
||||||
document.getElementById('product-modal').classList.add('active');
|
<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>
|
||||||
|
|
||||||
function closeModal() {
|
<div>
|
||||||
document.getElementById('product-modal').classList.remove('active');
|
<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>
|
||||||
|
|
||||||
function editProduct(id) {
|
<div class="col-span-2">
|
||||||
const product = products.find(p => p.id === id);
|
<label class="text-sm text-gray-400 block mb-2">Descripción</label>
|
||||||
if (!product) return;
|
<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>
|
||||||
|
|
||||||
document.getElementById('modal-title').textContent = 'Editar Producto';
|
<div>
|
||||||
document.getElementById('product-id').value = product.id;
|
<label class="text-sm text-gray-400 block mb-2">Stock</label>
|
||||||
document.getElementById('product-name').value = product.name;
|
<input type="number" id="product-stock" min="0" value="0"
|
||||||
document.getElementById('product-category').value = product.category;
|
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 focus:border-primary focus:outline-none">
|
||||||
document.getElementById('product-price').value = product.price;
|
</div>
|
||||||
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-specs').value = product.specs ? JSON.stringify(product.specs, null, 2) : '';
|
|
||||||
document.getElementById('product-active').checked = product.is_active;
|
|
||||||
document.getElementById('product-featured').checked = product.is_featured;
|
|
||||||
|
|
||||||
document.getElementById('product-modal').classList.add('active');
|
<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>
|
||||||
|
|
||||||
async function saveProduct(event) {
|
<div class="col-span-2">
|
||||||
event.preventDefault();
|
<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>
|
||||||
|
|
||||||
const id = document.getElementById('product-id').value;
|
<div class="col-span-2 flex items-center gap-6">
|
||||||
const highlightsText = document.getElementById('product-highlights').value;
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
const specsText = document.getElementById('product-specs').value;
|
<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>
|
||||||
|
|
||||||
let specs = {};
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
if (specsText) {
|
<button type="button" onclick="closeModal()" class="btn-secondary px-4 py-2 rounded-xl">
|
||||||
try {
|
Cancelar
|
||||||
specs = JSON.parse(specsText);
|
</button>
|
||||||
} catch (e) {
|
<button type="submit" class="btn-primary px-4 py-2 rounded-xl">
|
||||||
alert('JSON de especificaciones inválido');
|
Guardar
|
||||||
return;
|
</button>
|
||||||
}
|
</div>
|
||||||
}
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
const data = {
|
{% block extra_scripts %}
|
||||||
name: document.getElementById('product-name').value,
|
<script>
|
||||||
category: document.getElementById('product-category').value,
|
let products = [];
|
||||||
price: parseFloat(document.getElementById('product-price').value),
|
let categories = [];
|
||||||
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()) : [],
|
|
||||||
specs: specs,
|
|
||||||
is_active: document.getElementById('product-active').checked,
|
|
||||||
is_featured: document.getElementById('product-featured').checked
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const url = id ? `/api/products/${id}` : '/api/products/';
|
loadCategories();
|
||||||
const method = id ? 'PUT' : 'POST';
|
loadProducts();
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(url, {
|
async function loadCategories() {
|
||||||
method,
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const response = await fetch('/api/products/categories');
|
||||||
body: JSON.stringify(data)
|
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;
|
||||||
if (response.ok) {
|
|
||||||
closeModal();
|
|
||||||
loadProducts();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(error.detail || 'Error al guardar');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Error de conexión');
|
|
||||||
}
|
}
|
||||||
|
} 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteProduct(id) {
|
grid.classList.remove('hidden');
|
||||||
if (!confirm('¿Eliminar este producto?')) return;
|
empty.classList.add('hidden');
|
||||||
|
|
||||||
try {
|
grid.innerHTML = products.map(product => `
|
||||||
const response = await fetch(`/api/products/${id}`, { method: 'DELETE' });
|
<div class="card rounded-2xl p-4 ${!product.is_active ? 'opacity-60' : ''}">
|
||||||
if (response.ok) {
|
${product.image_url ?
|
||||||
loadProducts();
|
`<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>`
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generatePost(productId) {
|
<div class="flex justify-between items-start mb-2">
|
||||||
const product = products.find(p => p.id === productId);
|
<h4 class="font-bold text-lg">${product.name}</h4>
|
||||||
if (!product) return;
|
${product.is_featured ? '<span class="text-yellow-400">⭐</span>' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
const platform = prompt('¿Para qué plataforma? (x, threads, instagram, facebook)', 'x');
|
<div class="text-sm text-gray-400 mb-2">
|
||||||
if (!platform) return;
|
${product.category.replace('_', ' ')}
|
||||||
|
</div>
|
||||||
|
|
||||||
try {
|
<div class="text-xl font-bold text-primary mb-3">
|
||||||
const response = await fetch('/api/generate/product', {
|
$${product.price.toLocaleString('es-MX')} MXN
|
||||||
method: 'POST',
|
</div>
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
product_id: productId,
|
|
||||||
platform: platform
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
${product.description ?
|
||||||
const data = await response.json();
|
`<p class="text-sm text-gray-400 mb-3 line-clamp-2">${product.description}</p>` : ''
|
||||||
alert('Post generado:\n\n' + data.content);
|
|
||||||
} else {
|
|
||||||
alert('Error al generar post');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
<div class="flex justify-between items-center text-sm mb-4">
|
||||||
alert('Error de conexión');
|
<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>');
|
||||||
}
|
}
|
||||||
</script>
|
}
|
||||||
</body>
|
|
||||||
</html>
|
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 %}
|
||||||
|
|||||||
@@ -1,486 +1,464 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="es">
|
|
||||||
<head>
|
{% block title %}Servicios{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{% block content %}
|
||||||
<title>Servicios - Social Media Automation</title>
|
<div class="animate-fade-in">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { background-color: #1a1a2e; color: #eee; }
|
|
||||||
.card { background-color: #16213e; border-radius: 12px; }
|
|
||||||
.accent { color: #d4a574; }
|
|
||||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
|
||||||
.btn-primary:hover { background-color: #c49564; }
|
|
||||||
.btn-secondary { background-color: #374151; }
|
|
||||||
.btn-secondary:hover { background-color: #4b5563; }
|
|
||||||
.btn-danger { background-color: #dc2626; }
|
|
||||||
.btn-danger:hover { background-color: #b91c1c; }
|
|
||||||
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 50; }
|
|
||||||
.modal.active { display: flex; justify-content: center; align-items: center; }
|
|
||||||
.tag { background-color: #374151; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen">
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<div>
|
||||||
<h1 class="text-2xl font-bold">
|
<h1 class="text-3xl font-bold">Servicios</h1>
|
||||||
<span class="accent">Consultoría AS</span> - Social Media
|
<p class="text-gray-400 mt-1">Catálogo de servicios para generar contenido</p>
|
||||||
</h1>
|
|
||||||
<nav class="flex gap-4">
|
|
||||||
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
|
||||||
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800">+ Crear</a>
|
|
||||||
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
|
||||||
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
|
||||||
<a href="/dashboard/products" class="px-4 py-2 rounded hover:bg-gray-800">Productos</a>
|
|
||||||
<a href="/dashboard/services" class="px-4 py-2 rounded bg-gray-800 accent">Servicios</a>
|
|
||||||
<a href="/dashboard/settings" class="px-4 py-2 rounded hover:bg-gray-800">Config</a>
|
|
||||||
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<button onclick="openModal('add')" class="btn-primary px-6 py-3 rounded-xl font-medium flex items-center gap-2">
|
||||||
|
<span>➕</span>
|
||||||
|
<span>Agregar Servicio</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="container mx-auto px-6 py-8">
|
<!-- Stats -->
|
||||||
<!-- Header & Actions -->
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<h2 class="text-xl font-bold">Catálogo de Servicios</h2>
|
<p class="text-3xl font-bold text-primary" id="stat-total">0</p>
|
||||||
<button onclick="openModal('add')" class="btn-primary px-4 py-2 rounded">
|
<p class="text-gray-400 text-sm mt-1">Total Servicios</p>
|
||||||
+ Agregar Servicio
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<!-- Filters -->
|
<p class="text-3xl font-bold text-green-400" id="stat-active">0</p>
|
||||||
<div class="card p-4 mb-6">
|
<p class="text-gray-400 text-sm mt-1">Activos</p>
|
||||||
<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="loadServices()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
<option value="">Todas</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">Estado</label>
|
|
||||||
<select id="filter-active" onchange="loadServices()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
<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="loadServices()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
<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="loadServices()" placeholder="Nombre del servicio..."
|
|
||||||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 w-full">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<!-- Stats -->
|
<p class="text-3xl font-bold text-yellow-400" id="stat-featured">0</p>
|
||||||
<div class="grid grid-cols-4 gap-4 mb-6">
|
<p class="text-gray-400 text-sm mt-1">Destacados</p>
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold accent" id="stat-total">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Total Servicios</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-green-400" id="stat-active">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Activos</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-yellow-400" id="stat-featured">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Destacados</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 text-center">
|
|
||||||
<div class="text-2xl font-bold text-blue-400" id="stat-categories">0</div>
|
|
||||||
<div class="text-gray-400 text-sm">Categorías</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card card rounded-2xl p-6">
|
||||||
<!-- Services Grid -->
|
<p class="text-3xl font-bold text-blue-400" id="stat-categories">0</p>
|
||||||
<div id="services-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<p class="text-gray-400 text-sm mt-1">Categorías</p>
|
||||||
<!-- Services loaded dynamically -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div id="empty-state" class="card p-12 text-center hidden">
|
|
||||||
<div class="text-6xl mb-4">🛠️</div>
|
|
||||||
<h3 class="text-xl font-bold mb-2">No hay servicios</h3>
|
|
||||||
<p class="text-gray-400 mb-4">Agrega tu primer servicio para empezar a generar contenido</p>
|
|
||||||
<button onclick="openModal('add')" class="btn-primary px-6 py-2 rounded">
|
|
||||||
+ Agregar Servicio
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Add/Edit Modal -->
|
|
||||||
<div id="service-modal" class="modal">
|
|
||||||
<div class="card p-6 w-full max-w-2xl max-h-screen overflow-y-auto">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h3 class="text-xl font-bold" id="modal-title">Agregar Servicio</h3>
|
|
||||||
<button onclick="closeModal()" class="text-gray-400 hover:text-white text-2xl">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="service-form" onsubmit="saveService(event)">
|
|
||||||
<input type="hidden" id="service-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-1">Nombre *</label>
|
|
||||||
<input type="text" id="service-name" required
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">Categoría *</label>
|
|
||||||
<select id="service-category" required class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
<option value="ai_automation">Automatización con IA</option>
|
|
||||||
<option value="consulting">Consultoría TI</option>
|
|
||||||
<option value="development">Desarrollo de Software</option>
|
|
||||||
<option value="infrastructure">Infraestructura</option>
|
|
||||||
<option value="support">Soporte Técnico</option>
|
|
||||||
<option value="training">Capacitación</option>
|
|
||||||
<option value="3d_printing">Impresión 3D</option>
|
|
||||||
<option value="data_analysis">Análisis de Datos</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">Descripción</label>
|
|
||||||
<textarea id="service-description" rows="3"
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">Sectores Objetivo (uno por línea)</label>
|
|
||||||
<textarea id="service-sectors" rows="3" placeholder="Retail Manufactura Servicios"
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">Beneficios (uno por línea)</label>
|
|
||||||
<textarea id="service-benefits" rows="3" placeholder="Reduce costos operativos Aumenta productividad Operación 24/7"
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">Call to Action</label>
|
|
||||||
<input type="text" id="service-cta" placeholder="Agenda una demo gratuita"
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-sm text-gray-400 block mb-1">URL Imagen</label>
|
|
||||||
<input type="url" id="service-image"
|
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" id="service-active" checked class="w-4 h-4">
|
|
||||||
<span>Activo</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" id="service-featured" class="w-4 h-4">
|
|
||||||
<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">
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn-primary px-4 py-2 rounded">
|
|
||||||
Guardar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- Filters -->
|
||||||
let services = [];
|
<div class="card rounded-2xl p-4 mb-6">
|
||||||
let categories = [];
|
<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="loadServices()" 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="loadServices()" 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="loadServices()" 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="loadServices()" placeholder="Nombre del servicio..."
|
||||||
|
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>
|
||||||
|
|
||||||
// Load services on page load
|
<!-- Services Grid -->
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
<div id="services-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
loadCategories();
|
<!-- Services loaded dynamically -->
|
||||||
loadServices();
|
</div>
|
||||||
});
|
|
||||||
|
|
||||||
async function loadCategories() {
|
<!-- Empty State -->
|
||||||
try {
|
<div id="empty-state" class="card rounded-2xl p-12 text-center hidden">
|
||||||
const response = await fetch('/api/services/categories');
|
<div class="text-6xl mb-4">🛠️</div>
|
||||||
if (response.ok) {
|
<h3 class="text-xl font-bold mb-2">No hay servicios</h3>
|
||||||
categories = await response.json();
|
<p class="text-gray-400 mb-4">Agrega tu primer servicio para empezar a generar contenido</p>
|
||||||
const select = document.getElementById('filter-category');
|
<button onclick="openModal('add')" class="btn-primary px-6 py-3 rounded-xl">
|
||||||
categories.forEach(cat => {
|
+ Agregar Servicio
|
||||||
const option = document.createElement('option');
|
</button>
|
||||||
option.value = cat;
|
</div>
|
||||||
option.textContent = formatCategory(cat);
|
</div>
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
document.getElementById('stat-categories').textContent = categories.length;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading categories:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCategory(cat) {
|
<!-- Add/Edit Modal -->
|
||||||
const names = {
|
<div id="service-modal" class="fixed inset-0 bg-black/70 backdrop-blur-sm hidden flex items-center justify-center z-50">
|
||||||
'ai_automation': 'Automatización con IA',
|
<div class="card rounded-2xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4">
|
||||||
'consulting': 'Consultoría TI',
|
<div class="flex justify-between items-center mb-6">
|
||||||
'development': 'Desarrollo de Software',
|
<h3 class="text-xl font-semibold" id="modal-title">Agregar Servicio</h3>
|
||||||
'infrastructure': 'Infraestructura',
|
<button onclick="closeModal()" class="text-gray-400 hover:text-white text-xl">✕</button>
|
||||||
'support': 'Soporte Técnico',
|
</div>
|
||||||
'training': 'Capacitación',
|
|
||||||
'3d_printing': 'Impresión 3D',
|
|
||||||
'data_analysis': 'Análisis de Datos'
|
|
||||||
};
|
|
||||||
return names[cat] || cat.replace('_', ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadServices() {
|
<form id="service-form" onsubmit="saveService(event)">
|
||||||
const category = document.getElementById('filter-category').value;
|
<input type="hidden" id="service-id">
|
||||||
const active = document.getElementById('filter-active').value;
|
|
||||||
const featured = document.getElementById('filter-featured').value;
|
|
||||||
const search = document.getElementById('filter-search').value;
|
|
||||||
|
|
||||||
let url = '/api/services/?limit=100';
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
if (category) url += `&category=${category}`;
|
<div class="col-span-2">
|
||||||
if (active) url += `&is_active=${active}`;
|
<label class="text-sm text-gray-400 block mb-2">Nombre *</label>
|
||||||
if (featured === 'true') url += `&is_featured=true`;
|
<input type="text" id="service-name" required
|
||||||
|
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 focus:border-primary focus:outline-none">
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (response.ok) {
|
|
||||||
services = await response.json();
|
|
||||||
|
|
||||||
// Client-side search filter
|
|
||||||
if (search) {
|
|
||||||
services = services.filter(s =>
|
|
||||||
s.name.toLowerCase().includes(search.toLowerCase())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderServices();
|
|
||||||
updateStats();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading services:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStats() {
|
|
||||||
document.getElementById('stat-total').textContent = services.length;
|
|
||||||
document.getElementById('stat-active').textContent = services.filter(s => s.is_active).length;
|
|
||||||
document.getElementById('stat-featured').textContent = services.filter(s => s.is_featured).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderServices() {
|
|
||||||
const grid = document.getElementById('services-grid');
|
|
||||||
const empty = document.getElementById('empty-state');
|
|
||||||
|
|
||||||
if (services.length === 0) {
|
|
||||||
grid.classList.add('hidden');
|
|
||||||
empty.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
grid.classList.remove('hidden');
|
|
||||||
empty.classList.add('hidden');
|
|
||||||
|
|
||||||
grid.innerHTML = services.map(service => `
|
|
||||||
<div class="card p-4 ${!service.is_active ? 'opacity-60' : ''}">
|
|
||||||
<div class="flex justify-between items-start mb-3">
|
|
||||||
<div class="text-3xl">
|
|
||||||
${getCategoryIcon(service.category)}
|
|
||||||
</div>
|
|
||||||
${service.is_featured ? '<span class="text-yellow-400">⭐</span>' : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 class="font-bold text-lg mb-2">${service.name}</h4>
|
|
||||||
|
|
||||||
<div class="text-sm text-gray-400 mb-3">
|
|
||||||
${formatCategory(service.category)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${service.description ?
|
|
||||||
`<p class="text-sm text-gray-400 mb-3 line-clamp-2">${service.description}</p>` : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
${service.target_sectors && service.target_sectors.length ?
|
|
||||||
`<div class="mb-3">
|
|
||||||
<div class="text-xs text-gray-500 mb-1">Sectores:</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
${service.target_sectors.slice(0, 3).map(s => `<span class="tag">${s}</span>`).join('')}
|
|
||||||
${service.target_sectors.length > 3 ? `<span class="tag">+${service.target_sectors.length - 3}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>` : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
${service.benefits && service.benefits.length ?
|
|
||||||
`<div class="mb-3">
|
|
||||||
<div class="text-xs text-gray-500 mb-1">Beneficios:</div>
|
|
||||||
<ul class="text-sm text-gray-400">
|
|
||||||
${service.benefits.slice(0, 2).map(b => `<li>✓ ${b}</li>`).join('')}
|
|
||||||
</ul>
|
|
||||||
</div>` : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="flex gap-2 mt-4">
|
|
||||||
<button onclick="generatePost(${service.id})"
|
|
||||||
class="flex-1 btn-primary px-3 py-2 rounded text-sm">
|
|
||||||
Generar Post
|
|
||||||
</button>
|
|
||||||
<button onclick="editService(${service.id})"
|
|
||||||
class="btn-secondary px-3 py-2 rounded text-sm">
|
|
||||||
Editar
|
|
||||||
</button>
|
|
||||||
<button onclick="deleteService(${service.id})"
|
|
||||||
class="btn-danger px-3 py-2 rounded text-sm">
|
|
||||||
🗑
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCategoryIcon(category) {
|
<div class="col-span-2">
|
||||||
const icons = {
|
<label class="text-sm text-gray-400 block mb-2">Categoría *</label>
|
||||||
'ai_automation': '🤖',
|
<select id="service-category" required class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 focus:border-primary focus:outline-none">
|
||||||
'consulting': '💼',
|
<option value="ai_automation">Automatización con IA</option>
|
||||||
'development': '💻',
|
<option value="consulting">Consultoría TI</option>
|
||||||
'infrastructure': '🖥️',
|
<option value="development">Desarrollo de Software</option>
|
||||||
'support': '🛠️',
|
<option value="infrastructure">Infraestructura</option>
|
||||||
'training': '📚',
|
<option value="support">Soporte Técnico</option>
|
||||||
'3d_printing': '🖨️',
|
<option value="training">Capacitación</option>
|
||||||
'data_analysis': '📊'
|
<option value="3d_printing">Impresión 3D</option>
|
||||||
};
|
<option value="data_analysis">Análisis de Datos</option>
|
||||||
return icons[category] || '🛠️';
|
</select>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
function openModal(mode, serviceId = null) {
|
<div class="col-span-2">
|
||||||
document.getElementById('modal-title').textContent =
|
<label class="text-sm text-gray-400 block mb-2">Descripción</label>
|
||||||
mode === 'add' ? 'Agregar Servicio' : 'Editar Servicio';
|
<textarea id="service-description" rows="3"
|
||||||
document.getElementById('service-form').reset();
|
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>
|
||||||
document.getElementById('service-id').value = '';
|
</div>
|
||||||
document.getElementById('service-active').checked = true;
|
|
||||||
document.getElementById('service-modal').classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
<div class="col-span-2">
|
||||||
document.getElementById('service-modal').classList.remove('active');
|
<label class="text-sm text-gray-400 block mb-2">Sectores Objetivo (uno por línea)</label>
|
||||||
}
|
<textarea id="service-sectors" rows="3" placeholder="Retail Manufactura Servicios"
|
||||||
|
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>
|
||||||
|
|
||||||
function editService(id) {
|
<div class="col-span-2">
|
||||||
const service = services.find(s => s.id === id);
|
<label class="text-sm text-gray-400 block mb-2">Beneficios (uno por línea)</label>
|
||||||
if (!service) return;
|
<textarea id="service-benefits" rows="3" placeholder="Reduce costos operativos Aumenta productividad Operación 24/7"
|
||||||
|
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>
|
||||||
|
|
||||||
document.getElementById('modal-title').textContent = 'Editar Servicio';
|
<div class="col-span-2">
|
||||||
document.getElementById('service-id').value = service.id;
|
<label class="text-sm text-gray-400 block mb-2">Call to Action</label>
|
||||||
document.getElementById('service-name').value = service.name;
|
<input type="text" id="service-cta" placeholder="Agenda una demo gratuita"
|
||||||
document.getElementById('service-category').value = service.category;
|
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 focus:border-primary focus:outline-none">
|
||||||
document.getElementById('service-description').value = service.description || '';
|
</div>
|
||||||
document.getElementById('service-sectors').value = (service.target_sectors || []).join('\n');
|
|
||||||
document.getElementById('service-benefits').value = (service.benefits || []).join('\n');
|
|
||||||
document.getElementById('service-cta').value = service.call_to_action || '';
|
|
||||||
document.getElementById('service-image').value = service.image_url || '';
|
|
||||||
document.getElementById('service-active').checked = service.is_active;
|
|
||||||
document.getElementById('service-featured').checked = service.is_featured;
|
|
||||||
|
|
||||||
document.getElementById('service-modal').classList.add('active');
|
<div>
|
||||||
}
|
<label class="text-sm text-gray-400 block mb-2">URL Imagen</label>
|
||||||
|
<input type="url" id="service-image"
|
||||||
|
class="w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 focus:border-primary focus:outline-none">
|
||||||
|
</div>
|
||||||
|
|
||||||
async function saveService(event) {
|
<div class="flex items-center gap-6">
|
||||||
event.preventDefault();
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="service-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="service-featured" class="w-4 h-4 rounded">
|
||||||
|
<span>Destacado</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
const id = document.getElementById('service-id').value;
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
const sectorsText = document.getElementById('service-sectors').value;
|
<button type="button" onclick="closeModal()" class="btn-secondary px-4 py-2 rounded-xl">
|
||||||
const benefitsText = document.getElementById('service-benefits').value;
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn-primary px-4 py-2 rounded-xl">
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
const data = {
|
{% block extra_scripts %}
|
||||||
name: document.getElementById('service-name').value,
|
<style>
|
||||||
category: document.getElementById('service-category').value,
|
.tag { background-color: rgba(99, 102, 241, 0.2); color: #a5b4fc; padding: 2px 8px; border-radius: 6px; font-size: 12px; }
|
||||||
description: document.getElementById('service-description').value,
|
.line-clamp-2 {
|
||||||
target_sectors: sectorsText ? sectorsText.split('\n').filter(s => s.trim()) : [],
|
display: -webkit-box;
|
||||||
benefits: benefitsText ? benefitsText.split('\n').filter(b => b.trim()) : [],
|
-webkit-line-clamp: 2;
|
||||||
call_to_action: document.getElementById('service-cta').value || null,
|
-webkit-box-orient: vertical;
|
||||||
image_url: document.getElementById('service-image').value || null,
|
overflow: hidden;
|
||||||
is_active: document.getElementById('service-active').checked,
|
}
|
||||||
is_featured: document.getElementById('service-featured').checked
|
</style>
|
||||||
};
|
<script>
|
||||||
|
let services = [];
|
||||||
|
let categories = [];
|
||||||
|
|
||||||
try {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const url = id ? `/api/services/${id}` : '/api/services/';
|
loadCategories();
|
||||||
const method = id ? 'PUT' : 'POST';
|
loadServices();
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(url, {
|
async function loadCategories() {
|
||||||
method,
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const response = await fetch('/api/services/categories');
|
||||||
body: JSON.stringify(data)
|
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 = formatCategory(cat);
|
||||||
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
|
document.getElementById('stat-categories').textContent = categories.length;
|
||||||
if (response.ok) {
|
|
||||||
closeModal();
|
|
||||||
loadServices();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(error.detail || 'Error al guardar');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Error de conexión');
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading categories:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCategory(cat) {
|
||||||
|
const names = {
|
||||||
|
'ai_automation': 'Automatización con IA',
|
||||||
|
'consulting': 'Consultoría TI',
|
||||||
|
'development': 'Desarrollo de Software',
|
||||||
|
'infrastructure': 'Infraestructura',
|
||||||
|
'support': 'Soporte Técnico',
|
||||||
|
'training': 'Capacitación',
|
||||||
|
'3d_printing': 'Impresión 3D',
|
||||||
|
'data_analysis': 'Análisis de Datos'
|
||||||
|
};
|
||||||
|
return names[cat] || cat.replace('_', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryIcon(category) {
|
||||||
|
const icons = {
|
||||||
|
'ai_automation': '🤖',
|
||||||
|
'consulting': '💼',
|
||||||
|
'development': '💻',
|
||||||
|
'infrastructure': '🖥️',
|
||||||
|
'support': '🛠️',
|
||||||
|
'training': '📚',
|
||||||
|
'3d_printing': '🖨️',
|
||||||
|
'data_analysis': '📊'
|
||||||
|
};
|
||||||
|
return icons[category] || '🛠️';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServices() {
|
||||||
|
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/services/?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) {
|
||||||
|
services = await response.json();
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
services = services.filter(s =>
|
||||||
|
s.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderServices();
|
||||||
|
updateStats();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading services:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
document.getElementById('stat-total').textContent = services.length;
|
||||||
|
document.getElementById('stat-active').textContent = services.filter(s => s.is_active).length;
|
||||||
|
document.getElementById('stat-featured').textContent = services.filter(s => s.is_featured).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServices() {
|
||||||
|
const grid = document.getElementById('services-grid');
|
||||||
|
const empty = document.getElementById('empty-state');
|
||||||
|
|
||||||
|
if (services.length === 0) {
|
||||||
|
grid.classList.add('hidden');
|
||||||
|
empty.classList.remove('hidden');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteService(id) {
|
grid.classList.remove('hidden');
|
||||||
if (!confirm('¿Eliminar este servicio?')) return;
|
empty.classList.add('hidden');
|
||||||
|
|
||||||
try {
|
grid.innerHTML = services.map(service => `
|
||||||
const response = await fetch(`/api/services/${id}`, { method: 'DELETE' });
|
<div class="card rounded-2xl p-4 ${!service.is_active ? 'opacity-60' : ''}">
|
||||||
if (response.ok) {
|
<div class="flex justify-between items-start mb-3">
|
||||||
loadServices();
|
<div class="text-4xl">
|
||||||
|
${getCategoryIcon(service.category)}
|
||||||
|
</div>
|
||||||
|
${service.is_featured ? '<span class="text-yellow-400 text-xl">⭐</span>' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="font-bold text-lg mb-2">${service.name}</h4>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-400 mb-3">
|
||||||
|
${formatCategory(service.category)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${service.description ?
|
||||||
|
`<p class="text-sm text-gray-400 mb-3 line-clamp-2">${service.description}</p>` : ''
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generatePost(serviceId) {
|
${service.target_sectors && service.target_sectors.length ?
|
||||||
const service = services.find(s => s.id === serviceId);
|
`<div class="mb-3">
|
||||||
if (!service) return;
|
<div class="text-xs text-gray-500 mb-1">Sectores:</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
const platform = prompt('¿Para qué plataforma? (x, threads, instagram, facebook)', 'x');
|
${service.target_sectors.slice(0, 3).map(s => `<span class="tag">${s}</span>`).join('')}
|
||||||
if (!platform) return;
|
${service.target_sectors.length > 3 ? `<span class="tag">+${service.target_sectors.length - 3}</span>` : ''}
|
||||||
|
</div>
|
||||||
try {
|
</div>` : ''
|
||||||
const response = await fetch('/api/generate/service', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
service_id: serviceId,
|
|
||||||
platform: platform
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
alert('Post generado:\n\n' + data.content);
|
|
||||||
} else {
|
|
||||||
alert('Error al generar post');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
${service.benefits && service.benefits.length ?
|
||||||
alert('Error de conexión');
|
`<div class="mb-3">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">Beneficios:</div>
|
||||||
|
<ul class="text-sm text-gray-400">
|
||||||
|
${service.benefits.slice(0, 2).map(b => `<li>✓ ${b}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<button onclick="generatePost(${service.id})"
|
||||||
|
class="flex-1 btn-primary px-3 py-2 rounded-xl text-sm">
|
||||||
|
Generar Post
|
||||||
|
</button>
|
||||||
|
<button onclick="editService(${service.id})"
|
||||||
|
class="btn-secondary px-3 py-2 rounded-xl text-sm">
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteService(${service.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, serviceId = null) {
|
||||||
|
document.getElementById('modal-title').textContent =
|
||||||
|
mode === 'add' ? 'Agregar Servicio' : 'Editar Servicio';
|
||||||
|
document.getElementById('service-form').reset();
|
||||||
|
document.getElementById('service-id').value = '';
|
||||||
|
document.getElementById('service-active').checked = true;
|
||||||
|
document.getElementById('service-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('service-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editService(id) {
|
||||||
|
const service = services.find(s => s.id === id);
|
||||||
|
if (!service) return;
|
||||||
|
|
||||||
|
document.getElementById('modal-title').textContent = 'Editar Servicio';
|
||||||
|
document.getElementById('service-id').value = service.id;
|
||||||
|
document.getElementById('service-name').value = service.name;
|
||||||
|
document.getElementById('service-category').value = service.category;
|
||||||
|
document.getElementById('service-description').value = service.description || '';
|
||||||
|
document.getElementById('service-sectors').value = (service.target_sectors || []).join('\n');
|
||||||
|
document.getElementById('service-benefits').value = (service.benefits || []).join('\n');
|
||||||
|
document.getElementById('service-cta').value = service.call_to_action || '';
|
||||||
|
document.getElementById('service-image').value = service.image_url || '';
|
||||||
|
document.getElementById('service-active').checked = service.is_active;
|
||||||
|
document.getElementById('service-featured').checked = service.is_featured;
|
||||||
|
|
||||||
|
document.getElementById('service-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveService(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const id = document.getElementById('service-id').value;
|
||||||
|
const sectorsText = document.getElementById('service-sectors').value;
|
||||||
|
const benefitsText = document.getElementById('service-benefits').value;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: document.getElementById('service-name').value,
|
||||||
|
category: document.getElementById('service-category').value,
|
||||||
|
description: document.getElementById('service-description').value,
|
||||||
|
target_sectors: sectorsText ? sectorsText.split('\n').filter(s => s.trim()) : [],
|
||||||
|
benefits: benefitsText ? benefitsText.split('\n').filter(b => b.trim()) : [],
|
||||||
|
call_to_action: document.getElementById('service-cta').value || null,
|
||||||
|
image_url: document.getElementById('service-image').value || null,
|
||||||
|
is_active: document.getElementById('service-active').checked,
|
||||||
|
is_featured: document.getElementById('service-featured').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = id ? `/api/services/${id}` : '/api/services/';
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
closeModal();
|
||||||
|
loadServices();
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">✅</span><p>Servicio 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>');
|
||||||
}
|
}
|
||||||
</script>
|
}
|
||||||
</body>
|
|
||||||
</html>
|
async function deleteService(id) {
|
||||||
|
if (!confirm('¿Eliminar este servicio?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/services/${id}`, { method: 'DELETE' });
|
||||||
|
if (response.ok) {
|
||||||
|
loadServices();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePost(serviceId) {
|
||||||
|
const service = services.find(s => s.id === serviceId);
|
||||||
|
if (!service) 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/service', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
service_id: serviceId,
|
||||||
|
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>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,422 +1,343 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="es">
|
|
||||||
<head>
|
{% block title %}Configuración{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{% block content %}
|
||||||
<title>Configuración - Social Media Automation</title>
|
<div class="animate-fade-in">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { background-color: #1a1a2e; color: #eee; }
|
|
||||||
.card { background-color: #16213e; border-radius: 12px; }
|
|
||||||
.accent { color: #d4a574; }
|
|
||||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
|
||||||
.btn-primary:hover { background-color: #c49564; }
|
|
||||||
.btn-secondary { background-color: #374151; }
|
|
||||||
.btn-secondary:hover { background-color: #4b5563; }
|
|
||||||
.status-ok { color: #10b981; }
|
|
||||||
.status-error { color: #ef4444; }
|
|
||||||
.status-warning { color: #f59e0b; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen">
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
<div class="mb-8">
|
||||||
<div class="flex justify-between items-center">
|
<h1 class="text-3xl font-bold">Configuración</h1>
|
||||||
<h1 class="text-2xl font-bold">
|
<p class="text-gray-400 mt-1">Administra las conexiones y preferencias del sistema</p>
|
||||||
<span class="accent">Consultoría AS</span> - Social Media
|
</div>
|
||||||
</h1>
|
|
||||||
<nav class="flex gap-4">
|
|
||||||
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
|
||||||
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800">+ Crear</a>
|
|
||||||
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
|
||||||
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
|
||||||
<a href="/dashboard/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
|
|
||||||
<a href="/dashboard/settings" class="px-4 py-2 rounded bg-gray-800 accent">Config</a>
|
|
||||||
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="container mx-auto px-6 py-8 max-w-4xl">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<h2 class="text-2xl font-bold mb-6">Configuración del Sistema</h2>
|
<!-- Platform Connections -->
|
||||||
|
<div class="card rounded-2xl p-6">
|
||||||
<!-- API Connections -->
|
<h2 class="text-lg font-semibold mb-6 flex items-center gap-2">
|
||||||
<div class="card p-6 mb-6">
|
<span>🔗</span>
|
||||||
<h3 class="font-semibold text-lg mb-4">Conexiones de APIs</h3>
|
<span>Conexiones de Plataformas</span>
|
||||||
<div id="api-status" class="space-y-4">
|
</h2>
|
||||||
<div class="text-gray-400">Verificando conexiones...</div>
|
<div class="space-y-4" id="platforms-container">
|
||||||
</div>
|
<div class="text-center py-4 text-gray-500">
|
||||||
<button onclick="testAllConnections()" class="btn-secondary px-4 py-2 rounded mt-4">
|
<div class="animate-spin w-6 h-6 border-2 border-primary border-t-transparent rounded-full mx-auto"></div>
|
||||||
🔄 Verificar Todas
|
<p class="mt-2">Verificando conexiones...</p>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI Configuration -->
|
|
||||||
<div class="card p-6 mb-6">
|
|
||||||
<h3 class="font-semibold text-lg mb-4">Configuración de IA (DeepSeek)</h3>
|
|
||||||
<div id="ai-status" class="mb-4">
|
|
||||||
<div class="text-gray-400">Verificando...</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-800 rounded-lg p-4 text-sm">
|
|
||||||
<p class="text-gray-400 mb-2">Para configurar DeepSeek API:</p>
|
|
||||||
<ol class="list-decimal list-inside space-y-1 text-gray-300">
|
|
||||||
<li>Visita <a href="https://platform.deepseek.com/" target="_blank" class="text-blue-400 hover:underline">platform.deepseek.com</a></li>
|
|
||||||
<li>Crea una cuenta y genera una API Key</li>
|
|
||||||
<li>Agrega al archivo <code class="bg-gray-700 px-1 rounded">.env</code>: <code class="bg-gray-700 px-1 rounded">DEEPSEEK_API_KEY=tu_key</code></li>
|
|
||||||
<li>Reinicia la aplicación</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Telegram Notifications -->
|
<!-- Telegram Notifications -->
|
||||||
<div class="card p-6 mb-6">
|
<div class="card rounded-2xl p-6">
|
||||||
<h3 class="font-semibold text-lg mb-4">Notificaciones Telegram</h3>
|
<h2 class="text-lg font-semibold mb-6 flex items-center gap-2">
|
||||||
<div id="telegram-status" class="mb-4">
|
<span>📱</span>
|
||||||
<div class="text-gray-400">Verificando...</div>
|
<span>Notificaciones Telegram</span>
|
||||||
|
</h2>
|
||||||
|
<div id="telegram-status" class="mb-6">
|
||||||
|
<div class="text-center py-4 text-gray-500">
|
||||||
|
<div class="animate-spin w-6 h-6 border-2 border-primary border-t-transparent rounded-full mx-auto"></div>
|
||||||
|
<p class="mt-2">Verificando...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-3">
|
||||||
<button onclick="testTelegram()" class="btn-secondary px-4 py-2 rounded">
|
<button onclick="testTelegram()" class="btn-primary px-4 py-2 rounded-lg flex items-center gap-2 transition-all">
|
||||||
📱 Enviar Prueba
|
<span>📤</span>
|
||||||
|
<span>Enviar Prueba</span>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="showTelegramGuide()" class="btn-secondary px-4 py-2 rounded">
|
<button onclick="toggleTelegramGuide()" class="btn-secondary px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
📖 Ver Guía
|
<span>📖</span>
|
||||||
|
<span>Ver Guía</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="telegram-guide" class="hidden mt-4 bg-gray-800 rounded-lg p-4 text-sm">
|
<div id="telegram-guide" class="hidden mt-6 bg-dark-800 rounded-xl p-4 text-sm space-y-3">
|
||||||
<!-- Guide loaded dynamically -->
|
<h4 class="font-medium text-primary">Configurar Telegram Bot</h4>
|
||||||
|
<ol class="list-decimal list-inside space-y-2 text-gray-400">
|
||||||
|
<li>Busca <strong>@BotFather</strong> en Telegram</li>
|
||||||
|
<li>Envía <code class="bg-dark-700 px-1 rounded">/newbot</code> y sigue las instrucciones</li>
|
||||||
|
<li>Copia el <strong>Bot Token</strong> que te proporciona</li>
|
||||||
|
<li>Inicia chat con tu nuevo bot</li>
|
||||||
|
<li>Visita <code class="bg-dark-700 px-1 rounded">api.telegram.org/bot{TOKEN}/getUpdates</code></li>
|
||||||
|
<li>Copia tu <strong>Chat ID</strong> del resultado</li>
|
||||||
|
<li>Configura ambos valores en el archivo .env</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Odoo Integration -->
|
||||||
|
<div class="card rounded-2xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-6 flex items-center gap-2">
|
||||||
|
<span>🏢</span>
|
||||||
|
<span>Integración Odoo</span>
|
||||||
|
</h2>
|
||||||
|
<div id="odoo-status" class="mb-6">
|
||||||
|
<div class="text-center py-4 text-gray-500">
|
||||||
|
<div class="animate-spin w-6 h-6 border-2 border-primary border-t-transparent rounded-full mx-auto"></div>
|
||||||
|
<p class="mt-2">Verificando...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button onclick="syncOdooProducts()" class="btn-secondary px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<span>📦</span>
|
||||||
|
<span>Sync Productos</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="syncOdooServices()" class="btn-secondary px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<span>🛠️</span>
|
||||||
|
<span>Sync Servicios</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="syncOdooLeads()" class="btn-secondary px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<span>🎯</span>
|
||||||
|
<span>Exportar Leads</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Generation -->
|
||||||
|
<div class="card rounded-2xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-6 flex items-center gap-2">
|
||||||
|
<span>🤖</span>
|
||||||
|
<span>Generación con IA</span>
|
||||||
|
</h2>
|
||||||
|
<div id="ai-status" class="mb-6">
|
||||||
|
<div class="text-center py-4 text-gray-500">
|
||||||
|
<div class="animate-spin w-6 h-6 border-2 border-primary border-t-transparent rounded-full mx-auto"></div>
|
||||||
|
<p class="mt-2">Verificando...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-dark-800/50 rounded-xl p-4">
|
||||||
|
<h4 class="font-medium mb-2">Información del Negocio</h4>
|
||||||
|
<div class="space-y-2 text-sm text-gray-400">
|
||||||
|
<p><strong>Nombre:</strong> <span id="business-name">-</span></p>
|
||||||
|
<p><strong>Ubicación:</strong> <span id="business-location">-</span></p>
|
||||||
|
<p><strong>Tono:</strong> <span id="content-tone">-</span></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Info -->
|
<!-- System Info -->
|
||||||
<div class="card p-6 mb-6">
|
<div class="card rounded-2xl p-6 lg:col-span-2">
|
||||||
<h3 class="font-semibold text-lg mb-4">Información del Sistema</h3>
|
<h2 class="text-lg font-semibold mb-6 flex items-center gap-2">
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
<span>ℹ️</span>
|
||||||
<div>
|
<span>Información del Sistema</span>
|
||||||
<span class="text-gray-400">Versión:</span>
|
</h2>
|
||||||
<span class="ml-2">1.0.0</span>
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-dark-800/50 rounded-xl p-4 text-center">
|
||||||
|
<p class="text-2xl font-bold text-primary">1.0.0</p>
|
||||||
|
<p class="text-sm text-gray-500">Versión</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="bg-dark-800/50 rounded-xl p-4 text-center">
|
||||||
<span class="text-gray-400">Entorno:</span>
|
<p class="text-2xl font-bold text-green-400" id="system-status">●</p>
|
||||||
<span class="ml-2" id="app-env">-</span>
|
<p class="text-sm text-gray-500">Estado</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="bg-dark-800/50 rounded-xl p-4 text-center">
|
||||||
<span class="text-gray-400">Base de datos:</span>
|
<p class="text-2xl font-bold" id="db-status">-</p>
|
||||||
<span class="ml-2" id="db-status">-</span>
|
<p class="text-sm text-gray-500">Base de Datos</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="bg-dark-800/50 rounded-xl p-4 text-center">
|
||||||
<span class="text-gray-400">Redis:</span>
|
<p class="text-2xl font-bold" id="worker-status">-</p>
|
||||||
<span class="ml-2" id="redis-status">-</span>
|
<p class="text-sm text-gray-500">Workers</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
|
||||||
<div class="card p-6 mb-6">
|
|
||||||
<h3 class="font-semibold text-lg mb-4">Acciones Rápidas</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<button onclick="generateWeekContent()" class="btn-secondary p-4 rounded text-left">
|
|
||||||
<div class="font-semibold">📅 Generar Contenido Semanal</div>
|
|
||||||
<div class="text-sm text-gray-400">Crear posts para los próximos 7 días</div>
|
|
||||||
</button>
|
|
||||||
<button onclick="syncAllInteractions()" class="btn-secondary p-4 rounded text-left">
|
|
||||||
<div class="font-semibold">🔄 Sincronizar Interacciones</div>
|
|
||||||
<div class="text-sm text-gray-400">Obtener comentarios y menciones</div>
|
|
||||||
</button>
|
|
||||||
<button onclick="viewApiDocs()" class="btn-secondary p-4 rounded text-left">
|
|
||||||
<div class="font-semibold">📚 Documentación API</div>
|
|
||||||
<div class="text-sm text-gray-400">Ver endpoints disponibles</div>
|
|
||||||
</button>
|
|
||||||
<button onclick="downloadLogs()" class="btn-secondary p-4 rounded text-left">
|
|
||||||
<div class="font-semibold">📋 Ver Logs</div>
|
|
||||||
<div class="text-sm text-gray-400">Registro de actividad</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Usage Stats -->
|
|
||||||
<div class="card p-6">
|
|
||||||
<h3 class="font-semibold text-lg mb-4">Estadísticas de Uso</h3>
|
|
||||||
<div id="usage-stats" class="grid grid-cols-4 gap-4">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-2xl font-bold accent" id="stat-posts-total">-</div>
|
|
||||||
<div class="text-sm text-gray-400">Posts Totales</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-2xl font-bold text-green-400" id="stat-posts-month">-</div>
|
|
||||||
<div class="text-sm text-gray-400">Este Mes</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-2xl font-bold text-blue-400" id="stat-interactions">-</div>
|
|
||||||
<div class="text-sm text-gray-400">Interacciones</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-2xl font-bold text-purple-400" id="stat-leads">-</div>
|
|
||||||
<div class="text-sm text-gray-400">Leads</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Result Modal -->
|
|
||||||
<div id="result-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
|
||||||
<div class="card p-6 max-w-md w-full mx-4">
|
|
||||||
<div id="result-content"></div>
|
|
||||||
<button onclick="closeModal()" class="btn-primary w-full mt-4 py-2 rounded">Cerrar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<script>
|
{% block extra_scripts %}
|
||||||
window.addEventListener('load', () => {
|
<script>
|
||||||
loadApiStatus();
|
// Load all statuses on page load
|
||||||
loadAiStatus();
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadTelegramStatus();
|
loadPlatformStatus();
|
||||||
loadSystemInfo();
|
loadTelegramStatus();
|
||||||
loadUsageStats();
|
loadOdooStatus();
|
||||||
});
|
loadAIStatus();
|
||||||
|
loadSystemStatus();
|
||||||
|
});
|
||||||
|
|
||||||
async function loadApiStatus() {
|
async function loadPlatformStatus() {
|
||||||
const container = document.getElementById('api-status');
|
const container = document.getElementById('platforms-container');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/publish/test');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
try {
|
container.innerHTML = '';
|
||||||
const response = await fetch('/api/publish/test');
|
for (const [platform, status] of Object.entries(data)) {
|
||||||
const data = await response.json();
|
const isConnected = status.configured && status.connected;
|
||||||
|
container.innerHTML += `
|
||||||
const platforms = ['x', 'threads', 'facebook', 'instagram'];
|
<div class="flex items-center justify-between bg-dark-800/50 rounded-xl p-4">
|
||||||
let html = '';
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-2xl">${getPlatformIcon(platform)}</span>
|
||||||
for (const platform of platforms) {
|
<div>
|
||||||
const status = data.platforms[platform];
|
<p class="font-medium capitalize">${platform}</p>
|
||||||
const icon = status.connected ? '✅' : (status.configured ? '⚠️' : '❌');
|
${status.details?.username ? `<p class="text-xs text-gray-500">@${status.details.username}</p>` : ''}
|
||||||
const statusClass = status.connected ? 'status-ok' : (status.configured ? 'status-warning' : 'status-error');
|
|
||||||
const statusText = status.connected ? 'Conectado' : (status.configured ? 'Configurado (error de conexión)' : 'No configurado');
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-xl">${icon}</span>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium">${platform.charAt(0).toUpperCase() + platform.slice(1)}</div>
|
|
||||||
<div class="text-sm ${statusClass}">${statusText}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
${status.details ? `
|
|
||||||
<div class="text-sm text-gray-400">
|
|
||||||
${status.details.username ? '@' + status.details.username : ''}
|
|
||||||
${status.details.name ? status.details.name : ''}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = html;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
container.innerHTML = '<div class="text-red-400">Error al verificar APIs</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAiStatus() {
|
|
||||||
const container = document.getElementById('ai-status');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/generate/status');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const icon = data.status === 'connected' ? '✅' : (data.configured ? '⚠️' : '❌');
|
|
||||||
const statusClass = data.status === 'connected' ? 'status-ok' : (data.configured ? 'status-warning' : 'status-error');
|
|
||||||
const statusText = data.status === 'connected' ? 'Conectado y funcionando' : (data.configured ? 'Error: ' + (data.error || 'desconocido') : 'No configurado');
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="flex items-center gap-3 p-3 bg-gray-800 rounded-lg">
|
|
||||||
<span class="text-xl">${icon}</span>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium">DeepSeek API</div>
|
|
||||||
<div class="text-sm ${statusClass}">${statusText}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm ${isConnected ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}">
|
||||||
|
${isConnected ? '✓ Conectado' : '✗ Desconectado'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
container.innerHTML = '<div class="text-red-400">Error al verificar IA</div>';
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<div class="text-center text-red-400 py-4">Error cargando estado</div>';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTelegramStatus() {
|
async function loadTelegramStatus() {
|
||||||
const container = document.getElementById('telegram-status');
|
const container = document.getElementById('telegram-status');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/notifications/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
try {
|
const isConfigured = data.telegram_configured;
|
||||||
const response = await fetch('/api/notifications/status');
|
container.innerHTML = `
|
||||||
const data = await response.json();
|
<div class="flex items-center justify-between bg-dark-800/50 rounded-xl p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
const icon = data.telegram_configured ? '✅' : '❌';
|
<span class="text-2xl">📱</span>
|
||||||
const statusClass = data.telegram_configured ? 'status-ok' : 'status-error';
|
|
||||||
const statusText = data.telegram_configured ? 'Configurado' : 'No configurado';
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="flex items-center gap-3 p-3 bg-gray-800 rounded-lg">
|
|
||||||
<span class="text-xl">${icon}</span>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium">Telegram Bot</div>
|
<p class="font-medium">Telegram Bot</p>
|
||||||
<div class="text-sm ${statusClass}">${statusText}</div>
|
<p class="text-xs text-gray-500">${data.bot_token_set ? 'Token configurado' : 'Token no configurado'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
<span class="px-3 py-1 rounded-full text-sm ${isConfigured ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}">
|
||||||
|
${isConfigured ? '✓ Activo' : '✗ Inactivo'}
|
||||||
} catch (error) {
|
</span>
|
||||||
container.innerHTML = '<div class="text-red-400">Error al verificar Telegram</div>';
|
</div>
|
||||||
}
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<div class="text-center text-red-400 py-4">Error verificando Telegram</div>';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSystemInfo() {
|
async function loadOdooStatus() {
|
||||||
try {
|
const container = document.getElementById('odoo-status');
|
||||||
const response = await fetch('/api/health');
|
try {
|
||||||
const data = await response.json();
|
const response = await fetch('/api/odoo/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
document.getElementById('app-env').textContent = 'Desarrollo';
|
container.innerHTML = `
|
||||||
document.getElementById('db-status').innerHTML = '<span class="status-ok">Conectada</span>';
|
<div class="flex items-center justify-between bg-dark-800/50 rounded-xl p-4">
|
||||||
document.getElementById('redis-status').innerHTML = '<span class="status-warning">No verificado</span>';
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-2xl">🏢</span>
|
||||||
} catch (error) {
|
<div>
|
||||||
document.getElementById('db-status').innerHTML = '<span class="status-error">Error</span>';
|
<p class="font-medium">Odoo ERP</p>
|
||||||
}
|
<p class="text-xs text-gray-500">${data.version ? 'v' + data.version : (data.error || 'No configurado')}</p>
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsageStats() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/stats');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
document.getElementById('stat-posts-total').textContent = data.posts_month || 0;
|
|
||||||
document.getElementById('stat-posts-month').textContent = data.posts_week || 0;
|
|
||||||
document.getElementById('stat-interactions').textContent = data.interactions_pending || 0;
|
|
||||||
document.getElementById('stat-leads').textContent = 0;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading stats:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testAllConnections() {
|
|
||||||
showModal('Verificando conexiones...', true);
|
|
||||||
await loadApiStatus();
|
|
||||||
await loadAiStatus();
|
|
||||||
await loadTelegramStatus();
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testTelegram() {
|
|
||||||
showModal('Enviando mensaje de prueba...', true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/notifications/test', { method: 'POST' });
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
showModal('✅ Mensaje enviado. Revisa tu Telegram.');
|
|
||||||
} else {
|
|
||||||
showModal('❌ Error: ' + (data.detail || 'No se pudo enviar'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showModal('❌ Error de conexión');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showTelegramGuide() {
|
|
||||||
const container = document.getElementById('telegram-guide');
|
|
||||||
|
|
||||||
if (!container.classList.contains('hidden')) {
|
|
||||||
container.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/notifications/setup-guide');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
let html = `<h4 class="font-semibold mb-3">${data.title}</h4>`;
|
|
||||||
|
|
||||||
data.steps.forEach(step => {
|
|
||||||
html += `
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="font-medium text-amber-400">Paso ${step.step}: ${step.title}</div>
|
|
||||||
<ul class="list-disc list-inside text-gray-300 ml-2">
|
|
||||||
${step.instructions.map(i => `<li>${i}</li>`).join('')}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
</div>
|
||||||
});
|
<span class="px-3 py-1 rounded-full text-sm ${data.connected ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}">
|
||||||
|
${data.connected ? '✓ Conectado' : '⚠ ' + (data.configured ? 'Error' : 'No configurado')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<div class="text-center text-red-400 py-4">Error verificando Odoo</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
container.innerHTML = html;
|
async function loadAIStatus() {
|
||||||
container.classList.remove('hidden');
|
const container = document.getElementById('ai-status');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/generate/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
} catch (error) {
|
document.getElementById('business-name').textContent = data.business_name || '-';
|
||||||
container.innerHTML = '<div class="text-red-400">Error al cargar guía</div>';
|
document.getElementById('business-location').textContent = data.business_location || '-';
|
||||||
container.classList.remove('hidden');
|
document.getElementById('content-tone').textContent = data.content_tone || '-';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="flex items-center justify-between bg-dark-800/50 rounded-xl p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-2xl">🤖</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">DeepSeek API</p>
|
||||||
|
<p class="text-xs text-gray-500">${data.provider || 'DeepSeek'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm ${data.configured ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}">
|
||||||
|
${data.configured ? '✓ Configurado' : '✗ No configurado'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<div class="text-center text-red-400 py-4">Error verificando IA</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSystemStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/health');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('system-status').textContent = data.status === 'healthy' ? '● Online' : '● Offline';
|
||||||
|
document.getElementById('system-status').className = data.status === 'healthy' ? 'text-2xl font-bold text-green-400' : 'text-2xl font-bold text-red-400';
|
||||||
|
document.getElementById('db-status').textContent = '✓ OK';
|
||||||
|
document.getElementById('db-status').className = 'text-2xl font-bold text-green-400';
|
||||||
|
document.getElementById('worker-status').textContent = '✓ OK';
|
||||||
|
document.getElementById('worker-status').className = 'text-2xl font-bold text-green-400';
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('system-status').textContent = '● Error';
|
||||||
|
document.getElementById('system-status').className = 'text-2xl font-bold text-red-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testTelegram() {
|
||||||
|
showModal('Enviando mensaje de prueba...', true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/notifications/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">✅</span><p class="text-lg">¡Mensaje enviado!</p><p class="text-gray-400 mt-2">Revisa tu Telegram</p></div>');
|
||||||
|
} else {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p class="text-lg">Error al enviar</p><p class="text-gray-400 mt-2">' + (data.detail || 'Verifica la configuración') + '</p></div>');
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p class="text-lg">Error de conexión</p></div>');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function generateWeekContent() {
|
function toggleTelegramGuide() {
|
||||||
showModal('Generando contenido para la semana...', true);
|
const guide = document.getElementById('telegram-guide');
|
||||||
|
guide.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
async function syncOdooProducts() {
|
||||||
const response = await fetch('/api/generate/batch', {
|
showModal('Sincronizando productos...', true);
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const response = await fetch('/api/odoo/sync/products', { method: 'POST' });
|
||||||
body: JSON.stringify({
|
const data = await response.json();
|
||||||
platforms: ['x', 'threads'],
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">📦</span><p class="text-lg">Sincronización completada</p><p class="text-gray-400 mt-2">Creados: ${data.created} | Actualizados: ${data.updated}</p></div>`);
|
||||||
days: 7
|
} catch (error) {
|
||||||
})
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error sincronizando</p></div>');
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
showModal(`✅ Generados ${data.total_generated} posts para la semana`);
|
|
||||||
} else {
|
|
||||||
showModal('❌ Error: ' + (data.error || 'No se pudo generar'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showModal('❌ Error: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function syncAllInteractions() {
|
async function syncOdooServices() {
|
||||||
showModal('Sincronizando interacciones...', true);
|
showModal('Sincronizando servicios...', true);
|
||||||
|
try {
|
||||||
try {
|
const response = await fetch('/api/odoo/sync/services', { method: 'POST' });
|
||||||
const response = await fetch('/api/interactions/sync', { method: 'POST' });
|
const data = await response.json();
|
||||||
const data = await response.json();
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">🛠️</span><p class="text-lg">Sincronización completada</p><p class="text-gray-400 mt-2">Creados: ${data.created} | Actualizados: ${data.updated}</p></div>`);
|
||||||
showModal(`✅ Sincronización completada`);
|
} catch (error) {
|
||||||
} catch (error) {
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error sincronizando</p></div>');
|
||||||
showModal('❌ Error: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function viewApiDocs() {
|
async function syncOdooLeads() {
|
||||||
window.open('/api/docs', '_blank');
|
showModal('Exportando leads...', true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/odoo/sync/leads', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">🎯</span><p class="text-lg">Exportación completada</p><p class="text-gray-400 mt-2">Exportados: ${data.created}</p></div>`);
|
||||||
|
} catch (error) {
|
||||||
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error exportando</p></div>');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function downloadLogs() {
|
function getPlatformIcon(platform) {
|
||||||
showModal('Función de logs próximamente disponible');
|
const icons = { x: '𝕏', facebook: '📘', instagram: '📸', threads: '🧵' };
|
||||||
}
|
return icons[platform] || '📱';
|
||||||
|
}
|
||||||
function showModal(content, loading = false) {
|
</script>
|
||||||
const modal = document.getElementById('result-modal');
|
{% endblock %}
|
||||||
const contentDiv = document.getElementById('result-content');
|
|
||||||
|
|
||||||
contentDiv.innerHTML = loading
|
|
||||||
? `<div class="text-center py-4"><div class="text-2xl mb-2">⏳</div>${content}</div>`
|
|
||||||
: `<div class="text-center py-4">${content}</div>`;
|
|
||||||
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
modal.classList.add('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
document.getElementById('result-modal').classList.add('hidden');
|
|
||||||
document.getElementById('result-modal').classList.remove('flex');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
|
- ./dashboard:/app/dashboard
|
||||||
- ./templates:/app/templates
|
- ./templates:/app/templates
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- uploaded_images:/app/uploads
|
- uploaded_images:/app/uploads
|
||||||
|
|||||||
Reference in New Issue
Block a user