feat: Zona de marca tipo Estilo/Brief + brandbooks desde ads/RRSS

UI con nav lateral numerada y panel editable; parse de brandbook JSON string;
enrich desde Meta/Google/IG/FB/web/CSS para que app.4ventures.es muestre el kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-15 03:23:23 +02:00
parent 102c100427
commit 3cb3ad1db1
10 changed files with 3967 additions and 69 deletions

View File

@@ -0,0 +1,107 @@
/**
* POST — auto-fill Zona de marca for one or all tenants from available sources.
* Body: { tenant_id?, slug?, all?: true, force_seed?: true, dry_run?: true }
*/
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 {
enrichAllTenantBrandbooks,
enrichTenantBrandbook,
} from '@/lib/brandbook-enrich';
import { ensureCreativesDriveFolders } from '@/lib/creatives-drive';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
}
let body: {
tenant_id?: string;
slug?: string;
all?: boolean;
force_seed?: boolean;
dry_run?: boolean;
ensure_drive?: boolean;
} = {};
try {
body = await req.json();
} catch {
body = {};
}
const forceSeed = Boolean(body.force_seed);
const dryRun = Boolean(body.dry_run);
const ensureDrive = body.ensure_drive !== false;
if (body.all) {
const results = await enrichAllTenantBrandbooks({
forceSeed,
dryRun,
slugFilter: body.slug,
});
if (ensureDrive && !dryRun) {
for (const r of results) {
if (!r.ok) continue;
await ensureCreativesDriveFolders(r.tenantId, r.slug);
}
}
return NextResponse.json({
ok: true,
count: results.length,
results,
});
}
let tenantId = body.tenant_id?.trim() || '';
if (!tenantId && body.slug) {
const headers = serviceHeaders();
const base = supabaseUrl();
if (headers && base) {
const r = await fetch(
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(
body.slug
)}&select=id&limit=1`,
{ headers, cache: 'no-store' }
);
const rows = (await r.json()) as { id: string }[];
tenantId = rows[0]?.id || '';
}
}
if (!tenantId) {
const ctx = await resolveActiveTenant(user);
tenantId = ctx.tenantId || '';
}
if (!tenantId) {
return NextResponse.json(
{ error: 'Indica tenant_id, slug, all:true o selecciona cliente' },
{ status: 400 }
);
}
const result = await enrichTenantBrandbook({
tenantId,
forceSeed,
dryRun,
});
let drive: { ok: boolean; ads?: string; rrss?: string; warning?: string } | null =
null;
if (ensureDrive && !dryRun && result.ok) {
drive = await ensureCreativesDriveFolders(result.tenantId, result.slug);
}
return NextResponse.json({ ok: result.ok, result, drive });
}

View File

@@ -99,16 +99,18 @@ function PillRow({
}
function websiteFromBook(book: Brandbook): string | null {
const fromChannels =
book.channel_handles?.blog ||
book.channels?.blog ||
book.channels?.instagram ||
'';
const raw = String(fromChannels || '').trim();
if (!raw) return null;
if (/^https?:\/\//i.test(raw)) return raw;
if (raw.includes('.') && !raw.startsWith('@')) {
return `https://${raw.replace(/^\/+/, '')}`;
const candidates = [
book.website,
book.channel_handles?.blog,
book.channels?.blog,
];
for (const c of candidates) {
const raw = String(c || '').trim();
if (!raw) continue;
if (/^https?:\/\//i.test(raw)) return raw;
if (raw.includes('.') && !raw.startsWith('@')) {
return `https://${raw.replace(/^\/+/, '')}`;
}
}
return null;
}
@@ -120,6 +122,7 @@ export default function BrandOverview({
missing,
onEditSection,
onContinueEdit,
embedded = false,
}: {
book: Brandbook;
tenantLabel: string;
@@ -127,6 +130,8 @@ export default function BrandOverview({
missing: string[];
onEditSection: (id: BrandbookSectionId) => void;
onContinueEdit: () => void;
/** Cuando va dentro del panel de Zona de marca (sin card exterior). */
embedded?: boolean;
}) {
const logo = primaryLogo(book);
const site = websiteFromBook(book);
@@ -167,34 +172,44 @@ export default function BrandOverview({
).trim();
}, [book]);
return (
<div className="space-y-4">
<Surface className="!p-0 overflow-hidden border-gray-200">
<div className="border-b border-gray-200 bg-gradient-to-br from-gray-50 via-white to-brand-50/40 px-5 py-4 sm:px-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-brand-600">
Brand Overview
</p>
<h2 className="mt-1 text-xl font-extrabold tracking-tight text-gray-900 sm:text-2xl">
Perfil de marca
</h2>
<p className="mt-1 text-sm text-gray-600">
Resumen visual del brandbook · editable en Detalles del kit
</p>
</div>
<button
type="button"
onClick={onContinueEdit}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50"
>
Editar brand kit
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
const header = (
<div
className={
embedded
? 'flex flex-wrap items-start justify-between gap-3 pb-2'
: 'border-b border-gray-200 bg-gradient-to-br from-gray-50 via-white to-brand-50/40 px-5 py-4 sm:px-6'
}
>
<div className={embedded ? '' : undefined}>
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-brand-600">
Brand Overview
</p>
<h2
className={`mt-1 font-extrabold tracking-tight text-gray-900 ${
embedded ? 'text-base' : 'text-xl sm:text-2xl'
}`}
>
Perfil de marca
</h2>
{!embedded ? (
<p className="mt-1 text-sm text-gray-600">
Resumen visual del brandbook · editable por secciones
</p>
) : null}
</div>
<button
type="button"
onClick={onContinueEdit}
className="inline-flex items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-bold text-gray-800 hover:bg-gray-50"
>
Editar identidad
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
);
<div className="space-y-8 px-5 py-6 sm:px-6">
const content = (
<div className={embedded ? 'space-y-8' : 'space-y-8 px-5 py-6 sm:px-6'}>
{/* Identity */}
<section className="grid gap-6 lg:grid-cols-[1.2fr_1fr]">
<div className="space-y-3">
@@ -372,10 +387,70 @@ export default function BrandOverview({
)}
</div>
</section>
</div>
{/* Footer progress */}
<div className="flex flex-col gap-3 border-t border-gray-200 bg-gray-50 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
{/* Copy real ads / RRSS */}
<section className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500">
Copy real (Meta / Google / RRSS)
</p>
<button
type="button"
onClick={() => onEditSection('ads')}
className="text-[11px] font-semibold text-brand-700 hover:underline"
>
Ver en Ads →
</button>
</div>
{(book.winning_hooks || []).length ||
(book.ads_usps || []).length ||
(book.ads_ctas || []).length ? (
<div className="space-y-3 rounded-2xl border border-amber-200 bg-amber-50/40 px-4 py-4">
{(book.ads_ctas || []).length ? (
<p className="text-xs text-gray-700">
<span className="font-semibold">CTAs:</span>{' '}
{(book.ads_ctas || []).slice(0, 6).join(' · ')}
</p>
) : null}
{(book.ads_usps || []).length ? (
<PillRow
title="USPs en anuncios"
items={(book.ads_usps || []).slice(0, 6)}
emptyHint=""
accent="amber"
/>
) : null}
{(book.winning_hooks || []).length ? (
<ul className="space-y-1.5 text-xs text-gray-700 list-disc pl-4">
{(book.winning_hooks || []).slice(0, 5).map((h) => (
<li key={h.slice(0, 40)} className="leading-snug">
{h}
</li>
))}
</ul>
) : null}
</div>
) : (
<button
type="button"
onClick={() => onEditSection('ads')}
className="w-full rounded-xl border border-dashed border-gray-300 bg-gray-50 px-3 py-4 text-xs text-gray-500 hover:border-brand-300"
>
Sin copy de ads aún · Auto-rellenar marca o conectar Meta/Google
</button>
)}
</section>
</div>
);
const footer = (
<div
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between ${
embedded
? 'border-t border-gray-100 pt-4'
: 'border-t border-gray-200 bg-gray-50 px-5 py-4 sm:px-6'
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold text-gray-800">
@@ -407,6 +482,24 @@ export default function BrandOverview({
<ArrowRight className="h-4 w-4" />
</button>
</div>
);
if (embedded) {
return (
<div className="space-y-6">
{header}
{content}
{footer}
</div>
);
}
return (
<div className="space-y-4">
<Surface className="!p-0 overflow-hidden border-gray-200">
{header}
{content}
{footer}
</Surface>
</div>
);

View File

@@ -0,0 +1,723 @@
/**
* Extrae copy real de Meta Ads, Google Ads e Instagram para alimentar
* Zona de marca / brief creativo (winning_hooks, CTAs, ángulos, ejemplos).
*
* Solo datos observados en APIs — no inventa mensajes.
*/
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
import { resolveGoogleAdsAccessToken } from '@/lib/google-ads-auth';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import type { Brandbook } from '@/lib/brandbook';
const META_GRAPH = 'https://graph.facebook.com/v21.0';
const GADS_API = 'https://googleads.googleapis.com/v22';
export type AdsInsightBundle = {
partial: Partial<Brandbook>;
/** Texto para ingestSourceText / skill */
sourceText: string;
stats: {
metaAds: number;
googleAds: number;
igCaptions: number;
fbPosts: number;
calendarSocial: number;
};
};
function uniq(arr: string[], max = 24): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of arr) {
const s = String(raw || '')
.replace(/\s+/g, ' ')
.trim();
if (s.length < 4) continue;
const key = s.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(s);
if (out.length >= max) break;
}
return out;
}
function clip(s: string, n: number): string {
const t = s.trim();
return t.length <= n ? t : `${t.slice(0, n - 1)}…`;
}
function humanCta(raw: string): string {
const map: Record<string, string> = {
LEARN_MORE: 'Más información',
SIGN_UP: 'Registrarse',
CONTACT_US: 'Contactar',
WHATSAPP_MESSAGE: 'WhatsApp',
MESSAGE_PAGE: 'Enviar mensaje',
SHOP_NOW: 'Comprar',
BOOK_TRAVEL: 'Reservar',
GET_OFFER: 'Obtener oferta',
APPLY_NOW: 'Solicitar',
SUBSCRIBE: 'Suscribirse',
DOWNLOAD: 'Descargar',
GET_QUOTE: 'Pedir presupuesto',
SEND_MESSAGE: 'Enviar mensaje',
CALL_NOW: 'Llamar',
};
const u = raw.toUpperCase().replace(/\s+/g, '_');
return map[u] || raw.replace(/_/g, ' ').toLowerCase();
}
async function metaGet<T>(
path: string,
token: string,
params: Record<string, string> = {}
): Promise<T | null> {
const q = new URLSearchParams({ ...params, access_token: token });
const r = await fetch(`${META_GRAPH}/${path}?${q}`, { cache: 'no-store' });
if (!r.ok) return null;
return (await r.json()) as T;
}
function extractFromCreative(creative: Record<string, unknown>): {
headlines: string[];
bodies: string[];
descriptions: string[];
ctas: string[];
} {
const headlines: string[] = [];
const bodies: string[] = [];
const descriptions: string[] = [];
const ctas: string[] = [];
const push = (arr: string[], v: unknown) => {
const s = String(v || '').trim();
if (s) arr.push(s);
};
push(headlines, creative.title);
push(bodies, creative.body);
if (creative.call_to_action_type) {
push(ctas, humanCta(String(creative.call_to_action_type)));
}
const spec = (creative.object_story_spec || {}) as Record<string, unknown>;
const link = (spec.link_data || {}) as Record<string, unknown>;
const video = (spec.video_data || {}) as Record<string, unknown>;
push(headlines, link.name);
push(bodies, link.message);
push(descriptions, link.description);
const ctaObj = (link.call_to_action || video.call_to_action) as
| { type?: string }
| undefined;
if (ctaObj?.type) push(ctas, humanCta(ctaObj.type));
push(bodies, video.message);
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
if (feed) {
for (const t of (feed.titles as { text?: string }[]) || []) {
push(headlines, t?.text);
}
for (const b of (feed.bodies as { text?: string }[]) || []) {
push(bodies, b?.text);
}
for (const d of (feed.descriptions as { text?: string }[]) || []) {
push(descriptions, d?.text);
}
for (const c of (feed.call_to_action_types as string[]) || []) {
push(ctas, humanCta(c));
}
}
return { headlines, bodies, descriptions, ctas };
}
async function loadTenantAdsIds(tenantId: string): Promise<{
metaAdAccountId?: string;
metaIgUserId?: string;
metaPageId?: string;
googleAdsCustomerId?: string;
linkedinHandle?: string;
}> {
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) return {};
const tRes = await fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(
tenantId
)}&select=meta_ad_account_id,meta_ig_user_id,meta_page_id&limit=1`,
{ headers, cache: 'no-store' }
);
const tenants = tRes.ok
? ((await tRes.json()) as {
meta_ad_account_id?: string | null;
meta_ig_user_id?: string | null;
meta_page_id?: string | null;
}[])
: [];
const t = tenants[0] || {};
const iRes = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(
tenantId
)}&provider=in.(meta_ads,google_ads,linkedin)&status=eq.connected&select=provider,external_account_id,label`,
{ headers, cache: 'no-store' }
);
const ints = iRes.ok
? ((await iRes.json()) as {
provider: string;
external_account_id?: string | null;
label?: string | null;
}[])
: [];
const meta =
ints.find((i) => i.provider === 'meta_ads')?.external_account_id ||
t.meta_ad_account_id ||
undefined;
const gads = ints.find((i) => i.provider === 'google_ads')
?.external_account_id;
const li = ints.find((i) => i.provider === 'linkedin');
return {
metaAdAccountId: meta ? String(meta) : undefined,
metaIgUserId: t.meta_ig_user_id || undefined,
metaPageId: t.meta_page_id || undefined,
googleAdsCustomerId: gads ? String(gads).replace(/-/g, '') : undefined,
linkedinHandle:
li?.external_account_id || li?.label
? String(li.external_account_id || li.label)
: undefined,
};
}
async function collectMetaAdsCopy(
actId: string,
token: string
): Promise<{
headlines: string[];
bodies: string[];
descriptions: string[];
ctas: string[];
campaignNames: string[];
count: number;
}> {
const act = actId.startsWith('act_') ? actId : `act_${actId}`;
const headlines: string[] = [];
const bodies: string[] = [];
const descriptions: string[] = [];
const ctas: string[] = [];
const campaignNames: string[] = [];
let count = 0;
// Prefer ACTIVE, then PAUSED (histórico reciente)
for (const status of ['ACTIVE', 'PAUSED'] as const) {
const raw = await metaGet<{
data?: {
id?: string;
name?: string;
campaign?: { name?: string };
creative?: Record<string, unknown>;
}[];
}>(`${act}/ads`, token, {
fields:
'id,name,effective_status,campaign{name},creative{id,title,body,call_to_action_type,object_story_spec,asset_feed_spec}',
effective_status: JSON.stringify([status]),
limit: '40',
});
for (const ad of raw?.data || []) {
count += 1;
if (ad.campaign?.name) campaignNames.push(ad.campaign.name);
let creative = (ad.creative || {}) as Record<string, unknown>;
const cid = String(creative.id || '');
if (cid) {
const detail = await metaGet<Record<string, unknown>>(cid, token, {
fields:
'id,title,body,call_to_action_type,object_story_spec,asset_feed_spec',
});
if (detail) creative = { ...creative, ...detail };
}
const pack = extractFromCreative(creative);
headlines.push(...pack.headlines);
bodies.push(...pack.bodies);
descriptions.push(...pack.descriptions);
ctas.push(...pack.ctas);
}
if (count >= 25) break;
}
return { headlines, bodies, descriptions, ctas, campaignNames, count };
}
async function collectIgCaptions(
igUserId: string,
token: string
): Promise<string[]> {
const media = await metaGet<{
data?: { caption?: string }[];
}>(`${igUserId}/media`, token, {
fields: 'caption,timestamp',
limit: '24',
});
return (media?.data || [])
.map((m) => String(m.caption || '').trim())
.filter((c) => c.length >= 12);
}
async function collectFbPosts(
pageId: string,
token: string
): Promise<string[]> {
const fields =
'id,message,created_time,permalink_url,status_type';
for (const edge of ['published_posts', 'posts'] as const) {
const raw = await metaGet<{ data?: { message?: string }[] }>(
`${pageId}/${edge}`,
token,
{ fields, limit: '25' }
);
const msgs = (raw?.data || [])
.map((p) => String(p.message || '').trim())
.filter((m) => m.length >= 12);
if (msgs.length) return msgs;
}
return [];
}
/** Copy RRSS ya planificado/publicado en calendarios (IG/FB/LinkedIn). */
async function collectCalendarSocialCopy(tenantId: string): Promise<{
texts: string[];
linkedin: string[];
byPlatform: Record<string, number>;
}> {
const headers = serviceHeaders();
const base = supabaseUrl();
const empty = { texts: [] as string[], linkedin: [] as string[], byPlatform: {} };
if (!headers || !base) return empty;
try {
const r = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
tenantId
)}&type=eq.calendar&select=content&order=updated_at.desc&limit=8`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return empty;
const rows = (await r.json()) as { content?: unknown }[];
const texts: string[] = [];
const linkedin: string[] = [];
const byPlatform: Record<string, number> = {};
for (const row of rows) {
const c =
typeof row.content === 'string'
? JSON.parse(row.content)
: row.content;
const pieces = Array.isArray((c as { pieces?: unknown })?.pieces)
? ((c as {
pieces: {
platform?: string;
title?: string;
hook?: string;
body?: string;
caption?: string;
cta?: string;
}[];
}).pieces)
: [];
for (const p of pieces) {
const plat = String(p.platform || '').toLowerCase() || 'unknown';
byPlatform[plat] = (byPlatform[plat] || 0) + 1;
const blob = [p.hook, p.title, p.body || p.caption, p.cta]
.map((x) => String(x || '').trim())
.filter((x) => x.length >= 8)
.join(' · ');
if (blob.length < 12) continue;
texts.push(blob);
if (/linkedin|li\b/.test(plat)) linkedin.push(blob);
}
}
return {
texts: uniq(texts, 20),
linkedin: uniq(linkedin, 12),
byPlatform,
};
} catch {
return empty;
}
}
async function collectGoogleAdsCopy(
customerId: string
): Promise<{ headlines: string[]; descriptions: string[]; count: number }> {
const login =
process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID?.replace(/-/g, '') || '';
const dev = process.env.GOOGLE_ADS_DEVELOPER_TOKEN?.trim() || '';
if (!dev) return { headlines: [], descriptions: [], count: 0 };
let accessToken: string;
try {
accessToken = await resolveGoogleAdsAccessToken();
} catch {
return { headlines: [], descriptions: [], count: 0 };
}
const query = `
SELECT
ad_group_ad.ad.responsive_search_ad.headlines,
ad_group_ad.ad.responsive_search_ad.descriptions,
ad_group_ad.ad.responsive_display_ad.headlines,
ad_group_ad.ad.responsive_display_ad.descriptions,
ad_group_ad.status
FROM ad_group_ad
WHERE ad_group_ad.status != 'REMOVED'
AND campaign.status != 'REMOVED'
LIMIT 50
`.trim();
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
'developer-token': dev,
'Content-Type': 'application/json',
};
if (login) headers['login-customer-id'] = login;
const r = await fetch(
`${GADS_API}/customers/${customerId}/googleAds:search`,
{
method: 'POST',
headers,
body: JSON.stringify({ query }),
cache: 'no-store',
}
);
if (!r.ok) return { headlines: [], descriptions: [], count: 0 };
const j = (await r.json()) as {
results?: {
adGroupAd?: {
ad?: {
responsiveSearchAd?: {
headlines?: { text?: string }[];
descriptions?: { text?: string }[];
};
responsiveDisplayAd?: {
headlines?: { text?: string }[];
descriptions?: { text?: string }[];
};
};
};
}[];
};
const headlines: string[] = [];
const descriptions: string[] = [];
let count = 0;
for (const row of j.results || []) {
count += 1;
const ad = row.adGroupAd?.ad;
for (const h of ad?.responsiveSearchAd?.headlines || []) {
if (h.text) headlines.push(h.text);
}
for (const d of ad?.responsiveSearchAd?.descriptions || []) {
if (d.text) descriptions.push(d.text);
}
for (const h of ad?.responsiveDisplayAd?.headlines || []) {
if (h.text) headlines.push(h.text);
}
for (const d of ad?.responsiveDisplayAd?.descriptions || []) {
if (d.text) descriptions.push(d.text);
}
}
return { headlines, descriptions, count };
}
/** Ángulos a partir de nombres de campaña (TOFU/MOFU/BOFU, producto, etc.). */
function anglesFromCampaignNames(names: string[]): string[] {
const angles: string[] = [];
for (const n of names) {
const lower = n.toLowerCase();
if (/tofu|awareness|traf[ií]co|alcance/.test(lower)) {
angles.push('TOFU / tráfico / awareness');
}
if (/mofu|consideraci|interacci|engagement/.test(lower)) {
angles.push('MOFU / consideración');
}
if (/bofu|conversi|leads?|mensaje|whatsapp|ventas?/.test(lower)) {
angles.push('BOFU / conversión / leads');
}
if (/retarget|remarket|rmkt/.test(lower)) angles.push('Retargeting');
// keep short campaign fragments as angle hints
const clean = n
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (clean.length >= 8 && clean.length <= 60) angles.push(clean);
}
return uniq(angles, 12);
}
/**
* Recoge insights de ads/RRSS y propone campos de brandbook.
* Los campos de copy se deben aplicar con prioridad sobre síntesis vacía
* (ver applyAdsInsightsToBrandbook).
*/
export async function collectAdsInsightsForTenant(
tenantId: string
): Promise<AdsInsightBundle> {
const ids = await loadTenantAdsIds(tenantId);
const token = await resolveMetaAccessToken().catch(() => null);
const metaHeadlines: string[] = [];
const metaBodies: string[] = [];
const metaDescs: string[] = [];
const metaCtas: string[] = [];
const campaignNames: string[] = [];
let metaCount = 0;
let igCaptions: string[] = [];
let fbPosts: string[] = [];
let gadsHeadlines: string[] = [];
let gadsDescs: string[] = [];
let gadsCount = 0;
const calendarSocial = await collectCalendarSocialCopy(tenantId);
if (token && ids.metaAdAccountId) {
try {
const m = await collectMetaAdsCopy(ids.metaAdAccountId, token);
metaHeadlines.push(...m.headlines);
metaBodies.push(...m.bodies);
metaDescs.push(...m.descriptions);
metaCtas.push(...m.ctas);
campaignNames.push(...m.campaignNames);
metaCount = m.count;
} catch {
/* ignore */
}
}
if (token && ids.metaIgUserId) {
try {
igCaptions = await collectIgCaptions(ids.metaIgUserId, token);
} catch {
/* ignore */
}
}
if (token && ids.metaPageId) {
try {
fbPosts = await collectFbPosts(ids.metaPageId, token);
} catch {
/* ignore */
}
}
if (ids.googleAdsCustomerId) {
try {
const g = await collectGoogleAdsCopy(ids.googleAdsCustomerId);
gadsHeadlines = g.headlines;
gadsDescs = g.descriptions;
gadsCount = g.count;
} catch {
/* ignore */
}
}
const socialBodies = [
...igCaptions,
...fbPosts,
...calendarSocial.texts,
];
const winning_hooks = uniq(
[
...metaBodies.map((b) => clip(b, 180)),
...metaHeadlines.map((h) => clip(h, 80)),
...gadsHeadlines.map((h) => clip(h, 80)),
...socialBodies.slice(0, 8).map((b) => clip(b, 180)),
],
16
);
const example_good = uniq(
[
...metaBodies.slice(0, 8).map((b) => clip(b, 280)),
...gadsDescs.slice(0, 6).map((d) => clip(d, 200)),
...igCaptions.slice(0, 6).map((c) => clip(c, 280)),
...fbPosts.slice(0, 5).map((c) => clip(c, 280)),
...calendarSocial.linkedin.slice(0, 5).map((c) => clip(c, 280)),
...calendarSocial.texts.slice(0, 4).map((c) => clip(c, 280)),
],
14
);
const ads_ctas = uniq([...metaCtas], 10);
const preferred_angles = anglesFromCampaignNames(campaignNames);
const ads_angles = preferred_angles;
const slogan_variants = uniq(
[...metaHeadlines, ...gadsHeadlines].map((h) => clip(h, 60)),
12
);
// USPs cortos: titulares Meta/Google (son claims ya usados en ads)
const ads_usps = uniq(
[...metaHeadlines, ...gadsHeadlines]
.filter((h) => h.length >= 8 && h.length <= 90)
.map((h) => clip(h, 90)),
10
);
const linkedin_themes = uniq(
calendarSocial.linkedin
.map((t) => clip(t.split(/[·|]/)[0] || t, 80))
.filter((t) => t.length >= 8),
8
);
const lines: string[] = [
'=== COPY REAL DESDE ADS / RRSS (no inventado) ===',
`Meta ads: ${metaCount} · Google ads: ${gadsCount} · IG captions: ${igCaptions.length} · FB posts: ${fbPosts.length} · Calendario RRSS: ${calendarSocial.texts.length}`,
];
if (ids.linkedinHandle) {
lines.push(`LinkedIn company (integración): ${ids.linkedinHandle}`);
}
if (winning_hooks.length) {
lines.push('', 'Hooks / primary texts / titulares observados:');
winning_hooks.forEach((h, i) => lines.push(`${i + 1}. ${h}`));
}
if (ads_ctas.length) {
lines.push('', `CTAs Meta observados: ${ads_ctas.join(', ')}`);
}
if (preferred_angles.length) {
lines.push('', `Ángulos (nombres de campaña): ${preferred_angles.join(' · ')}`);
}
if (igCaptions.length) {
lines.push('', 'Captions IG recientes (muestra):');
igCaptions.slice(0, 5).forEach((c, i) => lines.push(`${i + 1}. ${clip(c, 200)}`));
}
if (fbPosts.length) {
lines.push('', 'Posts Facebook recientes (muestra):');
fbPosts.slice(0, 5).forEach((c, i) => lines.push(`${i + 1}. ${clip(c, 200)}`));
}
if (calendarSocial.linkedin.length) {
lines.push('', 'Copy LinkedIn (calendario / planificado):');
calendarSocial.linkedin
.slice(0, 5)
.forEach((c, i) => lines.push(`${i + 1}. ${clip(c, 200)}`));
} else if (ids.linkedinHandle) {
lines.push(
'',
`LinkedIn: página conectada (${ids.linkedinHandle}). Sin API de posts — usar temas del calendario cuando existan.`
);
}
if (gadsHeadlines.length) {
lines.push('', 'Headlines Google Ads (muestra):');
uniq(gadsHeadlines, 10).forEach((h, i) => lines.push(`${i + 1}. ${h}`));
}
const partial: Partial<Brandbook> = {};
if (winning_hooks.length) partial.winning_hooks = winning_hooks;
if (example_good.length) partial.example_good = example_good;
if (ads_ctas.length) {
partial.ads_ctas = ads_ctas;
partial.allowed_ctas = ads_ctas;
}
if (preferred_angles.length) {
partial.preferred_angles = preferred_angles;
partial.ads_angles = ads_angles;
}
if (slogan_variants.length) partial.slogan_variants = slogan_variants;
if (ads_usps.length) partial.ads_usps = ads_usps;
if (linkedin_themes.length) partial.linkedin_themes = linkedin_themes;
// Campos editoriales derivados SOLO de copy observado (relleno si vacíos en apply)
if (ads_usps.length) {
partial.usp_differentiators = ads_usps.slice(0, 5).join(' · ');
partial.differentiators = ads_usps.slice(0, 8);
partial.value_proposition = ads_usps.slice(0, 3).join(' · ');
}
if (preferred_angles.length) {
partial.messaging_pillars = preferred_angles.slice(0, 6);
} else if (ads_usps.length) {
partial.messaging_pillars = ads_usps.slice(0, 5);
}
const proof = uniq(
[...metaBodies, ...gadsDescs, ...igCaptions]
.filter((t) => /\d/.test(t) && t.length >= 20)
.map((t) => clip(t, 160)),
6
);
if (proof.length) partial.proof_points = proof;
if (slogan_variants.length && !partial.tagline) {
// no: tagline no se inventa desde un headline suelto
}
return {
partial,
sourceText: lines.join('\n'),
stats: {
metaAds: metaCount,
googleAds: gadsCount,
igCaptions: igCaptions.length,
fbPosts: fbPosts.length,
calendarSocial: calendarSocial.texts.length,
},
};
}
/**
* Aplica insights de ads al brandbook.
* - Por defecto rellena vacíos.
* - Si `overwriteAdsFields`, pisa hooks/CTAs/ángulos/usps/ejemplos con copy real
* (prioridad sobre síntesis editorial previa).
*/
export function applyAdsInsightsToBrandbook(
book: Brandbook,
bundle: AdsInsightBundle,
opts?: { overwriteAdsFields?: boolean }
): Brandbook {
const overwrite = opts?.overwriteAdsFields !== false;
const p = bundle.partial;
const out: Brandbook = { ...book };
const applyArr = (
key: keyof Brandbook,
next?: string[],
mode: 'overwrite' | 'empty' = 'overwrite'
) => {
if (!next?.length) return;
const cur = out[key] as string[] | undefined;
if (mode === 'overwrite') {
if (overwrite || !cur?.length) {
(out as Record<string, unknown>)[key] = next;
}
} else if (!cur?.length) {
(out as Record<string, unknown>)[key] = next;
}
};
const applyStrEmpty = (key: keyof Brandbook, next?: string) => {
if (!next?.trim()) return;
const cur = String(out[key] || '').trim();
if (!cur) (out as Record<string, unknown>)[key] = next.trim();
};
// Copy de ads: prioridad (overwrite)
applyArr('winning_hooks', p.winning_hooks, 'overwrite');
applyArr('example_good', p.example_good, 'overwrite');
applyArr('ads_ctas', p.ads_ctas, 'overwrite');
applyArr('allowed_ctas', p.allowed_ctas, 'overwrite');
applyArr('preferred_angles', p.preferred_angles, 'overwrite');
applyArr('ads_angles', p.ads_angles, 'overwrite');
applyArr('slogan_variants', p.slogan_variants, 'overwrite');
applyArr('ads_usps', p.ads_usps, 'overwrite');
applyArr('linkedin_themes', p.linkedin_themes, 'overwrite');
// Editoriales: solo si vacíos
applyStrEmpty('usp_differentiators', p.usp_differentiators);
applyStrEmpty('value_proposition', p.value_proposition);
applyArr('differentiators', p.differentiators, 'empty');
applyArr('messaging_pillars', p.messaging_pillars, 'empty');
applyArr('proof_points', p.proof_points, 'empty');
return out;
}

1090
src/lib/brandbook-enrich.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,10 @@
*/
import { brandBrief } from '@/lib/calendar-generate-brand';
import {
brandSiblingNote,
resolveBrandSlug,
} from '@/lib/tenant-brand-aliases';
export const BRANDBOOK_DOC_TYPE = 'brandbook';
export const BRANDBOOK_DOC_TITLE = 'Brandbook';
@@ -807,40 +811,205 @@ export function seedBrandbook(slug: string, label: string): Brandbook {
blog: 'SEO certificaciones y objeciones',
},
},
'talento-y-deporte': {
tyd: {
vertical: brief.vertical,
tone: brief.tone,
elevator_pitch:
'Becas deportivas y académicas en USA con acompañamiento end-to-end.',
brand_promise: 'Tu talento abre puertas; nosotros abrimos el camino.',
positioning: 'Deporte + estudios en USA con proceso guiado',
'Becas deportivas en USA con acompañamiento end-to-end para atletas-estudiantes.',
brand_promise: 'Tu talento deportivo abre puertas; nosotros abrimos el camino.',
positioning: 'TYD · Becas deportivas USA con proceso guiado',
mission:
'Ayudar a atletas-estudiantes a conseguir becas deportivas reales en universidades de EE.UU. con proceso guiado.',
tagline: 'Tu talento, tu campus en USA',
icp: 'Atletas-estudiantes (y familias) en España/LATAM que buscan beca deportiva + estudios en EE.UU.',
archetype: 'El Héroe + El Mentor',
website: 'talentoydeporte.com',
pillars_rrss: brief.pillars,
series_recurrentes: [
'Nuevo alumno',
'Beca académica',
'Beca deportiva',
'Campus USA',
'Objeciones',
'English tips',
'Vida deportiva',
],
products_services: [
'Becas deportivas USA',
'Acompañamiento académico',
'Campus visits',
'Recruiting deportivo',
'Acompañamiento visa/campus',
],
ads_usps: [
'Becas reales',
'Becas deportivas reales',
'Acompañamiento end-to-end',
'Historias de alumnos',
'Historias de atletas',
],
ads_ctas: ['Agenda llamada', 'Evalúa tu perfil'],
ads_ctas: ['Agenda llamada', 'Evalúa tu perfil deportivo'],
linkedin_themes: ['Educación internacional', 'Deporte + estudios'],
blog_themes: ['Guía becas USA', 'Requisitos TOEFL/SAT', 'Vida en campus'],
blog_themes: ['Guía becas deportivas USA', 'Requisitos', 'Vida en campus'],
claims_forbidden: [
'Garantizar beca al 100%',
'Promesas de universidades sin matiz',
'Usar creatividades o claims de TSP Scholar sin matiz deportivo',
],
freeform_notes: brandSiblingNote('tyd') || '',
channels: {
...base.channels,
instagram: 'Canal principal · historias de atletas',
facebook: 'Comunidad padres / familias',
},
channel_handles: {
...base.channel_handles,
instagram: 'https://instagram.com/talentoydeporte',
},
},
tsp: {
vertical: brief.vertical,
tone: brief.tone,
elevator_pitch:
'Programa Scholar de becas académicas en USA con orientación de admisiones y financiación.',
brand_promise: 'Tu expediente también puede abrirte el campus americano.',
positioning: 'TSP · Becas académicas USA (marca hermana de TYD, no mezclar)',
mission:
'Orientar a estudiantes con buen expediente hacia becas académicas y admisiones en EE.UU.',
tagline: 'Tu expediente, tu campus en USA',
icp: 'Estudiantes (y familias) con perfil académico sólido que buscan universidad en EE.UU. sin eje deportivo.',
archetype: 'El Sabio + El Mentor',
website: 'talentoydeporte.com',
pillars_rrss: brief.pillars,
series_recurrentes: [
'Beca académica',
'Admisiones',
'Financiación',
'Campus USA',
'Casos de éxito',
'Objeciones',
],
products_services: [
'Becas académicas USA',
'Orientación de admisiones',
'Plan de financiación',
],
ads_usps: [
'Foco académico Scholar',
'Admisiones guiadas',
'Financiación clara',
],
ads_ctas: ['Evalúa tu perfil académico', 'Agenda orientación'],
linkedin_themes: ['Educación internacional', 'Admisiones USA'],
blog_themes: ['SAT/TOEFL', 'Financiación becas', 'Vida académica en campus'],
claims_forbidden: [
'Garantizar admisión o beca al 100%',
'Usar creatividades o claims deportivos de TYD sin matiz Scholar',
],
freeform_notes: brandSiblingNote('tsp') || '',
},
dtatf: {
vertical: brief.vertical,
tone: brief.tone,
elevator_pitch:
'Formación profesional cercana orientada a empleabilidad y cambio de vida laboral.',
brand_promise: 'Formarte de tú a tú para trabajar con seguridad.',
positioning: 'DTATF · Formación práctica (marca distinta de ECP)',
mission:
'Acercar formación profesional práctica y acreditaciones con salida laboral real.',
icp: 'Adultos en reconversión o mejora laboral que buscan cursos/acreditaciones con empleabilidad (p. ej. docencia FP).',
archetype: 'El Cuidador + El Ciudadano',
pillars_rrss: brief.pillars,
series_recurrentes: [
'Convocatorias',
'Empleabilidad',
'Testimonios',
'Tips formación',
],
products_services: ['Cursos de formación', 'Orientación laboral'],
ads_usps: ['Cercanía', 'Empleabilidad', 'Convocatorias claras'],
ads_ctas: ['Pedir info', 'Reservar plaza'],
claims_forbidden: [
'Usar marca visual o claims de ECP (cuidadores) sin matiz DTATF',
],
freeform_notes: brandSiblingNote('dtatf') || '',
channels: {
...base.channels,
instagram: 'Alcance y comunidad alumnos',
facebook: 'Convocatorias y familias',
},
},
ecp: {
vertical: brief.vertical,
tone: brief.tone,
elevator_pitch:
'Escuela de Cuidadores Profesionales: formación humana y empleable para el sector cuidados.',
brand_promise: 'Cuidar con vocación y con método.',
positioning: 'ECP · Referente en formación de cuidadores (marca distinta de DTATF)',
mission:
'Formar cuidadores profesionales con método, humanidad y conexión real con empleo.',
icp: 'Personas que quieren trabajar en cuidados (domicilio/residencias) y empresas que buscan perfiles formados.',
archetype: 'El Cuidador',
website: 'escueladecuidadoresprofesionales.com',
pillars_rrss: brief.pillars,
series_recurrentes: [
'Día a día del cuidador',
'Formación',
'Empleabilidad',
'Testimonios',
'Convocatorias',
],
products_services: [
'Cursos de cuidador profesional',
'Prácticas / empleabilidad',
],
ads_usps: [
'Formación especializada',
'Salida laboral',
'Comunidad ECP',
],
ads_ctas: ['Infórmate', 'Reserva tu plaza'],
claims_forbidden: [
'Usar marca visual o claims de DTATF sin matiz ECP/cuidadores',
],
freeform_notes: brandSiblingNote('ecp') || '',
channels: {
...base.channels,
instagram: 'Canal principal @escuelaecp',
facebook: 'Comunidad y convocatorias',
},
channel_handles: {
...base.channel_handles,
instagram: 'https://instagram.com/escuelaecp',
blog: 'https://escueladecuidadoresprofesionales.com',
},
},
'agua-miia': {
vertical: brief.vertical,
tone: brief.tone,
elevator_pitch:
'Agua mineral de origen natural para hidratación diaria con identidad fresca y premium accesible.',
brand_promise: 'Hidratación limpia, natural y con carácter.',
positioning: 'Agua con personalidad: fresca, cercana y visualmente clara',
archetype: 'El Inocente + El Explorador',
pillars_rrss: brief.pillars,
series_recurrentes: [
'Producto hero',
'Ritual diario',
'Origen',
'Estilo de vida',
'Sostenibilidad',
],
products_services: ['Agua mineral', 'Formatos retail', 'Horeca'],
ads_usps: ['Origen natural', 'Sabor limpio', 'Formato para el día a día'],
ads_ctas: ['Descúbrela', 'Encuéntrala', 'Pruébala'],
photography_style:
'Producto hero con luz natural, escenas de hidratación y lifestyle limpio; mucho aire y tipografía clara.',
claims_forbidden: [
'Claims médicos o curativos sin respaldo',
'Comparativas agresivas con competencia nombrada',
],
channels: {
...base.channels,
instagram: 'Canal principal · producto + lifestyle',
facebook: 'Alcance y comunidad local',
tiktok: 'Reels cortos de ritual / producto',
},
},
'halal-safety': {
vertical: 'Certificación Halal y Consultoría de Exportación Agroalimentaria',
@@ -1482,7 +1651,8 @@ export function seedBrandbook(slug: string, label: string): Brandbook {
},
};
const resolvedSlug = slug === 'blue-parrot-school' ? 'bps' : slug;
const resolvedSlug =
slug === 'blue-parrot-school' ? 'bps' : resolveBrandSlug(slug);
const extra = seeds[resolvedSlug] || {
vertical: brief.vertical,
tone: brief.tone,
@@ -1517,16 +1687,33 @@ export function seedBrandbook(slug: string, label: string): Brandbook {
export function parseBrandbook(raw: unknown): Brandbook | null {
if (!raw) return null;
let o: any = raw;
if (typeof raw === 'string') {
// documents.content a menudo llega como string JSON (columna text / doble encode)
let data: unknown = raw;
if (typeof data === 'string') {
const t = data.trim();
if (!t) return null;
try {
o = JSON.parse(raw);
data = JSON.parse(t);
} catch {
return null;
}
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch {
return null;
}
}
}
if (!o || typeof o !== 'object') return null;
if (o.content && (typeof o.content === 'object' || typeof o.content === 'string')) {
if (!data || typeof data !== 'object') return null;
const o = data as Record<string, unknown>;
// wrapper { content: brandbook }
if (
o.content &&
(typeof o.content === 'object' || typeof o.content === 'string') &&
!o.brand_name &&
!('tone' in o)
) {
return parseBrandbook(o.content);
}
const ver = Number(o.version);
@@ -1548,11 +1735,46 @@ export function parseBrandbook(raw: unknown): Brandbook | null {
const empty = emptyBrandbook(String(partial.brand_name || ''));
const colors = migrateColors(partial, empty);
const fonts_kit = migrateFonts(partial, empty);
const str = (v: unknown, fallback = ''): string => {
if (v == null) return fallback;
return String(v);
};
return {
...empty,
...partial,
version: 2,
brand_name: str(partial.brand_name, empty.brand_name),
tagline: str(partial.tagline),
elevator_pitch: str(partial.elevator_pitch),
brand_promise: str(partial.brand_promise),
positioning: str(partial.positioning),
archetype: str(partial.archetype),
origin_story: str(partial.origin_story),
mission: str(partial.mission),
vision: str(partial.vision),
vertical: str(partial.vertical),
tone: str(partial.tone),
visual_notes: str(partial.visual_notes),
logo_usage: str(partial.logo_usage),
photography_style: str(partial.photography_style),
illustration_style: str(partial.illustration_style),
icon_style: str(partial.icon_style),
icp: str(partial.icp),
geo: str(partial.geo),
customer_journey: str(partial.customer_journey),
value_proposition: str(partial.value_proposition),
guarantee: str(partial.guarantee),
pricing_notes: str(partial.pricing_notes),
content_matrix: str(partial.content_matrix),
channel_cadence: str(partial.channel_cadence),
landing_notes: str(partial.landing_notes),
abm_notes: str(partial.abm_notes),
blog_cadence: str(partial.blog_cadence),
legal_notes: str(partial.legal_notes),
sector_rules: str(partial.sector_rules),
image_rights: str(partial.image_rights),
freeform_notes: str(partial.freeform_notes),
values: arr(partial.values),
personality: arr(partial.personality),
voice_do: arr(partial.voice_do),
@@ -1776,6 +1998,12 @@ export function brandbookToPrompt(book: Brandbook): string {
book.icp && `ICP: ${book.icp}`,
book.ads_usps.length && `USPs ads: ${book.ads_usps.join('; ')}`,
book.ads_ctas.length && `CTAs: ${book.ads_ctas.join('; ')}`,
book.winning_hooks.length &&
`Hooks ads/RRSS observados: ${book.winning_hooks.slice(0, 10).join(' | ')}`,
book.example_good.length &&
`Ejemplos copy real (ads/IG/FB/LI): ${book.example_good.slice(0, 6).join(' | ')}`,
book.preferred_angles.length &&
`Ángulos campaña: ${book.preferred_angles.slice(0, 8).join(' · ')}`,
book.linkedin_themes.length &&
`LinkedIn: ${book.linkedin_themes.join(', ')}`,
book.blog_themes.length && `Blog: ${book.blog_themes.join(', ')}`,
@@ -1792,30 +2020,40 @@ export function brandbookCompleteness(book: Brandbook): {
score: number;
missing: string[];
} {
const s = (v: unknown) => String(v || '').trim();
const n = (v: unknown) => (Array.isArray(v) ? v.length : 0);
const checks: [string, boolean][] = [
[
'Identidad (pitch/misión/posicionamiento)',
!!(book.elevator_pitch || book.mission || book.positioning),
!!(s(book.elevator_pitch) || s(book.mission) || s(book.positioning)),
],
['Voz/tono', !!book.tone],
['Voz/tono', !!s(book.tone)],
[
'Visual (color o logo)',
!!(book.colors.length || book.kit_assets.some((a) => a.kind === 'logo')),
!!(n(book.colors) || (book.kit_assets || []).some((a) => a.kind === 'logo')),
],
['Tipografía', n(book.fonts_kit) > 0],
[
'Audiencia/ICP',
!!(
s(book.icp) ||
n(book.personas) ||
n(book.b2b_personas) ||
n(book.b2c_personas)
),
],
['Tipografía', book.fonts_kit.length > 0],
['Audiencia/ICP', !!(book.icp || book.personas.length)],
[
'Oferta',
!!(book.products_services.length || book.value_proposition),
!!(n(book.products_services) || s(book.value_proposition)),
],
[
'Pilares/series RRSS',
!!(book.pillars_rrss.length || book.series_recurrentes.length),
!!(n(book.pillars_rrss) || n(book.series_recurrentes)),
],
['Ads (USP o CTA)', !!(book.ads_usps.length || book.ads_ctas.length)],
['Ads (USP o CTA)', !!(n(book.ads_usps) || n(book.ads_ctas))],
[
'LinkedIn B2B o blog',
!!(book.linkedin_themes.length || book.blog_themes.length),
!!(n(book.linkedin_themes) || n(book.blog_themes)),
],
];
const missing = checks.filter(([, ok]) => !ok).map(([l]) => l);
@@ -2158,19 +2396,66 @@ export function compileClientSkillFromBrandbook(
lines.push(fallbackAud);
lines.push('');
// 8. Ejemplos de hooks
// 8. Copy real de Meta / Google / IG / FB / LinkedIn
const adsExcerpt = (book.source_excerpts || []).find((e) =>
/ads-insights|meta-graph|COPY REAL DESDE ADS/i.test(
`${e.filename || ''} ${e.excerpt || ''}`
)
);
const hooks =
book.winning_hooks && book.winning_hooks.length > 0
? book.winning_hooks
: book.slogan_variants;
if (hooks && hooks.length > 0) {
lines.push(
'## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar)'
'## Copy real de campañas y redes (Meta Ads / Google Ads / IG / FB / LinkedIn)'
);
for (const h of hooks) {
lines.push('');
lines.push(
'> Extraído de anuncios, captions IG, posts FB y piezas LinkedIn del calendario. Registro de qué ya se ha dicho — **no copiar literal**; variar ángulo y prueba.'
);
lines.push('');
lines.push('### Hooks / primary texts / titulares observados');
for (const h of hooks.slice(0, 16)) {
lines.push(`- "${h}"`);
}
lines.push('');
if (book.example_good && book.example_good.length > 0) {
lines.push('### Ejemplos de copy observado (cuerpo / captions)');
for (const ex of book.example_good.slice(0, 8)) {
lines.push(`- "${ex}"`);
}
lines.push('');
}
if (book.ads_ctas && book.ads_ctas.length > 0) {
lines.push(
`### CTAs observados en Meta: ${book.ads_ctas.join(', ')}`
);
lines.push('');
}
const campAngles =
book.preferred_angles && book.preferred_angles.length > 0
? book.preferred_angles
: book.ads_angles;
if (campAngles && campAngles.length > 0) {
lines.push(
`### Ángulos de campaña observados: ${campAngles.slice(0, 12).join(' · ')}`
);
lines.push('');
}
if (book.linkedin_themes && book.linkedin_themes.length > 0) {
lines.push(
`### Temas LinkedIn observados: ${book.linkedin_themes.join(' · ')}`
);
lines.push('');
}
} else if (adsExcerpt?.excerpt) {
lines.push(
'## Copy real de campañas y redes (Meta Ads / Google Ads / IG / FB / LinkedIn)'
);
lines.push('');
lines.push(adsExcerpt.excerpt.slice(0, 2500));
lines.push('');
}
const extra =

