feat: análisis extendido (10 campos nuevos) + generador de guiones con GPT-4o

Análisis extendido:
- Nuevos campos: apertura_exacta, cierre_exacto, tecnica_retencion, momento_pico_seg
- Copywriting: nivel_consciencia (Schwartz), objecion_principal, avatar_descripcion
- Replicabilidad: ingredientes_clave, replicabilidad, ratio_emocion_logica
- analizador.js: prompt extendido con metodología Schwartz + retención
- validador.js: schema Zod actualizado con 6 nuevos enums
- Migración SQL 05: ALTER TABLE + nuevos ENUMs + índices

Generador de guiones:
- generador.js: lib GPT-4o con temperatura 0.7 y contexto de patrones
- server.js: endpoints POST /api/generar, GET /api/generados, GET /api/generados/:id
- backend/api/generar.js + api/generar.js + api/generados.js: Vercel handlers
- Migración SQL 06: tabla guiones_generados con score_estimado, variantes, notas
- GenerateView.vue: formulario completo + preview del guion con copy al portapapeles
- SideNavBar: nueva entrada "Generar" con ícono auto_fix_high
- Router: ruta /generate → GenerateView
- api.js: api.generar() + api.generados.listar/obtener()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 20:52:25 -05:00
parent 78e8e48759
commit 2fc4168301
15 changed files with 1019 additions and 86 deletions

3
.gitignore vendored
View File

@ -22,6 +22,9 @@ npm-debug.log*
*.njsproj *.njsproj
*.sln *.sln
# Claude Code settings
.claude/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db

21
api/generados.js Normal file
View File

@ -0,0 +1,21 @@
import { supabase } from '../backend/lib/supabase.js'
export default async function handler(req, res) {
if (req.method !== 'GET') return res.status(405).json({ error: 'Método no permitido' })
const { niche, cliente_id, page = 1, limit = 20 } = req.query
const offset = (Number(page) - 1) * Number(limit)
let query = supabase
.from('guiones_generados')
.select('id, niche, tema, audiencia, plataforma, tono, objetivo, titulo_sugerido, score_estimado, aprobado, fecha_generacion, num_referencias', { count: 'exact' })
.order('fecha_generacion', { ascending: false })
.range(offset, offset + Number(limit) - 1)
if (niche) query = query.eq('niche', niche)
if (cliente_id) query = query.eq('cliente_id', cliente_id)
const { data, error, count } = await query
if (error) return res.status(500).json({ error: error.message })
res.json({ generados: data, total: count, page: Number(page), limit: Number(limit) })
}

1
api/generar.js Normal file
View File

@ -0,0 +1 @@
export { default } from '../backend/api/generar.js'

93
backend/api/generar.js Normal file
View File

@ -0,0 +1,93 @@
import { generarGuion } from '../lib/generador.js'
import { supabase } from '../lib/supabase.js'
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).json({ error: 'Método no permitido' })
const {
niche, tema, audiencia, plataforma,
duracion_objetivo = 60,
tono = 'educativo',
objetivo = 'engagement',
estructura = 'AIDA',
instrucciones_extra = '',
cliente_id = null,
referencias_ids = [],
num_referencias = 3,
} = req.body
if (!niche) return res.status(400).json({ error: 'El campo "niche" es requerido' })
if (!tema) return res.status(400).json({ error: 'El campo "tema" es requerido' })
if (!audiencia) return res.status(400).json({ error: 'El campo "audiencia" es requerido' })
try {
let patrones = []
if (referencias_ids.length > 0) {
const { data } = await supabase
.from('guiones')
.select(`
estructura_narrativa, gancho_tipo, gancho_texto, apertura_exacta,
tecnica_retencion, trigger_emocional, intensidad_emocional,
cialdini_reciprocidad, cialdini_escasez, cialdini_autoridad,
cialdini_consistencia, cialdini_prueba_social, cialdini_simpatia, cialdini_unidad,
ingredientes_clave, resumen_patron, score_virabilidad
`)
.in('id', referencias_ids)
.eq('procesado_ok', true)
patrones = data || []
} else {
let query = supabase
.from('guiones')
.select(`
estructura_narrativa, gancho_tipo, gancho_texto, apertura_exacta,
tecnica_retencion, trigger_emocional, intensidad_emocional,
cialdini_reciprocidad, cialdini_escasez, cialdini_autoridad,
cialdini_consistencia, cialdini_prueba_social, cialdini_simpatia, cialdini_unidad,
ingredientes_clave, resumen_patron, score_virabilidad
`)
.eq('procesado_ok', true)
.eq('niche', niche)
.order('score_virabilidad', { ascending: false })
.limit(num_referencias)
if (plataforma) query = query.eq('plataforma', plataforma)
const { data } = await query
patrones = data || []
}
const guion = await generarGuion({
niche, tema, audiencia, plataforma, duracion_objetivo,
tono, objetivo, estructura, instrucciones_extra,
}, patrones)
const { data: guardado, error: errGuardado } = await supabase
.from('guiones_generados')
.insert({
cliente_id, niche, tema, audiencia, plataforma,
duracion_objetivo, tono, objetivo,
estructura_usada: estructura,
instrucciones_extra: instrucciones_extra || null,
titulo_sugerido: guion.titulo_sugerido,
gancho: guion.gancho,
desarrollo: guion.desarrollo,
cta: guion.cta,
guion_completo: guion.guion_completo,
variantes_gancho: guion.variantes_gancho,
tecnicas_aplicadas: guion.tecnicas_aplicadas,
notas_produccion: guion.notas_produccion,
duracion_estimada_seg: guion.duracion_estimada_seg,
score_estimado: guion.score_estimado,
version_prompt: 'v1.0',
})
.select('id')
.single()
if (errGuardado) throw new Error(`Supabase: ${errGuardado.message}`)
res.json({ ok: true, guion_id: guardado.id, guion })
} catch (err) {
console.error('[generar]', err.message)
res.status(500).json({ ok: false, error: err.message })
}
}

View File

