feat: Zona de marca tipo Estilo/Brief + brandbooks desde ads/RRSS
UI con nav lateral numerada y panel editable; parse de brandbook JSON string; enrich desde Meta/Google/IG/FB/web/CSS para que app.4ventures.es muestre el kit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
107
src/app/api/brandbook/enrich/route.ts
Normal file
107
src/app/api/brandbook/enrich/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -99,16 +99,18 @@ function PillRow({
|
||||
}
|
||||
|
||||
function websiteFromBook(book: Brandbook): string | null {
|
||||
const fromChannels =
|
||||
book.channel_handles?.blog ||
|
||||
book.channels?.blog ||
|
||||
book.channels?.instagram ||
|
||||
'';
|
||||
const raw = String(fromChannels || '').trim();
|
||||
if (!raw) return null;
|
||||
if (/^https?:\/\//i.test(raw)) return raw;
|
||||
if (raw.includes('.') && !raw.startsWith('@')) {
|
||||
return `https://${raw.replace(/^\/+/, '')}`;
|
||||
const candidates = [
|
||||
book.website,
|
||||
book.channel_handles?.blog,
|
||||
book.channels?.blog,
|
||||
];
|
||||
for (const c of candidates) {
|
||||
const raw = String(c || '').trim();
|
||||
if (!raw) continue;
|
||||
if (/^https?:\/\//i.test(raw)) return raw;
|
||||
if (raw.includes('.') && !raw.startsWith('@')) {
|
||||
return `https://${raw.replace(/^\/+/, '')}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -120,6 +122,7 @@ export default function BrandOverview({
|
||||
missing,
|
||||
onEditSection,
|
||||
onContinueEdit,
|
||||
embedded = false,
|
||||
}: {
|
||||
book: Brandbook;
|
||||
tenantLabel: string;
|
||||
@@ -127,6 +130,8 @@ export default function BrandOverview({
|
||||
missing: string[];
|
||||
onEditSection: (id: BrandbookSectionId) => void;
|
||||
onContinueEdit: () => void;
|
||||
/** Cuando va dentro del panel de Zona de marca (sin card exterior). */
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const logo = primaryLogo(book);
|
||||
const site = websiteFromBook(book);
|
||||
@@ -167,34 +172,44 @@ export default function BrandOverview({
|
||||
).trim();
|
||||
}, [book]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Surface className="!p-0 overflow-hidden border-gray-200">
|
||||
<div className="border-b border-gray-200 bg-gradient-to-br from-gray-50 via-white to-brand-50/40 px-5 py-4 sm:px-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-brand-600">
|
||||
Brand Overview
|
||||
</p>
|
||||
<h2 className="mt-1 text-xl font-extrabold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Perfil de marca
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Resumen visual del brandbook · editable en Detalles del kit
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinueEdit}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50"
|
||||
>
|
||||
Editar brand kit
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
const header = (
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? 'flex flex-wrap items-start justify-between gap-3 pb-2'
|
||||
: 'border-b border-gray-200 bg-gradient-to-br from-gray-50 via-white to-brand-50/40 px-5 py-4 sm:px-6'
|
||||
}
|
||||
>
|
||||
<div className={embedded ? '' : undefined}>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-brand-600">
|
||||
Brand Overview
|
||||
</p>
|
||||
<h2
|
||||
className={`mt-1 font-extrabold tracking-tight text-gray-900 ${
|
||||
embedded ? 'text-base' : 'text-xl sm:text-2xl'
|
||||
}`}
|
||||
>
|
||||
Perfil de marca
|
||||
</h2>
|
||||
{!embedded ? (
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Resumen visual del brandbook · editable por secciones
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinueEdit}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-bold text-gray-800 hover:bg-gray-50"
|
||||
>
|
||||
Editar identidad
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
<div className="space-y-8 px-5 py-6 sm:px-6">
|
||||
const content = (
|
||||
<div className={embedded ? 'space-y-8' : 'space-y-8 px-5 py-6 sm:px-6'}>
|
||||
{/* Identity */}
|
||||
<section className="grid gap-6 lg:grid-cols-[1.2fr_1fr]">
|
||||
<div className="space-y-3">
|
||||
@@ -372,10 +387,70 @@ export default function BrandOverview({
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer progress */}
|
||||
<div className="flex flex-col gap-3 border-t border-gray-200 bg-gray-50 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
{/* Copy real ads / RRSS */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-500">
|
||||
Copy real (Meta / Google / RRSS)
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditSection('ads')}
|
||||
className="text-[11px] font-semibold text-brand-700 hover:underline"
|
||||
>
|
||||
Ver en Ads →
|
||||
</button>
|
||||
</div>
|
||||
{(book.winning_hooks || []).length ||
|
||||
(book.ads_usps || []).length ||
|
||||
(book.ads_ctas || []).length ? (
|
||||
<div className="space-y-3 rounded-2xl border border-amber-200 bg-amber-50/40 px-4 py-4">
|
||||
{(book.ads_ctas || []).length ? (
|
||||
<p className="text-xs text-gray-700">
|
||||
<span className="font-semibold">CTAs:</span>{' '}
|
||||
{(book.ads_ctas || []).slice(0, 6).join(' · ')}
|
||||
</p>
|
||||
) : null}
|
||||
{(book.ads_usps || []).length ? (
|
||||
<PillRow
|
||||
title="USPs en anuncios"
|
||||
items={(book.ads_usps || []).slice(0, 6)}
|
||||
emptyHint=""
|
||||
accent="amber"
|
||||
/>
|
||||
) : null}
|
||||
{(book.winning_hooks || []).length ? (
|
||||
<ul className="space-y-1.5 text-xs text-gray-700 list-disc pl-4">
|
||||
{(book.winning_hooks || []).slice(0, 5).map((h) => (
|
||||
<li key={h.slice(0, 40)} className="leading-snug">
|
||||
{h}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditSection('ads')}
|
||||
className="w-full rounded-xl border border-dashed border-gray-300 bg-gray-50 px-3 py-4 text-xs text-gray-500 hover:border-brand-300"
|
||||
>
|
||||
Sin copy de ads aún · Auto-rellenar marca o conectar Meta/Google
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<div
|
||||
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between ${
|
||||
embedded
|
||||
? 'border-t border-gray-100 pt-4'
|
||||
: 'border-t border-gray-200 bg-gray-50 px-5 py-4 sm:px-6'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-semibold text-gray-800">
|
||||
@@ -407,6 +482,24 @@ export default function BrandOverview({
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{header}
|
||||
{content}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Surface className="!p-0 overflow-hidden border-gray-200">
|
||||
{header}
|
||||
{content}
|
||||
{footer}
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
|
||||
723
src/lib/brandbook-ads-insights.ts
Normal file
723
src/lib/brandbook-ads-insights.ts
Normal 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
1090
src/lib/brandbook-enrich.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,10 @@
|
||||
*/
|
||||
|
||||
import { brandBrief } from '@/lib/calendar-generate-brand';
|
||||
import {
|
||||
brandSiblingNote,
|
||||
resolveBrandSlug,
|
||||
} from '@/lib/tenant-brand-aliases';
|
||||
|
||||
export const BRANDBOOK_DOC_TYPE = 'brandbook';
|
||||
export const BRANDBOOK_DOC_TITLE = 'Brandbook';
|
||||
@@ -807,40 +811,205 @@ export function seedBrandbook(slug: string, label: string): Brandbook {
|
||||
blog: 'SEO certificaciones y objeciones',
|
||||
},
|
||||
},
|
||||
'talento-y-deporte': {
|
||||
tyd: {
|
||||
vertical: brief.vertical,
|
||||
tone: brief.tone,
|
||||
elevator_pitch:
|
||||
'Becas deportivas y académicas en USA con acompañamiento end-to-end.',
|
||||
brand_promise: 'Tu talento abre puertas; nosotros abrimos el camino.',
|
||||
positioning: 'Deporte + estudios en USA con proceso guiado',
|
||||
'Becas deportivas en USA con acompañamiento end-to-end para atletas-estudiantes.',
|
||||
brand_promise: 'Tu talento deportivo abre puertas; nosotros abrimos el camino.',
|
||||
positioning: 'TYD · Becas deportivas USA con proceso guiado',
|
||||
mission:
|
||||
'Ayudar a atletas-estudiantes a conseguir becas deportivas reales en universidades de EE.UU. con proceso guiado.',
|
||||
tagline: 'Tu talento, tu campus en USA',
|
||||
icp: 'Atletas-estudiantes (y familias) en España/LATAM que buscan beca deportiva + estudios en EE.UU.',
|
||||
archetype: 'El Héroe + El Mentor',
|
||||
website: 'talentoydeporte.com',
|
||||
pillars_rrss: brief.pillars,
|
||||
series_recurrentes: [
|
||||
'Nuevo alumno',
|
||||
'Beca académica',
|
||||
'Beca deportiva',
|
||||
'Campus USA',
|
||||
'Objeciones',
|
||||
'English tips',
|
||||
'Vida deportiva',
|
||||
],
|
||||
products_services: [
|
||||
'Becas deportivas USA',
|
||||
'Acompañamiento académico',
|
||||
'Campus visits',
|
||||
'Recruiting deportivo',
|
||||
'Acompañamiento visa/campus',
|
||||
],
|
||||
ads_usps: [
|
||||
'Becas reales',
|
||||
'Becas deportivas reales',
|
||||
'Acompañamiento end-to-end',
|
||||
'Historias de alumnos',
|
||||
'Historias de atletas',
|
||||
],
|
||||
ads_ctas: ['Agenda llamada', 'Evalúa tu perfil'],
|
||||
ads_ctas: ['Agenda llamada', 'Evalúa tu perfil deportivo'],
|
||||
linkedin_themes: ['Educación internacional', 'Deporte + estudios'],
|
||||
blog_themes: ['Guía becas USA', 'Requisitos TOEFL/SAT', 'Vida en campus'],
|
||||
blog_themes: ['Guía becas deportivas USA', 'Requisitos', 'Vida en campus'],
|
||||
claims_forbidden: [
|
||||
'Garantizar beca al 100%',
|
||||
'Promesas de universidades sin matiz',
|
||||
'Usar creatividades o claims de TSP Scholar sin matiz deportivo',
|
||||
],
|
||||
freeform_notes: brandSiblingNote('tyd') || '',
|
||||
channels: {
|
||||
...base.channels,
|
||||
instagram: 'Canal principal · historias de atletas',
|
||||
facebook: 'Comunidad padres / familias',
|
||||
},
|
||||
channel_handles: {
|
||||
...base.channel_handles,
|
||||
instagram: 'https://instagram.com/talentoydeporte',
|
||||
},
|
||||
},
|
||||
tsp: {
|
||||
vertical: brief.vertical,
|
||||
tone: brief.tone,
|
||||
elevator_pitch:
|
||||
'Programa Scholar de becas académicas en USA con orientación de admisiones y financiación.',
|
||||
brand_promise: 'Tu expediente también puede abrirte el campus americano.',
|
||||
positioning: 'TSP · Becas académicas USA (marca hermana de TYD, no mezclar)',
|
||||
mission:
|
||||
'Orientar a estudiantes con buen expediente hacia becas académicas y admisiones en EE.UU.',
|
||||
tagline: 'Tu expediente, tu campus en USA',
|
||||
icp: 'Estudiantes (y familias) con perfil académico sólido que buscan universidad en EE.UU. sin eje deportivo.',
|
||||
archetype: 'El Sabio + El Mentor',
|
||||
website: 'talentoydeporte.com',
|
||||
pillars_rrss: brief.pillars,
|
||||
series_recurrentes: [
|
||||
'Beca académica',
|
||||
'Admisiones',
|
||||
'Financiación',
|
||||
'Campus USA',
|
||||
'Casos de éxito',
|
||||
'Objeciones',
|
||||
],
|
||||
products_services: [
|
||||
'Becas académicas USA',
|
||||
'Orientación de admisiones',
|
||||
'Plan de financiación',
|
||||
],
|
||||
ads_usps: [
|
||||
'Foco académico Scholar',
|
||||
'Admisiones guiadas',
|
||||
'Financiación clara',
|
||||
],
|
||||
ads_ctas: ['Evalúa tu perfil académico', 'Agenda orientación'],
|
||||
linkedin_themes: ['Educación internacional', 'Admisiones USA'],
|
||||
blog_themes: ['SAT/TOEFL', 'Financiación becas', 'Vida académica en campus'],
|
||||
claims_forbidden: [
|
||||
'Garantizar admisión o beca al 100%',
|
||||
'Usar creatividades o claims deportivos de TYD sin matiz Scholar',
|
||||
],
|
||||
freeform_notes: brandSiblingNote('tsp') || '',
|
||||
},
|
||||
dtatf: {
|
||||
vertical: brief.vertical,
|
||||
tone: brief.tone,
|
||||
elevator_pitch:
|
||||
'Formación profesional cercana orientada a empleabilidad y cambio de vida laboral.',
|
||||
brand_promise: 'Formarte de tú a tú para trabajar con seguridad.',
|
||||
positioning: 'DTATF · Formación práctica (marca distinta de ECP)',
|
||||
mission:
|
||||
'Acercar formación profesional práctica y acreditaciones con salida laboral real.',
|
||||
icp: 'Adultos en reconversión o mejora laboral que buscan cursos/acreditaciones con empleabilidad (p. ej. docencia FP).',
|
||||
archetype: 'El Cuidador + El Ciudadano',
|
||||
pillars_rrss: brief.pillars,
|
||||
series_recurrentes: [
|
||||
'Convocatorias',
|
||||
'Empleabilidad',
|
||||
'Testimonios',
|
||||
'Tips formación',
|
||||
],
|
||||
products_services: ['Cursos de formación', 'Orientación laboral'],
|
||||
ads_usps: ['Cercanía', 'Empleabilidad', 'Convocatorias claras'],
|
||||
ads_ctas: ['Pedir info', 'Reservar plaza'],
|
||||
claims_forbidden: [
|
||||
'Usar marca visual o claims de ECP (cuidadores) sin matiz DTATF',
|
||||
],
|
||||
freeform_notes: brandSiblingNote('dtatf') || '',
|
||||
channels: {
|
||||
...base.channels,
|
||||
instagram: 'Alcance y comunidad alumnos',
|
||||
facebook: 'Convocatorias y familias',
|
||||
},
|
||||
},
|
||||
ecp: {
|
||||
vertical: brief.vertical,
|
||||
tone: brief.tone,
|
||||
elevator_pitch:
|
||||
'Escuela de Cuidadores Profesionales: formación humana y empleable para el sector cuidados.',
|
||||
brand_promise: 'Cuidar con vocación y con método.',
|
||||
positioning: 'ECP · Referente en formación de cuidadores (marca distinta de DTATF)',
|
||||
mission:
|
||||
'Formar cuidadores profesionales con método, humanidad y conexión real con empleo.',
|
||||
icp: 'Personas que quieren trabajar en cuidados (domicilio/residencias) y empresas que buscan perfiles formados.',
|
||||
archetype: 'El Cuidador',
|
||||
website: 'escueladecuidadoresprofesionales.com',
|
||||
pillars_rrss: brief.pillars,
|
||||
series_recurrentes: [
|
||||
'Día a día del cuidador',
|
||||
'Formación',
|
||||
'Empleabilidad',
|
||||
'Testimonios',
|
||||
'Convocatorias',
|
||||
],
|
||||
products_services: [
|
||||
'Cursos de cuidador profesional',
|
||||
'Prácticas / empleabilidad',
|
||||
],
|
||||
ads_usps: [
|
||||
'Formación especializada',
|
||||
'Salida laboral',
|
||||
'Comunidad ECP',
|
||||
],
|
||||
ads_ctas: ['Infórmate', 'Reserva tu plaza'],
|
||||
claims_forbidden: [
|
||||
'Usar marca visual o claims de DTATF sin matiz ECP/cuidadores',
|
||||
],
|
||||
freeform_notes: brandSiblingNote('ecp') || '',
|
||||
channels: {
|
||||
...base.channels,
|
||||
instagram: 'Canal principal @escuelaecp',
|
||||
facebook: 'Comunidad y convocatorias',
|
||||
},
|
||||
channel_handles: {
|
||||
...base.channel_handles,
|
||||
instagram: 'https://instagram.com/escuelaecp',
|
||||
blog: 'https://escueladecuidadoresprofesionales.com',
|
||||
},
|
||||
},
|
||||
'agua-miia': {
|
||||
vertical: brief.vertical,
|
||||
tone: brief.tone,
|
||||
elevator_pitch:
|
||||
'Agua mineral de origen natural para hidratación diaria con identidad fresca y premium accesible.',
|
||||
brand_promise: 'Hidratación limpia, natural y con carácter.',
|
||||
positioning: 'Agua con personalidad: fresca, cercana y visualmente clara',
|
||||
archetype: 'El Inocente + El Explorador',
|
||||
pillars_rrss: brief.pillars,
|
||||
series_recurrentes: [
|
||||
'Producto hero',
|
||||
'Ritual diario',
|
||||
'Origen',
|
||||
'Estilo de vida',
|
||||
'Sostenibilidad',
|
||||
],
|
||||
products_services: ['Agua mineral', 'Formatos retail', 'Horeca'],
|
||||
ads_usps: ['Origen natural', 'Sabor limpio', 'Formato para el día a día'],
|
||||
ads_ctas: ['Descúbrela', 'Encuéntrala', 'Pruébala'],
|
||||
photography_style:
|
||||
'Producto hero con luz natural, escenas de hidratación y lifestyle limpio; mucho aire y tipografía clara.',
|
||||
claims_forbidden: [
|
||||
'Claims médicos o curativos sin respaldo',
|
||||
'Comparativas agresivas con competencia nombrada',
|
||||
],
|
||||
channels: {
|
||||
...base.channels,
|
||||
instagram: 'Canal principal · producto + lifestyle',
|
||||
facebook: 'Alcance y comunidad local',
|
||||
tiktok: 'Reels cortos de ritual / producto',
|
||||
},
|
||||
},
|
||||
'halal-safety': {
|
||||
vertical: 'Certificación Halal y Consultoría de Exportación Agroalimentaria',
|
||||
@@ -1482,7 +1651,8 @@ export function seedBrandbook(slug: string, label: string): Brandbook {
|
||||
},
|
||||
};
|
||||
|
||||
const resolvedSlug = slug === 'blue-parrot-school' ? 'bps' : slug;
|
||||
const resolvedSlug =
|
||||
slug === 'blue-parrot-school' ? 'bps' : resolveBrandSlug(slug);
|
||||
const extra = seeds[resolvedSlug] || {
|
||||
vertical: brief.vertical,
|
||||
tone: brief.tone,
|
||||
@@ -1517,16 +1687,33 @@ export function seedBrandbook(slug: string, label: string): Brandbook {
|
||||
|
||||
export function parseBrandbook(raw: unknown): Brandbook | null {
|
||||
if (!raw) return null;
|
||||
let o: any = raw;
|
||||
if (typeof raw === 'string') {
|
||||
// documents.content a menudo llega como string JSON (columna text / doble encode)
|
||||
let data: unknown = raw;
|
||||
if (typeof data === 'string') {
|
||||
const t = data.trim();
|
||||
if (!t) return null;
|
||||
try {
|
||||
o = JSON.parse(raw);
|
||||
data = JSON.parse(t);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!o || typeof o !== 'object') return null;
|
||||
if (o.content && (typeof o.content === 'object' || typeof o.content === 'string')) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const o = data as Record<string, unknown>;
|
||||
// wrapper { content: brandbook }
|
||||
if (
|
||||
o.content &&
|
||||
(typeof o.content === 'object' || typeof o.content === 'string') &&
|
||||
!o.brand_name &&
|
||||
!('tone' in o)
|
||||
) {
|
||||
return parseBrandbook(o.content);
|
||||
}
|
||||
const ver = Number(o.version);
|
||||
@@ -1548,11 +1735,46 @@ export function parseBrandbook(raw: unknown): Brandbook | null {
|
||||
const empty = emptyBrandbook(String(partial.brand_name || ''));
|
||||
const colors = migrateColors(partial, empty);
|
||||
const fonts_kit = migrateFonts(partial, empty);
|
||||
const str = (v: unknown, fallback = ''): string => {
|
||||
if (v == null) return fallback;
|
||||
return String(v);
|
||||
};
|
||||
|
||||
return {
|
||||
...empty,
|
||||
...partial,
|
||||
version: 2,
|
||||
brand_name: str(partial.brand_name, empty.brand_name),
|
||||
tagline: str(partial.tagline),
|
||||
elevator_pitch: str(partial.elevator_pitch),
|
||||
brand_promise: str(partial.brand_promise),
|
||||
positioning: str(partial.positioning),
|
||||
archetype: str(partial.archetype),
|
||||
origin_story: str(partial.origin_story),
|
||||
mission: str(partial.mission),
|
||||
vision: str(partial.vision),
|
||||
vertical: str(partial.vertical),
|
||||
tone: str(partial.tone),
|
||||
visual_notes: str(partial.visual_notes),
|
||||
logo_usage: str(partial.logo_usage),
|
||||
photography_style: str(partial.photography_style),
|
||||
illustration_style: str(partial.illustration_style),
|
||||
icon_style: str(partial.icon_style),
|
||||
icp: str(partial.icp),
|
||||
geo: str(partial.geo),
|
||||
customer_journey: str(partial.customer_journey),
|
||||
value_proposition: str(partial.value_proposition),
|
||||
guarantee: str(partial.guarantee),
|
||||
pricing_notes: str(partial.pricing_notes),
|
||||
content_matrix: str(partial.content_matrix),
|
||||
channel_cadence: str(partial.channel_cadence),
|
||||
landing_notes: str(partial.landing_notes),
|
||||
abm_notes: str(partial.abm_notes),
|
||||
blog_cadence: str(partial.blog_cadence),
|
||||
legal_notes: str(partial.legal_notes),
|
||||
sector_rules: str(partial.sector_rules),
|
||||
image_rights: str(partial.image_rights),
|
||||
freeform_notes: str(partial.freeform_notes),
|
||||
values: arr(partial.values),
|
||||
personality: arr(partial.personality),
|
||||
voice_do: arr(partial.voice_do),
|
||||
@@ -1776,6 +1998,12 @@ export function brandbookToPrompt(book: Brandbook): string {
|
||||
book.icp && `ICP: ${book.icp}`,
|
||||
book.ads_usps.length && `USPs ads: ${book.ads_usps.join('; ')}`,
|
||||
book.ads_ctas.length && `CTAs: ${book.ads_ctas.join('; ')}`,
|
||||
book.winning_hooks.length &&
|
||||
`Hooks ads/RRSS observados: ${book.winning_hooks.slice(0, 10).join(' | ')}`,
|
||||
book.example_good.length &&
|
||||
`Ejemplos copy real (ads/IG/FB/LI): ${book.example_good.slice(0, 6).join(' | ')}`,
|
||||
book.preferred_angles.length &&
|
||||
`Ángulos campaña: ${book.preferred_angles.slice(0, 8).join(' · ')}`,
|
||||
book.linkedin_themes.length &&
|
||||
`LinkedIn: ${book.linkedin_themes.join(', ')}`,
|
||||
book.blog_themes.length && `Blog: ${book.blog_themes.join(', ')}`,
|
||||
@@ -1792,30 +2020,40 @@ export function brandbookCompleteness(book: Brandbook): {
|
||||
score: number;
|
||||
missing: string[];
|
||||
} {
|
||||
const s = (v: unknown) => String(v || '').trim();
|
||||
const n = (v: unknown) => (Array.isArray(v) ? v.length : 0);
|
||||
const checks: [string, boolean][] = [
|
||||
[
|
||||
'Identidad (pitch/misión/posicionamiento)',
|
||||
!!(book.elevator_pitch || book.mission || book.positioning),
|
||||
!!(s(book.elevator_pitch) || s(book.mission) || s(book.positioning)),
|
||||
],
|
||||
['Voz/tono', !!book.tone],
|
||||
['Voz/tono', !!s(book.tone)],
|
||||
[
|
||||
'Visual (color o logo)',
|
||||
!!(book.colors.length || book.kit_assets.some((a) => a.kind === 'logo')),
|
||||
!!(n(book.colors) || (book.kit_assets || []).some((a) => a.kind === 'logo')),
|
||||
],
|
||||
['Tipografía', n(book.fonts_kit) > 0],
|
||||
[
|
||||
'Audiencia/ICP',
|
||||
!!(
|
||||
s(book.icp) ||
|
||||
n(book.personas) ||
|
||||
n(book.b2b_personas) ||
|
||||
n(book.b2c_personas)
|
||||
),
|
||||
],
|
||||
['Tipografía', book.fonts_kit.length > 0],
|
||||
['Audiencia/ICP', !!(book.icp || book.personas.length)],
|
||||
[
|
||||
'Oferta',
|
||||
!!(book.products_services.length || book.value_proposition),
|
||||
!!(n(book.products_services) || s(book.value_proposition)),
|
||||
],
|
||||
[
|
||||
'Pilares/series RRSS',
|
||||
!!(book.pillars_rrss.length || book.series_recurrentes.length),
|
||||
!!(n(book.pillars_rrss) || n(book.series_recurrentes)),
|
||||
],
|
||||
['Ads (USP o CTA)', !!(book.ads_usps.length || book.ads_ctas.length)],
|
||||
['Ads (USP o CTA)', !!(n(book.ads_usps) || n(book.ads_ctas))],
|
||||
[
|
||||
'LinkedIn B2B o blog',
|
||||
!!(book.linkedin_themes.length || book.blog_themes.length),
|
||||
!!(n(book.linkedin_themes) || n(book.blog_themes)),
|
||||
],
|
||||
];
|
||||
const missing = checks.filter(([, ok]) => !ok).map(([l]) => l);
|
||||
@@ -2158,19 +2396,66 @@ export function compileClientSkillFromBrandbook(
|
||||
lines.push(fallbackAud);
|
||||
lines.push('');
|
||||
|
||||
// 8. Ejemplos de hooks
|
||||
// 8. Copy real de Meta / Google / IG / FB / LinkedIn
|
||||
const adsExcerpt = (book.source_excerpts || []).find((e) =>
|
||||
/ads-insights|meta-graph|COPY REAL DESDE ADS/i.test(
|
||||
`${e.filename || ''} ${e.excerpt || ''}`
|
||||
)
|
||||
);
|
||||
const hooks =
|
||||
book.winning_hooks && book.winning_hooks.length > 0
|
||||
? book.winning_hooks
|
||||
: book.slogan_variants;
|
||||
if (hooks && hooks.length > 0) {
|
||||
lines.push(
|
||||
'## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar)'
|
||||
'## Copy real de campañas y redes (Meta Ads / Google Ads / IG / FB / LinkedIn)'
|
||||
);
|
||||
for (const h of hooks) {
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'> Extraído de anuncios, captions IG, posts FB y piezas LinkedIn del calendario. Registro de qué ya se ha dicho — **no copiar literal**; variar ángulo y prueba.'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push('### Hooks / primary texts / titulares observados');
|
||||
for (const h of hooks.slice(0, 16)) {
|
||||
lines.push(`- "${h}"`);
|
||||
}
|
||||
lines.push('');
|
||||
if (book.example_good && book.example_good.length > 0) {
|
||||
lines.push('### Ejemplos de copy observado (cuerpo / captions)');
|
||||
for (const ex of book.example_good.slice(0, 8)) {
|
||||
lines.push(`- "${ex}"`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
if (book.ads_ctas && book.ads_ctas.length > 0) {
|
||||
lines.push(
|
||||
`### CTAs observados en Meta: ${book.ads_ctas.join(', ')}`
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
const campAngles =
|
||||
book.preferred_angles && book.preferred_angles.length > 0
|
||||
? book.preferred_angles
|
||||
: book.ads_angles;
|
||||
if (campAngles && campAngles.length > 0) {
|
||||
lines.push(
|
||||
`### Ángulos de campaña observados: ${campAngles.slice(0, 12).join(' · ')}`
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
if (book.linkedin_themes && book.linkedin_themes.length > 0) {
|
||||
lines.push(
|
||||
`### Temas LinkedIn observados: ${book.linkedin_themes.join(' · ')}`
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
} else if (adsExcerpt?.excerpt) {
|
||||
lines.push(
|
||||
'## Copy real de campañas y redes (Meta Ads / Google Ads / IG / FB / LinkedIn)'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(adsExcerpt.excerpt.slice(0, 2500));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const extra =
|
||||
|
||||
119
src/lib/canva-templates.ts
Normal file
119
src/lib/canva-templates.ts
Normal 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
433
src/lib/creatives-drive.ts
Normal 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 };
|
||||
}
|
||||
66
src/lib/tenant-brand-aliases.ts
Normal file
66
src/lib/tenant-brand-aliases.ts
Normal 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.`;
|
||||
}
|
||||
Reference in New Issue
Block a user