feat(skills): update BPS & static skills, add audience to brief, inline angle/hook editing and montage notes

This commit is contained in:
Marcos Fargas
2026-10-12 20:32:15 +02:00
parent 34ce46774d
commit 317a3aa46d
9 changed files with 277 additions and 55 deletions

View File

@@ -57,6 +57,15 @@ Web: blueparrotschool.com
- **Tipografía:** Poppins. Extrabold en titulares, Regular en cuerpo. Nunca otra fuente. - **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. - **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. - **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. - **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) ## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar)

View File

@@ -57,6 +57,15 @@ Web: blueparrotschool.com
- **Tipografía:** Poppins. Extrabold en titulares, Regular en cuerpo. Nunca otra fuente. - **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. - **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. - **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. - **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) ## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar)

View File

@@ -16,10 +16,13 @@ Un objeto JSON con exactamente estos campos:
"cta": "uno de: 'Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'", "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", "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)", "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" "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 ## Reglas de ángulo
- Cada variante ataca un **ángulo distinto** entre sí y distinto del de la creatividad fatigada (viene en `avoid_angles`). - 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. - 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. - 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`. - 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. - Nunca pidas personas reconocibles, marcas de terceros, ni niños.
- Una generación por variante. No pidas variaciones. - Una generación por variante. No pidas variaciones.

View File

@@ -16,10 +16,13 @@ Un objeto JSON con exactamente estos campos:
"cta": "uno de: 'Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'", "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", "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)", "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" "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 ## Reglas de ángulo
- Cada variante ataca un **ángulo distinto** entre sí y distinto del de la creatividad fatigada (viene en `avoid_angles`). - 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. - 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. - 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`. - 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. - Nunca pidas personas reconocibles, marcas de terceros, ni niños.
- Una generación por variante. No pidas variaciones. - Una generación por variante. No pidas variaciones.

View File

