feat(skills): update BPS & static skills, add audience to brief, inline angle/hook editing and montage notes
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toInsert = items.map((c: any) => ({
|
const toInsert = items.map((c: any) => {
|
||||||
|
const meta = { ...(c.meta || {}) };
|
||||||
|
if (c.composition_notes) {
|
||||||
|
meta.composition_notes = c.composition_notes;
|
||||||
|
}
|
||||||
|
return {
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
campaign_name: c.campaign_name || null,
|
campaign_name: c.campaign_name || null,
|
||||||
adset_id: c.adset_id || null,
|
adset_id: c.adset_id || null,
|
||||||
@@ -66,8 +71,9 @@ export async function POST(req: NextRequest) {
|
|||||||
created_by: 'claude', // creadas por Claude
|
created_by: 'claude', // creadas por Claude
|
||||||
parent_id: c.parent_id || defaultParentId,
|
parent_id: c.parent_id || defaultParentId,
|
||||||
generation_request_id: c.generation_request_id || genRequestId || null,
|
generation_request_id: c.generation_request_id || genRequestId || null,
|
||||||
meta: c.meta || {},
|
meta,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const { data: inserted, error } = await adminClient
|
const { data: inserted, error } = await adminClient
|
||||||
.from('creatives')
|
.from('creatives')
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,7 +63,8 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
|
{
|
||||||
data: {
|
data: {
|
||||||
client_skill: clientSkill,
|
client_skill: clientSkill,
|
||||||
format_skill: formatSkill,
|
format_skill: formatSkill,
|
||||||
@@ -69,5 +72,11 @@ export async function GET(req: NextRequest) {
|
|||||||
// Compatibilidad en primer nivel
|
// Compatibilidad en primer nivel
|
||||||
client_skill: clientSkill,
|
client_skill: clientSkill,
|
||||||
format_skill: formatSkill,
|
format_skill: formatSkill,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
<div
|
||||||
|
className="p-2.5 rounded-xl bg-gray-50 border border-brand-300 space-y-2 mb-3 text-xs"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-semibold text-gray-500 block uppercase tracking-wider">
|
||||||
|
Ángulo
|
||||||
|
</label>
|
||||||
|
<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">
|
<h4 className="text-xs font-bold text-gray-900 line-clamp-1">
|
||||||
{c.headline || c.hook || 'Sin titular'}
|
{c.headline || c.title || 'Sin titular'}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
|
{/* Hook con botón de edición rápida */}
|
||||||
|
<div className="flex items-start justify-between gap-1 group/hook">
|
||||||
|
<p className="text-[11px] text-gray-700 leading-snug">
|
||||||
|
<span className="font-semibold text-gray-900">Hook: </span>
|
||||||
|
{c.hook ? (
|
||||||
|
<span>{c.hook}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-600 italic font-medium">Sin hook definido</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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 ? (
|
{c.primary_text ? (
|
||||||
<p className="text-[11px] text-gray-600 line-clamp-2 leading-relaxed">
|
<p className="text-[11px] text-gray-600 line-clamp-2 leading-relaxed">
|
||||||
{c.primary_text}
|
{c.primary_text}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Meta: Ángulo & Campaña */}
|
{/* Meta: Ángulo & Campaña */}
|
||||||
<div className="flex flex-wrap items-center gap-1 mb-3 text-[10px] text-gray-500">
|
<div className="flex flex-wrap items-center gap-1 pt-1 text-[10px] text-gray-500">
|
||||||
{c.angle ? (
|
{c.angle ? (
|
||||||
<span className="rounded-md bg-gray-100 px-1.5 py-0.5 font-medium text-gray-700">
|
<span className="rounded-md bg-purple-50 px-1.5 py-0.5 font-medium text-purple-700 border border-purple-200">
|
||||||
{c.angle}
|
{c.angle}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : (
|
||||||
|
<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 ? (
|
{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]">
|
<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}
|
{c.campaign_name}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user