From 317a3aa46dcff0e9a552e2880e5287d2bb529919 Mon Sep 17 00:00:00 2001 From: Marcos Fargas Date: Mon, 12 Oct 2026 20:32:15 +0200 Subject: [PATCH] feat(skills): update BPS & static skills, add audience to brief, inline angle/hook editing and montage notes --- skills/clients/blue-parrot-school.md | 9 + skills/clients/bps.md | 9 + skills/formats/estatico-4-5.md | 6 + skills/formats/estatico.md | 6 + src/app/api/generation-requests/route.ts | 15 +- src/app/api/integrations/creatives/route.ts | 46 ++-- .../api/integrations/fatigue/mark/route.ts | 15 +- src/app/api/integrations/skills/route.ts | 21 +- src/components/AgencyCreativesView.tsx | 205 +++++++++++++++--- 9 files changed, 277 insertions(+), 55 deletions(-) diff --git a/skills/clients/blue-parrot-school.md b/skills/clients/blue-parrot-school.md index af186ee..8872895 100644 --- a/skills/clients/blue-parrot-school.md +++ b/skills/clients/blue-parrot-school.md @@ -57,6 +57,15 @@ Web: blueparrotschool.com - **Tipografía:** Poppins. Extrabold en titulares, Regular en cuerpo. Nunca otra fuente. - **Logo:** lockup oficial *Blue Parrot* + *SCHOOL* en amarillo; versión con filete blanco sobre fondos azul o negro. Área de protección respetada, esquina inferior o superior, nunca centrado ni dominando. - **Estilo de imagen:** fotografía real de aula, oficina o reunión con luz natural; personas adultas de 25-50 en contexto de trabajo; sin poses de catálogo. Si es ilustración o composición gráfica, geometría limpia, mucho aire, una sola idea. +- **La imagen tiene que leerse como clase de idioma en 1,5 s.** Siempre un elemento que lo diga sin texto: pizarra con esquemas, libro o cuaderno de ejercicios abierto, auriculares en una sesión online, aula con sillas. Y los roles claros: el profesor de pie, señalando, o con la pizarra detrás; el alumno sentado, escuchando o escribiendo. Nada que parezca firma de documentos, entrevista de trabajo, reunión de banco o consulta. +- **Una persona basta** si el mensaje es individual (adulto en sesión online con auriculares y portátil, mirando a cámara o a la pantalla). Dos personas solo si el mensaje es la relación profesor-alumno. Nunca grupos genéricos. + +## Audiencia por campaña + +El brief trae `audience`. Si no viene, se deduce del nombre de campaña: +- **B2C** (`Matrículas`, `Intensivos`, `Cambridge`, `Aptis`, `Adultos`): dolor personal, apps que no avanzan, examen, horario. Habla de "tu inglés". +- **B2B** (`Empresas`, `FUNDAE`, `In-company`, `Equipos`, nombres de sector): coste bonificado, programas por sector, informes para RRHH, el equipo que habla con clientes. Habla de "tu equipo". +Si hay duda, se pregunta en `notes` y se genera en B2C. - **Texto en imagen:** máximo 2 niveles (titular + apoyo). El titular no supera 8 palabras. Nada de texto tocando bordes: margen mínimo 8 % del lado corto. ## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar) diff --git a/skills/clients/bps.md b/skills/clients/bps.md index af186ee..8872895 100644 --- a/skills/clients/bps.md +++ b/skills/clients/bps.md @@ -57,6 +57,15 @@ Web: blueparrotschool.com - **Tipografía:** Poppins. Extrabold en titulares, Regular en cuerpo. Nunca otra fuente. - **Logo:** lockup oficial *Blue Parrot* + *SCHOOL* en amarillo; versión con filete blanco sobre fondos azul o negro. Área de protección respetada, esquina inferior o superior, nunca centrado ni dominando. - **Estilo de imagen:** fotografía real de aula, oficina o reunión con luz natural; personas adultas de 25-50 en contexto de trabajo; sin poses de catálogo. Si es ilustración o composición gráfica, geometría limpia, mucho aire, una sola idea. +- **La imagen tiene que leerse como clase de idioma en 1,5 s.** Siempre un elemento que lo diga sin texto: pizarra con esquemas, libro o cuaderno de ejercicios abierto, auriculares en una sesión online, aula con sillas. Y los roles claros: el profesor de pie, señalando, o con la pizarra detrás; el alumno sentado, escuchando o escribiendo. Nada que parezca firma de documentos, entrevista de trabajo, reunión de banco o consulta. +- **Una persona basta** si el mensaje es individual (adulto en sesión online con auriculares y portátil, mirando a cámara o a la pantalla). Dos personas solo si el mensaje es la relación profesor-alumno. Nunca grupos genéricos. + +## Audiencia por campaña + +El brief trae `audience`. Si no viene, se deduce del nombre de campaña: +- **B2C** (`Matrículas`, `Intensivos`, `Cambridge`, `Aptis`, `Adultos`): dolor personal, apps que no avanzan, examen, horario. Habla de "tu inglés". +- **B2B** (`Empresas`, `FUNDAE`, `In-company`, `Equipos`, nombres de sector): coste bonificado, programas por sector, informes para RRHH, el equipo que habla con clientes. Habla de "tu equipo". +Si hay duda, se pregunta en `notes` y se genera en B2C. - **Texto en imagen:** máximo 2 niveles (titular + apoyo). El titular no supera 8 palabras. Nada de texto tocando bordes: margen mínimo 8 % del lado corto. ## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar) diff --git a/skills/formats/estatico-4-5.md b/skills/formats/estatico-4-5.md index 97f6df0..84e9eb4 100644 --- a/skills/formats/estatico-4-5.md +++ b/skills/formats/estatico-4-5.md @@ -16,10 +16,13 @@ Un objeto JSON con exactamente estos campos: "cta": "uno de: 'Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'", "image_prompt": "prompt en inglés para el generador de imagen, SIN texto ni logo, describiendo escena, personas, luz, encuadre y paleta", "layout": "una de: 'foto-con-franja' (foto + franja de color con titular), 'tipografico' (solo color y texto), 'dato-grande' (una cifra enorme + contexto)", + "composition_notes": "instrucciones de montaje para diseño en 3-5 líneas: dónde va la franja o el texto, qué palabra va en color de acento, posición del logo, qué recortar. Es lo que ve el diseñador en la tarjeta.", "rationale": "una frase: por qué este ángulo puede relevar a la fatigada" } ``` +Y fuera de las variantes, en `notes`, el diagnóstico de la fatigada en 2-3 frases (qué métrica se movió, si es mensaje o saturación) y cualquier dato que faltara para decidir mejor. + ## Reglas de ángulo - Cada variante ataca un **ángulo distinto** entre sí y distinto del de la creatividad fatigada (viene en `avoid_angles`). @@ -45,6 +48,9 @@ Un objeto JSON con exactamente estos campos: - Siempre en inglés, 40-80 palabras. - Describe: sujeto, acción, entorno, luz, encuadre (vertical 4:5, espacio libre arriba o abajo para texto), paleta en términos de la skill del cliente. +- **La escena tiene que contar el servicio sin texto.** Incluye siempre un objeto o gesto que identifique la actividad (lo que diga la skill del cliente) y deja claros los roles si hay dos personas. Evita escenas ambiguas que puedan leerse como otra cosa: firma de papeles, entrevista, reunión de banco. +- Pide explícitamente el aire para el titular con una fracción: `the upper third of the frame is empty wall or sky`. "Negative space" a secas no basta. +- Prefiere una persona a dos cuando el mensaje es individual; nunca más de dos. - Termina siempre con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline`. - Nunca pidas personas reconocibles, marcas de terceros, ni niños. - Una generación por variante. No pidas variaciones. diff --git a/skills/formats/estatico.md b/skills/formats/estatico.md index 97f6df0..84e9eb4 100644 --- a/skills/formats/estatico.md +++ b/skills/formats/estatico.md @@ -16,10 +16,13 @@ Un objeto JSON con exactamente estos campos: "cta": "uno de: 'Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'", "image_prompt": "prompt en inglés para el generador de imagen, SIN texto ni logo, describiendo escena, personas, luz, encuadre y paleta", "layout": "una de: 'foto-con-franja' (foto + franja de color con titular), 'tipografico' (solo color y texto), 'dato-grande' (una cifra enorme + contexto)", + "composition_notes": "instrucciones de montaje para diseño en 3-5 líneas: dónde va la franja o el texto, qué palabra va en color de acento, posición del logo, qué recortar. Es lo que ve el diseñador en la tarjeta.", "rationale": "una frase: por qué este ángulo puede relevar a la fatigada" } ``` +Y fuera de las variantes, en `notes`, el diagnóstico de la fatigada en 2-3 frases (qué métrica se movió, si es mensaje o saturación) y cualquier dato que faltara para decidir mejor. + ## Reglas de ángulo - Cada variante ataca un **ángulo distinto** entre sí y distinto del de la creatividad fatigada (viene en `avoid_angles`). @@ -45,6 +48,9 @@ Un objeto JSON con exactamente estos campos: - Siempre en inglés, 40-80 palabras. - Describe: sujeto, acción, entorno, luz, encuadre (vertical 4:5, espacio libre arriba o abajo para texto), paleta en términos de la skill del cliente. +- **La escena tiene que contar el servicio sin texto.** Incluye siempre un objeto o gesto que identifique la actividad (lo que diga la skill del cliente) y deja claros los roles si hay dos personas. Evita escenas ambiguas que puedan leerse como otra cosa: firma de papeles, entrevista, reunión de banco. +- Pide explícitamente el aire para el titular con una fracción: `the upper third of the frame is empty wall or sky`. "Negative space" a secas no basta. +- Prefiere una persona a dos cuando el mensaje es individual; nunca más de dos. - Termina siempre con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline`. - Nunca pidas personas reconocibles, marcas de terceros, ni niños. - Una generación por variante. No pidas variaciones. diff --git a/src/app/api/generation-requests/route.ts b/src/app/api/generation-requests/route.ts index 72c4791..e231ea1 100644 --- a/src/app/api/generation-requests/route.ts +++ b/src/app/api/generation-requests/route.ts @@ -84,10 +84,20 @@ export async function POST(req: NextRequest) { // Get tenant slug const { data: tenant } = await admin .from('tenants') - .select('slug, name') + .select('slug, name, campaign_audience') .eq('id', tenantId) .maybeSingle(); + const campaignName = body.campaign_name || null; + const campaignAudienceMap = (tenant as any)?.campaign_audience || {}; + let audience: 'b2b' | 'b2c' | null = body.audience || null; + if (!audience && campaignName && campaignAudienceMap && typeof campaignAudienceMap === 'object' && campaignAudienceMap[campaignName]) { + const val = String(campaignAudienceMap[campaignName]).toLowerCase().trim(); + if (val === 'b2b' || val === 'b2c') { + audience = val; + } + } + const brief = { tenant_id: tenantId, client_id: tenantId, @@ -96,8 +106,9 @@ export async function POST(req: NextRequest) { source_creative_id: body.source_creative_id || null, format: body.format || 'estatico', variants_requested: Number(body.variants_requested || 3), - campaign_name: body.campaign_name || null, + campaign_name: campaignName, adset_id: body.adset_id || null, + audience, notes: body.notes || null, }; diff --git a/src/app/api/integrations/creatives/route.ts b/src/app/api/integrations/creatives/route.ts index 7854e80..1c01d23 100644 --- a/src/app/api/integrations/creatives/route.ts +++ b/src/app/api/integrations/creatives/route.ts @@ -48,26 +48,32 @@ export async function POST(req: NextRequest) { } } - const toInsert = items.map((c: any) => ({ - tenant_id: tenantId, - campaign_name: c.campaign_name || null, - adset_id: c.adset_id || null, - meta_ad_id: c.meta_ad_id || null, - format: c.format || 'estatico', - angle: c.angle || null, - hook: c.hook || null, - primary_text: c.primary_text || null, - headline: c.headline || null, - cta: c.cta || null, - asset_url: c.asset_url || null, - thumbnail_url: c.thumbnail_url || null, - status: 'borrador', // nacen siempre en borrador - visibility: 'agency', // nacen siempre en agency - created_by: 'claude', // creadas por Claude - parent_id: c.parent_id || defaultParentId, - generation_request_id: c.generation_request_id || genRequestId || null, - meta: c.meta || {}, - })); + const toInsert = items.map((c: any) => { + const meta = { ...(c.meta || {}) }; + if (c.composition_notes) { + meta.composition_notes = c.composition_notes; + } + return { + tenant_id: tenantId, + campaign_name: c.campaign_name || null, + adset_id: c.adset_id || null, + meta_ad_id: c.meta_ad_id || null, + format: c.format || 'estatico', + angle: c.angle || null, + hook: c.hook || null, + primary_text: c.primary_text || null, + headline: c.headline || null, + cta: c.cta || null, + asset_url: c.asset_url || null, + thumbnail_url: c.thumbnail_url || null, + status: 'borrador', // nacen siempre en borrador + visibility: 'agency', // nacen siempre en agency + created_by: 'claude', // creadas por Claude + parent_id: c.parent_id || defaultParentId, + generation_request_id: c.generation_request_id || genRequestId || null, + meta, + }; + }); const { data: inserted, error } = await adminClient .from('creatives') diff --git a/src/app/api/integrations/fatigue/mark/route.ts b/src/app/api/integrations/fatigue/mark/route.ts index c4aebbf..60e824f 100644 --- a/src/app/api/integrations/fatigue/mark/route.ts +++ b/src/app/api/integrations/fatigue/mark/route.ts @@ -30,7 +30,7 @@ export async function POST(req: NextRequest) { // 1. Obtener la creatividad a marcar const { data: creative, error: getErr } = await adminClient .from('creatives') - .select('*, tenant:tenants(slug, name)') + .select('*, tenant:tenants(slug, name, campaign_audience)') .eq('id', creativeId) .eq('tenant_id', tenantId) .maybeSingle(); @@ -77,7 +77,17 @@ export async function POST(req: NextRequest) { if (rules?.auto_request_relief) { const avoidAngles = creative.angle ? [creative.angle] : []; - const clientSlug = creative.tenant?.slug || 'bps'; + const clientSlug = (creative.tenant as any)?.slug || 'bps'; + const campaignAudienceMap = (creative.tenant as any)?.campaign_audience || {}; + const campaignName = creative.campaign_name || ''; + + let audience: 'b2b' | 'b2c' | null = null; + if (campaignName && campaignAudienceMap && typeof campaignAudienceMap === 'object' && campaignAudienceMap[campaignName]) { + const val = String(campaignAudienceMap[campaignName]).toLowerCase().trim(); + if (val === 'b2b' || val === 'b2c') { + audience = val; + } + } const deadline = new Date(); deadline.setDate(deadline.getDate() + 1); // 24 horas después @@ -94,6 +104,7 @@ export async function POST(req: NextRequest) { avoid_angles: avoidAngles, campaign_name: creative.campaign_name || 'General', adset_id: creative.adset_id || null, + audience, language: 'es', deadline: deadline.toISOString(), }; diff --git a/src/app/api/integrations/skills/route.ts b/src/app/api/integrations/skills/route.ts index 9c7f4ba..6fa08a0 100644 --- a/src/app/api/integrations/skills/route.ts +++ b/src/app/api/integrations/skills/route.ts @@ -4,6 +4,8 @@ import { createClient } from '@supabase/supabase-js'; import fs from 'fs'; import path from 'path'; +export const dynamic = 'force-dynamic'; + export async function GET(req: NextRequest) { const auth = await authenticateIntegrationRequest(req, { requireTenant: true }); if (!auth.ok) { @@ -61,13 +63,20 @@ export async function GET(req: NextRequest) { } } - return NextResponse.json({ - data: { + return NextResponse.json( + { + data: { + client_skill: clientSkill, + format_skill: formatSkill, + }, + // Compatibilidad en primer nivel client_skill: clientSkill, format_skill: formatSkill, }, - // Compatibilidad en primer nivel - client_skill: clientSkill, - format_skill: formatSkill, - }); + { + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', + }, + } + ); } diff --git a/src/components/AgencyCreativesView.tsx b/src/components/AgencyCreativesView.tsx index e90854b..89279d9 100644 --- a/src/components/AgencyCreativesView.tsx +++ b/src/components/AgencyCreativesView.tsx @@ -25,6 +25,7 @@ import { BarChart2, FileCheck2, Filter, + Pencil, } from 'lucide-react'; import { PageHeader, Surface, EmptyState, SegmentedControl } from '@/components/ui'; @@ -49,7 +50,8 @@ export type CreativeItem = { parent_id?: string | null; generation_request_id?: string | null; asset_final: boolean; - created_by?: string; + title?: string | null; + meta?: Record | null; created_at: string; updated_at: string; metrics?: { @@ -132,6 +134,42 @@ export default function AgencyCreativesView({ const [detailCreative, setDetailCreative] = useState(null); const [detailEvents, setDetailEvents] = useState([]); + // Edición inline de ángulo y hook + const [inlineEditingId, setInlineEditingId] = useState(null); + const [inlineAngle, setInlineAngle] = useState(''); + const [inlineHook, setInlineHook] = useState(''); + const [savingInline, setSavingInline] = useState(false); + + const handleSaveInline = async (creativeId: string) => { + setSavingInline(true); + try { + const res = await fetch(`/api/creatives/${creativeId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + angle: inlineAngle.trim() || null, + hook: inlineHook.trim() || null, + }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Error al guardar'); + } + setCreatives((prev) => + prev.map((c) => + c.id === creativeId + ? { ...c, angle: inlineAngle.trim() || null, hook: inlineHook.trim() || null } + : c + ) + ); + setInlineEditingId(null); + } catch (err: any) { + alert(err.message); + } finally { + setSavingInline(false); + } + }; + // 1. Cargar datos const loadData = useCallback(async () => { setLoading(true); @@ -477,31 +515,134 @@ export default function AgencyCreativesView({ - {/* Info Titular y Copy */} -
-

- {c.headline || c.hook || 'Sin titular'} -

- {c.primary_text ? ( -

- {c.primary_text} -

- ) : null} -
+ {/* Info Titular, Hook, Montaje y Copy con edición inline */} + {inlineEditingId === c.id ? ( +
e.stopPropagation()} + > +
+ + setInlineAngle(e.target.value)} + placeholder="ej: dolor_tiempo, prueba_social, certificados..." + className="w-full mt-0.5 px-2 py-1 text-xs rounded border border-gray-300 bg-white" + /> +
+
+ +