@@ -84,10 +84,20 @@ export async function POST(req: NextRequest) {
// Get tenant slug // Get tenant slug
const { data: tenant } = await admin const { data: tenant } = await admin
.from('tenants') .from('tenants')
.select('slug, name') .select('slug, name, campaign_audience')
.eq('id', tenantId) .eq('id', tenantId)
.maybeSingle(); .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 = { const brief = {
tenant_id: tenantId, tenant_id: tenantId,
client_id: tenantId, client_id: tenantId,
@@ -96,8 +106,9 @@ export async function POST(req: NextRequest) {
source_creative_id: body.source_creative_id || null, source_creative_id: body.source_creative_id || null,
format: body.format || 'estatico', format: body.format || 'estatico',
variants_requested: Number(body.variants_requested || 3), variants_requested: Number(body.variants_requested || 3),
campaign_name: body.campaign_name || null, campaign_name: campaignName,
adset_id: body.adset_id || null, adset_id: body.adset_id || null,
audience,
notes: body.notes || null, notes: body.notes || null,
}; };

View File

@@ -48,26 +48,32 @@ export async function POST(req: NextRequest) {
} }
} }
const toInsert = items.map((c: any) => ({ const toInsert = items.map((c: any) => {
tenant_id: tenantId, const meta = { ...(c.meta || {}) };
campaign_name: c.campaign_name || null, if (c.composition_notes) {
adset_id: c.adset_id || null, meta.composition_notes = c.composition_notes;
meta_ad_id: c.meta_ad_id || null, }
format: c.format || 'estatico', return {
angle: c.angle || null, tenant_id: tenantId,
hook: c.hook || null, campaign_name: c.campaign_name || null,
primary_text: c.primary_text || null, adset_id: c.adset_id || null,
headline: c.headline || null, meta_ad_id: c.meta_ad_id || null,
cta: c.cta || null, format: c.format || 'estatico',
asset_url: c.asset_url || null, angle: c.angle || null,
thumbnail_url: c.thumbnail_url || null, hook: c.hook || null,
status: 'borrador', // nacen siempre en borrador primary_text: c.primary_text || null,
visibility: 'agency', // nacen siempre en agency headline: c.headline || null,
created_by: 'claude', // creadas por Claude cta: c.cta || null,
parent_id: c.parent_id || defaultParentId, asset_url: c.asset_url || null,
generation_request_id: c.generation_request_id || genRequestId || null, thumbnail_url: c.thumbnail_url || null,
meta: c.meta || {}, 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 const { data: inserted, error } = await adminClient
.from('creatives') .from('creatives')

View File

@@ -30,7 +30,7 @@ export async function POST(req: NextRequest) {
// 1. Obtener la creatividad a marcar // 1. Obtener la creatividad a marcar
const { data: creative, error: getErr } = await adminClient const { data: creative, error: getErr } = await adminClient
.from('creatives') .from('creatives')
.select('*, tenant:tenants(slug, name)') .select('*, tenant:tenants(slug, name, campaign_audience)')
.eq('id', creativeId) .eq('id', creativeId)
.eq('tenant_id', tenantId) .eq('tenant_id', tenantId)
.maybeSingle(); .maybeSingle();
@@ -77,7 +77,17 @@ export async function POST(req: NextRequest) {
if (rules?.auto_request_relief) { if (rules?.auto_request_relief) {
const avoidAngles = creative.angle ? [creative.angle] : []; 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(); const deadline = new Date();
deadline.setDate(deadline.getDate() + 1); // 24 horas después deadline.setDate(deadline.getDate() + 1); // 24 horas después
@@ -94,6 +104,7 @@ export async function POST(req: NextRequest) {
avoid_angles: avoidAngles, avoid_angles: avoidAngles,
campaign_name: creative.campaign_name || 'General', campaign_name: creative.campaign_name || 'General',
adset_id: creative.adset_id || null, adset_id: creative.adset_id || null,
audience,
language: 'es', language: 'es',
deadline: deadline.toISOString(), deadline: deadline.toISOString(),
}; };

View File

@@ -4,6 +4,8 @@ import { createClient } from '@supabase/supabase-js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const auth = await authenticateIntegrationRequest(req, { requireTenant: true }); const auth = await authenticateIntegrationRequest(req, { requireTenant: true });
if (!auth.ok) { if (!auth.ok) {
@@ -61,13 +63,20 @@ export async function GET(req: NextRequest) {
} }
} }
return NextResponse.json({ return NextResponse.json(
data: { {
data: {
client_skill: clientSkill,
format_skill: formatSkill,
},
// Compatibilidad en primer nivel
client_skill: clientSkill, client_skill: clientSkill,
format_skill: formatSkill, format_skill: formatSkill,
}, },
// Compatibilidad en primer nivel {
client_skill: clientSkill, headers: {
format_skill: formatSkill, 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
}); },
}
);
} }

View File

@@ -25,6 +25,7 @@ import {
BarChart2, BarChart2,
FileCheck2, FileCheck2,
Filter, Filter,
Pencil,
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader, Surface, EmptyState, SegmentedControl } from '@/components/ui'; import { PageHeader, Surface, EmptyState, SegmentedControl } from '@/components/ui';
@@ -49,7 +50,8 @@ export type CreativeItem = {
parent_id?: string | null; parent_id?: string | null;
generation_request_id?: string | null; generation_request_id?: string | null;
asset_final: boolean; asset_final: boolean;
created_by?: string; title?: string | null;
meta?: Record<string, any> | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
metrics?: { metrics?: {
@@ -132,6 +134,42 @@ export default function AgencyCreativesView({
const [detailCreative, setDetailCreative] = useState<CreativeItem | null>(null); const [detailCreative, setDetailCreative] = useState<CreativeItem | null>(null);
const [detailEvents, setDetailEvents] = useState<any[]>([]); const [detailEvents, setDetailEvents] = useState<any[]>([]);
// Edición inline de ángulo y hook
const [inlineEditingId, setInlineEditingId] = useState<string | null>(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 // 1. Cargar datos
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -477,31 +515,134 @@ export default function AgencyCreativesView({
</div> </div>
</div> </div>
{/* Info Titular y Copy */} {/* Info Titular, Hook, Montaje y Copy con edición inline */}
<div className="space-y-1 mb-2.5"> {inlineEditingId === c.id ? (
<h4 className="text-xs font-bold text-gray-900 line-clamp-1"> <div
{c.headline || c.hook || 'Sin titular'} className="p-2.5 rounded-xl bg-gray-50 border border-brand-300 space-y-2 mb-3 text-xs"
</h4> onClick={(e) => e.stopPropagation()}
{c.primary_text ? ( >
<p className="text-[11px] text-gray-600 line-clamp-2 leading-relaxed"> <div>
{c.primary_text} <label className="text-[10px] font-semibold text-gray-500 block uppercase tracking-wider">
</p> Ángulo
) : null} </label>
</div> <input
type="text"
value={inlineAngle}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-500 block uppercase tracking-wider">
Hook / Gancho
</label>
<textarea
rows={2}
value={inlineHook}
onChange={(e) => setInlineHook(e.target.value)}
placeholder="Gancho de la creatividad o nombre del anuncio..."
className="w-full mt-0.5 px-2 py-1 text-xs rounded border border-gray-300 bg-white resize-none"
/>
</div>
<div className="flex items-center justify-end gap-1.5 pt-1">
<button
type="button"
disabled={savingInline}
onClick={() => setInlineEditingId(null)}
className="px-2 py-1 rounded text-[11px] font-medium text-gray-600 hover:bg-gray-200"
>
Cancelar
</button>
<button
type="button"
disabled={savingInline}
onClick={() => void handleSaveInline(c.id)}
className="px-2.5 py-1 rounded text-[11px] font-semibold text-white bg-brand-500 hover:bg-brand-600"
>
{savingInline ? 'Guardando…' : 'Guardar'}
</button>
</div>
</div>
) : (
<div className="space-y-1.5 mb-2.5">
{/* Titular */}
<h4 className="text-xs font-bold text-gray-900 line-clamp-1">
{c.headline || c.title || 'Sin titular'}
</h4>
{/* Meta: Ángulo & Campaña */} {/* Hook con botón de edición rápida */}
<div className="flex flex-wrap items-center gap-1 mb-3 text-[10px] text-gray-500"> <div className="flex items-start justify-between gap-1 group/hook">
{c.angle ? ( <p className="text-[11px] text-gray-700 leading-snug">
<span className="rounded-md bg-gray-100 px-1.5 py-0.5 font-medium text-gray-700"> <span className="font-semibold text-gray-900">Hook: </span>
{c.angle} {c.hook ? (
</span> <span>{c.hook}</span>
) : null} ) : (
{c.campaign_name ? ( <span className="text-amber-600 italic font-medium">Sin hook definido</span>
<span className="rounded-md bg-gray-50 px-1.5 py-0.5 text-gray-600 border border-gray-200 line-clamp-1 max-w-[150px]"> )}
{c.campaign_name} </p>
</span> <button
) : null} type="button"
</div> onClick={(e) => {
e.stopPropagation();
setInlineEditingId(c.id);
setInlineAngle(c.angle || '');
setInlineHook(c.hook || '');
}}
className="text-gray-400 hover:text-brand-600 shrink-0 p-0.5 rounded transition-colors"
title="Editar Hook y Ángulo"
>
<Pencil className="w-3 h-3" />
</button>
</div>
{/* Montaje / composition_notes debajo del hook */}
{c.meta?.composition_notes && (
<div className="rounded-lg bg-amber-50/90 p-2 border border-amber-200/60 text-[11px] space-y-0.5">
<span className="font-semibold text-amber-950 block text-[10px] uppercase tracking-wider">
Montaje
</span>
<p className="text-amber-900 whitespace-pre-line leading-relaxed text-[11px]">
{c.meta.composition_notes}
</p>
</div>
)}
{/* Copy principal */}
{c.primary_text ? (
<p className="text-[11px] text-gray-600 line-clamp-2 leading-relaxed">
{c.primary_text}
</p>
) : null}
{/* Meta: Ángulo & Campaña */}
<div className="flex flex-wrap items-center gap-1 pt-1 text-[10px] text-gray-500">
{c.angle ? (
<span className="rounded-md bg-purple-50 px-1.5 py-0.5 font-medium text-purple-700 border border-purple-200">
{c.angle}
</span>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setInlineEditingId(c.id);
setInlineAngle('');
setInlineHook(c.hook || '');
}}
className="rounded-md bg-amber-50 hover:bg-amber-100 px-1.5 py-0.5 text-amber-700 font-medium border border-amber-200"
>
+ Definir ángulo
</button>
)}
{c.campaign_name ? (
<span className="rounded-md bg-gray-50 px-1.5 py-0.5 text-gray-600 border border-gray-200 line-clamp-1 max-w-[150px]">
{c.campaign_name}
</span>
) : null}
</div>
</div>
)}
{/* Métricas si existen */} {/* Métricas si existen */}
{c.metrics ? ( {c.metrics ? (
@@ -1084,6 +1225,20 @@ export default function AgencyCreativesView({
{detailCreative.primary_text ? ( {detailCreative.primary_text ? (
<p className="text-xs text-gray-600 pt-1">{detailCreative.primary_text}</p> <p className="text-xs text-gray-600 pt-1">{detailCreative.primary_text}</p>
) : null} ) : null}
{detailCreative.hook ? (
<p className="text-xs text-gray-700 pt-1"><strong>Hook:</strong> {detailCreative.hook}</p>
) : null}
{detailCreative.angle ? (
<p className="text-xs text-purple-700 pt-0.5"><strong>Ángulo:</strong> {detailCreative.angle}</p>
) : null}
{detailCreative.meta?.composition_notes ? (
<div className="mt-2 rounded-lg bg-amber-50 p-2.5 border border-amber-200 text-xs text-amber-950">
<span className="font-bold text-[10px] uppercase tracking-wider block text-amber-900">Montaje</span>
<p className="whitespace-pre-line text-amber-900 text-[11px] mt-0.5 leading-relaxed">
{detailCreative.meta.composition_notes}
</p>
</div>
) : null}
</div> </div>
<div> <div>