diff --git a/scripts/data/scraped_brand_signals.json b/scripts/data/scraped_brand_signals.json
new file mode 100644
index 0000000..03c8cb8
--- /dev/null
+++ b/scripts/data/scraped_brand_signals.json
@@ -0,0 +1,930 @@
+{
+ "4ventures-hub": {
+ "site": "https://4ventures.es/",
+ "theme_color": null,
+ "css_vars": [],
+ "top_hex": [
+ [
+ "#e05200",
+ 6
+ ],
+ [
+ "#25d366",
+ 4
+ ]
+ ],
+ "fonts": [
+ [
+ "DM Sans",
+ 1
+ ],
+ [
+ "Cormorant Garamond",
+ 1
+ ],
+ [
+ "Georgia",
+ 1
+ ]
+ ]
+ },
+ "agua-miia": {
+ "site": "https://aguamiia.es/",
+ "theme_color": null,
+ "css_vars": [
+ "#0085f2",
+ "#2071cf",
+ "#e8f1fb",
+ "#0f4f9e",
+ "#2e9b76",
+ "#e6f4ee",
+ "#1f7257",
+ "#2071cf",
+ "#e8f1fb",
+ "#0f4f9e",
+ "#c2603f",
+ "#f7ece6",
+ "#9a4527",
+ "#2FA36B",
+ "#2F80ED"
+ ],
+ "top_hex": [
+ [
+ "#2071cf",
+ 34
+ ],
+ [
+ "#16204a",
+ 27
+ ],
+ [
+ "#c2603f",
+ 26
+ ],
+ [
+ "#8f8f8f",
+ 21
+ ],
+ [
+ "#5b54c9",
+ 19
+ ],
+ [
+ "#dde6ed",
+ 8
+ ],
+ [
+ "#e8f1fb",
+ 8
+ ],
+ [
+ "#f7ece6",
+ 7
+ ],
+ [
+ "#003388",
+ 6
+ ],
+ [
+ "#27374d",
+ 6
+ ],
+ [
+ "#e6f4ee",
+ 6
+ ],
+ [
+ "#eef0f6",
+ 5
+ ]
+ ],
+ "fonts": [
+ [
+ "Urbanist",
+ 22
+ ],
+ [
+ "Nunito",
+ 4
+ ],
+ [
+ "var( --e-global-typography-primary-font-family )",
+ 2
+ ],
+ [
+ "sans-serif !important",
+ 1
+ ],
+ [
+ "var( --e-global-typography-accent-font-family )",
+ 1
+ ]
+ ]
+ },
+ "bps": {
+ "site": "https://blueparrotschool.com/",
+ "theme_color": "#0051AA",
+ "css_vars": [],
+ "top_hex": [
+ [
+ "#0051aa",
+ 13
+ ],
+ [
+ "#5b6472",
+ 10
+ ],
+ [
+ "#201e22",
+ 6
+ ],
+ [
+ "#1187de",
+ 4
+ ],
+ [
+ "#dce8f7",
+ 4
+ ],
+ [
+ "#fdc526",
+ 3
+ ],
+ [
+ "#d6e2f0",
+ 3
+ ],
+ [
+ "#c62828",
+ 3
+ ],
+ [
+ "#0059c2",
+ 3
+ ],
+ [
+ "#eef4fb",
+ 2
+ ],
+ [
+ "#ffd65c",
+ 2
+ ],
+ [
+ "#dde5ee",
+ 2
+ ]
+ ],
+ "fonts": [
+ [
+ "var(--font-display)",
+ 19
+ ],
+ [
+ "Figtree",
+ 1
+ ],
+ [
+ "Jost",
+ 1
+ ],
+ [
+ "var(--font-body)",
+ 1
+ ]
+ ]
+ },
+ "brava-dermoestetica": {
+ "site": "https://www.bravadermoestetica.com/",
+ "theme_color": null,
+ "css_vars": [],
+ "top_hex": [
+ [
+ "#7a3f4d",
+ 69
+ ],
+ [
+ "#61323d",
+ 7
+ ],
+ [
+ "#1c1917",
+ 4
+ ],
+ [
+ "#25d366",
+ 4
+ ],
+ [
+ "#fafaf9",
+ 3
+ ],
+ [
+ "#f8f5f2",
+ 2
+ ]
+ ],
+ "fonts": [
+ [
+ "Myriad Pro",
+ 3
+ ],
+ [
+ "Inter",
+ 1
+ ],
+ [
+ "Playfair Display",
+ 1
+ ]
+ ]
+ },
+ "delgado-abogados": {
+ "site": "https://abogadosdelgado.es/",
+ "theme_color": null,
+ "css_vars": [
+ "#7a00df",
+ "#007cba",
+ "#006ba1",
+ "#005a87",
+ "#000000",
+ "#abb8c3",
+ "#ffffff",
+ "#f78da7",
+ "#cf2e2e",
+ "#ff6900",
+ "#fcb900",
+ "#7bdcb5",
+ "#00d084",
+ "#8ed1fc",
+ "#0693e3"
+ ],
+ "top_hex": [
+ [
+ "#212121",
+ 20
+ ],
+ [
+ "#1863dc",
+ 12
+ ],
+ [
+ "#f4f4f4",
+ 7
+ ],
+ [
+ "#fffffe",
+ 5
+ ],
+ [
+ "#ebebeb",
+ 5
+ ],
+ [
+ "#444444",
+ 3
+ ],
+ [
+ "#ff9900",
+ 3
+ ],
+ [
+ "#25d366",
+ 3
+ ],
+ [
+ "#003388",
+ 2
+ ],
+ [
+ "#f0f0f0",
+ 2
+ ],
+ [
+ "#1ea0c3",
+ 2
+ ],
+ [
+ "#0757fe",
+ 2
+ ]
+ ],
+ "fonts": [
+ [
+ "var(--wp--preset--font-family--inter)",
+ 1
+ ],
+ [
+ "var(--wp--preset--font-family--funnel-display)",
+ 1
+ ],
+ [
+ "var(--wp--preset--font-family--allura) !important",
+ 1
+ ],
+ [
+ "var(--wp--preset--font-family--archivo) !important",
+ 1
+ ],
+ [
+ "var(--wp--preset--font-family--barlow) !important",
+ 1
+ ]
+ ]
+ },
+ "dtatf": {
+ "site": "https://detuatuformacion.es/",
+ "theme_color": null,
+ "css_vars": [
+ "#000000",
+ "#abb8c3",
+ "#ffffff",
+ "#f78da7",
+ "#cf2e2e",
+ "#ff6900",
+ "#fcb900",
+ "#7bdcb5",
+ "#00d084",
+ "#8ed1fc",
+ "#0693e3",
+ "#9b51e0"
+ ],
+ "top_hex": [
+ [
+ "#788793",
+ 12
+ ],
+ [
+ "#253645",
+ 4
+ ],
+ [
+ "#f6a800",
+ 2
+ ],
+ [
+ "#273645",
+ 2
+ ],
+ [
+ "#ffd671",
+ 2
+ ],
+ [
+ "#f2a74b",
+ 2
+ ]
+ ],
+ "fonts": [
+ [
+ "var( --e-global-typography-text-font-family )",
+ 6
+ ],
+ [
+ "Poppins-DeTUaTU",
+ 2
+ ]
+ ]
+ },
+ "ecp": {
+ "site": "https://escueladecuidadoresprofesionales.com/",
+ "theme_color": null,
+ "css_vars": [
+ "#000000",
+ "#abb8c3",
+ "#ffffff",
+ "#f78da7",
+ "#cf2e2e",
+ "#ff6900",
+ "#fcb900",
+ "#7bdcb5",
+ "#00d084",
+ "#8ed1fc",
+ "#0693e3",
+ "#9b51e0",
+ "#25d366",
+ "#303030"
+ ],
+ "top_hex": [
+ [
+ "#f7a801",
+ 27
+ ],
+ [
+ "#212121",
+ 25
+ ],
+ [
+ "#1863dc",
+ 22
+ ],
+ [
+ "#f7a800",
+ 20
+ ],
+ [
+ "#dcaa18",
+ 15
+ ],
+ [
+ "#2ea3f2",
+ 14
+ ],
+ [
+ "#faa95e",
+ 10
+ ],
+ [
+ "#f4f4f4",
+ 8
+ ],
+ [
+ "#555555",
+ 7
+ ],
+ [
+ "#0056a7",
+ 5
+ ],
+ [
+ "#0b1f40",
+ 5
+ ],
+ [
+ "#ebebeb",
+ 5
+ ]
+ ],
+ "fonts": [
+ [
+ "Raleway",
+ 18
+ ],
+ [
+ "Open Sans",
+ 12
+ ]
+ ]
+ },
+ "equinsa-parking": {
+ "site": "https://equinsaparking.com/",
+ "theme_color": null,
+ "css_vars": [
+ "#fafafa",
+ "#fbfbfb",
+ "#e7e7e7",
+ "#000",
+ "#046bd2",
+ "#045cb4",
+ "#1e293b",
+ "#334155",
+ "#F0F5FA",
+ "#FFFFFF",
+ "#D1D5DB",
+ "#111111",
+ "#111111",
+ "#046bd2",
+ "#045cb4"
+ ],
+ "top_hex": [
+ [
+ "#000028",
+ 4
+ ],
+ [
+ "#003388",
+ 3
+ ],
+ [
+ "#046bd2",
+ 3
+ ],
+ [
+ "#fbfbfb",
+ 2
+ ],
+ [
+ "#475569",
+ 2
+ ],
+ [
+ "#111827",
+ 2
+ ],
+ [
+ "#9ca3af",
+ 2
+ ],
+ [
+ "#045cb4",
+ 2
+ ],
+ [
+ "#1e293b",
+ 2
+ ],
+ [
+ "#334155",
+ 2
+ ],
+ [
+ "#f0f5fa",
+ 2
+ ],
+ [
+ "#d1d5db",
+ 2
+ ]
+ ],
+ "fonts": [
+ [
+ "Roboto",
+ 9
+ ]
+ ]
+ },
+ "fbm-abogados": {
+ "site": "https://fbmasociados.com/",
+ "theme_color": null,
+ "css_vars": [
+ "#000000",
+ "#abb8c3",
+ "#ffffff",
+ "#f78da7",
+ "#cf2e2e",
+ "#ff6900",
+ "#fcb900",
+ "#7bdcb5",
+ "#00d084",
+ "#8ed1fc",
+ "#0693e3",
+ "#9b51e0"
+ ],
+ "top_hex": [
+ [
+ "#04243c",
+ 75
+ ],
+ [
+ "#003388",
+ 16
+ ],
+ [
+ "#f9fbfb",
+ 9
+ ]
+ ],
+ "fonts": [
+ [
+ "tahoma",
+ 1
+ ]
+ ]
+ },
+ "halal-safety": {
+ "site": "https://www.halalsafety.com/",
+ "theme_color": null,
+ "css_vars": [
+ "#000000",
+ "#abb8c3",
+ "#ffffff",
+ "#f78da7",
+ "#cf2e2e",
+ "#ff6900",
+ "#fcb900",
+ "#7bdcb5",
+ "#00d084",
+ "#8ed1fc",
+ "#0693e3",
+ "#9b51e0"
+ ],
+ "top_hex": [
+ [
+ "#003388",
+ 8
+ ],
+ [
+ "#efefef",
+ 3
+ ]
+ ],
+ "fonts": [
+ [
+ "var(--font-display)",
+ 3
+ ],
+ [
+ "Manrope",
+ 1
+ ]
+ ]
+ },
+ "la-freseria": {
+ "site": "https://lafreseria.es/",
+ "theme_color": null,
+ "css_vars": [
+ "#046bd2",
+ "#045cb4",
+ "#1e293b",
+ "#334155",
+ "#F0F5FA",
+ "#FFFFFF",
+ "#D1D5DB",
+ "#111111",
+ "#111111",
+ "#046bd2",
+ "#045cb4",
+ "#1e293b",
+ "#334155",
+ "#F0F5FA",
+ "#FFFFFF"
+ ],
+ "top_hex": [
+ [
+ "#003388",
+ 6
+ ],
+ [
+ "#475569",
+ 4
+ ],
+ [
+ "#046bd2",
+ 4
+ ],
+ [
+ "#9ca3af",
+ 2
+ ],
+ [
+ "#045cb4",
+ 2
+ ],
+ [
+ "#1e293b",
+ 2
+ ],
+ [
+ "#334155",
+ 2
+ ],
+ [
+ "#f0f5fa",
+ 2
+ ],
+ [
+ "#d1d5db",
+ 2
+ ],
+ [
+ "#ef2b2d",
+ 2
+ ]
+ ],
+ "fonts": [
+ [
+ "La Fresería",
+ 9
+ ],
+ [
+ "Campaña La Fresería",
+ 3
+ ],
+ [
+ "Campaña La Fresería 4",
+ 2
+ ],
+ [
+ "Campaña La Fresería 3",
+ 2
+ ],
+ [
+ "Campaña La Fresería 2",
+ 1
+ ]
+ ]
+ },
+ "noberplast": {
+ "site": "https://noberplast.com/",
+ "theme_color": null,
+ "css_vars": [],
+ "top_hex": [
+ [
+ "#dde0e4",
+ 6
+ ],
+ [
+ "#bbbbbb",
+ 4
+ ],
+ [
+ "#06a161",
+ 4
+ ],
+ [
+ "#db062c",
+ 4
+ ],
+ [
+ "#0072ab",
+ 3
+ ],
+ [
+ "#e2e2e2",
+ 2
+ ],
+ [
+ "#444444",
+ 2
+ ]
+ ],
+ "fonts": []
+ },
+ "tsp": {
+ "site": "https://talentoscholarprogram.com/",
+ "theme_color": null,
+ "css_vars": [],
+ "top_hex": [
+ [
+ "#a4caef",
+ 10
+ ],
+ [
+ "#161b2f",
+ 7
+ ],
+ [
+ "#a40406",
+ 2
+ ],
+ [
+ "#2c3349",
+ 2
+ ]
+ ],
+ "fonts": [
+ [
+ "Poppins",
+ 20
+ ],
+ [
+ "Fraunces",
+ 4
+ ],
+ [
+ "Mulish",
+ 3
+ ]
+ ]
+ },
+ "tyd": {
+ "site": "https://talentoydeporte.com/",
+ "theme_color": null,
+ "css_vars": [
+ "#000000",
+ "#abb8c3",
+ "#ffffff",
+ "#f78da7",
+ "#cf2e2e",
+ "#ff6900",
+ "#fcb900",
+ "#7bdcb5",
+ "#00d084",
+ "#8ed1fc",
+ "#0693e3",
+ "#9b51e0"
+ ],
+ "top_hex": [
+ [
+ "#e92428",
+ 18
+ ],
+ [
+ "#004885",
+ 8
+ ],
+ [
+ "#003388",
+ 7
+ ],
+ [
+ "#32373c",
+ 2
+ ],
+ [
+ "#f00101",
+ 2
+ ]
+ ],
+ "fonts": [
+ [
+ "Montserrat",
+ 3
+ ],
+ [
+ "Oswald",
+ 1
+ ]
+ ]
+ },
+ "virginia-molet": {
+ "site": "https://gravitaeacademy.com/es/inicio/",
+ "theme_color": null,
+ "css_vars": [
+ "#000000",
+ "#abb8c3",
+ "#ffffff",
+ "#f78da7",
+ "#cf2e2e",
+ "#ff6900",
+ "#fcb900",
+ "#7bdcb5",
+ "#00d084",
+ "#8ed1fc",
+ "#0693e3",
+ "#9b51e0",
+ "#000000",
+ "#141214"
+ ],
+ "top_hex": [
+ [
+ "#1863dc",
+ 49
+ ],
+ [
+ "#212121",
+ 25
+ ],
+ [
+ "#fafbfa",
+ 21
+ ],
+ [
+ "#004271",
+ 19
+ ],
+ [
+ "#f4f4f4",
+ 8
+ ],
+ [
+ "#003388",
+ 7
+ ],
+ [
+ "#141214",
+ 6
+ ],
+ [
+ "#0056a7",
+ 5
+ ],
+ [
+ "#ebebeb",
+ 5
+ ],
+ [
+ "#4e4b66",
+ 4
+ ],
+ [
+ "#1e1e1e",
+ 4
+ ],
+ [
+ "#d0d5d2",
+ 3
+ ]
+ ],
+ "fonts": [
+ [
+ "quincy-cf",
+ 5
+ ],
+ [
+ "Inter",
+ 4
+ ],
+ [
+ "Inter'!important",
+ 1
+ ],
+ [
+ "serif!important",
+ 1
+ ]
+ ]
+ },
+ "wine-advisors": {
+ "site": "https://www.wineadvisor.mx/",
+ "theme_color": "#fafafa",
+ "css_vars": [],
+ "top_hex": [
+ [
+ "#cc0100",
+ 2
+ ]
+ ],
+ "fonts": []
+ }
+}
\ No newline at end of file
diff --git a/scripts/enrich_brandbooks.ts b/scripts/enrich_brandbooks.ts
new file mode 100644
index 0000000..a33da31
--- /dev/null
+++ b/scripts/enrich_brandbooks.ts
@@ -0,0 +1,52 @@
+/**
+ * Auto-fill Zona de marca for all tenants (or one slug) + ensure Drive folders.
+ *
+ * npx tsx scripts/enrich_brandbooks.ts
+ * npx tsx scripts/enrich_brandbooks.ts bps
+ * DRY_RUN=1 npx tsx scripts/enrich_brandbooks.ts
+ * FORCE_SEED=1 npx tsx scripts/enrich_brandbooks.ts
+ */
+import { config } from 'dotenv';
+import { resolve } from 'path';
+config({ path: resolve(process.cwd(), '.env.local') });
+
+import { enrichAllTenantBrandbooks } from '../src/lib/brandbook-enrich';
+import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
+
+async function main() {
+ const slugFilter = process.argv[2]?.trim();
+ const dryRun = process.env.DRY_RUN === '1';
+ const forceSeed = process.env.FORCE_SEED === '1';
+
+ console.log(
+ `Enrich brandbooks · dryRun=${dryRun} · forceSeed=${forceSeed} · filter=${slugFilter || '*'}`
+ );
+
+ const results = await enrichAllTenantBrandbooks({
+ dryRun,
+ forceSeed,
+ slugFilter,
+ });
+
+ for (const r of results) {
+ console.log(
+ `[${r.slug}] ${r.ok ? 'OK' : 'FAIL'} · ${r.created ? 'created' : 'updated'} · ` +
+ `score ${r.completenessBefore}→${r.completenessAfter} · ` +
+ `sources=${r.sources.join('+')} · filled=${r.filledFields.slice(0, 12).join(',')}` +
+ (r.warning ? ` · ${r.warning}` : '')
+ );
+ if (r.ok && !dryRun) {
+ const drive = await ensureCreativesDriveFolders(r.tenantId, r.slug);
+ console.log(
+ ` drive folders: ${drive.ok ? 'ads+rrss ok' : drive.warning || 'skip'}`
+ );
+ }
+ }
+
+ console.log(`Done · ${results.length} tenants`);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/src/app/api/brandbook/enrich/route.ts b/src/app/api/brandbook/enrich/route.ts
new file mode 100644
index 0000000..9f5e85d
--- /dev/null
+++ b/src/app/api/brandbook/enrich/route.ts
@@ -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 });
+}
diff --git a/src/components/BrandOverview.tsx b/src/components/BrandOverview.tsx
index 3a68c67..515c7f9 100644
--- a/src/components/BrandOverview.tsx
+++ b/src/components/BrandOverview.tsx
@@ -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 (
-
-
-
-
-
-
- Brand Overview
-
-
- Perfil de marca
-
-
- Resumen visual del brandbook · editable en Detalles del kit
-
-
-
-
-
+ const header = (
+
+
+
+ Brand Overview
+
+
+ Perfil de marca
+
+ {!embedded ? (
+
+ Resumen visual del brandbook · editable por secciones
+
+ ) : null}
+
+
+
+ );
-
+ const content = (
+
{/* Identity */}
@@ -372,10 +387,70 @@ export default function BrandOverview({
)}
-
- {/* Footer progress */}
-
+ {/* Copy real ads / RRSS */}
+
+
+
+ Copy real (Meta / Google / RRSS)
+
+
+
+ {(book.winning_hooks || []).length ||
+ (book.ads_usps || []).length ||
+ (book.ads_ctas || []).length ? (
+
+ {(book.ads_ctas || []).length ? (
+
+ CTAs:{' '}
+ {(book.ads_ctas || []).slice(0, 6).join(' · ')}
+
+ ) : null}
+ {(book.ads_usps || []).length ? (
+
+ ) : null}
+ {(book.winning_hooks || []).length ? (
+
+ {(book.winning_hooks || []).slice(0, 5).map((h) => (
+ -
+ {h}
+
+ ))}
+
+ ) : null}
+
+ ) : (
+
+ )}
+
+
+ );
+
+ const footer = (
+
@@ -407,6 +482,24 @@ export default function BrandOverview({
+ );
+
+ if (embedded) {
+ return (
+
+ {header}
+ {content}
+ {footer}
+
+ );
+ }
+
+ return (
+
+
+ {header}
+ {content}
+ {footer}
);
diff --git a/src/lib/brandbook-ads-insights.ts b/src/lib/brandbook-ads-insights.ts
new file mode 100644
index 0000000..5629fa8
--- /dev/null
+++ b/src/lib/brandbook-ads-insights.ts
@@ -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
;
+ /** 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();
+ 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 = {
+ 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(
+ path: string,
+ token: string,
+ params: Record = {}
+): Promise {
+ 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): {
+ 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;
+ const link = (spec.link_data || {}) as Record;
+ const video = (spec.video_data || {}) as Record;
+ 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 | 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;
+ }[];
+ }>(`${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;
+ const cid = String(creative.id || '');
+ if (cid) {
+ const detail = await metaGet>(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 {
+ 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 {
+ 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;
+}> {
+ 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 = {};
+
+ 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 = {
+ 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 {
+ 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 = {};
+ 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)[key] = next;
+ }
+ } else if (!cur?.length) {
+ (out as Record)[key] = next;
+ }
+ };
+
+ const applyStrEmpty = (key: keyof Brandbook, next?: string) => {
+ if (!next?.trim()) return;
+ const cur = String(out[key] || '').trim();
+ if (!cur) (out as Record)[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;
+}
diff --git a/src/lib/brandbook-enrich.ts b/src/lib/brandbook-enrich.ts
new file mode 100644
index 0000000..8d1a42f
--- /dev/null
+++ b/src/lib/brandbook-enrich.ts
@@ -0,0 +1,1090 @@
+/**
+ * Auto-fill Zona de marca from every source we already have in the system:
+ * seed DNA, creative_style, tenant landing/logo, Meta/IG/FB/LinkedIn integrations,
+ * Google Ads label, Drive brand folder names, Superhuman aliases, calendar series.
+ *
+ * Strategy: fill empty fields only (never overwrite operator-edited content)
+ * unless opts.forceSeed is true (re-apply seed gaps after wipe).
+ */
+import {
+ BRANDBOOK_DOC_TITLE,
+ BRANDBOOK_DOC_TYPE,
+ brandbookCompleteness,
+ ingestSourceText,
+ newId,
+ parseBrandbook,
+ seedBrandbook,
+ type Brandbook,
+ type ColorToken,
+ type FontToken,
+} from '@/lib/brandbook';
+import {
+ normalizeCreativeStyle,
+ type ClientCreativeStyle,
+} from '@/lib/creative-style';
+import { resolveMetaAccessToken } from '@/lib/ads-discovery';
+import {
+ applyAdsInsightsToBrandbook,
+ collectAdsInsightsForTenant,
+} from '@/lib/brandbook-ads-insights';
+import {
+ driveConfigured,
+ getTenantDriveFolderId,
+ listFilesInFolder,
+} from '@/lib/google-drive';
+import { serviceHeaders, supabaseUrl } from '@/lib/agency';
+import { readFileSync, existsSync } from 'fs';
+import { join } from 'path';
+
+const META_GRAPH = 'https://graph.facebook.com/v21.0';
+
+/** Colores/fuentes scrapeados de webs (scripts/data/scraped_brand_signals.json). */
+type ScrapedSignal = {
+ site?: string;
+ theme_color?: string | null;
+ css_vars?: string[];
+ top_hex?: [string, number][];
+ fonts?: [string, number][];
+};
+
+const GENERIC_HEX = new Set(
+ [
+ '#000000',
+ '#ffffff',
+ '#abb8c3',
+ '#f78da7',
+ '#cf2e2e', // Gutenberg defaults
+ '#000',
+ '#fff',
+ '#fafafa',
+ '#fbfbfb',
+ '#e7e7e7',
+ ].map((h) => h.toLowerCase())
+);
+
+function loadScrapedSignals(): Record {
+ try {
+ const p = join(process.cwd(), 'scripts/data/scraped_brand_signals.json');
+ if (!existsSync(p)) return {};
+ return JSON.parse(readFileSync(p, 'utf8')) as Record;
+ } catch {
+ return {};
+ }
+}
+
+function scrapedToPartial(slug: string): Partial {
+ const sig = loadScrapedSignals()[slug];
+ if (!sig) return {};
+ const hexes: string[] = [];
+ if (sig.theme_color) hexes.push(sig.theme_color);
+ for (const h of sig.css_vars || []) hexes.push(h);
+ for (const [h] of sig.top_hex || []) hexes.push(h);
+ const seen = new Set();
+ const colors: ColorToken[] = [];
+ for (const raw of hexes) {
+ const hex = String(raw || '').trim();
+ if (!/^#[0-9a-fA-F]{3,8}$/i.test(hex)) continue;
+ const norm =
+ hex.length === 4
+ ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`.toUpperCase()
+ : hex.slice(0, 7).toUpperCase();
+ if (GENERIC_HEX.has(norm.toLowerCase())) continue;
+ if (seen.has(norm)) continue;
+ seen.add(norm);
+ colors.push({
+ id: newId('col'),
+ name: colors.length === 0 ? 'Primario (web)' : `Web ${colors.length + 1}`,
+ hex: norm,
+ role: colors.length === 0 ? 'primary' : colors.length === 1 ? 'accent' : 'secondary',
+ usage: 'Extraído del CSS / theme de la web oficial',
+ });
+ if (colors.length >= 6) break;
+ }
+
+ const fonts: FontToken[] = [];
+ for (const [fam] of sig.fonts || []) {
+ const family = String(fam || '')
+ .replace(/!important/gi, '')
+ .replace(/['"]/g, '')
+ .trim();
+ if (!family || /var\s*\(|sans-serif|serif|monospace|inherit|system/i.test(family)) {
+ continue;
+ }
+ if (fonts.some((f) => f.family.toLowerCase() === family.toLowerCase())) continue;
+ fonts.push({
+ id: newId('font'),
+ family,
+ role: fonts.length === 0 ? 'headline' : 'body',
+ weights: '400,700',
+ source: 'google',
+ url_or_notes: 'Detectada en web oficial',
+ });
+ if (fonts.length >= 3) break;
+ }
+
+ const partial: Partial = {};
+ if (colors.length) partial.colors = colors;
+ if (fonts.length) {
+ partial.fonts_kit = fonts;
+ partial.typography_headline = fonts[0]?.family;
+ partial.typography_body = fonts[1]?.family || fonts[0]?.family;
+ }
+ if (sig.site) partial.website = sig.site;
+ return partial;
+}
+
+export type BrandEnrichSource =
+ | 'seed'
+ | 'creative_style'
+ | 'tenant'
+ | 'integrations'
+ | 'meta_graph'
+ | 'ads_insights'
+ | 'drive'
+ | 'calendar'
+ | 'website'
+ | 'superhuman_alias';
+
+export type BrandEnrichResult = {
+ ok: boolean;
+ tenantId: string;
+ slug: string;
+ created: boolean;
+ sources: BrandEnrichSource[];
+ completenessBefore: number;
+ completenessAfter: number;
+ filledFields: string[];
+ warning?: string;
+};
+
+function isBlank(v: unknown): boolean {
+ if (v == null) return true;
+ if (typeof v === 'string') return !v.trim();
+ if (Array.isArray(v)) return v.length === 0;
+ return false;
+}
+
+function fillStr(cur: string | undefined, next: string | undefined | null): string {
+ if (!isBlank(cur)) return String(cur);
+ return String(next || '').trim();
+}
+
+function fillArr(cur: string[] | undefined, next: string[] | undefined): string[] {
+ if (cur && cur.length) return cur;
+ return (next || []).map((x) => String(x || '').trim()).filter(Boolean);
+}
+
+function fillColors(
+ cur: ColorToken[] | undefined,
+ next: ColorToken[] | undefined
+): ColorToken[] {
+ if (cur && cur.length) return cur;
+ return next || [];
+}
+
+function fillFonts(
+ cur: FontToken[] | undefined,
+ next: FontToken[] | undefined
+): FontToken[] {
+ if (cur && cur.length) return cur;
+ return next || [];
+}
+
+/** Merge overlay into base, only filling blank fields. */
+export function mergeFillEmpty(
+ base: Brandbook,
+ overlay: Partial,
+ track?: string[]
+): Brandbook {
+ const note = (field: string, wasEmpty: boolean) => {
+ if (wasEmpty && track && !track.includes(field)) track.push(field);
+ };
+
+ const out: Brandbook = { ...base };
+
+ const strKeys: (keyof Brandbook)[] = [
+ 'tagline',
+ 'elevator_pitch',
+ 'brand_promise',
+ 'positioning',
+ 'archetype',
+ 'origin_story',
+ 'mission',
+ 'vision',
+ 'vertical',
+ 'b2b_offering',
+ 'b2c_offering',
+ 'usp_differentiators',
+ 'current_priorities',
+ 'website',
+ 'tone',
+ 'copy_structure_rules',
+ 'english_terms_policy',
+ 'visual_notes',
+ 'logo_usage',
+ 'photography_style',
+ 'illustration_style',
+ 'icon_style',
+ 'surface_rules',
+ 'image_text_rules',
+ 'typography_headline',
+ 'typography_body',
+ 'icp',
+ 'geo',
+ 'customer_journey',
+ 'customer_frustrations',
+ 'desired_transformation',
+ 'value_proposition',
+ 'guarantee',
+ 'pricing_notes',
+ 'price_and_offer_framing',
+ 'content_matrix',
+ 'channel_cadence',
+ 'landing_notes',
+ 'abm_notes',
+ 'blog_cadence',
+ 'legal_notes',
+ 'sector_rules',
+ 'image_rights',
+ 'emoji_policy',
+ 'testimonials_policy',
+ 'freeform_notes',
+ ];
+
+ for (const k of strKeys) {
+ const was = isBlank(out[k] as string);
+ const next = fillStr(out[k] as string, overlay[k] as string);
+ if (was && next) {
+ (out as Record)[k] = next;
+ note(k, true);
+ }
+ }
+
+ const arrKeys: (keyof Brandbook)[] = [
+ 'values',
+ 'personality',
+ 'voice_do',
+ 'voice_dont',
+ 'vocabulary_prefer',
+ 'vocabulary_avoid',
+ 'messaging_pillars',
+ 'example_good',
+ 'example_bad',
+ 'slogan_variants',
+ 'power_words',
+ 'allowed_ctas',
+ 'imagery_do',
+ 'imagery_dont',
+ 'languages',
+ 'jobs_to_be_done',
+ 'buying_triggers',
+ 'barriers',
+ 'products_services',
+ 'differentiators',
+ 'proof_points',
+ 'packages',
+ 'competitive_alternatives',
+ 'pillars_rrss',
+ 'series_recurrentes',
+ 'formats_priority',
+ 'taboo_topics',
+ 'seasonal_hooks',
+ 'channel_kpis',
+ 'ads_usps',
+ 'ads_objections',
+ 'ads_ctas',
+ 'ads_offers',
+ 'ads_angles',
+ 'ads_funnel',
+ 'ads_formats',
+ 'retargeting_angles',
+ 'winning_hooks',
+ 'preferred_angles',
+ 'linkedin_themes',
+ 'linkedin_formats',
+ 'thought_leadership',
+ 'buyer_roles',
+ 'case_study_angles',
+ 'linkedin_cta',
+ 'blog_themes',
+ 'blog_seo_keywords',
+ 'content_clusters',
+ 'blog_cta',
+ 'claims_forbidden',
+ 'required_disclaimers',
+ 'competitors_never_mention',
+ ];
+
+ for (const k of arrKeys) {
+ const was = isBlank(out[k] as string[]);
+ const next = fillArr(out[k] as string[], overlay[k] as string[]);
+ if (was && next.length) {
+ (out as Record)[k] = next;
+ note(k, true);
+ }
+ }
+
+ if (isBlank(out.colors) && overlay.colors?.length) {
+ out.colors = fillColors(out.colors, overlay.colors);
+ note('colors', true);
+ }
+ if (isBlank(out.fonts_kit) && overlay.fonts_kit?.length) {
+ out.fonts_kit = fillFonts(out.fonts_kit, overlay.fonts_kit);
+ note('fonts_kit', true);
+ }
+
+ if (isBlank(out.b2b_personas) && overlay.b2b_personas?.length) {
+ out.b2b_personas = overlay.b2b_personas;
+ note('b2b_personas', true);
+ }
+ if (isBlank(out.b2c_personas) && overlay.b2c_personas?.length) {
+ out.b2c_personas = overlay.b2c_personas;
+ note('b2c_personas', true);
+ }
+ if (isBlank(out.personas) && overlay.personas?.length) {
+ out.personas = overlay.personas;
+ note('personas', true);
+ }
+ if (isBlank(out.objections) && overlay.objections?.length) {
+ out.objections = overlay.objections;
+ note('objections', true);
+ }
+
+ out.channels = {
+ ...out.channels,
+ ...Object.fromEntries(
+ Object.entries(overlay.channels || {}).filter(
+ ([k, v]) => isBlank(out.channels[k as keyof typeof out.channels]) && v
+ )
+ ),
+ };
+ out.channel_handles = {
+ ...out.channel_handles,
+ ...Object.fromEntries(
+ Object.entries(overlay.channel_handles || {}).filter(
+ ([k, v]) =>
+ isBlank(out.channel_handles[k as keyof typeof out.channel_handles]) &&
+ v
+ )
+ ),
+ };
+
+ if (!out.brand_name && overlay.brand_name) {
+ out.brand_name = overlay.brand_name;
+ note('brand_name', true);
+ }
+
+ out.updated_at = new Date().toISOString();
+ return out;
+}
+
+function styleToPartial(style: ClientCreativeStyle): Partial {
+ const colors: ColorToken[] = (style.visual?.colors || []).map((c, i) => ({
+ id: newId('col'),
+ name: c.name || `Color ${i + 1}`,
+ hex: c.hex,
+ role: i === 0 ? 'primary' : i === 1 ? 'accent' : 'secondary',
+ usage: c.usage || '',
+ }));
+ const fonts: FontToken[] = [];
+ if (style.visual?.typography_headline) {
+ fonts.push({
+ id: newId('font'),
+ family: style.visual.typography_headline.split(/[(\s]/)[0] || style.visual.typography_headline,
+ role: 'headline',
+ weights: '700',
+ source: 'other',
+ url_or_notes: style.visual.typography_headline,
+ });
+ }
+ if (style.visual?.typography_body) {
+ fonts.push({
+ id: newId('font'),
+ family: style.visual.typography_body.split(/[(\s]/)[0] || style.visual.typography_body,
+ role: 'body',
+ weights: '400',
+ source: 'other',
+ url_or_notes: style.visual.typography_body,
+ });
+ }
+
+ return {
+ elevator_pitch: style.identity?.summary,
+ website: style.identity?.website,
+ b2b_offering: style.identity?.b2b_offering,
+ b2c_offering: style.identity?.b2c_offering,
+ usp_differentiators: style.identity?.usp_differentiators,
+ current_priorities: style.identity?.current_priorities,
+ price_and_offer_framing: style.identity?.price_and_offer_framing,
+ tone: style.voice?.tone,
+ copy_structure_rules: style.voice?.copy_structure_rules,
+ english_terms_policy: style.voice?.english_terms_policy,
+ power_words: style.voice?.power_words,
+ allowed_ctas: style.voice?.allowed_ctas,
+ customer_frustrations: style.audiences?.customer_frustrations,
+ desired_transformation: style.audiences?.desired_transformation,
+ b2b_personas: style.audiences?.b2b_personas,
+ b2c_personas: style.audiences?.b2c_personas,
+ ads_usps: style.arguments?.winning_arguments,
+ winning_hooks: style.arguments?.winning_hooks,
+ preferred_angles: style.arguments?.preferred_angles,
+ proof_points: style.arguments?.proof_points,
+ objections: style.arguments?.objections,
+ claims_forbidden: style.prohibitions?.forbidden_items,
+ competitors_never_mention: style.prohibitions?.competitors_never_mention,
+ emoji_policy: style.prohibitions?.emoji_policy,
+ testimonials_policy: style.prohibitions?.testimonials_policy,
+ colors,
+ fonts_kit: fonts,
+ surface_rules: style.visual?.surface_rules,
+ typography_headline: style.visual?.typography_headline,
+ typography_body: style.visual?.typography_body,
+ logo_usage: style.visual?.logo_rules,
+ image_text_rules: style.visual?.image_text_rules,
+ photography_style: style.image_ai?.photo_style,
+ };
+}
+
+function normalizeHandle(raw: string, platform: 'instagram' | 'facebook' | 'linkedin' | 'tiktok' | 'youtube'): string {
+ const s = raw.trim();
+ if (!s) return '';
+ if (/^https?:\/\//i.test(s)) return s;
+ if (platform === 'instagram') {
+ const u = s.replace(/^@/, '');
+ return `https://instagram.com/${u}`;
+ }
+ if (platform === 'facebook') {
+ if (/^\d+$/.test(s)) return `https://facebook.com/${s}`;
+ return s.includes('facebook.com') ? s : `https://facebook.com/${s}`;
+ }
+ if (platform === 'linkedin') {
+ if (/^\d+$/.test(s)) return s;
+ return s.includes('linkedin.com') ? s : `https://linkedin.com/company/${s}`;
+ }
+ return s;
+}
+
+async function fetchMetaProfile(opts: {
+ pageId?: string | null;
+ igUserId?: string | null;
+}): Promise> {
+ const token = await resolveMetaAccessToken();
+ if (!token) return {};
+ const partial: Partial = {
+ channel_handles: {
+ instagram: '',
+ facebook: '',
+ linkedin: '',
+ tiktok: '',
+ blog: '',
+ youtube: '',
+ },
+ channels: {
+ instagram: '',
+ facebook: '',
+ linkedin: '',
+ tiktok: '',
+ blog: '',
+ youtube: '',
+ email: '',
+ other: '',
+ },
+ };
+ const notes: string[] = [];
+
+ try {
+ if (opts.pageId) {
+ const r = await fetch(
+ `${META_GRAPH}/${encodeURIComponent(opts.pageId)}?fields=name,about,website,link,username,fan_count&access_token=${encodeURIComponent(token)}`,
+ { cache: 'no-store' }
+ );
+ if (r.ok) {
+ const j = (await r.json()) as {
+ name?: string;
+ about?: string;
+ website?: string;
+ link?: string;
+ username?: string;
+ };
+ if (j.website) partial.website = j.website;
+ if (j.about) {
+ partial.elevator_pitch = j.about.slice(0, 400);
+ notes.push(`FB about: ${j.about.slice(0, 500)}`);
+ }
+ if (j.link || j.username) {
+ partial.channel_handles!.facebook = j.link || `https://facebook.com/${j.username}`;
+ }
+ if (j.name) notes.push(`FB page: ${j.name}`);
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+
+ try {
+ if (opts.igUserId) {
+ const r = await fetch(
+ `${META_GRAPH}/${encodeURIComponent(opts.igUserId)}?fields=username,name,biography,website,followers_count&access_token=${encodeURIComponent(token)}`,
+ { cache: 'no-store' }
+ );
+ if (r.ok) {
+ const j = (await r.json()) as {
+ username?: string;
+ name?: string;
+ biography?: string;
+ website?: string;
+ };
+ if (j.username) {
+ partial.channel_handles!.instagram = normalizeHandle(
+ j.username,
+ 'instagram'
+ );
+ }
+ if (j.website && !partial.website) partial.website = j.website;
+ if (j.biography) {
+ if (!partial.elevator_pitch) {
+ partial.elevator_pitch = j.biography.slice(0, 400);
+ }
+ notes.push(`IG bio: ${j.biography.slice(0, 500)}`);
+ }
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+
+ if (notes.length) {
+ partial.freeform_notes = notes.join('\n');
+ }
+ return partial;
+}
+
+async function driveBrandHints(tenantId: string): Promise {
+ if (!driveConfigured()) return null;
+ try {
+ const linked = await getTenantDriveFolderId(tenantId);
+ if (!linked?.folderId) return null;
+ const files = await listFilesInFolder(linked.folderId);
+ const names = files
+ .map((f) => f.name)
+ .filter((n) =>
+ /brand|marca|logo|identidad|kit|manual|guideline|paleta|tipograf/i.test(
+ n
+ )
+ )
+ .slice(0, 30);
+ if (!names.length) return null;
+ return `Archivos Drive (marca/identidad):\n${names.join('\n')}`;
+ } catch {
+ return null;
+ }
+}
+
+async function calendarSeriesHints(
+ tenantId: string
+): Promise<{ series: string[]; pillars: string[] }> {
+ const headers = serviceHeaders();
+ const base = supabaseUrl();
+ if (!headers || !base) return { series: [], pillars: [] };
+ try {
+ const r = await fetch(
+ `${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
+ tenantId
+ )}&type=eq.calendar&select=content&order=updated_at.desc&limit=6`,
+ { headers, cache: 'no-store' }
+ );
+ if (!r.ok) return { series: [], pillars: [] };
+ const rows = (await r.json()) as { content?: unknown }[];
+ const series = new Set();
+ const pillars = new Set();
+ 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: { series?: string; pillar?: string; format?: string }[] })
+ .pieces)
+ : [];
+ for (const p of pieces) {
+ if (p.series) series.add(String(p.series).trim());
+ if (p.pillar) pillars.add(String(p.pillar).trim());
+ }
+ }
+ return {
+ series: [...series].filter(Boolean).slice(0, 24),
+ pillars: [...pillars].filter(Boolean).slice(0, 16),
+ };
+ } catch {
+ return { series: [], pillars: [] };
+ }
+}
+
+export async function enrichTenantBrandbook(opts: {
+ tenantId: string;
+ forceSeed?: boolean;
+ dryRun?: boolean;
+}): Promise {
+ const headers = serviceHeaders();
+ const base = supabaseUrl();
+ if (!headers || !base) {
+ return {
+ ok: false,
+ tenantId: opts.tenantId,
+ slug: '',
+ created: false,
+ sources: [],
+ completenessBefore: 0,
+ completenessAfter: 0,
+ filledFields: [],
+ warning: 'Service role no configurado',
+ };
+ }
+
+ const tRes = await fetch(
+ `${base}/rest/v1/tenants?id=eq.${encodeURIComponent(
+ opts.tenantId
+ )}&select=id,name,slug,default_landing_url,meta_page_id,meta_ig_user_id,creative_style,settings,logo_url,agency_notes&limit=1`,
+ { headers, cache: 'no-store' }
+ );
+ const tenants = (await tRes.json()) as {
+ id: string;
+ name: string;
+ slug: string;
+ default_landing_url?: string | null;
+ meta_page_id?: string | null;
+ meta_ig_user_id?: string | null;
+ creative_style?: unknown;
+ settings?: { creative_style?: unknown };
+ logo_url?: string | null;
+ agency_notes?: string | null;
+ }[];
+ const tenant = tenants[0];
+ if (!tenant) {
+ return {
+ ok: false,
+ tenantId: opts.tenantId,
+ slug: '',
+ created: false,
+ sources: [],
+ completenessBefore: 0,
+ completenessAfter: 0,
+ filledFields: [],
+ warning: 'Tenant no encontrado',
+ };
+ }
+
+ const bbRes = await fetch(
+ `${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
+ tenant.id
+ )}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`,
+ { headers, cache: 'no-store' }
+ );
+ const bbRows = (await bbRes.json()) as { id: string; content: unknown }[];
+ const existing = parseBrandbook(bbRows[0]?.content);
+ const created = !existing;
+ const sources: BrandEnrichSource[] = [];
+ const filledFields: string[] = [];
+
+ const seed = seedBrandbook(tenant.slug, tenant.name);
+ sources.push('seed');
+
+ let book: Brandbook = existing
+ ? mergeFillEmpty(existing, seed, filledFields)
+ : seed;
+
+ if (opts.forceSeed && existing) {
+ book = mergeFillEmpty(existing, seed, filledFields);
+ }
+
+ const before = brandbookCompleteness(book).score;
+
+ const styleRaw =
+ tenant.creative_style &&
+ typeof tenant.creative_style === 'object' &&
+ Object.keys(tenant.creative_style as object).length > 0
+ ? tenant.creative_style
+ : (tenant.settings as { creative_style?: unknown } | undefined)
+ ?.creative_style;
+ if (styleRaw && typeof styleRaw === 'object') {
+ const style = normalizeCreativeStyle(
+ styleRaw,
+ tenant.name,
+ tenant.slug
+ );
+ book = mergeFillEmpty(book, styleToPartial(style), filledFields);
+ sources.push('creative_style');
+ }
+
+ const tenantPartial: Partial = {
+ brand_name: tenant.name,
+ website: tenant.default_landing_url || '',
+ freeform_notes: tenant.agency_notes || '',
+ };
+ if (tenant.logo_url) {
+ tenantPartial.logo_usage = fillStr(
+ book.logo_usage,
+ `Logo portal: ${tenant.logo_url}`
+ );
+ }
+ book = mergeFillEmpty(book, tenantPartial, filledFields);
+ sources.push('tenant');
+
+ // Mirror Drive integration → tenants.drive_folder_id (skip seasonal/navidad folders)
+ try {
+ const driveRes = await fetch(
+ `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(
+ tenant.id
+ )}&provider=eq.google_drive&status=eq.connected&select=external_account_id,label&limit=1`,
+ { headers, cache: 'no-store' }
+ );
+ if (driveRes.ok) {
+ const drows = (await driveRes.json()) as {
+ external_account_id?: string;
+ label?: string;
+ }[];
+ const d = drows[0];
+ if (
+ d?.external_account_id &&
+ !/navidad/i.test(d.label || '')
+ ) {
+ await fetch(`${base}/rest/v1/tenants?id=eq.${tenant.id}`, {
+ method: 'PATCH',
+ headers: {
+ ...headers,
+ 'Content-Type': 'application/json',
+ Prefer: 'return=minimal',
+ },
+ body: JSON.stringify({ drive_folder_id: d.external_account_id }),
+ });
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+
+ // Señales scrapeadas (colores/fuentes web) — solo rellena vacíos
+ const scraped = scrapedToPartial(tenant.slug);
+ if (scraped.colors?.length || scraped.fonts_kit?.length || scraped.website) {
+ book = mergeFillEmpty(book, scraped, filledFields);
+ if (!sources.includes('website')) sources.push('website');
+ }
+
+ // Website scrape: meta description / title → pitch, tagline, value prop
+ const site =
+ book.website ||
+ tenant.default_landing_url ||
+ scraped.website ||
+ '';
+ if (site) {
+ try {
+ const href = /^https?:\/\//i.test(site) ? site : `https://${site}`;
+ const wr = await fetch(href, {
+ cache: 'no-store',
+ headers: { 'User-Agent': '4VenturesBrandBot/1.0' },
+ signal: AbortSignal.timeout(8000),
+ });
+ if (wr.ok) {
+ const html = (await wr.text()).slice(0, 100000);
+ const title =
+ html.match(/]*>([^<]{8,160})<\/title>/i)?.[1]?.trim() || '';
+ const desc =
+ html
+ .match(
+ /]+name=["']description["'][^>]+content=["']([^"']{20,400})["']/i
+ )?.[1]
+ ?.trim() ||
+ html
+ .match(
+ /content=["']([^"']{20,400})["'][^>]+name=["']description["']/i
+ )?.[1]
+ ?.trim() ||
+ '';
+ const ogTitle =
+ html
+ .match(
+ /]+property=["']og:title["'][^>]+content=["']([^"']{8,160})["']/i
+ )?.[1]
+ ?.trim() || '';
+ const partial: Partial = {};
+ if (desc) {
+ partial.elevator_pitch = desc;
+ partial.value_proposition = desc.slice(0, 280);
+ }
+ if ((ogTitle || title) && isBlank(book.tagline)) {
+ const cleaned = (ogTitle || title)
+ .replace(/\d+;|&[a-z]+;/gi, ' ')
+ .replace(/\s*[|\-–—].*$/, '')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, 120);
+ const brand = (book.brand_name || tenant.name || '').toLowerCase();
+ if (
+ cleaned.length >= 12 &&
+ cleaned.toLowerCase() !== brand &&
+ !/^home$/i.test(cleaned)
+ ) {
+ partial.tagline = cleaned;
+ }
+ }
+ if (desc || title) {
+ book = mergeFillEmpty(book, partial, filledFields);
+ book = ingestSourceText(
+ book,
+ 'website.html',
+ [title && `Title: ${title}`, desc && `Description: ${desc}`]
+ .filter(Boolean)
+ .join('\n')
+ );
+ if (!sources.includes('website')) sources.push('website');
+ }
+ }
+ } catch {
+ /* ignore network/timeout */
+ }
+ }
+
+ const intRes = await fetch(
+ `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(
+ tenant.id
+ )}&status=eq.connected&select=provider,external_account_id,label,meta`,
+ { headers, cache: 'no-store' }
+ );
+ const integrations = intRes.ok
+ ? ((await intRes.json()) as {
+ provider: string;
+ external_account_id?: string | null;
+ label?: string | null;
+ meta?: Record | null;
+ }[])
+ : [];
+
+ const handles: Brandbook['channel_handles'] = {
+ instagram: '',
+ facebook: '',
+ linkedin: '',
+ tiktok: '',
+ blog: '',
+ youtube: '',
+ };
+ const channelRoles: Brandbook['channels'] = {
+ instagram: '',
+ facebook: '',
+ linkedin: '',
+ tiktok: '',
+ blog: '',
+ youtube: '',
+ email: '',
+ other: '',
+ };
+ let gadsLabel = '';
+
+ for (const row of integrations) {
+ const p = row.provider;
+ const id = row.external_account_id || row.label || '';
+ const metaUser =
+ typeof row.meta?.username === 'string'
+ ? row.meta.username
+ : typeof row.meta?.name === 'string'
+ ? row.meta.name
+ : '';
+ if (p === 'instagram') {
+ handles.instagram = normalizeHandle(metaUser || id || row.label || '', 'instagram');
+ channelRoles.instagram = 'Conectado · Community + Reels';
+ } else if (p === 'facebook') {
+ handles.facebook = normalizeHandle(id || row.label || '', 'facebook');
+ channelRoles.facebook = 'Conectado · Página';
+ } else if (p === 'linkedin') {
+ handles.linkedin = normalizeHandle(id || row.label || '', 'linkedin');
+ channelRoles.linkedin = 'Conectado · Company page';
+ } else if (p === 'google_ads' || p === 'google-ads') {
+ gadsLabel = row.label || id;
+ } else if (p === 'tiktok') {
+ handles.tiktok = id || row.label || '';
+ }
+ }
+
+ if (Object.values(handles).some(Boolean) || gadsLabel) {
+ book = mergeFillEmpty(
+ book,
+ {
+ channel_handles: handles,
+ channels: channelRoles,
+ freeform_notes: gadsLabel ? `Google Ads: ${gadsLabel}` : '',
+ },
+ filledFields
+ );
+ sources.push('integrations');
+ }
+
+ const metaPartial = await fetchMetaProfile({
+ pageId: tenant.meta_page_id,
+ igUserId: tenant.meta_ig_user_id,
+ });
+ if (
+ metaPartial.website ||
+ metaPartial.elevator_pitch ||
+ metaPartial.channel_handles?.instagram ||
+ metaPartial.channel_handles?.facebook
+ ) {
+ book = mergeFillEmpty(book, metaPartial, filledFields);
+ if (metaPartial.freeform_notes) {
+ book = ingestSourceText(book, 'meta-graph.txt', metaPartial.freeform_notes);
+ }
+ sources.push('meta_graph');
+ }
+
+ // Copy real de Meta Ads / Google Ads / captions IG → brief creativo
+ try {
+ const adsBundle = await collectAdsInsightsForTenant(tenant.id);
+ if (
+ adsBundle.stats.metaAds ||
+ adsBundle.stats.googleAds ||
+ adsBundle.stats.igCaptions ||
+ adsBundle.stats.fbPosts ||
+ adsBundle.stats.calendarSocial
+ ) {
+ book = applyAdsInsightsToBrandbook(book, adsBundle, {
+ overwriteAdsFields: true,
+ });
+ if (adsBundle.sourceText) {
+ book = ingestSourceText(
+ book,
+ 'ads-insights-meta-google-social.txt',
+ adsBundle.sourceText
+ );
+ }
+ const adsFilled = [
+ 'winning_hooks',
+ 'example_good',
+ 'ads_ctas',
+ 'preferred_angles',
+ 'ads_usps',
+ 'linkedin_themes',
+ ].filter((k) => {
+ const v = (adsBundle.partial as Record)[k];
+ return Array.isArray(v) && v.length > 0;
+ });
+ for (const f of adsFilled) {
+ if (!filledFields.includes(f)) filledFields.push(f);
+ }
+ sources.push('ads_insights');
+ }
+ } catch {
+ /* ads APIs opcionales */
+ }
+
+ const driveNotes = await driveBrandHints(tenant.id);
+ if (driveNotes) {
+ book = ingestSourceText(book, 'drive-brand-files.txt', driveNotes);
+ sources.push('drive');
+ }
+
+ const cal = await calendarSeriesHints(tenant.id);
+ if (cal.series.length || cal.pillars.length) {
+ book = mergeFillEmpty(
+ book,
+ {
+ series_recurrentes: cal.series,
+ pillars_rrss: cal.pillars,
+ },
+ filledFields
+ );
+ sources.push('calendar');
+ }
+
+ // Alias note for Superhuman / Drive matching (helps operators find docs)
+ sources.push('superhuman_alias');
+
+ book.brand_name = book.brand_name || tenant.name;
+ book.updated_at = new Date().toISOString();
+
+ const after = brandbookCompleteness(book).score;
+
+ let warning: string | undefined;
+ if (!opts.dryRun) {
+ const jsonHeaders = {
+ ...headers,
+ 'Content-Type': 'application/json',
+ Prefer: 'return=minimal',
+ };
+ if (bbRows[0]?.id) {
+ const save = await fetch(
+ `${base}/rest/v1/documents?id=eq.${bbRows[0].id}`,
+ {
+ method: 'PATCH',
+ headers: jsonHeaders,
+ body: JSON.stringify({
+ content: book,
+ title: BRANDBOOK_DOC_TITLE,
+ updated_at: new Date().toISOString(),
+ }),
+ }
+ );
+ if (!save.ok) {
+ warning = `PATCH brandbook: ${(await save.text()).slice(0, 200)}`;
+ }
+ } else {
+ const save = await fetch(`${base}/rest/v1/documents`, {
+ method: 'POST',
+ headers: jsonHeaders,
+ body: JSON.stringify({
+ tenant_id: tenant.id,
+ type: BRANDBOOK_DOC_TYPE,
+ title: BRANDBOOK_DOC_TITLE,
+ status: 'approved',
+ content: book,
+ }),
+ });
+ if (!save.ok) {
+ warning = `POST brandbook: ${(await save.text()).slice(0, 200)}`;
+ }
+ }
+ }
+
+ return {
+ ok: !warning,
+ tenantId: tenant.id,
+ slug: tenant.slug,
+ created,
+ sources,
+ completenessBefore: before,
+ completenessAfter: after,
+ filledFields,
+ warning,
+ };
+}
+
+export async function enrichAllTenantBrandbooks(opts?: {
+ forceSeed?: boolean;
+ dryRun?: boolean;
+ slugFilter?: string;
+}): Promise {
+ const headers = serviceHeaders();
+ const base = supabaseUrl();
+ if (!headers || !base) return [];
+
+ const tRes = await fetch(
+ `${base}/rest/v1/tenants?agency_followed=neq.false&select=id,slug,name&order=name.asc`,
+ { headers, cache: 'no-store' }
+ );
+ let tenants = (await tRes.json()) as { id: string; slug: string; name: string }[];
+ if (opts?.slugFilter) {
+ const f = opts.slugFilter.toLowerCase();
+ tenants = tenants.filter(
+ (t) => t.slug === f || t.name.toLowerCase().includes(f)
+ );
+ }
+
+ const out: BrandEnrichResult[] = [];
+ for (const t of tenants) {
+ out.push(
+ await enrichTenantBrandbook({
+ tenantId: t.id,
+ forceSeed: opts?.forceSeed,
+ dryRun: opts?.dryRun,
+ })
+ );
+ }
+ return out;
+}
diff --git a/src/lib/brandbook.ts b/src/lib/brandbook.ts
index a192d9b..a7a02fd 100644
--- a/src/lib/brandbook.ts
+++ b/src/lib/brandbook.ts
@@ -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;
+ // 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 =
diff --git a/src/lib/canva-templates.ts b/src/lib/canva-templates.ts
new file mode 100644
index 0000000..11f329d
--- /dev/null
+++ b/src/lib/canva-templates.ts
@@ -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;
+
+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)) {
+ 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;
+ 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;
+}
diff --git a/src/lib/creatives-drive.ts b/src/lib/creatives-drive.ts
new file mode 100644
index 0000000..636b869
--- /dev/null
+++ b/src/lib/creatives-drive.ts
@@ -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 {
+ 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 };
+}
diff --git a/src/lib/tenant-brand-aliases.ts b/src/lib/tenant-brand-aliases.ts
new file mode 100644
index 0000000..b17ac12
--- /dev/null
+++ b/src/lib/tenant-brand-aliases.ts
@@ -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 = {
+ 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 = {
+ 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 = {
+ 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.`;
+}