119
src/lib/canva-templates.ts Normal file
View File

@@ -0,0 +1,119 @@
/**
* Canva Brand Templates per tenant.
*
* Recommended structure (1 Brand Kit + 2 template groups):
* canva_templates: {
* ads: { "foto-con-franja", "tipografico", "dato-grande", "stories_9_16", "landscape_1_91", "default" },
* rrss: { "foto-con-franja", "tipografico", "dato-grande", "stories_9_16", "default" },
* // flat legacy keys still work as fallback for both
* }
*
* Do NOT create two Canva "Brand Kits" per client — share logos/colors/fonts.
* Do create separate Brand Templates (or a folder of templates) for Ads vs RRSS layouts.
*/
export type CanvaTemplateMap = Record<string, string>;
export type CanvaTemplatesConfig = {
ads?: CanvaTemplateMap;
rrss?: CanvaTemplateMap;
} & {
[key: string]: string | CanvaTemplateMap | undefined;
};
const ADS_KEYS = [
'foto-con-franja',
'tipografico',
'dato-grande',
'stories_9_16',
'landscape_1_91',
'default',
] as const;
const RRSS_KEYS = [
'foto-con-franja',
'tipografico',
'dato-grande',
'stories_9_16',
'default',
] as const;
function asMap(v: unknown): CanvaTemplateMap {
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
const out: CanvaTemplateMap = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (k === 'ads' || k === 'rrss') continue;
if (typeof val === 'string' && val.trim()) out[k] = val.trim();
}
return out;
}
export function normalizeCanvaTemplates(raw: unknown): CanvaTemplatesConfig {
if (!raw || typeof raw !== 'object') return {};
const obj = raw as Record<string, unknown>;
const flat = asMap(obj);
const ads = { ...flat, ...asMap(obj.ads) };
const rrss = { ...flat, ...asMap(obj.rrss) };
return { ...flat, ads, rrss };
}
/** Resolve templates for n8n / WF5 for a given channel. */
export function resolveCanvaTemplatesForChannel(
raw: unknown,
channel: 'ads' | 'rrss'
): CanvaTemplateMap {
const cfg = normalizeCanvaTemplates(raw);
const channelMap = channel === 'ads' ? cfg.ads || {} : cfg.rrss || {};
const keys = channel === 'ads' ? ADS_KEYS : RRSS_KEYS;
const out: CanvaTemplateMap = {};
for (const k of keys) {
const fromChannel = channelMap[k];
const fromFlat = cfg[k];
const v =
(typeof fromChannel === 'string' && fromChannel) ||
(typeof fromFlat === 'string' && fromFlat) ||
'';
if (v) out[k] = v;
}
// pass through any extra keys on the channel map
for (const [k, v] of Object.entries(channelMap)) {
if (typeof v === 'string' && v && !out[k]) out[k] = v;
}
return out;
}
export function canvaTemplateKeyForLayout(
layout: string | null | undefined,
channel: 'ads' | 'rrss'
): string {
const l = String(layout || '').toLowerCase();
if (l.includes('tipograf')) return 'tipografico';
if (l.includes('dato')) return 'dato-grande';
if (channel === 'rrss' && (l.includes('stor') || l.includes('reel'))) {
return 'stories_9_16';
}
return 'foto-con-franja';
}
/**
* Resolve canva_templates for n8n payloads.
* Prefer nested ads/rrss; fall back to flat keys; BPS gets env default.
*/
export function resolveTenantCanvaTemplates(
raw: unknown,
opts?: { slug?: string | null; channel?: 'ads' | 'rrss' }
): CanvaTemplateMap {
const channel = opts?.channel || 'ads';
let map = resolveCanvaTemplatesForChannel(raw, channel);
if (
opts?.slug === 'bps' &&
(!map || Object.keys(map).length === 0)
) {
const defaultBps = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
map = {
'foto-con-franja': defaultBps,
default: defaultBps,
};
}
return map;
}

