From 142933a654c4fe4dc17572c1358f04115a2e183b Mon Sep 17 00:00:00 2001 From: Marcos Fargas Date: Tue, 13 Oct 2026 01:44:44 +0200 Subject: [PATCH] feat(creatives): add configurable per-client creative style and skill builder in UI and API --- src/app/api/creatives/style/route.ts | 194 ++ src/app/api/integrations/skills/route.ts | 40 +- src/components/AgencyCreativesView.tsx | 9 +- src/components/ClientCreativeStylePanel.tsx | 1988 +++++++++++++++++++ src/components/ClientFicha.tsx | 8 +- src/lib/creative-style.ts | 650 ++++++ 6 files changed, 2874 insertions(+), 15 deletions(-) create mode 100644 src/app/api/creatives/style/route.ts create mode 100644 src/components/ClientCreativeStylePanel.tsx create mode 100644 src/lib/creative-style.ts diff --git a/src/app/api/creatives/style/route.ts b/src/app/api/creatives/style/route.ts new file mode 100644 index 0000000..0f2e5da --- /dev/null +++ b/src/app/api/creatives/style/route.ts @@ -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 = { + 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, + }); +} diff --git a/src/app/api/integrations/skills/route.ts b/src/app/api/integrations/skills/route.ts index 6fa08a0..9ceb6f6 100644 --- a/src/app/api/integrations/skills/route.ts +++ b/src/app/api/integrations/skills/route.ts @@ -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/.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); } } diff --git a/src/components/AgencyCreativesView.tsx b/src/components/AgencyCreativesView.tsx index 89279d9..2cea028 100644 --- a/src/components/AgencyCreativesView.tsx +++ b/src/components/AgencyCreativesView.tsx @@ -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([]); const [rules, setRules] = useState(null); const [requests, setRequests] = useState([]); @@ -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({ ) : null} + {/* 0. ESTILO CREATIVO Y BRIEF DEL CLIENTE */} + {activeTab === 'estilo_cliente' && ( + + )} + {/* 1. VISTA KANBAN */} {activeTab === 'kanban' && (
diff --git a/src/components/ClientCreativeStylePanel.tsx b/src/components/ClientCreativeStylePanel.tsx new file mode 100644 index 0000000..fa3a520 --- /dev/null +++ b/src/components/ClientCreativeStylePanel.tsx @@ -0,0 +1,1988 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Building2, + Users, + MessageSquare, + Trophy, + ShieldAlert, + Palette, + Camera, + Settings2, + FileCode2, + Plus, + Trash2, + Save, + CheckCircle2, + Loader2, + Copy, + Sparkles, +} from 'lucide-react'; +import { + ClientCreativeStyle, + compileClientSkillMarkdown, + createEmptyCreativeStyle, +} from '@/lib/creative-style'; + +type SectionId = + | 'identity' + | 'audiences' + | 'voice' + | 'arguments' + | 'prohibitions' + | 'visual' + | 'image_ai' + | 'technical' + | 'preview'; + +const SECTIONS: { id: SectionId; label: string; subtitle: string; icon: React.ComponentType }[] = [ + { + id: 'identity', + label: '1. Quién es y Necesidades', + subtitle: 'Líneas B2B/B2C, diferenciación y foco actual', + icon: Building2, + }, + { + id: 'audiences', + label: '2. A quién hablamos', + subtitle: 'Buyer personas B2B/B2C, dolores y transformación', + icon: Users, + }, + { + id: 'voice', + label: '3. Cómo habla (Copy)', + subtitle: 'Tono, postura, estructura, anglicismos y CTAs', + icon: MessageSquare, + }, + { + id: 'arguments', + label: '4. Argumentos y Hooks', + subtitle: 'Argumentos ganadores, objeciones, pruebas y ángulos', + icon: Trophy, + }, + { + id: 'prohibitions', + label: '5. Prohibido (Líneas rojas)', + subtitle: 'Promesas vetadas, competencia, emojis y testimonios', + icon: ShieldAlert, + }, + { + id: 'visual', + label: '6. Identidad Visual', + subtitle: 'Paleta HEX, superficies, tipografía, logo y layout', + icon: Palette, + }, + { + id: 'image_ai', + label: '7. Dirección de Arte IA', + subtitle: 'Estilo Freepik, regla 1,5 s, roles, personas y aire', + icon: Camera, + }, + { + id: 'technical', + label: '8. Campañas y Canva', + subtitle: 'Mapeo B2B/B2C por campaña, plantillas Canva e IDs', + icon: Settings2, + }, + { + id: 'preview', + label: '9. Skill Markdown (Claude)', + subtitle: 'Documento final que recibe Claude en el WF3', + icon: FileCode2, + }, +]; + +type CampaignRow = { + campaign_name: string; + audience: 'b2b' | 'b2c'; + product: string; + landing_url: string; +}; + +export default function ClientCreativeStylePanel({ + tenantId, + clientName, +}: { + tenantId: string; + clientName: string; +}) { + const [activeSection, setActiveSection] = useState('identity'); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + const [slug, setSlug] = useState('bps'); + const [creativesEnabled, setCreativesEnabled] = useState(true); + const [metaAdAccountId, setMetaAdAccountId] = useState(''); + const [metaPageId, setMetaPageId] = useState(''); + const [metaIgUserId, setMetaIgUserId] = useState(''); + const [slackChannelId, setSlackChannelId] = useState(''); + const [driveFolderId, setDriveFolderId] = useState(''); + const [defaultLandingUrl, setDefaultLandingUrl] = useState(''); + const [canvaTemplates, setCanvaTemplates] = useState<{ + 'foto-con-franja'?: string; + tipografico?: string; + 'dato-grande'?: string; + default?: string; + }>({}); + const [campaignRows, setCampaignRows] = useState([]); + + const [style, setStyle] = useState(() => + createEmptyCreativeStyle(clientName, 'bps') + ); + + const loadStyle = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/creatives/style?tenant_id=${encodeURIComponent(tenantId)}`); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Error al cargar estilo creativo'); + + setSlug(data.slug || 'cliente'); + setCreativesEnabled(Boolean(data.creatives_enabled)); + setMetaAdAccountId(data.meta_ad_account_id || ''); + setMetaPageId(data.meta_page_id || ''); + setMetaIgUserId(data.meta_ig_user_id || ''); + setSlackChannelId(data.slack_channel_id || ''); + setDriveFolderId(data.drive_folder_id || ''); + setDefaultLandingUrl(data.default_landing_url || ''); + setCanvaTemplates(data.canva_templates || {}); + if (data.style) { + setStyle(data.style); + } + + // Combinar campaign_audience y campaign_products en filas editables + const audMap: Record = data.campaign_audience || {}; + const prodMap: Record = data.campaign_products || {}; + const allCampaignNames = Array.from( + new Set([...Object.keys(audMap), ...Object.keys(prodMap)]) + ); + const rows: CampaignRow[] = allCampaignNames.map((cName) => { + const rawAud = String(audMap[cName] || 'b2c').toLowerCase(); + const pEntry = prodMap[cName]; + const product = + typeof pEntry === 'string' + ? pEntry + : pEntry && typeof pEntry === 'object' + ? pEntry.product || '' + : ''; + const landing = + pEntry && typeof pEntry === 'object' ? pEntry.landing_url || '' : ''; + return { + campaign_name: cName, + audience: rawAud === 'b2b' ? 'b2b' : 'b2c', + product, + landing_url: landing, + }; + }); + setCampaignRows(rows); + } catch (err: any) { + setError(err.message || 'Error al cargar la configuración creativa'); + } finally { + setLoading(false); + } + }, [tenantId]); + + useEffect(() => { + void loadStyle(); + }, [loadStyle]); + + const liveMarkdown = useMemo( + () => compileClientSkillMarkdown(style, clientName, slug), + [style, clientName, slug] + ); + + const handleSaveAll = async () => { + setSaving(true); + setSaveSuccess(false); + setError(null); + + try { + const campaignAudience: Record = {}; + const campaignProducts: Record = {}; + + for (const r of campaignRows) { + const key = r.campaign_name.trim(); + if (!key) continue; + campaignAudience[key] = r.audience; + campaignProducts[key] = { + product: r.product.trim(), + landing_url: r.landing_url.trim(), + }; + } + + const res = await fetch('/api/creatives/style', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tenant_id: tenantId, + creatives_enabled: creativesEnabled, + meta_ad_account_id: metaAdAccountId, + meta_page_id: metaPageId, + meta_ig_user_id: metaIgUserId, + slack_channel_id: slackChannelId, + drive_folder_id: driveFolderId, + default_landing_url: defaultLandingUrl, + canva_templates: canvaTemplates, + campaign_audience: campaignAudience, + campaign_products: campaignProducts, + style, + }), + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Error al guardar'); + + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 4000); + } catch (err: any) { + setError(err.message || 'No se pudo guardar'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ + Cargando estilo creativo y brief maestro de {clientName}… +
+ ); + } + + return ( +
+ {/* Barra superior de estado y guardado */} +
+
+
+ +
+
+

+ Estilo Creativo & Brief Maestro · {clientName} +

+

+ Todo lo que configures aquí alimenta en tiempo real a Claude (WF3) para el copy y estrategia, a Freepik Mystic para la escena fotográfica y a Canva (WF5) para el montaje automático. +

+
+
+ +
+ {saveSuccess && ( + + + Guardado y activo en WF3/WF5 + + )} + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Layout lateral + formulario */} +
+ {/* Navegación de secciones */} +
+ {SECTIONS.map((sec) => { + const Icon = sec.icon; + const active = activeSection === sec.id; + return ( + + ); + })} +
+ + {/* Contenido de la sección activa */} +
+ {/* SECCIÓN 1: QUIÉN ES Y NECESIDADES */} + {activeSection === 'identity' && ( +
+
+

+ 1. Quién es el cliente, líneas de negocio y prioridades +

+

+ Explica con detalle qué vende el cliente en B2B y B2C, qué le hace único frente a sus competidores y qué campañas o necesidades comerciales son prioritarias ahora mismo. +

+
+ +
+
+ +