Compare commits

...

2 Commits

Author SHA1 Message Date
4Ventures
0b3f80d790 fix: aplicar layout Estilo/Brief a Zona de marca tras rebase
Nav lateral numerada + panel editable como creatividades; Auto-rellenar;
parse brandbook robusto para que app.4ventures.es muestre el kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 03:28:18 +02:00
4Ventures
3cb3ad1db1 feat: Zona de marca tipo Estilo/Brief + brandbooks desde ads/RRSS
UI con nav lateral numerada y panel editable; parse de brandbook JSON string;
enrich desde Meta/Google/IG/FB/web/CSS para que app.4ventures.es muestre el kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 03:24:45 +02:00
11 changed files with 4153 additions and 314 deletions

View File

@@ -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": []
}
}

View File

@@ -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);
});

View File

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

View File

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

View File

@@ -18,19 +18,27 @@ import {
X, X,
Check, Check,
Rss, Rss,
Building2,
MessageSquareText,
Users,
Package,
Layers,
Share2,
Megaphone,
Linkedin,
FileText,
ShieldCheck,
LayoutDashboard,
} from 'lucide-react'; } from 'lucide-react';
import type { BlogAnalysisResult } from '@/lib/blog-analyzer'; import type { BlogAnalysisResult } from '@/lib/blog-analyzer';
import type { B2BAnalysisResult } from '@/lib/b2b-analyzer'; import type { B2BAnalysisResult } from '@/lib/b2b-analyzer';
import { PageHeader, Surface, SectionTitle, SegmentedControl } from '@/components/ui';
import { import {
ASSET_KIND_LABELS, ASSET_KIND_LABELS,
BRANDBOOK_SECTIONS,
emptyBrandbook, emptyBrandbook,
linesToList, linesToList,
listToLines, listToLines,
newId, newId,
normalizeHex, normalizeHex,
primaryLogo,
type BrandAssetKind, type BrandAssetKind,
type Brandbook, type Brandbook,
type BrandbookSectionId, type BrandbookSectionId,
@@ -43,6 +51,29 @@ import {
import GoogleFontsGallery from '@/components/GoogleFontsGallery'; import GoogleFontsGallery from '@/components/GoogleFontsGallery';
import BrandOverview from '@/components/BrandOverview'; import BrandOverview from '@/components/BrandOverview';
type NavId = BrandbookSectionId | 'overview';
const NAV_SECTIONS: {
id: NavId;
label: string;
subtitle: string;
icon: React.ComponentType<{ className?: string }>;
}[] = [
{ id: 'overview', label: '0. Brand Overview', subtitle: 'Resumen visual del kit', icon: LayoutDashboard },
{ id: 'identidad', label: '1. Identidad', subtitle: 'Pitch, promesa, posicionamiento', icon: Building2 },
{ id: 'voz', label: '2. Voz y tono', subtitle: 'Personalidad, do/don’t, ejemplos', icon: MessageSquareText },
{ id: 'visual', label: '3. Visual', subtitle: 'Logo, colores, tipografías, kit', icon: Palette },
{ id: 'audiencia', label: '4. Audiencia', subtitle: 'ICP, personas B2B/B2C', icon: Users },
{ id: 'oferta', label: '5. Oferta', subtitle: 'VP, productos, prueba social', icon: Package },
{ id: 'pilares', label: '6. Pilares de contenido', subtitle: 'Series, matriz, tabúes', icon: Layers },
{ id: 'canales', label: '7. Canales', subtitle: 'Handles, roles, cadencia', icon: Share2 },
{ id: 'ads', label: '8. Anuncios', subtitle: 'USPs, hooks, CTAs, ángulos', icon: Megaphone },
{ id: 'b2b', label: '9. LinkedIn B2B', subtitle: 'Temas, formatos, ABM', icon: Linkedin },
{ id: 'blog', label: '10. Blog corporativo', subtitle: 'SEO, clusters, CTAs', icon: FileText },
{ id: 'compliance', label: '11. Compliance', subtitle: 'Claims, disclaimers, límites', icon: ShieldCheck },
{ id: 'fuentes', label: '12. Fuentes', subtitle: 'Docs, excerpts, notas', icon: BookOpen },
];
function Field({ function Field({
label, label,
hint, hint,
@@ -55,20 +86,18 @@ function Field({
className?: string; className?: string;
}) { }) {
return ( return (
<label className={`block space-y-1 ${className}`}> <div className={`block ${className}`}>
<span className="text-xs font-semibold text-gray-800">{label}</span> <label className="block text-xs font-bold text-gray-700 mb-1">{label}</label>
{hint ? ( {hint ? (
<span className="block text-[11px] text-gray-500 leading-snug"> <p className="text-[11px] text-gray-500 leading-snug mb-1.5">{hint}</p>
{hint}
</span>
) : null} ) : null}
{children} {children}
</label> </div>
); );
} }
const inputCls = const inputCls =
'w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-200'; 'w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-xs text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-200';
const areaCls = `${inputCls} min-h-[88px] resize-y`; const areaCls = `${inputCls} min-h-[88px] resize-y`;
const selectCls = inputCls; const selectCls = inputCls;
@@ -113,13 +142,13 @@ export default function BrandZone({
tenantLabel: string; tenantLabel: string;
tenantSlug?: string; tenantSlug?: string;
}) { }) {
const [viewMode, setViewMode] = useState<'overview' | 'edit'>('overview'); const [section, setSection] = useState<NavId>('overview');
const [section, setSection] = useState<BrandbookSectionId>('identidad');
const [book, setBook] = useState<Brandbook>(() => emptyBrandbook(tenantLabel)); const [book, setBook] = useState<Brandbook>(() => emptyBrandbook(tenantLabel));
const [slug, setSlug] = useState(''); const [slug, setSlug] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [seeding, setSeeding] = useState(false); const [seeding, setSeeding] = useState(false);
const [enriching, setEnriching] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [kitUploading, setKitUploading] = useState(false); const [kitUploading, setKitUploading] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
@@ -488,256 +517,170 @@ export default function BrandZone({
}); });
} }
const logo = primaryLogo(book); async function enrichFromSources() {
const secMeta = BRANDBOOK_SECTIONS.find((s) => s.id === section); setEnriching(true);
setErr(null);
setOkMsg(null);
try {
const r = await fetch('/api/brandbook/enrich', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tenantId ? { tenant_id: tenantId } : {}),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo auto-rellenar');
await load();
const res = j.result;
setOkMsg(
res
? `Auto-relleno: ${res.completenessBefore}%→${res.completenessAfter}% · fuentes: ${(res.sources || []).join(', ')}`
: 'Marca actualizada desde fuentes conectadas'
);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error al auto-rellenar');
} finally {
setEnriching(false);
}
}
const secMeta = NAV_SECTIONS.find((s) => s.id === section);
if (loading) { if (loading) {
return ( return (
<div className="flex items-center gap-2 text-sm text-gray-500 py-12"> <div className="flex items-center justify-center rounded-2xl border border-gray-200 bg-white p-12 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin text-brand-600" />
Cargando zona de marca… Cargando zona de marca de {tenantLabel}…
</div> </div>
); );
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between rounded-2xl border border-brand-200 bg-brand-50/50 p-4">
eyebrow="Marca" <div className="flex items-start gap-3 min-w-0">
title="Zona de marca" <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-600 text-white shadow-xs">
description={`${tenantLabel}${slug ? ` · ${slug}` : ''} · Brand Overview + kit editable para ads, calendarios, LinkedIn y blog.`} <Building2 className="h-5 w-5" />
actions={ </div>
<div className="flex flex-wrap gap-2"> <div className="min-w-0">
<h2 className="text-base font-bold text-gray-900 truncate">
Zona de marca · {book.brand_name || tenantLabel}
</h2>
<p className="text-xs text-gray-600 mt-0.5">
ADN, voz, visual, audiencias y copy de ads/RRSS · mismo layout que Estilo y Brief
{slug ? <span className="text-gray-400"> · {slug}</span> : null}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-800">
Completitud {score}%
</span>
{missing.length ? (
<span className="text-[11px] text-amber-700">
Falta: {missing.slice(0, 2).join(' · ')}
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700">
<CheckCircle2 className="w-3.5 h-3.5" /> Kit sólido
</span>
)}
{book.colors.slice(0, 5).map((c) => (
<span
key={c.id}
title={`${c.name} ${c.hex}`}
className="h-4 w-4 rounded-full border border-black/10"
style={{ background: c.hex }}
/>
))}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
{okMsg ? (
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-3 py-1.5 rounded-xl">
<CheckCircle2 className="w-4 h-4" />
{okMsg}
</span>
) : null}
<button
type="button"
onClick={() => void enrichFromSources()}
disabled={enriching}
className="inline-flex items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs font-bold text-emerald-900 hover:bg-emerald-100 disabled:opacity-50"
>
{enriching ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
Auto-rellenar
</button>
<button <button
type="button" type="button"
onClick={() => void seed()} onClick={() => void seed()}
disabled={seeding} disabled={seeding}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-2.5 text-xs font-bold text-gray-800 hover:bg-gray-50 disabled:opacity-50"
> >
{seeding ? ( {seeding ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5 text-amber-500" />}
<Loader2 className="w-3.5 h-3.5 animate-spin" /> {exists ? 'Resembrar' : 'Base experta'}
) : (
<Sparkles className="w-3.5 h-3.5 text-amber-500" />
)}
{exists ? 'Resembrar base experta' : 'Crear base experta'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => void save()} onClick={() => void save()}
disabled={saving} disabled={saving}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-xs font-semibold text-white hover:bg-brand-700 disabled:opacity-50" className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-xs font-bold text-white shadow-sm hover:bg-brand-700 disabled:opacity-50"
> >
{saving ? ( {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
<Loader2 className="w-3.5 h-3.5 animate-spin" /> {saving ? 'Guardando…' : 'Guardar brand kit'}
) : (
<Save className="w-3.5 h-3.5" />
)}
Guardar brand kit
</button> </button>
</div> </div>
} </div>
/>
<SegmentedControl
value={viewMode}
onChange={setViewMode}
options={[
{ id: 'overview', label: 'Brand Overview' },
{ id: 'edit', label: 'Detalles del kit' },
]}
/>
{err ? ( {err ? (
<p className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-3 py-2"> <div className="rounded-xl border border-red-200 bg-red-50 p-3.5 text-xs font-medium text-red-700">
{err} {err}
</p> </div>
) : null}
{okMsg ? (
<p className="text-sm text-emerald-700 bg-emerald-50 border border-emerald-100 rounded-lg px-3 py-2">
{okMsg}
</p>
) : null} ) : null}
{viewMode === 'overview' ? ( <div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
<div className="lg:col-span-3 space-y-1.5 rounded-2xl border border-gray-200 bg-white p-2.5 shadow-xs max-h-[70vh] overflow-y-auto">
{NAV_SECTIONS.map((sec) => {
const Icon = sec.icon;
const active = section === sec.id;
return (
<button
key={sec.id}
type="button"
onClick={() => setSection(sec.id)}
className={`w-full flex items-start gap-2.5 rounded-xl px-3 py-2.5 text-left transition-all ${
active ? 'bg-brand-600 text-white shadow-xs' : 'text-gray-700 hover:bg-gray-50'
}`}
>
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${active ? 'text-white' : 'text-brand-600'}`} />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight">{sec.label}</div>
<div className={`text-[10px] mt-0.5 leading-snug line-clamp-1 ${active ? 'text-brand-100' : 'text-gray-400'}`}>
{sec.subtitle}
</div>
</div>
</button>
);
})}
</div>
<div className="lg:col-span-9 rounded-2xl border border-gray-200 bg-white p-6 shadow-xs space-y-6">
{section !== 'overview' ? (
<div>
<h3 className="text-base font-bold text-gray-900">{secMeta?.label}</h3>
<p className="text-xs text-gray-500 mt-0.5">{secMeta?.subtitle}</p>
</div>
) : null}
{section === 'overview' && (
<BrandOverview <BrandOverview
book={book} book={book}
tenantLabel={tenantLabel} tenantLabel={tenantLabel}
score={score} score={score}
missing={missing} missing={missing}
onContinueEdit={() => setViewMode('edit')} embedded
onEditSection={(id) => { onContinueEdit={() => setSection('identidad')}
setSection(id); onEditSection={(id) => setSection(id)}
setViewMode('edit');
}}
/> />
) : null}
{viewMode === 'edit' ? (
<>
{/* Identity strip */}
<Surface className="!p-4">
<div className="flex flex-col sm:flex-row gap-4 items-start">
<div className="w-28 shrink-0">
{logo ? (
<KitThumb assetFileId={logo.asset_file_id} alt={logo.label} />
) : (
<div className="h-20 rounded-lg border border-dashed border-gray-300 bg-gray-50 flex flex-col items-center justify-center text-[10px] text-gray-500 gap-1">
<ImageIcon className="w-4 h-4" />
Sin logo
</div>
)} )}
</div>
<div className="flex-1 min-w-0 space-y-2">
<p className="text-lg font-semibold text-gray-900 truncate">
{book.brand_name || tenantLabel}
</p>
<p className="text-sm text-gray-600">
{book.tagline || book.elevator_pitch || 'Sin tagline todavía'}
</p>
<div className="flex flex-wrap gap-1.5">
{book.colors.slice(0, 8).map((c) => (
<span
key={c.id}
title={`${c.name} ${c.hex}`}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 px-2 py-0.5 text-[10px] text-gray-700"
>
<span
className="w-3 h-3 rounded-full border border-black/10"
style={{ background: c.hex }}
/>
{c.hex}
</span>
))}
{book.fonts_kit.slice(0, 3).map((f) => (
<span
key={f.id}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 px-2 py-0.5 text-[10px] text-gray-700"
>
<Type className="w-3 h-3" />
{f.family}
</span>
))}
</div>
</div>
<div className="sm:w-40 shrink-0">
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">
Completitud
</p>
<p className="text-2xl font-semibold text-gray-900 mt-0.5">{score}%</p>
<div className="mt-2 h-1.5 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full bg-brand-500 transition-all"
style={{ width: `${score}%` }}
/>
</div>
{missing.length ? (
<p className="text-[10px] text-amber-700 mt-2 leading-snug">
Falta: {missing.slice(0, 3).join(' · ')}
</p>
) : (
<p className="text-[10px] text-emerald-700 mt-2 inline-flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Kit sólido
</p>
)}
</div>
</div>
</Surface>
{/* Quick upload bar */}
<Surface className="!p-3">
<div className="flex flex-col md:flex-row md:items-end gap-3">
<div className="flex items-center gap-2 text-xs font-semibold text-gray-800 shrink-0">
<Upload className="w-4 h-4 text-rose-500" />
Añadir al kit
</div>
<Field label="Tipo" className="md:w-36">
<select
className={selectCls}
value={kitKind}
onChange={(e) => {
const k = e.target.value as BrandAssetKind;
setKitKind(k);
setKitRole(
k === 'logo'
? 'primary'
: k === 'font'
? 'body'
: k === 'image'
? 'moodboard'
: 'asset'
);
}}
>
{Object.entries(ASSET_KIND_LABELS).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</Field>
<Field label="Rol" className="md:w-40" hint="ej. primary, dark, display">
<input
className={inputCls}
value={kitRole}
onChange={(e) => setKitRole(e.target.value)}
/>
</Field>
<label className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-4 py-2.5 text-xs font-semibold text-white cursor-pointer hover:bg-gray-800 disabled:opacity-50">
{kitUploading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Plus className="w-3.5 h-3.5" />
)}
Subir archivo
<input
type="file"
className="hidden"
accept={
kitKind === 'logo' || kitKind === 'image' || kitKind === 'icon'
? 'image/*,.svg,.pdf'
: kitKind === 'font'
? '.ttf,.otf,.woff,.woff2,.zip'
: kitKind === 'video'
? 'video/*'
: '*/*'
}
disabled={kitUploading}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void uploadKit(f);
e.target.value = '';
}}
/>
</label>
<p className="text-[11px] text-gray-500 md:ml-auto max-w-xs">
Logos, tipografías, patrones, iconos, PDFs de brandbook, moodboards…
</p>
</div>
</Surface>
<div className="flex flex-col lg:flex-row gap-4">
<nav className="lg:w-56 shrink-0 flex lg:flex-col gap-1 overflow-x-auto pb-1">
{BRANDBOOK_SECTIONS.map((s) => (
<button
key={s.id}
type="button"
onClick={() => setSection(s.id)}
className={`text-left rounded-lg px-3 py-2 text-xs font-medium whitespace-nowrap transition-colors ${
section === s.id
? 'bg-brand-50 text-gray-900 border border-brand-200'
: 'text-gray-600 border border-transparent hover:bg-gray-50'
}`}
>
{s.label}
</button>
))}
</nav>
<Surface className="flex-1 min-w-0 space-y-4">
<SectionTitle
title={secMeta?.label || ''}
description={secMeta?.expert || secMeta?.hint}
/>
{section === 'identidad' && ( {section === 'identidad' && (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
@@ -3003,10 +2946,8 @@ export default function BrandZone({
)} )}
</div> </div>
)} )}
</Surface>
</div> </div>
</> </div>
) : null}
</div> </div>
); );
} }

View File

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,10 @@
*/ */
import { brandBrief } from '@/lib/calendar-generate-brand'; import { brandBrief } from '@/lib/calendar-generate-brand';
import {
brandSiblingNote,
resolveBrandSlug,
} from '@/lib/tenant-brand-aliases';
export const BRANDBOOK_DOC_TYPE = 'brandbook'; export const BRANDBOOK_DOC_TYPE = 'brandbook';
export const BRANDBOOK_DOC_TITLE = 'Brandbook'; export const BRANDBOOK_DOC_TITLE = 'Brandbook';
@@ -807,40 +811,205 @@ export function seedBrandbook(slug: string, label: string): Brandbook {
blog: 'SEO certificaciones y objeciones', blog: 'SEO certificaciones y objeciones',
}, },
}, },
'talento-y-deporte': { tyd: {
vertical: brief.vertical, vertical: brief.vertical,
tone: brief.tone, tone: brief.tone,
elevator_pitch: elevator_pitch:
'Becas deportivas y académicas en USA con acompañamiento end-to-end.', 'Becas deportivas en USA con acompañamiento end-to-end para atletas-estudiantes.',
brand_promise: 'Tu talento abre puertas; nosotros abrimos el camino.', brand_promise: 'Tu talento deportivo abre puertas; nosotros abrimos el camino.',
positioning: 'Deporte + estudios en USA con proceso guiado', 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', archetype: 'El Héroe + El Mentor',
website: 'talentoydeporte.com',
pillars_rrss: brief.pillars, pillars_rrss: brief.pillars,
series_recurrentes: [ series_recurrentes: [
'Nuevo alumno', 'Nuevo alumno',
'Beca académica',
'Beca deportiva', 'Beca deportiva',
'Campus USA', 'Campus USA',
'Objeciones', 'Objeciones',
'English tips', 'English tips',
'Vida deportiva',
], ],
products_services: [ products_services: [
'Becas deportivas USA', 'Becas deportivas USA',
'Acompañamiento académico', 'Recruiting deportivo',
'Campus visits', 'Acompañamiento visa/campus',
], ],
ads_usps: [ ads_usps: [
'Becas reales', 'Becas deportivas reales',
'Acompañamiento end-to-end', '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'], 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: [ claims_forbidden: [
'Garantizar beca al 100%', 'Garantizar beca al 100%',
'Promesas de universidades sin matiz', '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': { 'halal-safety': {
vertical: 'Certificación Halal y Consultoría de Exportación Agroalimentaria', 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] || { const extra = seeds[resolvedSlug] || {
vertical: brief.vertical, vertical: brief.vertical,
tone: brief.tone, tone: brief.tone,
@@ -1517,16 +1687,33 @@ export function seedBrandbook(slug: string, label: string): Brandbook {
export function parseBrandbook(raw: unknown): Brandbook | null { export function parseBrandbook(raw: unknown): Brandbook | null {
if (!raw) return null; if (!raw) return null;
let o: any = raw; // documents.content a menudo llega como string JSON (columna text / doble encode)
if (typeof raw === 'string') { let data: unknown = raw;
if (typeof data === 'string') {
const t = data.trim();
if (!t) return null;
try { try {
o = JSON.parse(raw); data = JSON.parse(t);
} catch {
return null;
}
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch { } catch {
return null; return null;
} }
} }
if (!o || typeof o !== 'object') return null; }
if (o.content && (typeof o.content === 'object' || typeof o.content === 'string')) { if (!data || typeof data !== 'object') return null;
const o = data as Record<string, unknown>;
// wrapper { content: brandbook }
if (
o.content &&
(typeof o.content === 'object' || typeof o.content === 'string') &&
!o.brand_name &&
!('tone' in o)
) {
return parseBrandbook(o.content); return parseBrandbook(o.content);
} }
const ver = Number(o.version); const ver = Number(o.version);
@@ -1548,11 +1735,46 @@ export function parseBrandbook(raw: unknown): Brandbook | null {
const empty = emptyBrandbook(String(partial.brand_name || '')); const empty = emptyBrandbook(String(partial.brand_name || ''));
const colors = migrateColors(partial, empty); const colors = migrateColors(partial, empty);
const fonts_kit = migrateFonts(partial, empty); const fonts_kit = migrateFonts(partial, empty);
const str = (v: unknown, fallback = ''): string => {
if (v == null) return fallback;
return String(v);
};
return { return {
...empty, ...empty,
...partial, ...partial,
version: 2, 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), values: arr(partial.values),
personality: arr(partial.personality), personality: arr(partial.personality),
voice_do: arr(partial.voice_do), voice_do: arr(partial.voice_do),
@@ -1776,6 +1998,12 @@ export function brandbookToPrompt(book: Brandbook): string {
book.icp && `ICP: ${book.icp}`, book.icp && `ICP: ${book.icp}`,
book.ads_usps.length && `USPs ads: ${book.ads_usps.join('; ')}`, book.ads_usps.length && `USPs ads: ${book.ads_usps.join('; ')}`,
book.ads_ctas.length && `CTAs: ${book.ads_ctas.join('; ')}`, book.ads_ctas.length && `CTAs: ${book.ads_ctas.join('; ')}`,
(book.winning_hooks?.length || 0) > 0 &&
`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 || 0) > 0 &&
`Ángulos campaña: ${(book.preferred_angles || []).slice(0, 8).join(' · ')}`,
book.linkedin_themes.length && book.linkedin_themes.length &&
`LinkedIn: ${book.linkedin_themes.join(', ')}`, `LinkedIn: ${book.linkedin_themes.join(', ')}`,
book.blog_themes.length && `Blog: ${book.blog_themes.join(', ')}`, book.blog_themes.length && `Blog: ${book.blog_themes.join(', ')}`,
@@ -1792,30 +2020,40 @@ export function brandbookCompleteness(book: Brandbook): {
score: number; score: number;
missing: string[]; missing: string[];
} { } {
const s = (v: unknown) => String(v || '').trim();
const n = (v: unknown) => (Array.isArray(v) ? v.length : 0);
const checks: [string, boolean][] = [ const checks: [string, boolean][] = [
[ [
'Identidad (pitch/misión/posicionamiento)', '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)', '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', 'Oferta',
!!(book.products_services.length || book.value_proposition), !!(n(book.products_services) || s(book.value_proposition)),
], ],
[ [
'Pilares/series RRSS', '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', '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); const missing = checks.filter(([, ok]) => !ok).map(([l]) => l);
@@ -2158,19 +2396,66 @@ export function compileClientSkillFromBrandbook(
lines.push(fallbackAud); lines.push(fallbackAud);
lines.push(''); 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 = const hooks =
book.winning_hooks && book.winning_hooks.length > 0 book.winning_hooks && book.winning_hooks.length > 0
? book.winning_hooks ? book.winning_hooks
: book.slogan_variants; : book.slogan_variants;
if (hooks && hooks.length > 0) { if (hooks && hooks.length > 0) {
lines.push( 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(`- "${h}"`);
} }
lines.push(''); 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 = const extra =

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

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

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

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

View File

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