433
src/lib/creatives-drive.ts Normal file
View File

@@ -0,0 +1,433 @@
/**
* Drive storage for generated creatives (Ads + RRSS).
*
* {carpeta-cliente}/
* Creatividades-Ads_{slug}/
* YYYY/MM/
* {slug}_ads_{formato}_{YYYY-MM-DD}_v{N}[.slide-K].ext
* Creatividades-RRSS_{slug}/
* YYYY/MM/
* {slug}_rrss_{formato}_{YYYY-MM-DD}_v{N}[.slide-K].ext
*
* Legacy RRSS root `Pipeline-creatividades_{slug}` is still accepted for reads/visibility.
*/
import { slugify } from '@/lib/calendar-import';
import {
applyMatches,
driveConfigured,
ensureChildFolder,
getTenantDriveFolderId,
listClientFoldersUnderClientes,
listFilesInFolder,
matchFoldersToTenants,
uploadToDrive,
getValidAccessToken,
} from '@/lib/google-drive';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
export type CreativeDriveChannel = 'ads' | 'rrss';
export type CreativeDriveItem = {
id: string;
title?: string | null;
format?: string | null;
caption?: string | null;
notes?: string | null;
scheduled_at?: string | null;
created_at?: string | null;
angle?: string | null;
hook?: string | null;
};
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
export function pieceLocalDate(item: CreativeDriveItem): Date {
const raw = item.scheduled_at || item.created_at;
if (raw) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d;
}
return new Date();
}
export function creativesDriveRootFolderName(
channel: CreativeDriveChannel,
clientSlug: string
): string {
const name = slugify(clientSlug) || 'cliente';
return channel === 'ads'
? `Creatividades-Ads_${name}`
: `Creatividades-RRSS_${name}`;
}
/** Legacy RRSS folder name (pre-split). */
export function legacyRrssPipelineFolderName(clientSlug: string): string {
const name = slugify(clientSlug) || 'cliente';
return `Pipeline-creatividades_${name}`;
}
export function isoDateStamp(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/**
* Concise Drive filename:
* {slug}_{ads|rrss}_{formato}_{YYYY-MM-DD}_v{N}[.slide-K].{ext}
*/
export function buildCreativeDriveFileName(opts: {
clientSlug: string;
channel: CreativeDriveChannel;
format: string;
date: Date;
version: number;
ext: string;
slideNumber?: number;
}): string {
const slug = slugify(opts.clientSlug) || 'cliente';
const format = slugify(opts.format || 'pieza') || 'pieza';
const date = isoDateStamp(opts.date);
const ver = Math.max(1, opts.version || 1);
const ext = (opts.ext || 'jpg').replace(/^\./, '').toLowerCase();
let base = `${slug}_${opts.channel}_${format}_${date}_v${ver}`;
if (opts.slideNumber && opts.slideNumber > 0) {
base += `.slide-${opts.slideNumber}`;
}
return `${base}.${ext}`;
}
/** Match existing versions for same slug/channel/format/date stem. */
export function nextVersionFromExistingNames(
fileNames: string[],
opts: {
clientSlug: string;
channel: CreativeDriveChannel;
format: string;
date: Date;
}
): number {
const slug = slugify(opts.clientSlug) || 'cliente';
const format = slugify(opts.format || 'pieza') || 'pieza';
const date = isoDateStamp(opts.date);
const prefix = `${slug}_${opts.channel}_${format}_${date}_v`;
let max = 0;
for (const name of fileNames) {
if (!name.startsWith(prefix)) continue;
const m = name.slice(prefix.length).match(/^(\d+)/);
if (m) max = Math.max(max, Number(m[1]));
}
return max + 1;
}
async function replaceDriveFileContent(
fileId: string,
mimeType: string,
buffer: Buffer
): Promise<void> {
const token = await getValidAccessToken();
const r = await fetch(
`https://www.googleapis.com/upload/drive/v3/files/${encodeURIComponent(
fileId
)}?uploadType=media&supportsAllDrives=true`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': mimeType,
},
body: new Uint8Array(buffer),
}
);
if (!r.ok) {
throw new Error(`Drive update: ${(await r.text()).slice(0, 300)}`);
}
}
async function uploadOrReplace(opts: {
parentFolderId: string;
fileName: string;
mimeType: string;
buffer: Buffer;
}): Promise<{ id: string; webViewLink?: string; replaced: boolean }> {
const existing = await listFilesInFolder(opts.parentFolderId);
const hit = existing.find(
(f) =>
f.name === opts.fileName &&
f.mimeType !== 'application/vnd.google-apps.folder'
);
if (hit) {
await replaceDriveFileContent(hit.id, opts.mimeType, opts.buffer);
return { id: hit.id, webViewLink: hit.webViewLink, replaced: true };
}
const created = await uploadToDrive(opts);
return { ...created, replaced: false };
}
async function loadTenant(tenantId: string): Promise<{
slug: string;
name: string;
} | null> {
const headers = serviceHeaders();
if (!headers) return null;
const r = await fetch(
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(
tenantId
)}&select=slug,name&limit=1`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return null;
const rows = (await r.json()) as { slug?: string; name?: string }[];
if (!rows[0]) return null;
return {
slug: rows[0].slug || rows[0].name || 'cliente',
name: rows[0].name || rows[0].slug || 'cliente',
};
}
export async function resolveOrLinkTenantDriveFolder(
tenantId: string,
clientLabel: string
): Promise<{ folderId: string; folderName?: string; autoLinked?: boolean } | null> {
const linked = await getTenantDriveFolderId(tenantId);
if (linked?.folderId) return linked;
const tenant = await loadTenant(tenantId);
if (!tenant) return null;
const { folders } = await listClientFoldersUnderClientes();
if (!folders.length) return null;
const matches = await matchFoldersToTenants(
[{ id: tenantId, name: tenant.name || clientLabel, slug: tenant.slug }],
folders
);
const hit = matches.find((m) => m.status === 'matched' && m.folderId);
if (!hit?.folderId) return null;
await applyMatches([hit]);
return {
folderId: hit.folderId,
folderName: hit.folderName || undefined,
autoLinked: true,
};
}
export async function syncCreativeAssetToDrive(opts: {
tenantId: string;
clientLabel: string;
channel: CreativeDriveChannel;
item: CreativeDriveItem;
buffer: Buffer;
mimeType: string;
ext: string;
slot?: 'cover' | 'slide' | 'asset';
slideIndex?: number;
}): Promise<{
ok: boolean;
skipped?: boolean;
warning?: string;
driveFileId?: string;
drivePath?: string;
fileName?: string;
autoLinked?: boolean;
}> {
if (!driveConfigured()) {
return {
ok: false,
skipped: true,
warning: 'Google Drive no configurado (OAuth agencia)',
};
}
let linked: {
folderId: string;
folderName?: string;
autoLinked?: boolean;
} | null = null;
try {
linked = await resolveOrLinkTenantDriveFolder(
opts.tenantId,
opts.clientLabel
);
} catch (e) {
return {
ok: false,
warning:
e instanceof Error
? `Drive auto-link: ${e.message}`
: 'Drive auto-link falló',
};
}
if (!linked?.folderId) {
return {
ok: false,
skipped: true,
warning:
'Cliente sin carpeta Drive. Empareja Drive en Agencia (o crea carpeta bajo 03-Clientes).',
};
}
try {
const tenant = await loadTenant(opts.tenantId);
const slug = tenant?.slug || opts.clientLabel || 'cliente';
const rootName = creativesDriveRootFolderName(opts.channel, slug);
const d = pieceLocalDate(opts.item);
const year = String(d.getFullYear());
const month = pad2(d.getMonth() + 1);
const format = opts.item.format || 'pieza';
const rootId = await ensureChildFolder(linked.folderId, rootName);
const yearId = await ensureChildFolder(rootId, year);
const monthId = await ensureChildFolder(yearId, month);
const existing = await listFilesInFolder(monthId);
const version = nextVersionFromExistingNames(
existing.map((f) => f.name),
{
clientSlug: slug,
channel: opts.channel,
format,
date: d,
}
);
let slideNumber: number | undefined;
if ((format || '').toLowerCase() === 'carrusel') {
const slot = opts.slot || 'cover';
const idx = opts.slideIndex || 0;
slideNumber = slot === 'cover' ? 1 : Math.max(2, idx + 2);
}
const fileName = buildCreativeDriveFileName({
clientSlug: slug,
channel: opts.channel,
format,
date: d,
version,
ext: opts.ext,
slideNumber,
});
const up = await uploadOrReplace({
parentFolderId: monthId,
fileName,
mimeType: opts.mimeType,
buffer: opts.buffer,
});
return {
ok: true,
driveFileId: up.id,
fileName,
drivePath: `${linked.folderName || 'cliente'}/${rootName}/${year}/${month}/${fileName}`,
autoLinked: linked.autoLinked,
};
} catch (e) {
return {
ok: false,
warning: e instanceof Error ? e.message : 'Error al subir a Drive',
};
}
}
/** Ensure both Ads + RRSS creatives folders exist under the client root. */
export async function ensureCreativesDriveFolders(
tenantId: string,
clientLabel: string
): Promise<{
ok: boolean;
ads?: string;
rrss?: string;
warning?: string;
}> {
if (!driveConfigured()) {
return { ok: false, warning: 'Drive no configurado' };
}
const linked = await resolveOrLinkTenantDriveFolder(tenantId, clientLabel);
if (!linked?.folderId) {
return { ok: false, warning: 'Sin carpeta cliente en Drive' };
}
const tenant = await loadTenant(tenantId);
const slug = tenant?.slug || clientLabel;
const adsName = creativesDriveRootFolderName('ads', slug);
const rrssName = creativesDriveRootFolderName('rrss', slug);
const adsId = await ensureChildFolder(linked.folderId, adsName);
const rrssId = await ensureChildFolder(linked.folderId, rrssName);
return { ok: true, ads: adsId, rrss: rrssId };
}
export async function backfillCreativeItemToDrive(opts: {
tenantId: string;
clientLabel: string;
channel: CreativeDriveChannel;
item: CreativeDriveItem & {
cover_path?: string | null;
slide_paths?: unknown;
storage_path?: string | null;
};
}): Promise<{
ok: boolean;
results: { slot: string; ok: boolean; detail: string }[];
}> {
const svc = serviceSupabase();
const results: { slot: string; ok: boolean; detail: string }[] = [];
if (!svc) {
return { ok: false, results: [{ slot: '-', ok: false, detail: 'no service' }] };
}
const jobs: { path: string; slot: 'cover' | 'slide' | 'asset'; index: number }[] =
[];
if (opts.channel === 'ads' && opts.item.storage_path) {
jobs.push({ path: opts.item.storage_path, slot: 'asset', index: 0 });
}
if (opts.item.cover_path && !/^https?:\/\//i.test(opts.item.cover_path)) {
jobs.push({ path: opts.item.cover_path, slot: 'cover', index: 0 });
}
const slides = Array.isArray(opts.item.slide_paths)
? (opts.item.slide_paths as string[])
: [];
slides.forEach((p, i) => {
if (p && !/^https?:\/\//i.test(p)) {
jobs.push({ path: p, slot: 'slide', index: i });
}
});
for (const job of jobs) {
const dl = await svc.storage.from(ASSETS_BUCKET).download(job.path);
if (dl.error || !dl.data) {
results.push({
slot: job.slot,
ok: false,
detail: dl.error?.message || 'download fail',
});
continue;
}
const ab = await dl.data.arrayBuffer();
const buf = Buffer.from(ab);
const ext =
job.path.split('.').pop()?.toLowerCase() ||
(dl.data.type.includes('png') ? 'png' : 'jpg');
const mime = dl.data.type || 'image/jpeg';
const sync = await syncCreativeAssetToDrive({
tenantId: opts.tenantId,
clientLabel: opts.clientLabel,
channel: opts.channel,
item: opts.item,
buffer: buf,
mimeType: mime,
ext,
slot: job.slot,
slideIndex: job.index,
});
results.push({
slot: `${job.slot}${job.slot === 'slide' ? job.index + 1 : ''}`,
ok: sync.ok,
detail: sync.ok ? sync.drivePath || 'ok' : sync.warning || 'fail',
});
}
return { ok: results.every((r) => r.ok), results };
}

View File

@@ -0,0 +1,66 @@
/**
* Canonical brand codes for portfolio clients that used to be merged.
*
* tyd = Talento y Deporte (deportivo)
* tsp = Talento Scholar Program (académico)
* ecp = Escuela de Cuidadores Profesionales
* dtatf = De Tú a Tú Formación
*/
export const BRAND_CANONICAL_SLUGS = {
tyd: 'tyd',
tsp: 'tsp',
ecp: 'ecp',
dtatf: 'dtatf',
} as const;
export type BrandCanonicalSlug =
(typeof BRAND_CANONICAL_SLUGS)[keyof typeof BRAND_CANONICAL_SLUGS];
/** Legacy → canonical (and identity). */
const SLUG_ALIASES: Record<string, BrandCanonicalSlug> = {
tyd: 'tyd',
'talento-y-deporte': 'tyd',
talentoydeporte: 'tyd',
tsp: 'tsp',
'talento-scholar-program': 'tsp',
'talento-scholar': 'tsp',
scholar: 'tsp',
ecp: 'ecp',
'escuela-de-cuidadores': 'ecp',
'escuela-cuidadores': 'ecp',
dtatf: 'dtatf',
'de-tu-a-tu-formacion': 'dtatf',
'de-tu-a-tu': 'dtatf',
detuatu: 'dtatf',
detuatuformacion: 'dtatf',
};
export const BRAND_DISPLAY_NAMES: Record<BrandCanonicalSlug, string> = {
tyd: 'Talento y Deporte',
tsp: 'Talento Scholar Program',
ecp: 'ECP · Escuela de Cuidadores Profesionales',
dtatf: 'De Tú a Tú Formación',
};
export const BRAND_SIBLINGS: Record<BrandCanonicalSlug, BrandCanonicalSlug> = {
tyd: 'tsp',
tsp: 'tyd',
ecp: 'dtatf',
dtatf: 'ecp',
};
/** Resolve any legacy/canonical slug to the brand DNA key. */
export function resolveBrandSlug(slug: string | null | undefined): string {
const s = String(slug || '')
.toLowerCase()
.trim();
if (!s) return s;
return SLUG_ALIASES[s] || s;
}
export function brandSiblingNote(slug: string): string | null {
const canon = resolveBrandSlug(slug);
if (!(canon in BRAND_SIBLINGS)) return null;
const sib = BRAND_SIBLINGS[canon as BrandCanonicalSlug];
return `Marca hermana (separada): ${BRAND_DISPLAY_NAMES[sib]} (${sib}). No mezclar creatividades, Meta ni Drive.`;
}