@ -1,7 +1,7 @@
// ============================================================ // ============================================================
// ANALIZADOR — GPT-4o // ANALIZADOR — GPT-4o
// Prompt maestro multidisciplinario: Storytelling + Cialdini // Prompt maestro multidisciplinario: Storytelling + Cialdini
// + Neuropublicidad → JSON de 55 campos analizables // + Neuropublicidad + Copywriting → JSON de 45 campos
// ============================================================ // ============================================================
import OpenAI from 'openai' import OpenAI from 'openai'
@ -11,6 +11,7 @@ const PROMPT_SISTEMA = `Eres un experto en ingeniería de guiones para video cor
- Storytelling y estructura narrativa - Storytelling y estructura narrativa
- Psicología de la persuasión (Cialdini, sesgos cognitivos) - Psicología de la persuasión (Cialdini, sesgos cognitivos)
- Neuropublicidad y neuromarketing - Neuropublicidad y neuromarketing
- Copywriting directo (Eugene Schwartz, Gary Halbert, David Ogilvy)
- Marketing de contenidos para múltiples nichos - Marketing de contenidos para múltiples nichos
Tu tarea es analizar la transcripción de un video y devolver un JSON con el análisis completo. Tu tarea es analizar la transcripción de un video y devolver un JSON con el análisis completo.
@ -37,6 +38,8 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan:
"estructura_narrativa": "<AIDA|PAS|hero_journey|storybrand|antes_despues|otra>", "estructura_narrativa": "<AIDA|PAS|hero_journey|storybrand|antes_despues|otra>",
"gancho_tipo": "<pregunta|declaracion_shock|dato_estadistica|historia|controversia|promesa_directa>", "gancho_tipo": "<pregunta|declaracion_shock|dato_estadistica|historia|controversia|promesa_directa>",
"gancho_texto": "<primeras 5-8 palabras del video>", "gancho_texto": "<primeras 5-8 palabras del video>",
"apertura_exacta": "<copia EXACTAMENTE las primeras 15 palabras del video tal como aparecen en la transcripción>",
"cierre_exacto": "<copia EXACTAMENTE las últimas 10 palabras del video tal como aparecen en la transcripción>",
"gancho_duracion_seg": <número entero estimado>, "gancho_duracion_seg": <número entero estimado>,
"desarrollo_tipo": "<problema_solucion|lista|demostracion|testimonio|tutorial|storytelling_puro>", "desarrollo_tipo": "<problema_solucion|lista|demostracion|testimonio|tutorial|storytelling_puro>",
"cta_tipo": "<seguir|comentar|compartir|comprar|visitar_link|guardar|ninguno>", "cta_tipo": "<seguir|comentar|compartir|comprar|visitar_link|guardar|ninguno>",
@ -46,6 +49,8 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan:
"resolucion": "<cómo se resuelve o qué promete resolver>", "resolucion": "<cómo se resuelve o qué promete resolver>",
"pacing_ritmo": "<lento|medio|rapido|variable>", "pacing_ritmo": "<lento|medio|rapido|variable>",
"numero_actos": <1, 2 o 3>, "numero_actos": <1, 2 o 3>,
"tecnica_retencion": "<open_loop|cliffhanger|curiosity_gap|countdown|pregunta_abierta|ninguna>",
"momento_pico_seg": <segundo estimado donde ocurre el pico emocional o de mayor tensión del video>,
"cialdini_reciprocidad": <true|false>, "cialdini_reciprocidad": <true|false>,
"cialdini_escasez": <true|false>, "cialdini_escasez": <true|false>,
@ -54,7 +59,7 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan:
"cialdini_prueba_social": <true|false>, "cialdini_prueba_social": <true|false>,
"cialdini_simpatia": <true|false>, "cialdini_simpatia": <true|false>,
"cialdini_unidad": <true|false>, "cialdini_unidad": <true|false>,
"sesgo_cognitivo": "<nombre del sesgo cognitivo principal, o null>", "sesgo_cognitivo": "<nombre del sesgo cognitivo principal, ej: FOMO, Efecto Halo, Anclaje, Prueba Social, Reciprocidad, o null>",
"trigger_emocional": "<miedo|esperanza|curiosidad|ira|orgullo|tristeza|sorpresa|humor>", "trigger_emocional": "<miedo|esperanza|curiosidad|ira|orgullo|tristeza|sorpresa|humor>",
"intensidad_emocional": <número entero del 1 al 10>, "intensidad_emocional": <número entero del 1 al 10>,
@ -68,6 +73,7 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan:
"velocidad_locucion": "<lenta|normal|rapida|muy_rapida>", "velocidad_locucion": "<lenta|normal|rapida|muy_rapida>",
"uso_musica": <true|false>, "uso_musica": <true|false>,
"micro_compromisos": <true|false>, "micro_compromisos": <true|false>,
"ratio_emocion_logica": "<emocional|logico|equilibrado>",
"tema_principal": "<tema en 1-3 palabras>", "tema_principal": "<tema en 1-3 palabras>",
"angulo_unico": "<qué diferencia a este video de otros del mismo tema, en 1 oración>", "angulo_unico": "<qué diferencia a este video de otros del mismo tema, en 1 oración>",
@ -76,14 +82,19 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan:
"persona_narradora": "<primera_persona|segunda_persona|tercera_persona|mixta>", "persona_narradora": "<primera_persona|segunda_persona|tercera_persona|mixta>",
"promesa_explicita": "<la promesa que hace el video al espectador, en 1 oración>", "promesa_explicita": "<la promesa que hace el video al espectador, en 1 oración>",
"nivel_especificidad": "<generico|especifico|ultra_especifico>", "nivel_especificidad": "<generico|especifico|ultra_especifico>",
"nivel_consciencia": "<inconsciente|problema_consciente|solucion_consciente|producto_consciente|mas_consciente>",
"objecion_principal": "<la objeción más probable del espectador que el video anticipa o desmonta, en 1 oración, o null si no hay>",
"avatar_descripcion": "<describe en 1 oración al perfil de persona a quien está dirigido este video: edad, situación, deseo o dolor principal>",
"ingredientes_clave": ["<elemento 1 que NO puede faltar si se replica este guion>", "<elemento 2>", "<elemento 3>"],
"replicabilidad": "<alta|media|baja>",
"score_virabilidad": <número entero del 1 al 100>, "score_virabilidad": <número entero del 1 al 100>,
"resumen_patron": "<párrafo de 2-3 oraciones describiendo el patrón ganador de este video>" "resumen_patron": "<párrafo de 3-4 oraciones describiendo el patrón ganador de este video: qué hace, por qué funciona psicológicamente y cómo se puede replicar>"
}` }`
const completion = await openai.chat.completions.create({ const completion = await openai.chat.completions.create({
model: 'gpt-4o', model: 'gpt-4o',
temperature: 0.2, // baja temperatura para análisis consistente temperature: 0.2,
messages: [ messages: [
{ role: 'system', content: PROMPT_SISTEMA }, { role: 'system', content: PROMPT_SISTEMA },
{ role: 'user', content: promptUsuario }, { role: 'user', content: promptUsuario },
@ -95,7 +106,6 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan:
throw new Error('GPT-4o devolvió una respuesta vacía') throw new Error('GPT-4o devolvió una respuesta vacía')
} }
// Limpiar posible markdown que GPT-4o a veces añade
const jsonLimpio = contenido const jsonLimpio = contenido
.replace(/^```json\n?/, '') .replace(/^```json\n?/, '')
.replace(/^```\n?/, '') .replace(/^```\n?/, '')

102
backend/lib/generador.js Normal file
View File

@ -0,0 +1,102 @@
// ============================================================
// GENERADOR — GPT-4o
// Crea guiones originales a partir de patrones analizados
// ============================================================
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const PROMPT_SISTEMA = `Eres un copywriter experto en video corto viral (TikTok, Reels, YouTube Shorts).
Tu especialidad es crear guiones que combinan storytelling, psicología de la persuasión y neuromarketing.
Generas guiones originales, concretos y listos para grabar — nunca genéricos ni corporativos.
SOLO devuelve el JSON pedido, sin texto adicional, sin markdown, sin explicaciones.`
/**
* Genera un guion nuevo a partir de patrones de referencia
* @param {object} params Parámetros de generación
* @param {Array} patrones Guiones de referencia del mismo niche
* @returns {object} Guion generado con estructura completa
*/
export async function generarGuion(params, patrones = []) {
const {
niche,
tema,
audiencia,
plataforma,
duracion_objetivo,
tono,
objetivo,
estructura,
instrucciones_extra = '',
} = params
// Construir contexto de patrones ganadores
const contextPatrones = patrones.length > 0
? patrones.map((p, i) => `
REFERENCIA ${i + 1} (Score viralidad: ${p.score_virabilidad}/100):
- Estructura: ${p.estructura_narrativa} | Gancho tipo: ${p.gancho_tipo}
- Técnica de retención: ${p.tecnica_retencion || 'no especificada'}
- Trigger emocional: ${p.trigger_emocional} | Intensidad: ${p.intensidad_emocional}/10
- Cialdini activos: ${[
p.cialdini_reciprocidad && 'Reciprocidad',
p.cialdini_escasez && 'Escasez',
p.cialdini_autoridad && 'Autoridad',
p.cialdini_consistencia && 'Consistencia',
p.cialdini_prueba_social && 'Prueba Social',
p.cialdini_simpatia && 'Simpatía',
p.cialdini_unidad && 'Unidad',
].filter(Boolean).join(', ') || 'ninguno'}
- Ingredientes clave: ${(p.ingredientes_clave || []).join(' / ')}
- Patrón ganador: ${p.resumen_patron}
- Apertura que usó: "${p.apertura_exacta || p.gancho_texto}"
`).join('\n')
: 'No se proporcionaron referencias — crea según las mejores prácticas del nicho.'
const promptUsuario = `Crea un guion de video para ${plataforma} sobre "${tema}" en el nicho "${niche}".
PARÁMETROS:
- Audiencia objetivo: ${audiencia}
- Duración objetivo: ${duracion_objetivo} segundos
- Tono: ${tono}
- Objetivo principal: ${objetivo}
- Estructura narrativa a usar: ${estructura}
${instrucciones_extra ? `- Instrucciones adicionales: ${instrucciones_extra}` : ''}
PATRONES DE ALTO RENDIMIENTO DEL MISMO NICHE (úsalos como inspiración, NO los copies):
${contextPatrones}
Genera el guion con EXACTAMENTE este JSON:
{
"gancho": "<texto del gancho, las primeras 1-3 oraciones que deben capturar la atención en los primeros 3 segundos>",
"desarrollo": "<el cuerpo del guion completo, párrafo a párrafo, en el tono y estructura indicados>",
"cta": "<el call to action final, específico y claro>",
"guion_completo": "<el guion completo de corrido: gancho + desarrollo + cta, listo para leer al grabar>",
"duracion_estimada_seg": <número entero estimado de segundos si se narra a ritmo normal>,
"titulo_sugerido": "<un título para este guion, máximo 8 palabras>",
"tecnicas_aplicadas": ["<técnica 1>", "<técnica 2>", "<técnica 3>"],
"notas_produccion": "<2-3 recomendaciones específicas para grabar este video: cámara, edición, música, etc.>",
"variantes_gancho": ["<versión alternativa del gancho 1>", "<versión alternativa del gancho 2>"],
"score_estimado": <número entero del 1 al 100 estimando el potencial de viralidad de este guion>
}`
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
temperature: 0.7,
messages: [
{ role: 'system', content: PROMPT_SISTEMA },
{ role: 'user', content: promptUsuario },
],
})
const contenido = completion.choices[0]?.message?.content?.trim()
if (!contenido) throw new Error('GPT-4o devolvió una respuesta vacía')
const jsonLimpio = contenido
.replace(/^```json\n?/, '')
.replace(/^```\n?/, '')
.replace(/\n?```$/, '')
.trim()
return JSON.parse(jsonLimpio)
}

View File

