diff --git a/src/app/api/brandbook/route.ts b/src/app/api/brandbook/route.ts index 7d5fd52..4ccaa2e 100644 --- a/src/app/api/brandbook/route.ts +++ b/src/app/api/brandbook/route.ts @@ -1,10 +1,12 @@ /** * GET / PUT brandbook for active tenant (documents type=brandbook). + * Sincronización bidireccional permanente con tenants.creative_style. */ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; +import { createClient as createAdminClient } from '@supabase/supabase-js'; import { BRANDBOOK_DOC_TITLE, BRANDBOOK_DOC_TYPE, @@ -17,6 +19,12 @@ import { export const dynamic = 'force-dynamic'; +function getAdmin() { + const url = supabaseUrl(); + const key = process.env.SUPABASE_SERVICE_ROLE_KEY!; + return createAdminClient(url, key); +} + async function loadDoc(tenantId: string): Promise<{ id: string | null; book: Brandbook | null; @@ -31,7 +39,7 @@ async function loadDoc(tenantId: string): Promise<{ return { id: row.id, book: parseBrandbook(row.content) }; } -export async function GET() { +export async function GET(req: NextRequest) { const supabase = await createClient(); const { data: { user }, @@ -39,22 +47,48 @@ export async function GET() { if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const ctx = await resolveActiveTenant(user); - if (!ctx.tenantId || ctx.portfolioWide) { - return NextResponse.json( - { error: 'Selecciona un cliente en Contexto.' }, - { status: 400 } - ); + + const isAgency = isAgencyRole(userRole(user)); + const queryTenantId = req.nextUrl.searchParams.get('tenant_id') || req.nextUrl.searchParams.get('tenantId'); + + let tenantId: string | null = null; + let label = 'Cliente'; + let slug = 'cliente'; + + if (isAgency && queryTenantId) { + const admin = getAdmin(); + const { data: tenant } = await admin + .from('tenants') + .select('id, name, slug') + .eq('id', queryTenantId) + .maybeSingle(); + + if (tenant) { + tenantId = tenant.id; + label = tenant.name || 'Cliente'; + slug = tenant.slug || 'cliente'; + } } - let slug = 'cliente'; - const t = await restGet<{ slug?: string }[]>( - `/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug` - ); - slug = t.data?.[0]?.slug || slug; + if (!tenantId) { + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente en Contexto.' }, + { status: 400 } + ); + } + tenantId = ctx.tenantId; + label = ctx.label; - const { id, book } = await loadDoc(ctx.tenantId); - const current = book || emptyBrandbook(ctx.label); + const t = await restGet<{ slug?: string }[]>( + `/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=slug` + ); + slug = t.data?.[0]?.slug || slug; + } + + const { id, book } = await loadDoc(tenantId); + const current = book || emptyBrandbook(label); const completeness = brandbookCompleteness(current); return NextResponse.json({ @@ -62,7 +96,7 @@ export async function GET() { id, brandbook: current, slug, - label: ctx.label, + label, completeness, exists: !!book, }); @@ -79,28 +113,59 @@ export async function PUT(req: NextRequest) { if (!isAgencyRole(userRole(user))) { return NextResponse.json({ error: 'Solo agencia' }, { status: 403 }); } - const ctx = await resolveActiveTenant(user); - if (!ctx.tenantId || ctx.portfolioWide) { - return NextResponse.json( - { error: 'Selecciona un cliente en Contexto.' }, - { status: 400 } - ); - } const body = (await req.json().catch(() => ({}))) as { brandbook?: unknown; seed?: boolean; + tenant_id?: string; + tenantId?: string; }; + const queryTenantId = + req.nextUrl.searchParams.get('tenant_id') || + req.nextUrl.searchParams.get('tenantId') || + body.tenant_id || + body.tenantId; + + let tenantId: string | null = null; + let label = 'Cliente'; let slug = 'cliente'; - const t = await restGet<{ slug?: string }[]>( - `/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug` - ); - slug = t.data?.[0]?.slug || slug; + + if (queryTenantId) { + const admin = getAdmin(); + const { data: tenant } = await admin + .from('tenants') + .select('id, name, slug') + .eq('id', queryTenantId) + .maybeSingle(); + + if (tenant) { + tenantId = tenant.id; + label = tenant.name || 'Cliente'; + slug = tenant.slug || 'cliente'; + } + } + + if (!tenantId) { + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente en Contexto.' }, + { status: 400 } + ); + } + tenantId = ctx.tenantId; + label = ctx.label; + + const t = await restGet<{ slug?: string }[]>( + `/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=slug` + ); + slug = t.data?.[0]?.slug || slug; + } let book: Brandbook | null = null; if (body.seed) { - book = seedBrandbook(slug, ctx.label); + book = seedBrandbook(slug, label); } else { book = parseBrandbook(body.brandbook); } @@ -108,68 +173,118 @@ export async function PUT(req: NextRequest) { return NextResponse.json({ error: 'brandbook inválido' }, { status: 400 }); } book.updated_at = new Date().toISOString(); - book.brand_name = book.brand_name || ctx.label; + book.brand_name = book.brand_name || label; - const headers = serviceHeaders(); - const base = supabaseUrl(); - if (!headers || !base) { - return NextResponse.json({ error: 'No service role' }, { status: 500 }); - } + const admin = getAdmin(); + const { id } = await loadDoc(tenantId); - const { id } = await loadDoc(ctx.tenantId); + let docId = id; if (id) { - const patch = await fetch(`${base}/rest/v1/documents?id=eq.${id}`, { - method: 'PATCH', - headers: { - ...headers, - 'Content-Type': 'application/json', - Prefer: 'return=representation', - }, - body: JSON.stringify({ + const { error: patchErr } = await admin + .from('documents') + .update({ content: book, title: BRANDBOOK_DOC_TITLE, updated_at: book.updated_at, - }), - }); - if (!patch.ok) { - return NextResponse.json( - { error: await patch.text() }, - { status: 500 } - ); + }) + .eq('id', id); + + if (patchErr) { + return NextResponse.json({ error: patchErr.message }, { status: 500 }); } - const rows = (await patch.json()) as { id: string }[]; - return NextResponse.json({ - ok: true, - id: rows[0]?.id || id, - brandbook: book, - completeness: brandbookCompleteness(book), - }); + } else { + const { data: insRow, error: insErr } = await admin + .from('documents') + .insert({ + tenant_id: tenantId, + type: BRANDBOOK_DOC_TYPE, + title: BRANDBOOK_DOC_TITLE, + status: 'approved', + content: book, + updated_at: book.updated_at, + }) + .select('id') + .single(); + + if (insErr) { + return NextResponse.json({ error: insErr.message }, { status: 500 }); + } + docId = insRow.id; } - const ins = await fetch(`${base}/rest/v1/documents`, { - method: 'POST', - headers: { - ...headers, - 'Content-Type': 'application/json', - Prefer: 'return=representation', - }, - body: JSON.stringify({ - tenant_id: ctx.tenantId, - type: BRANDBOOK_DOC_TYPE, - title: BRANDBOOK_DOC_TITLE, - status: 'approved', - content: book, - }), - }); - if (!ins.ok) { - return NextResponse.json({ error: await ins.text() }, { status: 500 }); + // Sincronizar simultáneamente con tenants.creative_style para consistencia 100% + const { data: tenantRow } = await admin + .from('tenants') + .select('creative_style, settings') + .eq('id', tenantId) + .maybeSingle(); + + if (tenantRow) { + const existingStyle = (tenantRow.creative_style && typeof tenantRow.creative_style === 'object') ? tenantRow.creative_style : {}; + const updatedStyle = { + ...existingStyle, + identity: { + ...(existingStyle.identity || {}), + summary: book.elevator_pitch || existingStyle.identity?.summary || '', + website: book.website || existingStyle.identity?.website || '', + b2b_offering: book.b2b_offering || existingStyle.identity?.b2b_offering || '', + b2c_offering: book.b2c_offering || existingStyle.identity?.b2c_offering || '', + usp_differentiators: book.usp_differentiators || existingStyle.identity?.usp_differentiators || '', + current_priorities: book.current_priorities || existingStyle.identity?.current_priorities || '', + price_and_offer_framing: book.price_and_offer_framing || existingStyle.identity?.price_and_offer_framing || '', + }, + voice: { + ...(existingStyle.voice || {}), + tone: book.tone || existingStyle.voice?.tone || '', + copy_structure_rules: book.copy_structure_rules || existingStyle.voice?.copy_structure_rules || '', + english_terms_policy: book.english_terms_policy || existingStyle.voice?.english_terms_policy || '', + power_words: book.power_words?.length ? book.power_words : existingStyle.voice?.power_words || [], + allowed_ctas: book.allowed_ctas?.length ? book.allowed_ctas : existingStyle.voice?.allowed_ctas || [], + }, + visual: { + ...(existingStyle.visual || {}), + surface_rules: book.surface_rules || existingStyle.visual?.surface_rules || '', + image_text_rules: book.image_text_rules || existingStyle.visual?.image_text_rules || '', + typography_headline: book.typography_headline || existingStyle.visual?.typography_headline || '', + typography_body: book.typography_body || existingStyle.visual?.typography_body || '', + }, + audiences: { + ...(existingStyle.audiences || {}), + b2b_personas: book.b2b_personas?.length ? book.b2b_personas : existingStyle.audiences?.b2b_personas || [], + b2c_personas: book.b2c_personas?.length ? book.b2c_personas : existingStyle.audiences?.b2c_personas || [], + customer_frustrations: book.customer_frustrations || existingStyle.audiences?.customer_frustrations || '', + desired_transformation: book.desired_transformation || existingStyle.audiences?.desired_transformation || '', + }, + arguments: { + ...(existingStyle.arguments || {}), + objections: book.objections?.length ? book.objections : existingStyle.arguments?.objections || [], + winning_hooks: book.winning_hooks?.length ? book.winning_hooks : existingStyle.arguments?.winning_hooks || [], + preferred_angles: book.preferred_angles?.length ? book.preferred_angles : existingStyle.arguments?.preferred_angles || [], + }, + prohibitions: { + ...(existingStyle.prohibitions || {}), + emoji_policy: book.emoji_policy || existingStyle.prohibitions?.emoji_policy || '', + testimonials_policy: book.testimonials_policy || existingStyle.prohibitions?.testimonials_policy || '', + competitors_never_mention: book.competitors_never_mention?.length ? book.competitors_never_mention : existingStyle.prohibitions?.competitors_never_mention || [], + }, + }; + + await admin + .from('tenants') + .update({ + creative_style: updatedStyle, + settings: { + ...(tenantRow.settings || {}), + creative_style: updatedStyle, + }, + }) + .eq('id', tenantId); } - const rows = (await ins.json()) as { id: string }[]; + return NextResponse.json({ ok: true, - id: rows[0]?.id, + id: docId, brandbook: book, completeness: brandbookCompleteness(book), - created: true, }); } diff --git a/src/app/api/creatives/style/route.ts b/src/app/api/creatives/style/route.ts index 02be8ef..58ad152 100644 --- a/src/app/api/creatives/style/route.ts +++ b/src/app/api/creatives/style/route.ts @@ -241,10 +241,10 @@ export async function PUT(req: NextRequest) { return NextResponse.json({ error: updErr.message }, { status: 500 }); } - // Cargar brandbook de documents para compilar skill actualizado + // Cargar brandbook de documents para compilar skill actualizado y mantener sincronización const { data: brandDoc } = await admin .from('documents') - .select('content') + .select('id, content') .eq('tenant_id', tenantId) .eq('type', 'brandbook') .order('updated_at', { ascending: false }) @@ -267,6 +267,60 @@ export async function PUT(req: NextRequest) { brandbook = seedBrandbook(existing.slug || 'cliente', existing.name || 'Cliente'); } + // Sincronizar campos de normalizedStyle hacia brandbook + if (normalizedStyle.identity) { + if (normalizedStyle.identity.summary) brandbook.elevator_pitch = normalizedStyle.identity.summary; + if (normalizedStyle.identity.website) brandbook.website = normalizedStyle.identity.website; + if (normalizedStyle.identity.b2b_offering) brandbook.b2b_offering = normalizedStyle.identity.b2b_offering; + if (normalizedStyle.identity.b2c_offering) brandbook.b2c_offering = normalizedStyle.identity.b2c_offering; + if (normalizedStyle.identity.usp_differentiators) brandbook.usp_differentiators = normalizedStyle.identity.usp_differentiators; + if (normalizedStyle.identity.current_priorities) brandbook.current_priorities = normalizedStyle.identity.current_priorities; + if (normalizedStyle.identity.price_and_offer_framing) brandbook.price_and_offer_framing = normalizedStyle.identity.price_and_offer_framing; + } + if (normalizedStyle.voice) { + if (normalizedStyle.voice.tone) brandbook.tone = normalizedStyle.voice.tone; + if (normalizedStyle.voice.copy_structure_rules) brandbook.copy_structure_rules = normalizedStyle.voice.copy_structure_rules; + if (normalizedStyle.voice.english_terms_policy) brandbook.english_terms_policy = normalizedStyle.voice.english_terms_policy; + if (normalizedStyle.voice.power_words?.length) brandbook.power_words = normalizedStyle.voice.power_words; + if (normalizedStyle.voice.allowed_ctas?.length) brandbook.allowed_ctas = normalizedStyle.voice.allowed_ctas; + } + if (normalizedStyle.visual) { + if (normalizedStyle.visual.surface_rules) brandbook.surface_rules = normalizedStyle.visual.surface_rules; + if (normalizedStyle.visual.image_text_rules) brandbook.image_text_rules = normalizedStyle.visual.image_text_rules; + if (normalizedStyle.visual.typography_headline) brandbook.typography_headline = normalizedStyle.visual.typography_headline; + if (normalizedStyle.visual.typography_body) brandbook.typography_body = normalizedStyle.visual.typography_body; + } + if (normalizedStyle.audiences) { + if (normalizedStyle.audiences.b2b_personas?.length) brandbook.b2b_personas = normalizedStyle.audiences.b2b_personas; + if (normalizedStyle.audiences.b2c_personas?.length) brandbook.b2c_personas = normalizedStyle.audiences.b2c_personas; + if (normalizedStyle.audiences.customer_frustrations) brandbook.customer_frustrations = normalizedStyle.audiences.customer_frustrations; + if (normalizedStyle.audiences.desired_transformation) brandbook.desired_transformation = normalizedStyle.audiences.desired_transformation; + } + if (normalizedStyle.arguments) { + if (normalizedStyle.arguments.objections?.length) brandbook.objections = normalizedStyle.arguments.objections; + if (normalizedStyle.arguments.winning_hooks?.length) brandbook.winning_hooks = normalizedStyle.arguments.winning_hooks; + if (normalizedStyle.arguments.preferred_angles?.length) brandbook.preferred_angles = normalizedStyle.arguments.preferred_angles; + } + if (normalizedStyle.prohibitions) { + if (normalizedStyle.prohibitions.emoji_policy) brandbook.emoji_policy = normalizedStyle.prohibitions.emoji_policy; + if (normalizedStyle.prohibitions.testimonials_policy) brandbook.testimonials_policy = normalizedStyle.prohibitions.testimonials_policy; + if (normalizedStyle.prohibitions.competitors_never_mention?.length) brandbook.competitors_never_mention = normalizedStyle.prohibitions.competitors_never_mention; + } + if (normalizedStyle.image_ai?.photo_style) { + brandbook.photography_style = normalizedStyle.image_ai.photo_style; + } + brandbook.updated_at = new Date().toISOString(); + + if (brandDoc?.id) { + await admin + .from('documents') + .update({ + content: brandbook, + updated_at: brandbook.updated_at, + }) + .eq('id', brandDoc.id); + } + const compiledMarkdown = compileClientSkillFromBrandbook( brandbook, { diff --git a/src/app/dashboard/marca/page.tsx b/src/app/dashboard/marca/page.tsx index 2d33ec2..4d884e3 100644 --- a/src/app/dashboard/marca/page.tsx +++ b/src/app/dashboard/marca/page.tsx @@ -32,5 +32,5 @@ export default async function MarcaPage() { ); } - return ; + return ; } diff --git a/src/components/BrandZone.tsx b/src/components/BrandZone.tsx index 1670cf6..bb79d31 100644 --- a/src/components/BrandZone.tsx +++ b/src/components/BrandZone.tsx @@ -97,7 +97,15 @@ function KitThumb({ assetFileId, alt }: { assetFileId: string; alt: string }) { ); } -export default function BrandZone({ tenantLabel }: { tenantLabel: string }) { +export default function BrandZone({ + tenantId, + tenantLabel, + tenantSlug, +}: { + tenantId?: string; + tenantLabel: string; + tenantSlug?: string; +}) { const [viewMode, setViewMode] = useState<'overview' | 'edit'>('overview'); const [section, setSection] = useState('identidad'); const [book, setBook] = useState(() => emptyBrandbook(tenantLabel)); @@ -120,11 +128,12 @@ export default function BrandZone({ tenantLabel }: { tenantLabel: string }) { setLoading(true); setErr(null); try { - const r = await fetch('/api/brandbook'); + const query = tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : ''; + const r = await fetch(`/api/brandbook${query}`); const j = await r.json(); if (!r.ok) throw new Error(j.error || 'No se pudo cargar'); setBook(j.brandbook || emptyBrandbook(tenantLabel)); - setSlug(j.slug || ''); + setSlug(j.slug || tenantSlug || ''); setScore(j.completeness?.score ?? 0); setMissing(j.completeness?.missing || []); setExists(!!j.exists); @@ -133,7 +142,7 @@ export default function BrandZone({ tenantLabel }: { tenantLabel: string }) { } finally { setLoading(false); } - }, [tenantLabel]); + }, [tenantId, tenantLabel, tenantSlug]); useEffect(() => { void load(); @@ -149,10 +158,11 @@ export default function BrandZone({ tenantLabel }: { tenantLabel: string }) { setErr(null); setOkMsg(null); try { - const r = await fetch('/api/brandbook', { + const query = tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : ''; + const r = await fetch(`/api/brandbook${query}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ brandbook: book }), + body: JSON.stringify({ brandbook: book, tenant_id: tenantId }), }); const j = await r.json(); if (!r.ok) throw new Error(j.error || 'No se pudo guardar'); @@ -160,7 +170,7 @@ export default function BrandZone({ tenantLabel }: { tenantLabel: string }) { setScore(j.completeness?.score ?? 0); setMissing(j.completeness?.missing || []); setExists(true); - setOkMsg('Brand kit guardado'); + setOkMsg('Brand kit guardado correctamente y sincronizado'); } catch (e) { setErr(e instanceof Error ? e.message : 'Error al guardar'); } finally { @@ -172,10 +182,11 @@ export default function BrandZone({ tenantLabel }: { tenantLabel: string }) { setSeeding(true); setErr(null); try { - const r = await fetch('/api/brandbook', { + const query = tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : ''; + const r = await fetch(`/api/brandbook${query}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ seed: true }), + body: JSON.stringify({ seed: true, tenant_id: tenantId }), }); const j = await r.json(); if (!r.ok) throw new Error(j.error || 'No se pudo sembrar'); diff --git a/src/components/ClientCreativeStylePanel.tsx b/src/components/ClientCreativeStylePanel.tsx index 3a87264..10c2538 100644 --- a/src/components/ClientCreativeStylePanel.tsx +++ b/src/components/ClientCreativeStylePanel.tsx @@ -2,8 +2,14 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { - Camera, + ExternalLink, + Building2, + Users, + MessageSquare, + Trophy, + ShieldAlert, Palette, + Camera, Settings2, FileCode2, Plus, @@ -13,9 +19,6 @@ import { Loader2, Copy, Sparkles, - Building2, - Sliders, - ExternalLink, } from 'lucide-react'; import { ClientCreativeStyle, @@ -23,42 +26,70 @@ import { createEmptyCreativeStyle, } from '@/lib/creative-style'; -type SectionId = 'image_ai' | 'canva' | 'campaigns' | 'overrides' | 'preview'; +type SectionId = + | 'identity' + | 'audiences' + | 'voice' + | 'arguments' + | 'prohibitions' + | 'visual' + | 'image_ai' + | 'technical' + | 'preview'; -const SECTIONS: { - id: SectionId; - label: string; - subtitle: string; - icon: React.ComponentType; -}[] = [ +const SECTIONS: { id: SectionId; label: string; subtitle: string; icon: React.ComponentType }[] = [ { - id: 'image_ai', - label: '1. Dirección de Arte IA (Freepik)', - subtitle: 'Estilo fotográfico, regla 1,5s, personas y espacio negativo', - icon: Camera, + id: 'identity', + label: '1. Quién es y Necesidades', + subtitle: 'Líneas B2B/B2C, diferenciación y foco actual', + icon: Building2, }, { - id: 'canva', - label: '2. Plantillas Canva & Conexiones', - subtitle: 'Brand Templates 4:5, 9:16 y 1.91:1, IDs Meta, Slack y Drive', + 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: 'campaigns', - label: '3. Meta Ads & Campañas', - subtitle: 'Reglas de detección B2B/B2C y diccionario de campañas', + 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: 'overrides', - label: '4. Overrides Ads (Opcional)', - subtitle: 'Instrucciones específicas para Claude en relevos de Ads', - icon: Sliders, - }, { id: 'preview', - label: '5. Skill Claude (WF3 Live)', - subtitle: 'Markdown compilado desde Brandbook + Operativa', + label: '9. Skill Markdown (Claude)', + subtitle: 'Documento final que recibe Claude en el WF3', icon: FileCode2, }, ]; @@ -77,7 +108,7 @@ export default function ClientCreativeStylePanel({ tenantId: string; clientName: string; }) { - const [activeSection, setActiveSection] = useState('image_ai'); + const [activeSection, setActiveSection] = useState('identity'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false); @@ -102,7 +133,6 @@ export default function ClientCreativeStylePanel({ }>({}); const [campaignRows, setCampaignRows] = useState([]); const [detectedCampaigns, setDetectedCampaigns] = useState([]); - const [compiledSkillFromApi, setCompiledSkillFromApi] = useState(''); const [style, setStyle] = useState(() => createEmptyCreativeStyle(clientName, 'bps') @@ -112,12 +142,9 @@ export default function ClientCreativeStylePanel({ setLoading(true); setError(null); try { - const res = await fetch( - `/api/creatives/style?tenant_id=${encodeURIComponent(tenantId)}` - ); + 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'); + if (!res.ok) throw new Error(data.error || 'Error al cargar estilo creativo'); setSlug(data.slug || 'cliente'); setCreativesEnabled(Boolean(data.creatives_enabled)); @@ -128,15 +155,10 @@ export default function ClientCreativeStylePanel({ setDriveFolderId(data.drive_folder_id || ''); setDefaultLandingUrl(data.default_landing_url || ''); setCanvaTemplates(data.canva_templates || {}); - setDetectedCampaigns( - Array.isArray(data.detected_campaigns) ? data.detected_campaigns : [] - ); + setDetectedCampaigns(Array.isArray(data.detected_campaigns) ? data.detected_campaigns : []); if (data.style) { setStyle(data.style); } - if (data.compiled_skill) { - setCompiledSkillFromApi(data.compiled_skill); - } // Combinar campaign_audience y campaign_products en filas editables const audMap: Record = data.campaign_audience || {}; @@ -151,8 +173,8 @@ export default function ClientCreativeStylePanel({ typeof pEntry === 'string' ? pEntry : pEntry && typeof pEntry === 'object' - ? pEntry.product || '' - : ''; + ? pEntry.product || '' + : ''; const landing = pEntry && typeof pEntry === 'object' ? pEntry.landing_url || '' : ''; return { @@ -174,10 +196,10 @@ export default function ClientCreativeStylePanel({ void loadStyle(); }, [loadStyle]); - const liveMarkdown = useMemo(() => { - if (compiledSkillFromApi) return compiledSkillFromApi; - return compileClientSkillMarkdown(style, clientName, slug); - }, [compiledSkillFromApi, style, clientName, slug]); + const liveMarkdown = useMemo( + () => compileClientSkillMarkdown(style, clientName, slug), + [style, clientName, slug] + ); const handleSaveAll = async () => { setSaving(true); @@ -186,10 +208,7 @@ export default function ClientCreativeStylePanel({ try { const campaignAudience: Record = {}; - const campaignProducts: Record< - string, - { product: string; landing_url: string } - > = {}; + const campaignProducts: Record = {}; for (const r of campaignRows) { const key = r.campaign_name.trim(); @@ -223,10 +242,6 @@ export default function ClientCreativeStylePanel({ const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al guardar'); - if (data.compiled_skill) { - setCompiledSkillFromApi(data.compiled_skill); - } - setSaveSuccess(true); setTimeout(() => setSaveSuccess(false), 4000); } catch (err: any) { @@ -240,39 +255,37 @@ export default function ClientCreativeStylePanel({ return (
- Cargando configuración operativa de {clientName}… + Cargando estilo creativo y brief maestro de {clientName}…
); } return (
- {/* Banner Callout centralizado en Zona de Marca */} -
-
-
- + {/* BANNER DE SINCRONIZACIÓN CON ZONA DE MARCA */} +
+
+
+
-

- ADN de Marca, Voz, Audiencias y Estilo Centralizados en Zona de Marca -

-

- La identidad corporativa, el tono verbal, los buyer personas (B2B/B2C), las objeciones con refutación, la paleta de colores y las líneas rojas de compliance se gestionan en la{' '} - Zona de Marca. Este panel gestiona exclusivamente los parámetros técnicos y operativos de generación (Freepik Mystic AI, Canva, cuentas de Meta y automatizaciones n8n). +

+ Estilo Creativo & ADN Centralizado + Sincronizado +

+

+ Todo lo que configures aquí (Identidad, Audiencias, Voz, Argumentos, Visual y Operativa) se sincroniza automáticamente con la Zona de Marca y alimenta a Claude en tiempo real.

- Ir a Zona de Marca - +
- {/* Barra superior de estado y guardado */}
@@ -281,10 +294,10 @@ export default function ClientCreativeStylePanel({

- Operativa Ads & Conexiones · {clientName} + Estilo Creativo & Brief Maestro · {clientName}

- Parámetros técnicos inyectados en Claude (WF3), generación fotográfica en Freepik Mystic y composición automática en Canva (WF5). + 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.

@@ -302,12 +315,8 @@ export default function ClientCreativeStylePanel({ disabled={saving} className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-xs font-bold text-white shadow-sm hover:bg-brand-700 disabled:opacity-50 transition-colors" > - {saving ? ( - - ) : ( - - )} - {saving ? 'Guardando…' : 'Guardar operativa ads'} + {saving ? : } + {saving ? 'Guardando…' : 'Guardar configuración creativa'}
@@ -358,15 +367,1215 @@ export default function ClientCreativeStylePanel({ {/* Contenido de la sección activa */}
- {/* SECCIÓN 1: DIRECCIÓN DE ARTE IA (FREEPIK MYSTIC) */} + {/* 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. +

+
+ +
+
+ +