feat(creatives): add configurable per-client creative style and skill builder in UI and API

This commit is contained in:
Marcos Fargas
2026-10-13 01:44:44 +02:00
parent 2a1629cda8
commit 142933a654
6 changed files with 2874 additions and 15 deletions

View File

@@ -0,0 +1,194 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { createClient as createAdminClient } from '@supabase/supabase-js';
import {
ClientCreativeStyle,
compileClientSkillMarkdown,
normalizeCreativeStyle,
} from '@/lib/creative-style';
export const dynamic = 'force-dynamic';
function getAdmin() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return createAdminClient(url, key);
}
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = userRole(user);
if (!isAgencyRole(role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(req.url);
let tenantId = searchParams.get('tenant_id') || searchParams.get('tenantId');
if (!tenantId) {
const ctx = await resolveActiveTenant(user);
tenantId = ctx.tenantId;
}
if (!tenantId) {
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
}
const admin = getAdmin();
const { data: tenant, error } = await admin
.from('tenants')
.select(
'id, name, slug, creatives_enabled, meta_ad_account_id, meta_page_id, meta_ig_user_id, slack_channel_id, drive_folder_id, default_landing_url, canva_templates, campaign_audience, campaign_products, creative_style, settings'
)
.eq('id', tenantId)
.maybeSingle();
if (error || !tenant) {
return NextResponse.json({ error: error?.message || 'Cliente no encontrado' }, { status: 404 });
}
const rawStyle =
tenant.creative_style && Object.keys(tenant.creative_style).length > 0
? tenant.creative_style
: (tenant.settings as any)?.creative_style;
const style = normalizeCreativeStyle(rawStyle, tenant.name || 'Cliente', tenant.slug || '');
const compiledMarkdown = compileClientSkillMarkdown(
style,
tenant.name || 'Cliente',
tenant.slug || 'cliente'
);
return NextResponse.json({
tenant_id: tenant.id,
name: tenant.name,
slug: tenant.slug,
creatives_enabled: Boolean(tenant.creatives_enabled),
meta_ad_account_id: tenant.meta_ad_account_id || '',
meta_page_id: tenant.meta_page_id || '',
meta_ig_user_id: tenant.meta_ig_user_id || '',
slack_channel_id: tenant.slack_channel_id || '',
drive_folder_id: tenant.drive_folder_id || '',
default_landing_url: tenant.default_landing_url || '',
canva_templates: tenant.canva_templates || {},
campaign_audience: tenant.campaign_audience || {},
campaign_products: tenant.campaign_products || {},
style,
compiled_skill: compiledMarkdown,
});
}
export async function PUT(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = userRole(user);
if (!isAgencyRole(role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const tenantId = body.tenant_id || body.tenantId;
if (!tenantId) {
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
}
const admin = getAdmin();
const { data: existing, error: getErr } = await admin
.from('tenants')
.select('id, name, slug, settings')
.eq('id', tenantId)
.maybeSingle();
if (getErr || !existing) {
return NextResponse.json({ error: 'Cliente no encontrado' }, { status: 404 });
}
const normalizedStyle: ClientCreativeStyle = normalizeCreativeStyle(
body.style,
existing.name || 'Cliente',
existing.slug || ''
);
normalizedStyle.updated_at = new Date().toISOString();
const updatePayload: Record<string, any> = {
creative_style: normalizedStyle,
settings: {
...(existing.settings || {}),
creative_style: normalizedStyle,
},
};
if (typeof body.creatives_enabled === 'boolean') {
updatePayload.creatives_enabled = body.creatives_enabled;
}
if (typeof body.meta_ad_account_id === 'string') {
updatePayload.meta_ad_account_id = body.meta_ad_account_id.trim() || null;
}
if (typeof body.meta_page_id === 'string') {
updatePayload.meta_page_id = body.meta_page_id.trim() || null;
}
if (typeof body.meta_ig_user_id === 'string') {
updatePayload.meta_ig_user_id = body.meta_ig_user_id.trim() || null;
}
if (typeof body.slack_channel_id === 'string') {
updatePayload.slack_channel_id = body.slack_channel_id.trim() || null;
}
if (typeof body.drive_folder_id === 'string') {
updatePayload.drive_folder_id = body.drive_folder_id.trim() || null;
}
if (typeof body.default_landing_url === 'string') {
updatePayload.default_landing_url = body.default_landing_url.trim() || null;
}
if (body.canva_templates && typeof body.canva_templates === 'object') {
updatePayload.canva_templates = body.canva_templates;
}
if (body.campaign_audience && typeof body.campaign_audience === 'object') {
updatePayload.campaign_audience = body.campaign_audience;
}
if (body.campaign_products && typeof body.campaign_products === 'object') {
updatePayload.campaign_products = body.campaign_products;
}
const { error: updErr } = await admin
.from('tenants')
.update(updatePayload)
.eq('id', tenantId);
if (updErr) {
return NextResponse.json({ error: updErr.message }, { status: 500 });
}
const compiledMarkdown = compileClientSkillMarkdown(
normalizedStyle,
existing.name || 'Cliente',
existing.slug || 'cliente'
);
return NextResponse.json({
ok: true,
style: normalizedStyle,
compiled_skill: compiledMarkdown,
});
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
import { compileClientSkillMarkdown, normalizeCreativeStyle } from '@/lib/creative-style';
import fs from 'fs';
import path from 'path';
@@ -17,10 +18,10 @@ export async function GET(req: NextRequest) {
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
// 1. Obtener slug del cliente
// 1. Obtener slug y creative_style del cliente desde BD
const { data: tenant, error: tErr } = await adminClient
.from('tenants')
.select('id, name, slug')
.select('id, name, slug, creative_style, settings')
.eq('id', tenantId)
.maybeSingle();
@@ -31,19 +32,32 @@ export async function GET(req: NextRequest) {
const slug = (tenant.slug || 'bps').toLowerCase().trim();
const formatParam = (req.nextUrl.searchParams.get('format') || 'estatico').toLowerCase().trim();
// 2. Resolver skill del cliente (skills/clients/<slug>.md)
const skillsDir = path.join(process.cwd(), 'skills');
const clientCandidates = [
path.join(skillsDir, 'clients', `${slug}.md`),
path.join(skillsDir, 'clients', slug === 'bps' ? 'blue-parrot-school.md' : `${slug}.md`),
path.join(skillsDir, 'clients', 'blue-parrot-school.md'),
];
// 2. Resolver skill del cliente: primero desde BD (creative_style editable en la app), fallback a archivo en disco
const rawDbStyle =
tenant.creative_style && Object.keys(tenant.creative_style).length > 0
? tenant.creative_style
: (tenant.settings as any)?.creative_style;
let clientSkill = '';
for (const p of clientCandidates) {
if (fs.existsSync(p)) {
clientSkill = fs.readFileSync(p, 'utf-8');
break;
const skillsDir = path.join(process.cwd(), 'skills');
if (rawDbStyle && Object.keys(rawDbStyle).length > 0) {
const normalized = normalizeCreativeStyle(rawDbStyle, tenant.name || 'Cliente', slug);
clientSkill = compileClientSkillMarkdown(normalized, tenant.name || 'Cliente', slug);
} else {
const clientCandidates = [
path.join(skillsDir, 'clients', `${slug}.md`),
path.join(skillsDir, 'clients', slug === 'bps' ? 'blue-parrot-school.md' : `${slug}.md`),
];
for (const p of clientCandidates) {
if (fs.existsSync(p)) {
clientSkill = fs.readFileSync(p, 'utf-8');
break;
}
}
if (!clientSkill) {
const normalized = normalizeCreativeStyle(null, tenant.name || 'Cliente', slug);
clientSkill = compileClientSkillMarkdown(normalized, tenant.name || 'Cliente', slug);
}
}

View File

@@ -28,6 +28,7 @@ import {
Pencil,
} from 'lucide-react';
import { PageHeader, Surface, EmptyState, SegmentedControl } from '@/components/ui';
import ClientCreativeStylePanel from '@/components/ClientCreativeStylePanel';
export type CreativeFormat = 'estatico' | 'carrusel' | 'video';
@@ -108,7 +109,7 @@ export default function AgencyCreativesView({
tenantId: string;
clientName: string;
}) {
const [activeTab, setActiveTab] = useState<'kanban' | 'fatiga' | 'cola_claude'>('kanban');
const [activeTab, setActiveTab] = useState<'kanban' | 'estilo_cliente' | 'fatiga' | 'cola_claude'>('kanban');
const [creatives, setCreatives] = useState<CreativeItem[]>([]);
const [rules, setRules] = useState<FatigueRules | null>(null);
const [requests, setRequests] = useState<GenerationRequest[]>([]);
@@ -428,6 +429,7 @@ export default function AgencyCreativesView({
value={activeTab}
options={[
{ id: 'kanban', label: `Kanban de Estados (${creatives.length})` },
{ id: 'estilo_cliente', label: 'Estilo y Brief del Cliente' },
{ id: 'fatiga', label: 'Detector de Fatiga & Reglas' },
{ id: 'cola_claude', label: `Cola de Claude (${requests.filter((r) => r.status === 'pendiente' || r.status === 'en_proceso').length} activas)` },
]}
@@ -441,6 +443,11 @@ export default function AgencyCreativesView({
</div>
) : null}
{/* 0. ESTILO CREATIVO Y BRIEF DEL CLIENTE */}
{activeTab === 'estilo_cliente' && (
<ClientCreativeStylePanel tenantId={tenantId} clientName={clientName} />
)}
{/* 1. VISTA KANBAN */}
{activeTab === 'kanban' && (
<div className="overflow-x-auto pb-6">

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@ import {
Users,
} from 'lucide-react';
import ClientIntegrationsPanel from '@/components/ClientIntegrationsPanel';
import ClientCreativeStylePanel from '@/components/ClientCreativeStylePanel';
import { PageHeader, SegmentedControl } from '@/components/ui';
import {
DASHBOARD_MODULES,
@@ -27,7 +28,7 @@ import {
suggestModulesFromIntegrations,
} from '@/lib/tenant-modules';
type Tab = 'general' | 'conexiones' | 'accesos' | 'modulos';
type Tab = 'general' | 'estilo' | 'conexiones' | 'accesos' | 'modulos';
type Integ = {
provider: string;
@@ -304,12 +305,17 @@ export default function ClientFicha({
onChange={setTab}
options={[
{ id: 'general', label: 'General' },
{ id: 'estilo', label: 'Estilo Creativo (IA & Ads)' },
{ id: 'conexiones', label: 'Conexiones' },
{ id: 'accesos', label: 'Accesos' },
{ id: 'modulos', label: 'Módulos' },
]}
/>
{tab === 'estilo' && (
<ClientCreativeStylePanel tenantId={tenantId} clientName={name} />
)}
{tab === 'general' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<section className="lg:col-span-2 space-y-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">

650
src/lib/creative-style.ts Normal file
View File

@@ -0,0 +1,650 @@
export type CreativeB2BPersona = {
name: string;
role: string;
goal: string;
pain_point: string;
buying_trigger: string;
};
export type CreativeB2CPersona = {
name: string;
age_range: string;
situation: string;
pain_point: string;
goal: string;
};
export type CreativeObjection = {
objection: string;
rebuttal: string;
};
export type CreativeColorRule = {
name: string;
hex: string;
usage: string;
};
export type ClientCreativeStyle = {
version: 1;
updated_at?: string;
/** 1. Quién es, líneas de negocio y prioridades actuales */
identity: {
summary: string;
website: string;
b2b_offering: string;
b2c_offering: string;
usp_differentiators: string;
current_priorities: string;
price_and_offer_framing: string;
};
/** 2. A quién le hablamos (Buyer Personas B2B / B2C y dolores) */
audiences: {
b2b_personas: CreativeB2BPersona[];
b2c_personas: CreativeB2CPersona[];
customer_frustrations: string;
desired_transformation: string;
};
/** 3. Cómo habla (Voz, tono y reglas de copywriting) */
voice: {
tone: string;
posture: string;
language_and_pronoun: string;
copy_structure_rules: string;
english_terms_policy: string;
power_words: string[];
allowed_ctas: string[];
};
/** 4. Argumentos que funcionan, objeciones, prueba social y hooks */
arguments: {
winning_arguments: string[];
objections: CreativeObjection[];
proof_points: string[];
winning_hooks: string[];
preferred_angles: string[];
};
/** 5. Prohibido (Líneas rojas de copy, competencia e imagen) */
prohibitions: {
forbidden_items: string[];
competitors_never_mention: string[];
emoji_policy: string;
testimonials_policy: string;
};
/** 6. Identidad visual, tipografía y reglas de layout */
visual: {
colors: CreativeColorRule[];
surface_rules: string;
typography_headline: string;
typography_body: string;
logo_rules: string;
image_text_rules: string;
};
/** 7. Dirección de arte para IA / Freepik Mystic */
image_ai: {
photo_style: string;
rule_1_5_seconds: string;
forbidden_scenes: string;
people_count_rules: string;
negative_space_prompt: string;
};
/** 8. Reglas de audiencia por campaña e instrucciones extra para Claude */
campaign_rules: {
b2c_detection_rules: string;
b2b_detection_rules: string;
fallback_audience_rule: string;
extra_claude_instructions: string;
};
};
export const BPS_DEFAULT_CREATIVE_STYLE: ClientCreativeStyle = {
version: 1,
identity: {
summary:
'Academia de idiomas con sede en Madrid y Málaga, clases presenciales, online e in-company.',
website: 'blueparrotschool.com',
b2b_offering:
'Programas de inglés profesional para equipos. Diferencia frente a apps conversacionales tipo Twenix: profesor fijo, sesiones de 60 minutos, programas por sector (Inglés Legal, IT, Financiero, Comercial Internacional), modalidad híbrida. Es Entidad Organizadora de FUNDAE (gestiona la bonificación directamente, no a través de un partner).',
b2c_offering:
'Cursos de inglés para adultos, preparación de exámenes oficiales (Cambridge, Aptis, IELTS), intensivos, clases 1to1 y plataforma online.',
usp_differentiators:
'Profesor fijo asignado + sesiones completas de 60 min (progreso estructural frente a conversación suelta de 15-20 min en apps). Gestión integral FUNDAE como Entidad Organizadora oficial. Especialización real por vertical profesional.',
current_priorities:
'B2C: captación de matrículas de adultos y preparación de certificados oficiales (Cambridge / Aptis). B2B: captación de empresas que quieren aprovechar su crédito anual FUNDAE con programas in-company u online estructurados.',
price_and_offer_framing:
'En B2B destacar el coste bonificado vía FUNDAE (coste cero o casi cero para la empresa, expresado en coste neto o gestión bonificada). En B2C destacar el valor del programa estructurado con profesor fijo, sin recurrir nunca a descuentos agresivos ni cuentas atrás.',
},
audiences: {
b2b_personas: [
{
name: 'Marta (RRHH / Formación)',
role: 'Responsable de RRHH o Desarrollo de Talento',
goal: 'Quiere un proveedor llave en mano que no le dé trabajo administrativo.',
pain_point: 'Proveedores que obligan a RRHH a gestionar papeleo FUNDAE, perseguir asistencias o lidiar con quejas por cambios constantes de profesor.',
buying_trigger: 'Gestión FUNDAE 100% resuelta por Blue Parrot, informes periódicos de asistencia y progreso, y un único interlocutor.',
},
{
name: 'Carlos (Socio / Director de Despacho o Consultora)',
role: 'Director General o Socio en firma legal, financiera o consultoría',
goal: 'Que su equipo hable con clientes internacionales con solvencia y sin hacer el ridículo.',
pain_point: 'Su equipo sabe gramática pero se bloquea al negociar, redactar contratos o defender propuestas en inglés técnico.',
buying_trigger: 'Compra rigor y especialización sectorial (Inglés Legal, Financiero, IT), no "clases de inglés general".',
},
{
name: 'Lucía (Directora Comercial / Internacional)',
role: 'Directora Comercial o de Expansión Internacional',
goal: 'Resultados medibles en poco tiempo para presentaciones, negociación y llamadas internacionales.',
pain_point: 'Las apps conversacionales entretienen pero el equipo sigue sin cerrar reuniones complejas en inglés.',
buying_trigger: 'Sesiones de 60 minutos enfocadas en casos reales de su puesto de trabajo.',
},
],
b2c_personas: [
{
name: 'Adulto Profesional / Opositor (25-45 años)',
age_range: '25-45 años',
situation: 'Trabaja o busca salto laboral / necesita certificar nivel oficial (Cambridge B2/C1, Aptis, IELTS).',
pain_point: 'Ha fracasado o se ha estancado con apps y cursos grabados; siente que pierde el tiempo sin un profesor que le corrija y le marque ritmo.',
goal: 'Avanzar de nivel de forma real o aprobar su examen oficial con horario compatible con su jornada.',
},
],
customer_frustrations:
'Estancamiento en el "nivel intermedio eterno" por usar apps de 15 minutos con profesores distintos cada día que solo dan charla superficial sin estructura ni seguimiento.',
desired_transformation:
'Pasar de evitar hablar en inglés o depender de traductores a desenvolverse con seguridad en reuniones de trabajo o aprobar su certificación oficial a la primera.',
},
voice: {
tone: 'Profesional, seguro, directo. Cercano sin ser coloquial. Nunca infantil, nunca grito de descuento.',
posture:
'"Formación profesional estructurada", no "aprende inglés fácil". Vendemos programas, no clases sueltas.',
language_and_pronoun:
'Español de España, tú o usted según el público: tú en B2C, tú en B2B cuando hablamos a la persona, usted nunca.',
copy_structure_rules:
'Frases cortas. Un dato concreto vale más que un adjetivo. En primary_text: 2-4 frases, máx. 300 caracteres; la frase 1 amplía el hook, frases 2-3 dan un beneficio o dato concreto, frase final cierra con el CTA sin enlaces ni hashtags.',
english_terms_policy:
'Inglés en el copy: solo nombres propios de exámenes (Cambridge, Aptis, IELTS) o términos sin traducción natural (in-company). Nada de "boost", "unlock", "level up".',
power_words: [
'Profesor fijo',
'60 minutos',
'Entidad Organizadora FUNDAE',
'Inglés de tu sector',
'Programa estructurado',
'Madrid y Málaga + Online',
],
allowed_ctas: [
'Más información',
'Solicitar presupuesto',
'Reservar',
'Contactar',
'Registrarte',
],
},
arguments: {
winning_arguments: [
'Profesor fijo + 60 minutos: progreso real, no mantenimiento.',
'FUNDAE gestionado por nosotros: coste cero o casi cero para la empresa.',
'Programas por sector: tu equipo aprende el inglés de su trabajo (Legal, IT, Financiero, Comercial).',
'Presencial en Madrid y Málaga + online: híbrido de verdad.',
'Informes de asistencia y progreso para RRHH.',
],
objections: [
{
objection: 'Ya usamos una app',
rebuttal: 'La app mantiene, no avanza. Sin profesor fijo ni sesiones de 60 min no se sale del nivel intermedio.',
},
{
objection: 'Es caro',
rebuttal: 'Bonificación FUNDAE gestionada directamente por nosotros; coste mínimo por empleado y mes.',
},
{
objection: 'No tenemos tiempo',
rebuttal: 'Sesiones in-company u online integradas dentro del horario laboral.',
},
],
proof_points: [
'Entidad Organizadora acreditada de FUNDAE (gestión directa sin intermediarios).',
'Sedes físicas propias en Madrid y Málaga + aula virtual en directo.',
'Sesiones completas de 60 minutos con el mismo profesor todo el curso.',
],
winning_hooks: [
'Tu equipo no necesita más inglés. Necesita el inglés de su trabajo.',
'FUNDAE lo paga. Nosotros lo gestionamos.',
'Un profesor. Sesenta minutos. Cada semana. Así se avanza.',
'El inglés legal no se aprende en una app.',
],
preferred_angles: [
'objeción tiempo',
'coste FUNDAE',
'contraste app vs programa real',
'especialización por sector (Legal / IT / Finanzas)',
'cero carga para RRHH',
'certificación oficial (Cambridge / Aptis)',
],
},
prohibitions: {
forbidden_items: [
'Prometer niveles o plazos cerrados ("B2 en 3 meses").',
'Comparar nombrando explícitamente a la competencia (ni Twenix ni ninguna).',
'Descuentos agresivos, cuentas atrás, "última oportunidad".',
'Fotografía de stock genérica de "gente feliz con portátil" con aspecto de banco de imágenes.',
'Deformar el logo, recolorearlo o recrearlo (se coloca siempre el archivo oficial en plantilla).',
'Empezar un hook por "¿Sabías que", "Descubre" o "Imagina".',
],
competitors_never_mention: ['Twenix', 'Preply', 'Italki', 'Open English', 'What\'s Up!'],
emoji_policy:
'Prohibidos los emojis en creatividades de imagen. En copy (primary_text), como mucho uno al inicio y solo en B2C (nunca en B2B).',
testimonials_policy:
'Prohibidos testimonios inventados o con nombre y cargo de personas que no existen. Si no hay un caso real aprobado, no se usa testimonio.',
},
visual: {
colors: [
{
name: 'Azul Principal',
hex: '#2A4D9C',
usage: 'Superficie base principal de marca y fondos corporativos.',
},
{
name: 'Amarillo Acento',
hex: '#FBC02D',
usage: 'Solo para resaltar una palabra clave, un dato o el CTA.',
},
{
name: 'Negro',
hex: '#000000',
usage: 'Reservado a mensajes de formalidad o urgencia (FUNDAE que caduca, manifiesto).',
},
{
name: 'Blanco',
hex: '#FFFFFF',
usage: 'Superficie base limpia y tipografía sobre fondos oscuros.',
},
],
surface_rules:
'Azul (#2A4D9C) y blanco (#FFFFFF) como base. Amarillo (#FBC02D) solo para resaltar una palabra, un dato o el CTA. Negro (#000000) reservado a mensajes de formalidad o urgencia (FUNDAE que caduca, manifiesto). Ningún otro color.',
typography_headline: 'Poppins Extrabold (siempre en titulares / hook). Nunca otra fuente.',
typography_body: 'Poppins Regular (en cuerpo y línea de apoyo / support).',
logo_rules:
'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 (máx. 12% del ancho).',
image_text_rules:
'Máximo 2 niveles en imagen (titular hook + línea de apoyo support). El titular no supera 8 palabras. Margen seguro mínimo del 8% en todos los lados; nunca texto en el 15% superior ni inferior.',
},
image_ai: {
photo_style:
'Fotografía real de aula, oficina o reunión con luz natural; personas adultas de 25-50 años en contexto de trabajo; sin poses de catálogo. Si es ilustración o composición gráfica, geometría limpia, mucho aire y una sola idea.',
rule_1_5_seconds:
'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.',
forbidden_scenes:
'Nada que parezca firma de documentos, entrevista de trabajo, reunión de banco, consulta médica o foto de catálogo sonriendo a cámara sin contexto de clase.',
people_count_rules:
'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.',
negative_space_prompt:
'the upper third of the frame is empty wall or sky. no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline.',
},
campaign_rules: {
b2c_detection_rules:
'Campañas B2C (Matrículas, Intensivos, Cambridge, Aptis, Adultos, Plataforma, Otros Idiomas): dolor personal, apps que no avanzan, examen oficial, horario. Habla siempre de "tu inglés".',
b2b_detection_rules:
'Campañas B2B (Empresas, FUNDAE, In-company, Equipos, nombres de sector): coste bonificado, programas por sector, informes para RRHH, el equipo que habla con clientes internacionales. Habla siempre de "tu equipo".',
fallback_audience_rule:
'Si el brief no trae audience y no se puede deducir del nombre de campaña, se pregunta en notes y se genera en B2C.',
extra_claude_instructions: '',
},
};
export function createEmptyCreativeStyle(clientName: string, slug: string): ClientCreativeStyle {
if (slug === 'bps' || slug === 'blue-parrot-school') {
return structuredClone(BPS_DEFAULT_CREATIVE_STYLE);
}
return {
version: 1,
identity: {
summary: `${clientName} — definir actividad principal, sedes y propuesta de valor.`,
website: '',
b2b_offering: '',
b2c_offering: '',
usp_differentiators: '',
current_priorities: '',
price_and_offer_framing: '',
},
audiences: {
b2b_personas: [],
b2c_personas: [],
customer_frustrations: '',
desired_transformation: '',
},
voice: {
tone: 'Profesional, directo y cercano.',
posture: '',
language_and_pronoun: 'Español de España, tratamiento de tú.',
copy_structure_rules: 'Frases cortas y directas. Un dato concreto vale más que un adjetivo.',
english_terms_policy: '',
power_words: [],
allowed_ctas: ['Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'],
},
arguments: {
winning_arguments: [],
objections: [],
proof_points: [],
winning_hooks: [],
preferred_angles: ['dolor', 'solución', 'prueba social', 'objeción precio/tiempo', 'autoridad'],
},
prohibitions: {
forbidden_items: [
'Promesas irreales o plazos garantizados sin base.',
'Fotografía de stock genérica con aspecto artificial.',
'Deformar o recrear el logo mediante IA.',
],
competitors_never_mention: [],
emoji_policy: 'Sin emojis en imagen. En copy, máximo uno al inicio si aplica.',
testimonials_policy: 'Solo casos reales aprobados por el cliente; nunca testimonios ficticios.',
},
visual: {
colors: [
{ name: 'Primario', hex: '#111827', usage: 'Color base de marca' },
{ name: 'Acento', hex: '#2563EB', usage: 'Resaltar palabra clave o CTA' },
{ name: 'Blanco', hex: '#FFFFFF', usage: 'Fondos limpios y texto sobre oscuro' },
],
surface_rules: '',
typography_headline: '',
typography_body: '',
logo_rules: '',
image_text_rules:
'Máximo 2 niveles (titular ≤ 8 palabras + apoyo ≤ 12 palabras). Margen mínimo del 8% en todos los lados.',
},
image_ai: {
photo_style: 'Fotografía realista con luz natural, personas reales en contexto del servicio, sin poses forzadas.',
rule_1_5_seconds: 'La escena debe mostrar claramente el producto o servicio en menos de 1,5 segundos sin necesidad de leer el texto.',
forbidden_scenes: 'Escenas ambiguas que puedan confundirse con otro sector.',
people_count_rules: '1 persona si el mensaje es individual; 2 personas si muestra interacción cliente-profesional.',
negative_space_prompt:
'the upper third of the frame is empty wall or sky. no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline.',
},
campaign_rules: {
b2c_detection_rules: '',
b2b_detection_rules: '',
fallback_audience_rule: 'Si hay duda sobre la audiencia, priorizar el perfil principal del cliente.',
extra_claude_instructions: '',
},
};
}
export function normalizeCreativeStyle(
raw: Partial<ClientCreativeStyle> | null | undefined,
clientName: string,
slug: string
): ClientCreativeStyle {
const base = createEmptyCreativeStyle(clientName, slug);
if (!raw || typeof raw !== 'object' || Object.keys(raw).length === 0) {
return base;
}
return {
version: 1,
updated_at: raw.updated_at || base.updated_at,
identity: { ...base.identity, ...(raw.identity || {}) },
audiences: {
...base.audiences,
...(raw.audiences || {}),
b2b_personas: Array.isArray(raw.audiences?.b2b_personas)
? raw.audiences!.b2b_personas
: base.audiences.b2b_personas,
b2c_personas: Array.isArray(raw.audiences?.b2c_personas)
? raw.audiences!.b2c_personas
: base.audiences.b2c_personas,
},
voice: {
...base.voice,
...(raw.voice || {}),
power_words: Array.isArray(raw.voice?.power_words) ? raw.voice!.power_words : base.voice.power_words,
allowed_ctas: Array.isArray(raw.voice?.allowed_ctas) ? raw.voice!.allowed_ctas : base.voice.allowed_ctas,
},
arguments: {
...base.arguments,
...(raw.arguments || {}),
winning_arguments: Array.isArray(raw.arguments?.winning_arguments)
? raw.arguments!.winning_arguments
: base.arguments.winning_arguments,
objections: Array.isArray(raw.arguments?.objections)
? raw.arguments!.objections
: base.arguments.objections,
proof_points: Array.isArray(raw.arguments?.proof_points)
? raw.arguments!.proof_points
: base.arguments.proof_points,
winning_hooks: Array.isArray(raw.arguments?.winning_hooks)
? raw.arguments!.winning_hooks
: base.arguments.winning_hooks,
preferred_angles: Array.isArray(raw.arguments?.preferred_angles)
? raw.arguments!.preferred_angles
: base.arguments.preferred_angles,
},
prohibitions: {
...base.prohibitions,
...(raw.prohibitions || {}),
forbidden_items: Array.isArray(raw.prohibitions?.forbidden_items)
? raw.prohibitions!.forbidden_items
: base.prohibitions.forbidden_items,
competitors_never_mention: Array.isArray(raw.prohibitions?.competitors_never_mention)
? raw.prohibitions!.competitors_never_mention
: base.prohibitions.competitors_never_mention,
},
visual: {
...base.visual,
...(raw.visual || {}),
colors: Array.isArray(raw.visual?.colors) ? raw.visual!.colors : base.visual.colors,
},
image_ai: { ...base.image_ai, ...(raw.image_ai || {}) },
campaign_rules: { ...base.campaign_rules, ...(raw.campaign_rules || {}) },
};
}
/**
* Compiles the structured ClientCreativeStyle into the exact Markdown skill
* injected into Claude in WF3 (`/api/integrations/skills`).
*/
export function compileClientSkillMarkdown(
style: ClientCreativeStyle,
clientName: string,
slug: string
): string {
const lines: string[] = [];
lines.push(`# Skill de cliente · ${clientName} (slug: ${slug})`);
lines.push('');
lines.push(
`> Esta skill define cómo habla y cómo se ve ${clientName} en cualquier creatividad. Se inyecta como contexto fijo a Claude en cada petición de relevo. Si algo aquí contradice un brief puntual, manda esta skill.`
);
lines.push('');
// 1. Quién es
lines.push('## Quién es');
lines.push('');
if (style.identity.summary) lines.push(style.identity.summary);
lines.push('');
if (style.identity.b2b_offering) {
lines.push(`- **B2B (empresas):** ${style.identity.b2b_offering}`);
}
if (style.identity.b2c_offering) {
lines.push(`- **B2C (particulares):** ${style.identity.b2c_offering}`);
}
if (style.identity.usp_differentiators) {
lines.push(`- **Diferenciación clave:** ${style.identity.usp_differentiators}`);
}
if (style.identity.current_priorities) {
lines.push(`- **Prioridades actuales y foco comercial:** ${style.identity.current_priorities}`);
}
if (style.identity.price_and_offer_framing) {
lines.push(`- **Enfoque de precio / oferta:** ${style.identity.price_and_offer_framing}`);
}
if (style.identity.website) {
lines.push('');
lines.push(`Web: ${style.identity.website}`);
}
lines.push('');
// 2. A quién le hablamos
lines.push('## A quién le hablamos');
lines.push('');
if (style.audiences.b2b_personas.length > 0) {
lines.push('**B2B**');
for (const p of style.audiences.b2b_personas) {
const details = [p.role, p.goal, p.pain_point, p.buying_trigger].filter(Boolean).join('. ');
lines.push(`- *${p.name}* — ${details}`);
}
lines.push('');
}
if (style.audiences.b2c_personas.length > 0) {
lines.push('**B2C**');
for (const p of style.audiences.b2c_personas) {
const details = [p.age_range, p.situation, p.pain_point, p.goal].filter(Boolean).join('. ');
lines.push(`- *${p.name}* — ${details}`);
}
lines.push('');
}
if (style.audiences.customer_frustrations) {
lines.push(`- **Frustración principal previa:** ${style.audiences.customer_frustrations}`);
}
if (style.audiences.desired_transformation) {
lines.push(`- **Transformación que buscan:** ${style.audiences.desired_transformation}`);
}
lines.push('');
// 3. Cómo habla
lines.push('## Cómo habla');
lines.push('');
if (style.voice.tone) lines.push(`- **Tono:** ${style.voice.tone}`);
if (style.voice.posture) lines.push(`- **Postura:** ${style.voice.posture}`);
if (style.voice.language_and_pronoun) lines.push(`- **Idioma y tratamiento:** ${style.voice.language_and_pronoun}`);
if (style.voice.copy_structure_rules) lines.push(`- **Estructura y estilo:** ${style.voice.copy_structure_rules}`);
if (style.voice.english_terms_policy) lines.push(`- **Idiomas / anglicismos en el copy:** ${style.voice.english_terms_policy}`);
if (style.voice.power_words.length > 0) {
lines.push(`- **Conceptos fuerza:** ${style.voice.power_words.join(' · ')}`);
}
if (style.voice.allowed_ctas.length > 0) {
lines.push(`- **CTAs permitidos:** ${style.voice.allowed_ctas.join(', ')}`);
}
lines.push('');
// 4. Argumentos y objeciones
if (style.arguments.winning_arguments.length > 0) {
lines.push('### Argumentos que funcionan');
style.arguments.winning_arguments.forEach((arg, idx) => {
lines.push(`${idx + 1}. ${arg}`);
});
lines.push('');
}
if (style.arguments.objections.length > 0) {
lines.push('### Objeciones a desactivar');
for (const obj of style.arguments.objections) {
lines.push(`- "${obj.objection}" → ${obj.rebuttal}`);
}
lines.push('');
}
if (style.arguments.proof_points.length > 0) {
lines.push('### Pruebas y datos reales verificados');
for (const pt of style.arguments.proof_points) {
lines.push(`- ${pt}`);
}
lines.push('');
}
if (style.arguments.preferred_angles.length > 0) {
lines.push(`### Ángulos recomendados para rotar\n- ${style.arguments.preferred_angles.join(' · ')}\n`);
}
// 5. Prohibido
lines.push('## Prohibido');
for (const item of style.prohibitions.forbidden_items) {
lines.push(`- ${item}`);
}
if (style.prohibitions.competitors_never_mention.length > 0) {
lines.push(
`- Prohibido mencionar a competidores (${style.prohibitions.competitors_never_mention.join(', ')}).`
);
}
if (style.prohibitions.emoji_policy) {
lines.push(`- **Emojis:** ${style.prohibitions.emoji_policy}`);
}
if (style.prohibitions.testimonials_policy) {
lines.push(`- **Testimonios:** ${style.prohibitions.testimonials_policy}`);
}
lines.push('');
// 6. Identidad visual + Dirección de arte IA
lines.push('## Identidad visual');
lines.push('');
if (style.visual.colors.length > 0) {
const paletteStr = style.visual.colors
.map((c) => `${c.name} \`${c.hex}\` (${c.usage})`)
.join(' · ');
lines.push(`- **Paleta exacta y única:** ${paletteStr}`);
}
if (style.visual.surface_rules) {
lines.push(`- **Uso de superficies:** ${style.visual.surface_rules}`);
}
if (style.visual.typography_headline || style.visual.typography_body) {
lines.push(
`- **Tipografía:** Titulares: ${style.visual.typography_headline || 'N/A'}. Cuerpo/Apoyo: ${style.visual.typography_body || 'N/A'}.`
);
}
if (style.visual.logo_rules) {
lines.push(`- **Logo:** ${style.visual.logo_rules}`);
}
if (style.image_ai.photo_style) {
lines.push(`- **Estilo de imagen:** ${style.image_ai.photo_style}`);
}
if (style.image_ai.rule_1_5_seconds) {
lines.push(`- **Regla de lectura en 1,5 s:** ${style.image_ai.rule_1_5_seconds}`);
}
if (style.image_ai.forbidden_scenes) {
lines.push(`- **Escenas a evitar en imagen:** ${style.image_ai.forbidden_scenes}`);
}
if (style.image_ai.people_count_rules) {
lines.push(`- **Personas en escena:** ${style.image_ai.people_count_rules}`);
}
if (style.image_ai.negative_space_prompt) {
lines.push(`- **Espacio negativo (image_prompt):** ${style.image_ai.negative_space_prompt}`);
}
if (style.visual.image_text_rules) {
lines.push(`- **Texto en imagen:** ${style.visual.image_text_rules}`);
}
lines.push('');
// 7. Audiencia por campaña
lines.push('## Audiencia por campaña');
lines.push('');
lines.push('El brief trae `audience`. Si no viene, se deduce del nombre de campaña:');
if (style.campaign_rules.b2c_detection_rules) {
lines.push(`- **B2C:** ${style.campaign_rules.b2c_detection_rules}`);
}
if (style.campaign_rules.b2b_detection_rules) {
lines.push(`- **B2B:** ${style.campaign_rules.b2b_detection_rules}`);
}
if (style.campaign_rules.fallback_audience_rule) {
lines.push(style.campaign_rules.fallback_audience_rule);
}
lines.push('');
// 8. Ejemplos de hooks
if (style.arguments.winning_hooks.length > 0) {
lines.push('## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar)');
for (const h of style.arguments.winning_hooks) {
lines.push(`- "${h}"`);
}
lines.push('');
}
if (style.campaign_rules.extra_claude_instructions?.trim()) {
lines.push('## Instrucciones adicionales específicas del cliente');
lines.push(style.campaign_rules.extra_claude_instructions.trim());
lines.push('');
}
return lines.join('\n');
}