@ -5,25 +5,31 @@
// ============================================================ // ============================================================
import { z } from 'zod' import { z } from 'zod'
const EstructuraEnum = z.enum(['AIDA','PAS','hero_journey','storybrand','antes_despues','otra']) const EstructuraEnum = z.enum(['AIDA','PAS','hero_journey','storybrand','antes_despues','otra'])
const GanchoTipoEnum = z.enum(['pregunta','declaracion_shock','dato_estadistica','historia','controversia','promesa_directa']) const GanchoTipoEnum = z.enum(['pregunta','declaracion_shock','dato_estadistica','historia','controversia','promesa_directa'])
const DesarrolloEnum = z.enum(['problema_solucion','lista','demostracion','testimonio','tutorial','storytelling_puro']) const DesarrolloEnum = z.enum(['problema_solucion','lista','demostracion','testimonio','tutorial','storytelling_puro'])
const CtaTipoEnum = z.enum(['seguir','comentar','compartir','comprar','visitar_link','guardar','ninguno']) const CtaTipoEnum = z.enum(['seguir','comentar','compartir','comprar','visitar_link','guardar','ninguno'])
const PacingEnum = z.enum(['lento','medio','rapido','variable']) const PacingEnum = z.enum(['lento','medio','rapido','variable'])
const TriggerEnum = z.enum(['miedo','esperanza','curiosidad','ira','orgullo','tristeza','sorpresa','humor']) const TriggerEnum = z.enum(['miedo','esperanza','curiosidad','ira','orgullo','tristeza','sorpresa','humor'])
const AtencionVisualEnum= z.enum(['zoom_agresivo','corte_rapido','texto_pantalla','cara_camara','broll_dinamico','ninguno']) const AtencionVisualEnum = z.enum(['zoom_agresivo','corte_rapido','texto_pantalla','cara_camara','broll_dinamico','ninguno'])
const DolorPlacerEnum = z.enum(['apela_dolor','apela_placer','ambos']) const DolorPlacerEnum = z.enum(['apela_dolor','apela_placer','ambos'])
const CargaEnum = z.enum(['baja','media','alta']) const CargaEnum = z.enum(['baja','media','alta'])
const VelocidadEnum = z.enum(['lenta','normal','rapida','muy_rapida']) const VelocidadEnum = z.enum(['lenta','normal','rapida','muy_rapida'])
const TonoEnum = z.enum(['educativo','entretenimiento','inspiracional','controversial','informativo','humoristico']) const TonoEnum = z.enum(['educativo','entretenimiento','inspiracional','controversial','informativo','humoristico'])
const PersonaEnum = z.enum(['primera_persona','segunda_persona','tercera_persona','mixta']) const PersonaEnum = z.enum(['primera_persona','segunda_persona','tercera_persona','mixta'])
const EspecificidadEnum = z.enum(['generico','especifico','ultra_especifico']) const EspecificidadEnum = z.enum(['generico','especifico','ultra_especifico'])
const TecnicaRetencionEnum= z.enum(['open_loop','cliffhanger','curiosity_gap','countdown','pregunta_abierta','ninguna'])
const RatioEmocionEnum = z.enum(['emocional','logico','equilibrado'])
const NivelConcienciaEnum = z.enum(['inconsciente','problema_consciente','solucion_consciente','producto_consciente','mas_consciente'])
const ReplicabilidadEnum = z.enum(['alta','media','baja'])
export const AnalisisSchema = z.object({ export const AnalisisSchema = z.object({
// Storytelling // Storytelling
estructura_narrativa: EstructuraEnum, estructura_narrativa: EstructuraEnum,
gancho_tipo: GanchoTipoEnum, gancho_tipo: GanchoTipoEnum,
gancho_texto: z.string().min(1).max(200), gancho_texto: z.string().min(1).max(200),
apertura_exacta: z.string().min(1).max(300),
cierre_exacto: z.string().min(1).max(200),
gancho_duracion_seg: z.number().int().min(0).max(30), gancho_duracion_seg: z.number().int().min(0).max(30),
desarrollo_tipo: DesarrolloEnum, desarrollo_tipo: DesarrolloEnum,
cta_tipo: CtaTipoEnum, cta_tipo: CtaTipoEnum,
@ -33,6 +39,8 @@ export const AnalisisSchema = z.object({
resolucion: z.string().min(1).max(500), resolucion: z.string().min(1).max(500),
pacing_ritmo: PacingEnum, pacing_ritmo: PacingEnum,
numero_actos: z.number().int().min(1).max(4), numero_actos: z.number().int().min(1).max(4),
tecnica_retencion: TecnicaRetencionEnum,
momento_pico_seg: z.number().int().min(0).max(600),
// Cialdini // Cialdini
cialdini_reciprocidad: z.boolean(), cialdini_reciprocidad: z.boolean(),
@ -57,8 +65,9 @@ export const AnalisisSchema = z.object({
velocidad_locucion: VelocidadEnum, velocidad_locucion: VelocidadEnum,
uso_musica: z.boolean(), uso_musica: z.boolean(),
micro_compromisos: z.boolean(), micro_compromisos: z.boolean(),
ratio_emocion_logica: RatioEmocionEnum,
// Contenido // Contenido + Copywriting
tema_principal: z.string().min(1).max(100), tema_principal: z.string().min(1).max(100),
angulo_unico: z.string().min(1).max(500), angulo_unico: z.string().min(1).max(500),
palabras_clave: z.array(z.string()).min(1).max(10), palabras_clave: z.array(z.string()).min(1).max(10),
@ -66,10 +75,15 @@ export const AnalisisSchema = z.object({
persona_narradora: PersonaEnum, persona_narradora: PersonaEnum,
promesa_explicita: z.string().min(1).max(500), promesa_explicita: z.string().min(1).max(500),
nivel_especificidad: EspecificidadEnum, nivel_especificidad: EspecificidadEnum,
nivel_consciencia: NivelConcienciaEnum,
objecion_principal: z.string().max(500).nullable(),
avatar_descripcion: z.string().min(1).max(500),
ingredientes_clave: z.array(z.string()).min(1).max(7),
replicabilidad: ReplicabilidadEnum,
// Métricas calculadas por GPT-4o // Métricas
score_virabilidad: z.number().int().min(1).max(100), score_virabilidad: z.number().int().min(1).max(100),
resumen_patron: z.string().min(10).max(1000), resumen_patron: z.string().min(10).max(1500),
}) })
/** /**

View File

@ -6,6 +6,7 @@ import { transcribir } from './lib/transcriptor.js'
import { analizarTranscript } from './lib/analizador.js' import { analizarTranscript } from './lib/analizador.js'
import { validarAnalisis } from './lib/validador.js' import { validarAnalisis } from './lib/validador.js'
import { generarEmbedding } from './lib/embeddings.js' import { generarEmbedding } from './lib/embeddings.js'
import { generarGuion } from './lib/generador.js'
import { supabase } from './lib/supabase.js' import { supabase } from './lib/supabase.js'
// ── Validar variables de entorno requeridas ────────────────── // ── Validar variables de entorno requeridas ──────────────────
@ -187,4 +188,135 @@ app.post('/api/analizar', async (req, res) => {
} }
}) })
// ── POST /api/generar ───────────────────────────────────────
// Genera un guion nuevo a partir de patrones analizados
app.post('/api/generar', async (req, res) => {
const {
niche, tema, audiencia, plataforma,
duracion_objetivo = 60,
tono = 'educativo',
objetivo = 'engagement',
estructura = 'AIDA',
instrucciones_extra = '',
cliente_id = null,
referencias_ids = [], // IDs de guiones analizados a usar como referencia
num_referencias = 3, // cuántos guiones top tomar si no se dan IDs explícitos
} = req.body
if (!niche) return res.status(400).json({ error: 'El campo "niche" es requerido' })
if (!tema) return res.status(400).json({ error: 'El campo "tema" es requerido' })
if (!audiencia) return res.status(400).json({ error: 'El campo "audiencia" es requerido' })
try {
// Obtener patrones de referencia
let patrones = []
if (referencias_ids.length > 0) {
// Usar las referencias explícitas del usuario
const { data } = await supabase
.from('guiones')
.select(`
estructura_narrativa, gancho_tipo, gancho_texto, apertura_exacta,
tecnica_retencion, trigger_emocional, intensidad_emocional,
cialdini_reciprocidad, cialdini_escasez, cialdini_autoridad,
cialdini_consistencia, cialdini_prueba_social, cialdini_simpatia, cialdini_unidad,
ingredientes_clave, resumen_patron, score_virabilidad
`)
.in('id', referencias_ids)
.eq('procesado_ok', true)
patrones = data || []
} else {
// Auto-seleccionar los mejores del mismo niche y plataforma
let query = supabase
.from('guiones')
.select(`
estructura_narrativa, gancho_tipo, gancho_texto, apertura_exacta,
tecnica_retencion, trigger_emocional, intensidad_emocional,
cialdini_reciprocidad, cialdini_escasez, cialdini_autoridad,
cialdini_consistencia, cialdini_prueba_social, cialdini_simpatia, cialdini_unidad,
ingredientes_clave, resumen_patron, score_virabilidad
`)
.eq('procesado_ok', true)
.eq('niche', niche)
.order('score_virabilidad', { ascending: false })
.limit(num_referencias)
if (plataforma) query = query.eq('plataforma', plataforma)
const { data } = await query
patrones = data || []
}
const guion = await generarGuion({
niche, tema, audiencia, plataforma, duracion_objetivo,
tono, objetivo, estructura, instrucciones_extra,
}, patrones)
// Guardar en Supabase
const { data: guardado, error: errGuardado } = await supabase
.from('guiones_generados')
.insert({
cliente_id,
niche, tema, audiencia, plataforma,
duracion_objetivo, tono, objetivo,
estructura_usada: estructura,
instrucciones_extra: instrucciones_extra || null,
referencias_ids: referencias_ids.length > 0 ? referencias_ids : (patrones.map ? null : null),
titulo_sugerido: guion.titulo_sugerido,
gancho: guion.gancho,
desarrollo: guion.desarrollo,
cta: guion.cta,
guion_completo: guion.guion_completo,
variantes_gancho: guion.variantes_gancho,
tecnicas_aplicadas: guion.tecnicas_aplicadas,
notas_produccion: guion.notas_produccion,
duracion_estimada_seg: guion.duracion_estimada_seg,
score_estimado: guion.score_estimado,
version_prompt: 'v1.0',
})
.select('id')
.single()
if (errGuardado) throw new Error(`Supabase: ${errGuardado.message}`)
res.json({ ok: true, guion_id: guardado.id, guion })
} catch (err) {
console.error('[generar] Error:', err.message)
res.status(500).json({ ok: false, error: err.message })
}
})
// ── GET /api/generados ──────────────────────────────────────
// Lista guiones generados con paginación
app.get('/api/generados', async (req, res) => {
const { niche, cliente_id, page = 1, limit = 20 } = req.query
const offset = (Number(page) - 1) * Number(limit)
let query = supabase
.from('guiones_generados')
.select('id, niche, tema, audiencia, plataforma, tono, objetivo, titulo_sugerido, score_estimado, aprobado, fecha_generacion, num_referencias', { count: 'exact' })
.order('fecha_generacion', { ascending: false })
.range(offset, offset + Number(limit) - 1)
if (niche) query = query.eq('niche', niche)
if (cliente_id) query = query.eq('cliente_id', cliente_id)
const { data, error, count } = await query
if (error) return res.status(500).json({ error: error.message })
res.json({ generados: data, total: count, page: Number(page), limit: Number(limit) })
})
// ── GET /api/generados/:id ──────────────────────────────────
app.get('/api/generados/:id', async (req, res) => {
const { data, error } = await supabase
.from('guiones_generados')
.select('*')
.eq('id', req.params.id)
.single()
if (error) return res.status(404).json({ error: 'Guion generado no encontrado' })
res.json(data)
})
app.listen(PORT, () => console.log(`Backend local corriendo en http://localhost:${PORT}`)) app.listen(PORT, () => console.log(`Backend local corriendo en http://localhost:${PORT}`))

View File

@ -0,0 +1,67 @@
-- ============================================================
-- MIGRACIÓN 05 — Análisis Extendido
-- Nuevos campos de retención, copywriting y replicabilidad
-- Ejecutar en Supabase SQL Editor
-- ============================================================
-- ── Nuevos ENUMS ─────────────────────────────────────────────
create type tecnica_retencion_enum as enum (
'open_loop', 'cliffhanger', 'curiosity_gap', 'countdown', 'pregunta_abierta', 'ninguna'
);
create type ratio_emocion_enum as enum ('emocional', 'logico', 'equilibrado');
create type nivel_consciencia_enum as enum (
'inconsciente', 'problema_consciente', 'solucion_consciente',
'producto_consciente', 'mas_consciente'
);
create type replicabilidad_enum as enum ('alta', 'media', 'baja');
-- ── Bloque Storytelling Extendido ────────────────────────────
alter table guiones
add column if not exists apertura_exacta text,
add column if not exists cierre_exacto text,
add column if not exists tecnica_retencion tecnica_retencion_enum,
add column if not exists momento_pico_seg integer check (momento_pico_seg >= 0);
-- ── Bloque Neuropublicidad Extendido ─────────────────────────
alter table guiones
add column if not exists ratio_emocion_logica ratio_emocion_enum;
-- ── Bloque Copywriting / Avatar ──────────────────────────────
alter table guiones
add column if not exists nivel_consciencia nivel_consciencia_enum,
add column if not exists objecion_principal text,
add column if not exists avatar_descripcion text,
add column if not exists ingredientes_clave text[],
add column if not exists replicabilidad replicabilidad_enum;
-- ── Índices útiles para la generación de guiones ─────────────
-- Filtrar rápido por replicabilidad alta (para el generador)
create index if not exists idx_guiones_replicabilidad
on guiones(replicabilidad)
where procesado_ok = true;
-- Filtrar por nivel de consciencia (segmentación de copywriting)
create index if not exists idx_guiones_nivel_consciencia
on guiones(nivel_consciencia)
where procesado_ok = true;
-- ── Comentarios de columna ────────────────────────────────────
comment on column guiones.apertura_exacta is 'Primeras ~15 palabras exactas del video según la transcripción';
comment on column guiones.cierre_exacto is 'Últimas ~10 palabras exactas del video según la transcripción';
comment on column guiones.tecnica_retencion is 'Técnica principal que hace al espectador quedarse hasta el final';
comment on column guiones.momento_pico_seg is 'Segundo estimado del mayor pico emocional o de tensión del video';
comment on column guiones.ratio_emocion_logica is 'Balance entre apelaciones emocionales y argumentos lógicos';
comment on column guiones.nivel_consciencia is 'Nivel de consciencia del avatar según escala Eugene Schwartz';
comment on column guiones.objecion_principal is 'Objeción del espectador que el video anticipa o desmonta';
comment on column guiones.avatar_descripcion is 'Perfil de la persona a quien está dirigido el video';
comment on column guiones.ingredientes_clave is 'Elementos que no pueden faltar al replicar este guion';
comment on column guiones.replicabilidad is 'Qué tan fácil es replicar este patrón en otro contexto';

View File

@ -0,0 +1,62 @@
-- ============================================================
-- MIGRACIÓN 06 — Tabla de Guiones Generados
-- Almacena los guiones creados por el generador IA
-- Ejecutar en Supabase SQL Editor después de la migración 05
-- ============================================================
create type objetivo_guion_enum as enum (
'awareness', 'engagement', 'conversion', 'educacion', 'entretenimiento'
);
create table guiones_generados (
-- Identificadores
id uuid primary key default gen_random_uuid(),
cliente_id uuid references clientes(id) on delete set null,
-- Parámetros de entrada
niche text not null,
tema text not null,
audiencia text not null,
plataforma plataforma_enum not null,
duracion_objetivo integer, -- segundos objetivo
tono tono_enum,
objetivo objetivo_guion_enum,
estructura_usada estructura_narrativa_enum,
instrucciones_extra text,
-- Referencias usadas para generar
referencias_ids uuid[], -- IDs de guiones analizados usados como base
num_referencias integer generated always as (
coalesce(array_length(referencias_ids, 1), 0)
) stored,
-- Contenido generado
titulo_sugerido text,
gancho text not null,
desarrollo text not null,
cta text,
guion_completo text not null,
variantes_gancho text[],
tecnicas_aplicadas text[],
notas_produccion text,
duracion_estimada_seg integer,
score_estimado integer check (score_estimado between 1 and 100),
-- Auditoría
fecha_generacion timestamp with time zone default now(),
version_prompt text default 'v1.0',
aprobado boolean default false, -- marcado manualmente cuando el guion se usa
notas_usuario text -- feedback del usuario sobre el guion
);
-- Índices
create index idx_gg_niche on guiones_generados(niche);
create index idx_gg_cliente on guiones_generados(cliente_id);
create index idx_gg_fecha on guiones_generados(fecha_generacion desc);
create index idx_gg_aprobado on guiones_generados(aprobado) where aprobado = true;
comment on table guiones_generados is 'Guiones creados por el generador IA a partir de patrones analizados';
comment on column guiones_generados.referencias_ids is 'IDs de guiones analizados que se usaron como patrones de referencia';
comment on column guiones_generados.aprobado is 'True cuando el usuario marca el guion como usado/aprobado';

View File

@ -25,6 +25,10 @@
<span class="material-symbols-outlined text-[20px]">description</span> <span class="material-symbols-outlined text-[20px]">description</span>
<span class="text-sm font-medium">Guiones</span> <span class="text-sm font-medium">Guiones</span>
</router-link> </router-link>
<router-link to="/generate" class="flex items-center gap-3 px-3 py-2.5 text-[#c7c4d7] hover:text-white hover:bg-white/5 transition-all duration-200 group relative" active-class="bg-white/10 !text-white rounded-lg font-semibold">
<span class="material-symbols-outlined text-[20px]">auto_fix_high</span>
<span class="text-sm font-medium">Generar</span>
</router-link>
<router-link to="/settings" class="flex items-center gap-3 px-3 py-2.5 text-[#c7c4d7] hover:text-white hover:bg-white/5 transition-all duration-200 group relative" active-class="bg-white/10 !text-white rounded-lg font-semibold"> <router-link to="/settings" class="flex items-center gap-3 px-3 py-2.5 text-[#c7c4d7] hover:text-white hover:bg-white/5 transition-all duration-200 group relative" active-class="bg-white/10 !text-white rounded-lg font-semibold">
<span class="material-symbols-outlined text-[20px]">settings</span> <span class="material-symbols-outlined text-[20px]">settings</span>
<span class="text-sm font-medium">Configuración</span> <span class="text-sm font-medium">Configuración</span>

View File

@ -11,13 +11,18 @@ async function request(path, options = {}) {
} }
export const api = { export const api = {
guiones: { guiones: {
listar: (params = {}) => request('/guiones?' + new URLSearchParams(params)), listar: (params = {}) => request('/guiones?' + new URLSearchParams(params)),
listarTodos: (params = {}) => request('/guiones?' + new URLSearchParams({ ...params, todos: '1' })), listarTodos: (params = {}) => request('/guiones?' + new URLSearchParams({ ...params, todos: '1' })),
obtener: (id) => request(`/guiones/${id}`), obtener: (id) => request(`/guiones/${id}`),
}, },
analizar: (body) => request('/analizar', { method: 'POST', body: JSON.stringify(body) }), generados: {
nichos: () => request('/nichos'), listar: (params = {}) => request('/generados?' + new URLSearchParams(params)),
clientes: () => request('/clientes'), obtener: (id) => request(`/generados/${id}`),
stats: () => request('/stats'), },
analizar: (body) => request('/analizar', { method: 'POST', body: JSON.stringify(body) }),
generar: (body) => request('/generar', { method: 'POST', body: JSON.stringify(body) }),
nichos: () => request('/nichos'),
clientes: () => request('/clientes'),
stats: () => request('/stats'),
} }

View File

@ -4,6 +4,7 @@ import AnalysisCreateView from '../views/AnalysisCreateView.vue'
import AnalysisDetailView from '../views/AnalysisDetailView.vue' import AnalysisDetailView from '../views/AnalysisDetailView.vue'
import AnalysisListView from '../views/AnalysisListView.vue' import AnalysisListView from '../views/AnalysisListView.vue'
import ScriptsView from '../views/ScriptsView.vue' import ScriptsView from '../views/ScriptsView.vue'
import GenerateView from '../views/GenerateView.vue'
import SettingsView from '../views/SettingsView.vue' import SettingsView from '../views/SettingsView.vue'
const routes = [ const routes = [
@ -32,6 +33,11 @@ const routes = [
name: 'Scripts', name: 'Scripts',
component: ScriptsView component: ScriptsView
}, },
{
path: '/generate',
name: 'Generate',
component: GenerateView
},
{ {
path: '/settings', path: '/settings',
name: 'Settings', name: 'Settings',

View File

@ -8,13 +8,16 @@
<!-- Encabezado --> <!-- Encabezado -->
<header class="relative z-10 flex flex-col md:flex-row md:items-end justify-between gap-6"> <header class="relative z-10 flex flex-col md:flex-row md:items-end justify-between gap-6">
<div> <div>
<router-link to="/" class="flex items-center gap-2 text-outline hover:text-white transition-colors text-sm font-bold uppercase tracking-widest mb-6 w-fit"> <router-link to="/analysis" class="flex items-center gap-2 text-outline hover:text-white transition-colors text-sm font-bold uppercase tracking-widest mb-6 w-fit">
<span class="material-symbols-outlined text-lg">west</span> Volver al Panel <span class="material-symbols-outlined text-lg">west</span> Volver al Historial
</router-link> </router-link>
<div class="flex items-center gap-3 mb-3 flex-wrap"> <div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="px-3 py-1 bg-surface-container-highest text-on-surface-variant text-[11px] font-black rounded uppercase tracking-widest">{{ guion.niche }}</span> <span class="px-3 py-1 bg-surface-container-highest text-on-surface-variant text-[11px] font-black rounded uppercase tracking-widest">{{ guion.niche }}</span>
<span v-if="guion.sub_niche" class="px-3 py-1 bg-surface-container-low border border-outline-variant/20 text-outline text-[11px] font-bold rounded shadow-sm">{{ guion.sub_niche }}</span> <span v-if="guion.sub_niche" class="px-3 py-1 bg-surface-container-low border border-outline-variant/20 text-outline text-[11px] font-bold rounded">{{ guion.sub_niche }}</span>
<span :class="plataformaBadge(guion.plataforma)" class="px-3 py-1 text-[11px] font-black rounded uppercase tracking-widest">{{ guion.plataforma }}</span> <span :class="plataformaBadge(guion.plataforma)" class="px-3 py-1 text-[11px] font-black rounded uppercase tracking-widest">{{ guion.plataforma }}</span>
<span v-if="guion.replicabilidad" :class="replicabilidadBadge(guion.replicabilidad)" class="px-3 py-1 text-[11px] font-black rounded uppercase tracking-widest">
Replicabilidad {{ guion.replicabilidad }}
</span>
</div> </div>
<h1 class="text-4xl md:text-5xl font-extrabold font-headline tracking-tighter text-white mb-2 leading-tight max-w-3xl"> <h1 class="text-4xl md:text-5xl font-extrabold font-headline tracking-tighter text-white mb-2 leading-tight max-w-3xl">
{{ guion.tema_principal || 'Análisis sin título' }} {{ guion.tema_principal || 'Análisis sin título' }}
@ -29,20 +32,18 @@
<button v-if="guion.url_origen" class="h-12 w-12 rounded-xl bg-surface-container-low border border-outline-variant/20 flex items-center justify-center text-on-surface hover:bg-surface-container transition-colors shadow-lg" title="Ver video original" @click="openUrl(guion.url_origen)"> <button v-if="guion.url_origen" class="h-12 w-12 rounded-xl bg-surface-container-low border border-outline-variant/20 flex items-center justify-center text-on-surface hover:bg-surface-container transition-colors shadow-lg" title="Ver video original" @click="openUrl(guion.url_origen)">
<span class="material-symbols-outlined">link</span> <span class="material-symbols-outlined">link</span>
</button> </button>
<div class="h-12 w-12 rounded-xl bg-surface-container-low border border-outline-variant/20 flex items-center justify-center text-on-surface hover:bg-surface-container transition-colors shadow-lg cursor-pointer">
<span class="material-symbols-outlined">bookmark</span>
</div>
<button class="px-6 py-3 bg-gradient-to-br from-primary-container to-primary text-on-primary-container font-headline font-bold rounded-xl shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm h-12 flex items-center gap-2"> <button class="px-6 py-3 bg-gradient-to-br from-primary-container to-primary text-on-primary-container font-headline font-bold rounded-xl shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm h-12 flex items-center gap-2">
<span class="material-symbols-outlined text-sm">download</span> Exportar Vector <span class="material-symbols-outlined text-sm">auto_fix_high</span> Generar Guion
</button> </button>
</div> </div>
</header> </header>
<!-- Cuadrícula principal --> <!-- Cuadrícula principal -->
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8 relative z-10"> <div class="grid grid-cols-1 xl:grid-cols-12 gap-8 relative z-10">
<!-- Columna izquierda: Analíticas --> <!-- Columna izquierda -->
<div class="xl:col-span-4 flex flex-col gap-6"> <div class="xl:col-span-4 flex flex-col gap-6">
<!-- Tarjeta de Puntaje -->
<!-- Puntaje -->
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-2xl relative overflow-hidden group"> <div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-2xl relative overflow-hidden group">
<div class="absolute -top-24 -right-24 w-48 h-48 bg-primary/20 blur-3xl rounded-full group-hover:bg-primary/30 transition-colors pointer-events-none"></div> <div class="absolute -top-24 -right-24 w-48 h-48 bg-primary/20 blur-3xl rounded-full group-hover:bg-primary/30 transition-colors pointer-events-none"></div>
<h3 class="text-sm font-headline font-bold text-white mb-8 flex items-center gap-2"> <h3 class="text-sm font-headline font-bold text-white mb-8 flex items-center gap-2">
@ -51,25 +52,30 @@
<div class="flex justify-center mb-6 relative"> <div class="flex justify-center mb-6 relative">
<svg class="w-48 h-48 transform -rotate-90" viewBox="0 0 100 100"> <svg class="w-48 h-48 transform -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="none" stroke="rgba(255,255,255,0.05)" stroke-width="8" /> <circle cx="50" cy="50" r="45" fill="none" stroke="rgba(255,255,255,0.05)" stroke-width="8" />
<circle cx="50" cy="50" r="45" fill="none" class="stroke-primary drop-shadow-[0_0_8px_rgba(78,222,163,0.5)] transition-all duration-1000 ease-out" stroke-width="8" :stroke-dasharray="`${(guion.score_virabilidad || 0)/100 * 283} 283`" stroke-linecap="round" /> <circle cx="50" cy="50" r="45" fill="none" class="stroke-primary transition-all duration-1000 ease-out" stroke-width="8" :stroke-dasharray="`${(guion.score_virabilidad || 0)/100 * 283} 283`" stroke-linecap="round" />
</svg> </svg>
<div class="absolute inset-0 flex flex-col items-center justify-center"> <div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-6xl font-black font-headline text-white tracking-tighter neon-glow">{{ guion.score_virabilidad || 0 }}</span> <span class="text-6xl font-black font-headline text-white tracking-tighter">{{ guion.score_virabilidad || 0 }}</span>
<span class="text-xs font-bold text-primary uppercase tracking-widest mt-1">/ 100</span> <span class="text-xs font-bold text-primary uppercase tracking-widest mt-1">/ 100</span>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4 pt-6 border-t border-white/5"> <div class="grid grid-cols-3 gap-3 pt-6 border-t border-white/5">
<div> <div class="text-center">
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-1">Índice Cialdini</p> <p class="text-[9px] text-outline uppercase tracking-widest font-bold mb-1">Cialdini</p>
<p class="text-xl font-bold text-white">{{ guion.score_cialdini ?? 0 }}<span class="text-sm text-outline">/7</span></p> <p class="text-xl font-bold text-white">{{ guion.score_cialdini ?? 0 }}<span class="text-sm text-outline">/7</span></p>
</div> </div>
<div> <div class="text-center">
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-1">Engagement Real</p> <p class="text-[9px] text-outline uppercase tracking-widest font-bold mb-1">Engagement</p>
<p class="text-xl font-bold text-emerald-400">{{ guion.score_engagement ? (guion.score_engagement*1).toFixed(2) + '%' : 'N/A' }}</p> <p class="text-xl font-bold text-secondary">{{ guion.score_engagement ? (guion.score_engagement*1).toFixed(2) + '%' : 'N/A' }}</p>
</div>
<div class="text-center">
<p class="text-[9px] text-outline uppercase tracking-widest font-bold mb-1">Intensidad</p>
<p class="text-xl font-bold text-orange-400">{{ guion.intensidad_emocional || 0 }}<span class="text-sm text-outline">/10</span></p>
</div> </div>
</div> </div>
</div> </div>
<!-- Ganchos semánticos -->
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl"> <div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl">
<h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2"> <h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2">
<span class="material-symbols-outlined text-secondary">psychology_alt</span> Ganchos Semánticos <span class="material-symbols-outlined text-secondary">psychology_alt</span> Ganchos Semánticos
@ -77,7 +83,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Estructura Narrativa</p> <p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Estructura Narrativa</p>
<div class="glass-panel p-3 rounded-lg border border-white/5 inline-block w-full"> <div class="p-3 rounded-lg border border-white/5 bg-surface-container-low">
<span class="text-sm font-bold text-on-surface">{{ guion.estructura_narrativa || 'No detectada' }}</span> <span class="text-sm font-bold text-on-surface">{{ guion.estructura_narrativa || 'No detectada' }}</span>
</div> </div>
</div> </div>
@ -86,43 +92,123 @@
<span>Gancho Principal</span> <span>Gancho Principal</span>
<span class="text-secondary">{{ guion.gancho_duracion_seg ? guion.gancho_duracion_seg + 's' : '' }}</span> <span class="text-secondary">{{ guion.gancho_duracion_seg ? guion.gancho_duracion_seg + 's' : '' }}</span>
</p> </p>
<div class="glass-panel p-4 rounded-xl border border-secondary/20 relative"> <div class="p-4 rounded-xl border border-secondary/20 bg-surface-container-low relative">
<div class="absolute top-0 right-0 p-2"><span class="w-1.5 h-1.5 rounded-full bg-secondary block"></span></div> <div class="absolute top-0 right-0 p-2"><span class="w-1.5 h-1.5 rounded-full bg-secondary block"></span></div>
<p class="text-xs text-secondary font-bold uppercase tracking-wider mb-2">{{ guion.gancho_tipo || 'Gancho Estándar' }}</p> <p class="text-xs text-secondary font-bold uppercase tracking-wider mb-2">{{ guion.gancho_tipo || 'Gancho Estándar' }}</p>
<p class="text-sm text-white font-medium leading-relaxed italic">"{{ guion.gancho_texto || '—' }}"</p> <p class="text-sm text-white font-medium leading-relaxed italic">"{{ guion.gancho_texto || '—' }}"</p>
</div> </div>
</div> </div>
<div>
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Técnica de Retención</p>
<div class="p-3 rounded-lg border border-primary/20 bg-primary/5 flex items-center gap-2">
<span class="material-symbols-outlined text-primary text-base">repeat</span>
<span class="text-sm font-bold text-primary">{{ guion.tecnica_retencion || '—' }}</span>
<span v-if="guion.momento_pico_seg" class="ml-auto text-[10px] text-outline font-bold">Pico: {{ guion.momento_pico_seg }}s</span>
</div>
</div>
</div> </div>
</div> </div>
<!-- Avatar y Consciencia -->
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl">
<h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2">
<span class="material-symbols-outlined text-tertiary">person_search</span> Avatar & Copywriting
</h3>
<div class="space-y-4">
<div>
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Nivel de Consciencia</p>
<div class="relative">
<div class="flex gap-1">
<div v-for="(nivel, i) in nivelesConciencia" :key="nivel.key"
class="flex-1 h-2 rounded-full transition-all"
:class="nivelConcienciaIndex >= i ? 'bg-primary' : 'bg-surface-container-highest'"
></div>
</div>
<p class="text-xs font-bold text-primary mt-2">{{ guion.nivel_consciencia?.replace(/_/g,' ') || '—' }}</p>
</div>
</div>
<div>
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Avatar Objetivo</p>
<p class="text-xs text-on-surface-variant leading-relaxed">{{ guion.avatar_descripcion || '—' }}</p>
</div>
<div v-if="guion.objecion_principal">
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Objeción Principal</p>
<div class="p-3 rounded-xl border border-red-500/20 bg-red-500/5">
<p class="text-xs text-red-400 leading-relaxed italic">"{{ guion.objecion_principal }}"</p>
</div>
</div>
<div>
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Balance Emoción / Lógica</p>
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-base" :class="ratioColor">{{ ratioIcon }}</span>
<span class="text-sm font-bold" :class="ratioColor">{{ guion.ratio_emocion_logica || '—' }}</span>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Columnas Central y Derecha --> <!-- Columna derecha -->
<div class="xl:col-span-8 flex flex-col gap-6"> <div class="xl:col-span-8 flex flex-col gap-6">
<!-- Patrón ganador -->
<div class="bg-surface-container p-8 rounded-3xl border border-primary/20 shadow-2xl relative overflow-hidden"> <div class="bg-surface-container p-8 rounded-3xl border border-primary/20 shadow-2xl relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent pointer-events-none"></div> <div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent pointer-events-none"></div>
<p class="text-xs text-primary font-bold uppercase tracking-widest mb-4">Síntesis del Patrón Ganador</p> <p class="text-xs text-primary font-bold uppercase tracking-widest mb-4">Síntesis del Patrón Ganador</p>
<p class="text-lg md:text-xl text-white font-medium leading-relaxed max-w-3xl relative z-10">{{ guion.resumen_patron }}</p> <p class="text-lg md:text-xl text-white font-medium leading-relaxed max-w-3xl relative z-10">{{ guion.resumen_patron }}</p>
</div> </div>
<!-- Apertura y Cierre -->
<div v-if="guion.apertura_exacta || guion.cierre_exacto" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-surface-container p-5 rounded-2xl border border-secondary/20">
<p class="text-[10px] text-secondary font-black uppercase tracking-widest mb-3 flex items-center gap-2">
<span class="material-symbols-outlined text-sm">play_arrow</span> Apertura Exacta
</p>
<p class="text-sm text-white font-medium leading-relaxed italic">"{{ guion.apertura_exacta }}"</p>
</div>
<div class="bg-surface-container p-5 rounded-2xl border border-tertiary/20">
<p class="text-[10px] text-tertiary font-black uppercase tracking-widest mb-3 flex items-center gap-2">
<span class="material-symbols-outlined text-sm">stop</span> Cierre Exacto
</p>
<p class="text-sm text-white font-medium leading-relaxed italic">"{{ guion.cierre_exacto }}"</p>
</div>
</div>
<!-- Ingredientes clave -->
<div v-if="guion.ingredientes_clave?.length" class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl">
<h3 class="text-sm font-headline font-bold text-white mb-5 flex items-center gap-2">
<span class="material-symbols-outlined text-yellow-400">key</span> Ingredientes Clave para Replicar
</h3>
<div class="space-y-2">
<div v-for="(ing, i) in guion.ingredientes_clave" :key="i" class="flex items-start gap-3 p-3 rounded-xl bg-surface-container-low border border-white/5">
<span class="w-5 h-5 rounded-full bg-yellow-400/10 border border-yellow-400/30 text-yellow-400 text-[10px] font-black flex items-center justify-center shrink-0 mt-0.5">{{ i + 1 }}</span>
<p class="text-sm text-on-surface-variant leading-relaxed">{{ ing }}</p>
</div>
</div>
</div>
<!-- Emocional + Cialdini -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl"> <div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl">
<h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2"> <h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2">
<span class="material-symbols-outlined text-orange-400">local_fire_department</span> Resonancia Emocional <span class="material-symbols-outlined text-orange-400">local_fire_department</span> Resonancia Emocional
</h3> </h3>
<div class="mb-6"> <div class="mb-5">
<div class="flex justify-between text-xs font-bold uppercase tracking-widest mb-2"> <div class="flex justify-between text-xs font-bold uppercase tracking-widest mb-2">
<span class="text-outline">Intensidad</span> <span class="text-outline">Intensidad</span>
<span class="text-orange-400">{{ guion.intensidad_emocional || 0 }}/10</span> <span class="text-orange-400">{{ guion.intensidad_emocional || 0 }}/10</span>
</div> </div>
<div class="w-full bg-surface-container-highest h-1.5 rounded-full overflow-hidden"> <div class="w-full bg-surface-container-highest h-1.5 rounded-full overflow-hidden">
<div class="bg-gradient-to-r from-orange-500/50 to-orange-400 h-full" :style="{ width: ((guion.intensidad_emocional||0)*10) + '%' }"></div> <div class="bg-gradient-to-r from-orange-500/50 to-orange-400 h-full" :style="{ width: ((guion.intensidad_emocional||0)*10) + '%' }"></div>
</div> </div>
</div> </div>
<div class="space-y-4"> <div class="space-y-3">
<DataRow label="Trigger Principal" :value="guion.trigger_emocional" highlight /> <DataRow label="Trigger Principal" :value="guion.trigger_emocional" highlight />
<DataRow label="Sesgo Cognitivo" :value="guion.sesgo_cognitivo" /> <DataRow label="Arco Emocional" :value="guion.arco_emocional" />
<DataRow label="Dolor / Placer" :value="guion.dolor_placer" highlight /> <DataRow label="Sesgo Cognitivo" :value="guion.sesgo_cognitivo" />
</div> <DataRow label="Dolor / Placer" :value="guion.dolor_placer" highlight />
</div>
</div> </div>
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl flex flex-col"> <div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl flex flex-col">
@ -141,6 +227,7 @@
</div> </div>
</div> </div>
<!-- Neuromarketing + Entrega -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl"> <div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl">
<h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2"> <h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2">
@ -153,6 +240,7 @@
<DataRow label="Lenguaje Sensorial" :value="guion.lenguaje_sensorial" type="boolean" /> <DataRow label="Lenguaje Sensorial" :value="guion.lenguaje_sensorial" type="boolean" />
<DataRow label="Contraste Narrativo" :value="guion.contraste_narrativo" type="boolean" /> <DataRow label="Contraste Narrativo" :value="guion.contraste_narrativo" type="boolean" />
<DataRow label="Efecto Novedad" :value="guion.efecto_novedad" type="boolean" /> <DataRow label="Efecto Novedad" :value="guion.efecto_novedad" type="boolean" />
<DataRow label="Micro Compromisos" :value="guion.micro_compromisos" type="boolean" />
</div> </div>
</div> </div>
@ -160,29 +248,48 @@
<h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2"> <h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2">
<span class="material-symbols-outlined text-cyan-400">record_voice_over</span> Entrega y Alcance <span class="material-symbols-outlined text-cyan-400">record_voice_over</span> Entrega y Alcance
</h3> </h3>
<div class="space-y-4 mb-6"> <div class="space-y-3 mb-5">
<DataRow label="Tono" :value="guion.tono" highlight/> <DataRow label="Tono" :value="guion.tono" highlight />
<DataRow label="Perspectiva" :value="guion.persona_narradora" highlight/> <DataRow label="Perspectiva" :value="guion.persona_narradora" highlight />
<DataRow label="Especificidad" :value="guion.nivel_especificidad" highlight/> <DataRow label="Especificidad" :value="guion.nivel_especificidad" highlight />
<div> <DataRow label="Velocidad" :value="guion.velocidad_locucion" />
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Palabras Clave Extraídas</p> <DataRow label="CTA" :value="guion.cta_tipo" highlight />
<div class="flex flex-wrap gap-2"> </div>
<span v-for="kw in guion.palabras_clave" :key="kw" class="px-2 py-1 bg-surface-container-lowest border border-white/5 rounded text-[10px] font-bold text-on-surface-variant">{{ kw }}</span> <div v-if="guion.cta_texto" class="p-3 rounded-xl bg-surface-container-low border border-white/5">
</div> <p class="text-[9px] text-outline font-black uppercase tracking-widest mb-1">Texto del CTA</p>
<p class="text-xs text-on-surface-variant italic">"{{ guion.cta_texto }}"</p>
</div>
<div class="mt-3">
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Palabras Clave</p>
<div class="flex flex-wrap gap-2">
<span v-for="kw in guion.palabras_clave" :key="kw" class="px-2 py-1 bg-surface-container-lowest border border-white/5 rounded text-[10px] font-bold text-on-surface-variant">{{ kw }}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Visor de Transcripción --> <!-- Promesa + Conflicto -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-surface-container p-5 rounded-2xl border border-outline-variant/10">
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Promesa Explícita</p>
<p class="text-sm text-white leading-relaxed">{{ guion.promesa_explicita || '—' }}</p>
</div>
<div class="bg-surface-container p-5 rounded-2xl border border-outline-variant/10">
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Conflicto Resolución</p>
<p class="text-xs text-on-surface-variant leading-relaxed">{{ guion.conflicto_central || '—' }}</p>
<p v-if="guion.resolucion" class="text-xs text-secondary mt-2 leading-relaxed"> {{ guion.resolucion }}</p>
</div>
</div>
<!-- Transcripción -->
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl"> <div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-headline font-bold text-white flex items-center gap-2"> <h3 class="text-sm font-headline font-bold text-white flex items-center gap-2">
<span class="material-symbols-outlined text-outline">notes</span> Transcripción Completa <span class="material-symbols-outlined text-outline">notes</span> Transcripción Completa
</h3> </h3>
<button @click="showTranscript = !showTranscript" class="text-xs font-bold uppercase tracking-widest text-primary hover:text-white transition-colors"> <button @click="showTranscript = !showTranscript" class="text-xs font-bold uppercase tracking-widest text-primary hover:text-white transition-colors">
{{ showTranscript ? 'Colapsar' : 'Expandir' }} {{ showTranscript ? 'Colapsar' : 'Expandir' }}
</button> </button>
</div> </div>
<div :class="showTranscript ? 'max-h-[800px]' : 'max-h-24'" class="overflow-hidden relative transition-all duration-500 ease-in-out"> <div :class="showTranscript ? 'max-h-[800px]' : 'max-h-24'" class="overflow-hidden relative transition-all duration-500 ease-in-out">
<div v-if="!showTranscript" class="absolute inset-0 bg-gradient-to-t from-surface-container to-transparent z-10"></div> <div v-if="!showTranscript" class="absolute inset-0 bg-gradient-to-t from-surface-container to-transparent z-10"></div>
@ -191,13 +298,14 @@
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { api } from '../lib/api.js' import { api } from '../lib/api.js'
import CialdiniItem from '../components/CialdiniItem.vue' import CialdiniItem from '../components/CialdiniItem.vue'
@ -208,18 +316,49 @@ const guion = ref(null)
const cargando = ref(true) const cargando = ref(true)
const showTranscript = ref(false) const showTranscript = ref(false)
const nivelesConciencia = [
{ key: 'inconsciente' },
{ key: 'problema_consciente' },
{ key: 'solucion_consciente' },
{ key: 'producto_consciente' },
{ key: 'mas_consciente' },
]
const nivelConcienciaIndex = computed(() => {
if (!guion.value?.nivel_consciencia) return -1
return nivelesConciencia.findIndex(n => n.key === guion.value.nivel_consciencia)
})
const ratioColor = computed(() => {
const map = { emocional: 'text-orange-400', logico: 'text-blue-400', equilibrado: 'text-secondary' }
return map[guion.value?.ratio_emocion_logica] || 'text-outline'
})
const ratioIcon = computed(() => {
const map = { emocional: 'favorite', logico: 'psychology', equilibrado: 'balance' }
return map[guion.value?.ratio_emocion_logica] || 'help'
})
function openUrl(url) { function openUrl(url) {
if(url) window.open(url, '_blank') if (url) window.open(url, '_blank')
} }
function plataformaBadge(p) { function plataformaBadge(p) {
return { return {
tiktok: 'bg-red-500/20 text-red-400', tiktok: 'bg-red-500/20 text-red-400',
reels: 'bg-fuchsia-500/20 text-fuchsia-400', reels: 'bg-fuchsia-500/20 text-fuchsia-400',
shorts: 'bg-red-600/20 text-red-500' shorts: 'bg-red-600/20 text-red-500',
}[p] ?? 'bg-white/5 text-on-surface-variant' }[p] ?? 'bg-white/5 text-on-surface-variant'
} }
function replicabilidadBadge(r) {
return {
alta: 'bg-secondary/10 text-secondary border border-secondary/20',
media: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20',
baja: 'bg-red-500/10 text-red-400 border border-red-500/20',
}[r] ?? 'bg-white/5 text-outline'
}
onMounted(async () => { onMounted(async () => {
try { try {
guion.value = await api.guiones.obtener(route.params.id) guion.value = await api.guiones.obtener(route.params.id)

View File

@ -0,0 +1,274 @@
<template>
<div class="max-w-7xl mx-auto flex flex-col gap-10">
<!-- Encabezado -->
<header class="flex flex-col md:flex-row md:items-end justify-between gap-6 border-b border-white/5 pb-8">
<div>
<h1 class="text-5xl font-extrabold font-headline tracking-tighter text-white mb-2 leading-tight">Generador de Guiones</h1>
<p class="text-secondary text-sm font-bold flex items-center gap-2 tracking-widest uppercase">
<span class="material-symbols-outlined text-sm">auto_fix_high</span>
IA que aprende de tus patrones de alto rendimiento
</p>
</div>
</header>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-10">
<!-- Formulario -->
<div class="xl:col-span-7 flex flex-col gap-8">
<!-- Paso 1: Contexto -->
<section class="space-y-6">
<div class="flex items-center gap-3">
<span class="w-8 h-8 rounded-lg bg-secondary/10 text-secondary flex items-center justify-center font-black text-sm border border-secondary/20">01</span>
<h2 class="text-xl font-headline font-extrabold text-white tracking-tight">Contexto del Guion</h2>
</div>
<div class="bg-surface-container p-8 rounded-3xl border border-white/5 shadow-2xl space-y-6">
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-outline">Nicho</label>
<input v-model="form.niche" list="nichos-gen" placeholder="ej. fitness, finanzas..." class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm text-white placeholder:text-outline/40 focus:ring-2 focus:ring-secondary/40 font-black uppercase tracking-widest" :disabled="generando" />
<datalist id="nichos-gen">
<option v-for="n in nichos" :key="n" :value="n" />
</datalist>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-outline">Plataforma</label>
<select v-model="form.plataforma" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm text-on-surface appearance-none focus:ring-2 focus:ring-secondary/40" :disabled="generando">
<option value="tiktok">TikTok</option>
<option value="reels">Instagram Reels</option>
<option value="shorts">YouTube Shorts</option>
</select>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-outline">Tema del Video</label>
<input v-model="form.tema" type="text" placeholder="ej. Cómo perder 5kg en 30 días sin pasar hambre" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm text-white placeholder:text-outline/40 focus:ring-2 focus:ring-secondary/40" :disabled="generando" />
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-outline">Audiencia Objetivo</label>
<input v-model="form.audiencia" type="text" placeholder="ej. Mujeres de 25-40 años con poco tiempo para el gimnasio" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm text-white placeholder:text-outline/40 focus:ring-2 focus:ring-secondary/40" :disabled="generando" />
</div>
</div>
</section>
<!-- Paso 2: Parámetros -->
<section class="space-y-6">
<div class="flex items-center gap-3">
<span class="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center font-black text-sm border border-primary/20">02</span>
<h2 class="text-xl font-headline font-extrabold text-white tracking-tight">Parámetros de Generación</h2>
</div>
<div class="bg-surface-container p-8 rounded-3xl border border-white/5 shadow-2xl space-y-6">
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-outline">Estructura Narrativa</label>
<select v-model="form.estructura" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm text-on-surface appearance-none focus:ring-2 focus:ring-primary/40" :disabled="generando">
<option value="AIDA">AIDA (Atención Interés Deseo Acción)</option>
<option value="PAS">PAS (Problema Agitación Solución)</option>
<option value="hero_journey">Hero's Journey</option>
<option value="storybrand">StoryBrand</option>
<option value="antes_despues">Antes / Después</option>
</select>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-outline">Objetivo</label>
<select v-model="form.objetivo" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm text-on-surface appearance-none focus:ring-2 focus:ring-primary/40" :disabled="generando">
<option value="engagement">Engagement (likes, comentarios)</option>
<option value="awareness">Awareness (alcance)</option>
<option value="conversion">Conversión (ventas, leads)</option>
<option value="educacion">Educación</option>
<option value="entretenimiento">Entretenimiento</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-outline">Tono</label>
<select v-model="form.tono" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm text-on-surface appearance-none focus:ring-2 focus:ring-primary/40" :disabled="generando">
<option value="educativo">Educativo</option>
<option value="entretenimiento">Entretenimiento</option>
<option value="inspiracional">Inspiracional</option>
<option value="controversial">Controversial</option>
<option value="informativo">Informativo</option>
<option value="humoristico">Humorístico</option>
</select>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-outline">Duración Objetivo (seg)</label>
<input v-model.number="form.duracion_objetivo" type="number" min="15" max="180" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm text-on-surface font-bold text-center focus:ring-2 focus:ring-primary/40" :disabled="generando" />
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-outline">Instrucciones adicionales (opcional)</label>
<textarea v-model="form.instrucciones_extra" rows="3" placeholder="ej. Incluir una estadística de estudio, no mencionar competidores, usar lenguaje informal..." class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm text-white placeholder:text-outline/40 focus:ring-2 focus:ring-primary/40 resize-none" :disabled="generando"></textarea>
</div>
</div>
</section>
<!-- Botón -->
<button
@click="generar"
:disabled="generando || !form.niche || !form.tema || !form.audiencia"
class="w-full py-5 bg-gradient-to-br from-secondary/80 to-secondary text-on-secondary font-headline font-black rounded-2xl shadow-xl shadow-secondary/20 hover:scale-[1.02] active:scale-95 transition-all text-base uppercase tracking-widest flex items-center justify-center gap-3 disabled:opacity-40 disabled:scale-100"
>
<span class="material-symbols-outlined text-xl" :class="generando ? 'animate-spin' : ''">{{ generando ? 'hourglass_top' : 'auto_fix_high' }}</span>
{{ generando ? 'Generando con GPT-4o...' : 'Generar Guion' }}
</button>
<p v-if="error" class="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-sm">{{ error }}</p>
</div>
<!-- Panel derecho: Preview + Info -->
<div class="xl:col-span-5 flex flex-col gap-6">
<!-- Resultado -->
<div v-if="resultado" class="flex flex-col gap-4">
<!-- Score -->
<div class="bg-surface-container p-6 rounded-3xl border border-secondary/20 shadow-xl relative overflow-hidden">
<div class="absolute -top-8 -right-8 w-32 h-32 bg-secondary/10 blur-3xl rounded-full"></div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-headline font-black text-white uppercase tracking-widest">{{ resultado.guion.titulo_sugerido }}</h3>
<div class="flex items-center gap-2 px-3 py-1.5 bg-secondary/10 rounded-full border border-secondary/20">
<span class="material-symbols-outlined text-secondary text-sm" style="font-variation-settings:'FILL' 1;">bolt</span>
<span class="text-sm font-black text-secondary">{{ resultado.guion.score_estimado }}/100</span>
</div>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<span v-for="t in resultado.guion.tecnicas_aplicadas" :key="t" class="text-[9px] font-black uppercase tracking-widest px-2 py-1 bg-surface-container-low border border-white/5 rounded text-outline">{{ t }}</span>
</div>
<p class="text-[10px] text-secondary font-black uppercase tracking-widest mb-1">Duración estimada</p>
<p class="text-2xl font-black text-white font-headline">{{ resultado.guion.duracion_estimada_seg }}s</p>
</div>
<!-- Guion completo -->
<div class="bg-surface-container rounded-3xl border border-outline-variant/10 shadow-xl overflow-hidden">
<div class="px-6 py-4 border-b border-white/5 bg-surface-container-high/50 flex items-center justify-between">
<h3 class="text-xs font-black text-white uppercase tracking-widest">Guion Completo</h3>
<button @click="copiarGuion" class="text-xs font-bold text-primary hover:text-white transition-colors flex items-center gap-1">
<span class="material-symbols-outlined text-sm">{{ copiado ? 'check' : 'content_copy' }}</span>
{{ copiado ? 'Copiado' : 'Copiar' }}
</button>
</div>
<div class="p-6 space-y-4">
<div>
<p class="text-[9px] text-secondary font-black uppercase tracking-widest mb-2">Gancho</p>
<p class="text-sm text-white leading-relaxed font-medium">{{ resultado.guion.gancho }}</p>
</div>
<div>
<p class="text-[9px] text-primary font-black uppercase tracking-widest mb-2">Desarrollo</p>
<p class="text-sm text-on-surface-variant leading-relaxed whitespace-pre-wrap">{{ resultado.guion.desarrollo }}</p>
</div>
<div v-if="resultado.guion.cta">
<p class="text-[9px] text-tertiary font-black uppercase tracking-widest mb-2">Call to Action</p>
<p class="text-sm text-white leading-relaxed font-medium">{{ resultado.guion.cta }}</p>
</div>
</div>
</div>
<!-- Variantes del gancho -->
<div v-if="resultado.guion.variantes_gancho?.length" class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10">
<h3 class="text-xs font-black text-white uppercase tracking-widest mb-4 flex items-center gap-2">
<span class="material-symbols-outlined text-outline text-base">shuffle</span> Variantes del Gancho
</h3>
<div class="space-y-3">
<div v-for="(v, i) in resultado.guion.variantes_gancho" :key="i" class="p-3 rounded-xl bg-surface-container-low border border-white/5">
<span class="text-[9px] text-outline font-black uppercase tracking-widest mr-2">V{{ i + 1 }}</span>
<span class="text-sm text-on-surface-variant italic">"{{ v }}"</span>
</div>
</div>
</div>
<!-- Notas de producción -->
<div v-if="resultado.guion.notas_produccion" class="bg-surface-container p-6 rounded-3xl border border-yellow-500/20">
<h3 class="text-xs font-black text-yellow-400 uppercase tracking-widest mb-3 flex items-center gap-2">
<span class="material-symbols-outlined text-base">videocam</span> Notas de Producción
</h3>
<p class="text-sm text-on-surface-variant leading-relaxed">{{ resultado.guion.notas_produccion }}</p>
</div>
<!-- Nuevo guion -->
<button @click="resultado = null; form.tema = ''; form.instrucciones_extra = ''" class="w-full py-3 bg-surface-container border border-white/5 text-outline font-bold rounded-xl text-sm hover:text-white hover:border-white/10 transition-colors">
Generar otro guion
</button>
</div>
<!-- Estado vacío / Info -->
<div v-else class="bg-surface-container rounded-3xl border border-outline-variant/10 p-8 flex flex-col gap-6 sticky top-24">
<div>
<h3 class="text-sm font-headline font-black text-white uppercase tracking-widest mb-2">Cómo funciona</h3>
<p class="text-xs text-outline/70 leading-relaxed">El generador analiza los guiones de mayor rendimiento de tu base de datos y aplica sus patrones estructurales, técnicas de retención y triggers emocionales a tu nuevo contenido.</p>
</div>
<div class="space-y-3">
<div v-for="paso in pasoInfo" :key="paso.label" class="flex items-start gap-3">
<div class="w-7 h-7 rounded-full bg-surface-container-low border border-white/5 flex items-center justify-center shrink-0">
<span class="material-symbols-outlined text-sm text-primary">{{ paso.icon }}</span>
</div>
<div>
<p class="text-xs font-black text-white">{{ paso.label }}</p>
<p class="text-[10px] text-outline/60">{{ paso.desc }}</p>
</div>
</div>
</div>
<div class="pt-4 border-t border-white/5">
<p class="text-[10px] text-outline/50 italic">Tiempo estimado: 5-10 segundos · Modelo: GPT-4o</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { api } from '../lib/api.js'
const generando = ref(false)
const error = ref(null)
const resultado = ref(null)
const copiado = ref(false)
const nichos = ref([])
const form = ref({
niche: '',
tema: '',
audiencia: '',
plataforma: 'tiktok',
estructura: 'AIDA',
objetivo: 'engagement',
tono: 'educativo',
duracion_objetivo: 60,
instrucciones_extra: '',
})
const pasoInfo = [
{ icon: 'search', label: 'Busca patrones', desc: 'Selecciona los mejores guiones del niche en tu biblioteca' },
{ icon: 'psychology', label: 'Extrae técnicas', desc: 'Identifica estructura, triggers y principios Cialdini activos' },
{ icon: 'auto_fix_high', label: 'Genera el guion', desc: 'GPT-4o crea contenido original aplicando los patrones' },
{ icon: 'save', label: 'Guarda en biblioteca', desc: 'El guion queda guardado con su score y notas de producción' },
]
async function generar() {
if (!form.value.niche || !form.value.tema || !form.value.audiencia) return
generando.value = true
error.value = null
resultado.value = null
try {
resultado.value = await api.generar(form.value)
} catch (e) {
error.value = e.message
} finally {
generando.value = false
}
}
async function copiarGuion() {
if (!resultado.value?.guion?.guion_completo) return
await navigator.clipboard.writeText(resultado.value.guion.guion_completo)
copiado.value = true
setTimeout(() => { copiado.value = false }, 2000)
}
onMounted(async () => {
try { nichos.value = await api.nichos() } catch {}
})
</script>