feat(creatives): add configurable per-client creative style and skill builder in UI and API
This commit is contained in:
194
src/app/api/creatives/style/route.ts
Normal file
194
src/app/api/creatives/style/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
|
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
|
||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { compileClientSkillMarkdown, normalizeCreativeStyle } from '@/lib/creative-style';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -17,10 +18,10 @@ export async function GET(req: NextRequest) {
|
|||||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
const adminClient = createClient(supabaseUrl, serviceRoleKey);
|
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
|
const { data: tenant, error: tErr } = await adminClient
|
||||||
.from('tenants')
|
.from('tenants')
|
||||||
.select('id, name, slug')
|
.select('id, name, slug, creative_style, settings')
|
||||||
.eq('id', tenantId)
|
.eq('id', tenantId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -31,19 +32,32 @@ export async function GET(req: NextRequest) {
|
|||||||
const slug = (tenant.slug || 'bps').toLowerCase().trim();
|
const slug = (tenant.slug || 'bps').toLowerCase().trim();
|
||||||
const formatParam = (req.nextUrl.searchParams.get('format') || 'estatico').toLowerCase().trim();
|
const formatParam = (req.nextUrl.searchParams.get('format') || 'estatico').toLowerCase().trim();
|
||||||
|
|
||||||
// 2. Resolver skill del cliente (skills/clients/<slug>.md)
|
// 2. Resolver skill del cliente: primero desde BD (creative_style editable en la app), fallback a archivo en disco
|
||||||
const skillsDir = path.join(process.cwd(), 'skills');
|
const rawDbStyle =
|
||||||
const clientCandidates = [
|
tenant.creative_style && Object.keys(tenant.creative_style).length > 0
|
||||||
path.join(skillsDir, 'clients', `${slug}.md`),
|
? tenant.creative_style
|
||||||
path.join(skillsDir, 'clients', slug === 'bps' ? 'blue-parrot-school.md' : `${slug}.md`),
|
: (tenant.settings as any)?.creative_style;
|
||||||
path.join(skillsDir, 'clients', 'blue-parrot-school.md'),
|
|
||||||
];
|
|
||||||
|
|
||||||
let clientSkill = '';
|
let clientSkill = '';
|
||||||
for (const p of clientCandidates) {
|
const skillsDir = path.join(process.cwd(), 'skills');
|
||||||
if (fs.existsSync(p)) {
|
|
||||||
clientSkill = fs.readFileSync(p, 'utf-8');
|
if (rawDbStyle && Object.keys(rawDbStyle).length > 0) {
|
||||||
break;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader, Surface, EmptyState, SegmentedControl } from '@/components/ui';
|
import { PageHeader, Surface, EmptyState, SegmentedControl } from '@/components/ui';
|
||||||
|
import ClientCreativeStylePanel from '@/components/ClientCreativeStylePanel';
|
||||||
|
|
||||||
export type CreativeFormat = 'estatico' | 'carrusel' | 'video';
|
export type CreativeFormat = 'estatico' | 'carrusel' | 'video';
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ export default function AgencyCreativesView({
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
clientName: 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 [creatives, setCreatives] = useState<CreativeItem[]>([]);
|
||||||
const [rules, setRules] = useState<FatigueRules | null>(null);
|
const [rules, setRules] = useState<FatigueRules | null>(null);
|
||||||
const [requests, setRequests] = useState<GenerationRequest[]>([]);
|
const [requests, setRequests] = useState<GenerationRequest[]>([]);
|
||||||
@@ -428,6 +429,7 @@ export default function AgencyCreativesView({
|
|||||||
value={activeTab}
|
value={activeTab}
|
||||||
options={[
|
options={[
|
||||||
{ id: 'kanban', label: `Kanban de Estados (${creatives.length})` },
|
{ 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: '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)` },
|
{ 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* 0. ESTILO CREATIVO Y BRIEF DEL CLIENTE */}
|
||||||
|
{activeTab === 'estilo_cliente' && (
|
||||||
|
<ClientCreativeStylePanel tenantId={tenantId} clientName={clientName} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 1. VISTA KANBAN */}
|
{/* 1. VISTA KANBAN */}
|
||||||
{activeTab === 'kanban' && (
|
{activeTab === 'kanban' && (
|
||||||
<div className="overflow-x-auto pb-6">
|
<div className="overflow-x-auto pb-6">
|
||||||
|
|||||||
1988
src/components/ClientCreativeStylePanel.tsx
Normal file
1988
src/components/ClientCreativeStylePanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import ClientIntegrationsPanel from '@/components/ClientIntegrationsPanel';
|
import ClientIntegrationsPanel from '@/components/ClientIntegrationsPanel';
|
||||||
|
import ClientCreativeStylePanel from '@/components/ClientCreativeStylePanel';
|
||||||
import { PageHeader, SegmentedControl } from '@/components/ui';
|
import { PageHeader, SegmentedControl } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
DASHBOARD_MODULES,
|
DASHBOARD_MODULES,
|
||||||
@@ -27,7 +28,7 @@ import {
|
|||||||
suggestModulesFromIntegrations,
|
suggestModulesFromIntegrations,
|
||||||
} from '@/lib/tenant-modules';
|
} from '@/lib/tenant-modules';
|
||||||
|
|
||||||
type Tab = 'general' | 'conexiones' | 'accesos' | 'modulos';
|
type Tab = 'general' | 'estilo' | 'conexiones' | 'accesos' | 'modulos';
|
||||||
|
|
||||||
type Integ = {
|
type Integ = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -304,12 +305,17 @@ export default function ClientFicha({
|
|||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
options={[
|
options={[
|
||||||
{ id: 'general', label: 'General' },
|
{ id: 'general', label: 'General' },
|
||||||
|
{ id: 'estilo', label: 'Estilo Creativo (IA & Ads)' },
|
||||||
{ id: 'conexiones', label: 'Conexiones' },
|
{ id: 'conexiones', label: 'Conexiones' },
|
||||||
{ id: 'accesos', label: 'Accesos' },
|
{ id: 'accesos', label: 'Accesos' },
|
||||||
{ id: 'modulos', label: 'Módulos' },
|
{ id: 'modulos', label: 'Módulos' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{tab === 'estilo' && (
|
||||||
|
<ClientCreativeStylePanel tenantId={tenantId} clientName={name} />
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === 'general' && (
|
{tab === 'general' && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<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">
|
<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
650
src/lib/creative-style.ts
Normal 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');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user