Add Zona de marca brandbooks and strategist calendar generation.
Per-tenant brandbooks power ads, RRSS calendars, LinkedIn B2B and blog; Nov calendars now create from trends + client DNA instead of copying history. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
143
scripts/seed_brandbooks_regen_nov.ts
Normal file
143
scripts/seed_brandbooks_regen_nov.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Seed brandbook for every tenant + regenerate Nov 2026 editorial strategy.
|
||||||
|
* Usage: npx tsx scripts/seed_brandbooks_regen_nov.ts
|
||||||
|
* Optional: MONTH=2026-11 FORCE_BRANDBOOK=1
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import type { RrssMonthPlan } from '../src/components/RrssMonthView';
|
||||||
|
import { generateMonthPreview } from '../src/lib/calendar-generate';
|
||||||
|
import {
|
||||||
|
BRANDBOOK_DOC_TITLE,
|
||||||
|
BRANDBOOK_DOC_TYPE,
|
||||||
|
parseBrandbook,
|
||||||
|
seedBrandbook,
|
||||||
|
} from '../src/lib/brandbook';
|
||||||
|
import { monthLabel } from '../src/lib/calendar-import';
|
||||||
|
|
||||||
|
const ym = process.env.MONTH || '2026-11';
|
||||||
|
const forceBook = process.env.FORCE_BRANDBOOK === '1';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const h = {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tenants = (await (
|
||||||
|
await fetch(
|
||||||
|
`${url}/rest/v1/tenants?select=id,slug,name&order=name.asc`,
|
||||||
|
{ headers: h }
|
||||||
|
)
|
||||||
|
).json()) as { id: string; slug: string; name: string }[];
|
||||||
|
|
||||||
|
console.log(`Tenants: ${tenants.length} · month ${ym} · forceBook=${forceBook}`);
|
||||||
|
|
||||||
|
for (const t of tenants) {
|
||||||
|
// --- brandbook ---
|
||||||
|
const bbRes = await fetch(
|
||||||
|
`${url}/rest/v1/documents?tenant_id=eq.${t.id}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`,
|
||||||
|
{ headers: h }
|
||||||
|
);
|
||||||
|
const bbRows = (await bbRes.json()) as { id: string; content: unknown }[];
|
||||||
|
const existing = parseBrandbook(bbRows[0]?.content);
|
||||||
|
let book = existing;
|
||||||
|
if (!existing || forceBook) {
|
||||||
|
book = seedBrandbook(t.slug, t.name);
|
||||||
|
if (bbRows[0]?.id) {
|
||||||
|
await fetch(`${url}/rest/v1/documents?id=eq.${bbRows[0].id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...h, Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: book,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(`[${t.slug}] brandbook updated`);
|
||||||
|
} else {
|
||||||
|
await fetch(`${url}/rest/v1/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...h, Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: t.id,
|
||||||
|
type: BRANDBOOK_DOC_TYPE,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
status: 'approved',
|
||||||
|
content: book,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(`[${t.slug}] brandbook created`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[${t.slug}] brandbook exists (skip)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- calendars ---
|
||||||
|
const calRes = await fetch(
|
||||||
|
`${url}/rest/v1/documents?tenant_id=eq.${t.id}&type=eq.calendar&select=id,content&order=created_at.desc&limit=48`,
|
||||||
|
{ headers: h }
|
||||||
|
);
|
||||||
|
const docs = (await calRes.json()) as { id: string; content: unknown }[];
|
||||||
|
const history: RrssMonthPlan[] = [];
|
||||||
|
let novId: string | null = null;
|
||||||
|
for (const d of docs) {
|
||||||
|
const c =
|
||||||
|
typeof d.content === 'string' ? JSON.parse(d.content) : d.content;
|
||||||
|
const plan = c as RrssMonthPlan;
|
||||||
|
if (plan?.month === ym) novId = d.id;
|
||||||
|
else if (plan?.month && Array.isArray(plan.pieces)) history.push(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generateMonthPreview({
|
||||||
|
ym,
|
||||||
|
slug: t.slug,
|
||||||
|
label: t.name,
|
||||||
|
history,
|
||||||
|
brandbook: book,
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = `RRSS ${monthLabel(ym)}`;
|
||||||
|
if (novId) {
|
||||||
|
const patch = await fetch(`${url}/rest/v1/documents?id=eq.${novId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...h, Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: result.plan,
|
||||||
|
title,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[${t.slug}] ${ym} patched ${patch.status} · ${result.plan.pieces.length} pcs · ${result.source}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const ins = await fetch(`${url}/rest/v1/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...h, Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: t.id,
|
||||||
|
type: 'calendar',
|
||||||
|
title,
|
||||||
|
status: 'draft',
|
||||||
|
content: result.plan,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[${t.slug}] ${ym} created ${ins.status} · ${result.plan.pieces.length} pcs · ${result.source}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
175
src/app/api/brandbook/route.ts
Normal file
175
src/app/api/brandbook/route.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* GET / PUT brandbook for active tenant (documents type=brandbook).
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||||
|
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||||
|
import {
|
||||||
|
BRANDBOOK_DOC_TITLE,
|
||||||
|
BRANDBOOK_DOC_TYPE,
|
||||||
|
brandbookCompleteness,
|
||||||
|
emptyBrandbook,
|
||||||
|
parseBrandbook,
|
||||||
|
seedBrandbook,
|
||||||
|
type Brandbook,
|
||||||
|
} from '@/lib/brandbook';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
async function loadDoc(tenantId: string): Promise<{
|
||||||
|
id: string | null;
|
||||||
|
book: Brandbook | null;
|
||||||
|
}> {
|
||||||
|
const r = await restGet<{ id: string; content: unknown }[]>(
|
||||||
|
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||||
|
tenantId
|
||||||
|
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`
|
||||||
|
);
|
||||||
|
const row = r.data?.[0];
|
||||||
|
if (!row) return { id: null, book: null };
|
||||||
|
return { id: row.id, book: parseBrandbook(row.content) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Selecciona un cliente en Contexto.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let slug = 'cliente';
|
||||||
|
const t = await restGet<{ slug?: string }[]>(
|
||||||
|
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug`
|
||||||
|
);
|
||||||
|
slug = t.data?.[0]?.slug || slug;
|
||||||
|
|
||||||
|
const { id, book } = await loadDoc(ctx.tenantId);
|
||||||
|
const current = book || emptyBrandbook(ctx.label);
|
||||||
|
const completeness = brandbookCompleteness(current);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
id,
|
||||||
|
brandbook: current,
|
||||||
|
slug,
|
||||||
|
label: ctx.label,
|
||||||
|
completeness,
|
||||||
|
exists: !!book,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(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 });
|
||||||
|
}
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Selecciona un cliente en Contexto.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
brandbook?: unknown;
|
||||||
|
seed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let slug = 'cliente';
|
||||||
|
const t = await restGet<{ slug?: string }[]>(
|
||||||
|
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug`
|
||||||
|
);
|
||||||
|
slug = t.data?.[0]?.slug || slug;
|
||||||
|
|
||||||
|
let book: Brandbook | null = null;
|
||||||
|
if (body.seed) {
|
||||||
|
book = seedBrandbook(slug, ctx.label);
|
||||||
|
} else {
|
||||||
|
book = parseBrandbook(body.brandbook);
|
||||||
|
}
|
||||||
|
if (!book) {
|
||||||
|
return NextResponse.json({ error: 'brandbook inválido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
book.updated_at = new Date().toISOString();
|
||||||
|
book.brand_name = book.brand_name || ctx.label;
|
||||||
|
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) {
|
||||||
|
return NextResponse.json({ error: 'No service role' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await loadDoc(ctx.tenantId);
|
||||||
|
if (id) {
|
||||||
|
const patch = await fetch(`${base}/rest/v1/documents?id=eq.${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: book,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
updated_at: book.updated_at,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!patch.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await patch.text() },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rows = (await patch.json()) as { id: string }[];
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
id: rows[0]?.id || id,
|
||||||
|
brandbook: book,
|
||||||
|
completeness: brandbookCompleteness(book),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ins = await fetch(`${base}/rest/v1/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: ctx.tenantId,
|
||||||
|
type: BRANDBOOK_DOC_TYPE,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
status: 'approved',
|
||||||
|
content: book,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!ins.ok) {
|
||||||
|
return NextResponse.json({ error: await ins.text() }, { status: 500 });
|
||||||
|
}
|
||||||
|
const rows = (await ins.json()) as { id: string }[];
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
id: rows[0]?.id,
|
||||||
|
brandbook: book,
|
||||||
|
completeness: brandbookCompleteness(book),
|
||||||
|
created: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
159
src/app/api/brandbook/upload/route.ts
Normal file
159
src/app/api/brandbook/upload/route.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* POST multipart: file (.txt/.md/.csv) or JSON { text, filename }
|
||||||
|
* Appends source excerpt into the tenant brandbook.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||||
|
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||||
|
import {
|
||||||
|
BRANDBOOK_DOC_TITLE,
|
||||||
|
BRANDBOOK_DOC_TYPE,
|
||||||
|
brandbookCompleteness,
|
||||||
|
emptyBrandbook,
|
||||||
|
ingestSourceText,
|
||||||
|
parseBrandbook,
|
||||||
|
type Brandbook,
|
||||||
|
} from '@/lib/brandbook';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
async function loadBook(tenantId: string, label: string): Promise<{
|
||||||
|
id: string | null;
|
||||||
|
book: Brandbook;
|
||||||
|
}> {
|
||||||
|
const r = await restGet<{ id: string; content: unknown }[]>(
|
||||||
|
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||||
|
tenantId
|
||||||
|
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`
|
||||||
|
);
|
||||||
|
const row = r.data?.[0];
|
||||||
|
return {
|
||||||
|
id: row?.id || null,
|
||||||
|
book: parseBrandbook(row?.content) || emptyBrandbook(label),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Selecciona un cliente en Contexto.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = 'pegado.txt';
|
||||||
|
let text = '';
|
||||||
|
const ctype = req.headers.get('content-type') || '';
|
||||||
|
if (ctype.includes('multipart/form-data')) {
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get('file');
|
||||||
|
const pasted = form.get('text');
|
||||||
|
if (typeof pasted === 'string' && pasted.trim()) {
|
||||||
|
text = pasted;
|
||||||
|
filename = String(form.get('filename') || 'pegado.txt');
|
||||||
|
} else if (file && typeof file === 'object' && 'arrayBuffer' in file) {
|
||||||
|
const f = file as File;
|
||||||
|
filename = f.name || 'upload.txt';
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
if (
|
||||||
|
!lower.endsWith('.txt') &&
|
||||||
|
!lower.endsWith('.md') &&
|
||||||
|
!lower.endsWith('.csv') &&
|
||||||
|
!lower.endsWith('.json')
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'Por ahora sube .txt, .md, .csv o .json. PDF/DOCX: pega el texto en el campo.',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
text = await f.text();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
text?: string;
|
||||||
|
filename?: string;
|
||||||
|
};
|
||||||
|
text = String(body.text || '');
|
||||||
|
filename = String(body.filename || 'pegado.txt');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.trim()) {
|
||||||
|
return NextResponse.json({ error: 'Sin texto' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (text.length > 200_000) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Texto demasiado largo (máx ~200k)' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, book } = await loadBook(ctx.tenantId, ctx.label);
|
||||||
|
const next = ingestSourceText(book, filename, text);
|
||||||
|
next.brand_name = next.brand_name || ctx.label;
|
||||||
|
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) {
|
||||||
|
return NextResponse.json({ error: 'No service role' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const patch = await fetch(`${base}/rest/v1/documents?id=eq.${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: next,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
updated_at: next.updated_at,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!patch.ok) {
|
||||||
|
return NextResponse.json({ error: await patch.text() }, { status: 500 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const ins = await fetch(`${base}/rest/v1/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: ctx.tenantId,
|
||||||
|
type: BRANDBOOK_DOC_TYPE,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
status: 'approved',
|
||||||
|
content: next,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!ins.ok) {
|
||||||
|
return NextResponse.json({ error: await ins.text() }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
brandbook: next,
|
||||||
|
completeness: brandbookCompleteness(next),
|
||||||
|
ingested: filename,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import { isAgencyRole, userRole } from '@/lib/agency';
|
|||||||
import { asPlanObject } from '@/lib/calendar-documents';
|
import { asPlanObject } from '@/lib/calendar-documents';
|
||||||
import { generateMonthPreview } from '@/lib/calendar-generate';
|
import { generateMonthPreview } from '@/lib/calendar-generate';
|
||||||
import type { RrssMonthPlan } from '@/components/RrssMonthView';
|
import type { RrssMonthPlan } from '@/components/RrssMonthView';
|
||||||
|
import {
|
||||||
|
BRANDBOOK_DOC_TYPE,
|
||||||
|
parseBrandbook,
|
||||||
|
} from '@/lib/brandbook';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
@@ -64,11 +68,19 @@ export async function POST(req: NextRequest) {
|
|||||||
return String(b.month).localeCompare(String(a.month));
|
return String(b.month).localeCompare(String(a.month));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bb = await restGet<{ content?: unknown }[]>(
|
||||||
|
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||||
|
ctx.tenantId
|
||||||
|
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=content&order=updated_at.desc&limit=1`
|
||||||
|
);
|
||||||
|
const brandbook = parseBrandbook(bb.data?.[0]?.content);
|
||||||
|
|
||||||
const result = await generateMonthPreview({
|
const result = await generateMonthPreview({
|
||||||
ym: month,
|
ym: month,
|
||||||
slug,
|
slug,
|
||||||
label: ctx.label,
|
label: ctx.label,
|
||||||
history,
|
history,
|
||||||
|
brandbook,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -77,5 +89,6 @@ export async function POST(req: NextRequest) {
|
|||||||
rationale: result.rationale,
|
rationale: result.rationale,
|
||||||
source: result.source,
|
source: result.source,
|
||||||
brand: slug,
|
brand: slug,
|
||||||
|
brandbook: !!brandbook,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/app/dashboard/marca/page.tsx
Normal file
36
src/app/dashboard/marca/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { EmptyState, PageHeader } from '@/components/ui';
|
||||||
|
import BrandZone from '@/components/BrandZone';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function MarcaPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
|
||||||
|
if (ctx.portfolioWide) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Marca"
|
||||||
|
title="Zona de marca"
|
||||||
|
description="Brandbooks por cliente para ads, calendarios, LinkedIn y blog."
|
||||||
|
/>
|
||||||
|
<EmptyState
|
||||||
|
title="Elige un cliente"
|
||||||
|
description="Selecciona un tenant en Contexto para crear o editar su brandbook."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BrandZone tenantLabel={ctx.label} />;
|
||||||
|
}
|
||||||
824
src/components/BrandZone.tsx
Normal file
824
src/components/BrandZone.tsx
Normal file
@@ -0,0 +1,824 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
Sparkles,
|
||||||
|
Upload,
|
||||||
|
CheckCircle2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { PageHeader, Surface, SectionTitle } from '@/components/ui';
|
||||||
|
import {
|
||||||
|
BRANDBOOK_SECTIONS,
|
||||||
|
emptyBrandbook,
|
||||||
|
linesToList,
|
||||||
|
listToLines,
|
||||||
|
type Brandbook,
|
||||||
|
type BrandbookSectionId,
|
||||||
|
} from '@/lib/brandbook';
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs font-semibold text-gray-800">{label}</span>
|
||||||
|
{hint ? <span className="block text-[11px] text-gray-500">{hint}</span> : null}
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
const areaCls = `${inputCls} min-h-[88px] resize-y`;
|
||||||
|
|
||||||
|
export default function BrandZone({ tenantLabel }: { tenantLabel: string }) {
|
||||||
|
const [section, setSection] = useState<BrandbookSectionId>('identidad');
|
||||||
|
const [book, setBook] = useState<Brandbook>(() => emptyBrandbook(tenantLabel));
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [seeding, setSeeding] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [okMsg, setOkMsg] = useState<string | null>(null);
|
||||||
|
const [score, setScore] = useState(0);
|
||||||
|
const [missing, setMissing] = useState<string[]>([]);
|
||||||
|
const [paste, setPaste] = useState('');
|
||||||
|
const [exists, setExists] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/brandbook');
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j.error || 'No se pudo cargar');
|
||||||
|
setBook(j.brandbook || emptyBrandbook(tenantLabel));
|
||||||
|
setSlug(j.slug || '');
|
||||||
|
setScore(j.completeness?.score ?? 0);
|
||||||
|
setMissing(j.completeness?.missing || []);
|
||||||
|
setExists(!!j.exists);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [tenantLabel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
function patch(partial: Partial<Brandbook>) {
|
||||||
|
setBook((b) => ({ ...b, ...partial }));
|
||||||
|
setOkMsg(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setErr(null);
|
||||||
|
setOkMsg(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/brandbook', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ brandbook: book }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j.error || 'No se pudo guardar');
|
||||||
|
setBook(j.brandbook);
|
||||||
|
setScore(j.completeness?.score ?? 0);
|
||||||
|
setMissing(j.completeness?.missing || []);
|
||||||
|
setExists(true);
|
||||||
|
setOkMsg('Brandbook guardado');
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Error al guardar');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
setSeeding(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/brandbook', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ seed: true }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j.error || 'No se pudo sembrar');
|
||||||
|
setBook(j.brandbook);
|
||||||
|
setScore(j.completeness?.score ?? 0);
|
||||||
|
setMissing(j.completeness?.missing || []);
|
||||||
|
setExists(true);
|
||||||
|
setOkMsg('Base de marca creada — completa y guarda lo que falte');
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Error');
|
||||||
|
} finally {
|
||||||
|
setSeeding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file: File | null) {
|
||||||
|
if (!file && !paste.trim()) return;
|
||||||
|
setUploading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
if (file) fd.append('file', file);
|
||||||
|
if (paste.trim()) {
|
||||||
|
fd.append('text', paste);
|
||||||
|
fd.append('filename', 'pegado.txt');
|
||||||
|
}
|
||||||
|
const r = await fetch('/api/brandbook/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j.error || 'Upload falló');
|
||||||
|
setBook(j.brandbook);
|
||||||
|
setScore(j.completeness?.score ?? 0);
|
||||||
|
setMissing(j.completeness?.missing || []);
|
||||||
|
setExists(true);
|
||||||
|
setPaste('');
|
||||||
|
setOkMsg(`Fuente añadida: ${j.ingested}`);
|
||||||
|
setSection('fuentes');
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Error upload');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500 py-12">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Cargando zona de marca…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Marca"
|
||||||
|
title="Zona de marca"
|
||||||
|
description={`${tenantLabel}${slug ? ` · ${slug}` : ''} · Brandbook para anuncios, calendarios RRSS, LinkedIn B2B y blog corporativo.`}
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void seed()}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{seeding ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-amber-500" />
|
||||||
|
)}
|
||||||
|
{exists ? 'Resembrar base' : 'Crear base de marca'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void save()}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Guardar brandbook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Surface className="!p-3">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
Completitud
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900 mt-1">{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>
|
||||||
|
</Surface>
|
||||||
|
<Surface className="!p-3 sm:col-span-2">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
Usos conectados
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-700 mt-1 leading-relaxed">
|
||||||
|
Calendario editorial · Pipeline creatividades / ads · Estrategia
|
||||||
|
LinkedIn B2B · Blog corporativo. Cuanto más completo el brandbook,
|
||||||
|
mejores los temas que genera la IA.
|
||||||
|
</p>
|
||||||
|
{missing.length ? (
|
||||||
|
<p className="text-xs text-amber-700 mt-2">
|
||||||
|
Falta: {missing.join(' · ')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-emerald-700 mt-2 inline-flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" /> Base mínima completa
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Surface>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err ? (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-3 py-2">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
) : 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}
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
<nav className="lg:w-52 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={BRANDBOOK_SECTIONS.find((s) => s.id === section)?.label || ''}
|
||||||
|
description={
|
||||||
|
BRANDBOOK_SECTIONS.find((s) => s.id === section)?.hint
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{section === 'identidad' && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Nombre de marca">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={book.brand_name}
|
||||||
|
onChange={(e) => patch({ brand_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Tagline">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={book.tagline}
|
||||||
|
onChange={(e) => patch({ tagline: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Vertical / sector">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={book.vertical}
|
||||||
|
onChange={(e) => patch({ vertical: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Valores (uno por línea)">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.values)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ values: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Misión">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={book.mission}
|
||||||
|
onChange={(e) => patch({ mission: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Visión">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={book.vision}
|
||||||
|
onChange={(e) => patch({ vision: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'voz' && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Tono">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={book.tone}
|
||||||
|
onChange={(e) => patch({ tone: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Personalidad (líneas)">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.personality)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ personality: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Sí hacer">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.voice_do)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ voice_do: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="No hacer">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.voice_dont)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ voice_dont: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Léxico preferido">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.vocabulary_prefer)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ vocabulary_prefer: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Léxico a evitar">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.vocabulary_avoid)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ vocabulary_avoid: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'visual' && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Colores primarios (#hex o nombre)">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.colors_primary)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ colors_primary: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Colores secundarios">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.colors_secondary)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ colors_secondary: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Tipografías">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.fonts)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ fonts: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Uso de logo">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={book.logo_usage}
|
||||||
|
onChange={(e) => patch({ logo_usage: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Notas visuales / arte">
|
||||||
|
<textarea
|
||||||
|
className={`${areaCls} sm:col-span-2`}
|
||||||
|
value={book.visual_notes}
|
||||||
|
onChange={(e) => patch({ visual_notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'audiencia' && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="ICP">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={book.icp}
|
||||||
|
onChange={(e) => patch({ icp: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Geo">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={book.geo}
|
||||||
|
onChange={(e) => patch({ geo: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Idiomas">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.languages)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ languages: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Personas (JSON o notas libres en Notas)"
|
||||||
|
hint="Formato: Nombre — descripción — pains (una por línea, sep. |)"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={book.personas
|
||||||
|
.map((p) => `${p.name} | ${p.description} | ${p.pains}`)
|
||||||
|
.join('\n')}
|
||||||
|
onChange={(e) => {
|
||||||
|
const personas = e.target.value
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const [name, description, pains] = line
|
||||||
|
.split('|')
|
||||||
|
.map((s) => s.trim());
|
||||||
|
return {
|
||||||
|
name: name || 'Persona',
|
||||||
|
description: description || '',
|
||||||
|
pains: pains || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
patch({ personas });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'oferta' && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Productos / servicios">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.products_services)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ products_services: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Diferenciadores">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.differentiators)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ differentiators: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Prueba social / cifras">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.proof_points)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ proof_points: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Notas de pricing / packs">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={book.pricing_notes}
|
||||||
|
onChange={(e) => patch({ pricing_notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'pilares' && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Pilares RRSS">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.pillars_rrss)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ pillars_rrss: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Series recurrentes (ADN)">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.series_recurrentes)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ series_recurrentes: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Formatos prioritarios">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.formats_priority)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ formats_priority: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'canales' && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['instagram', 'Instagram'],
|
||||||
|
['facebook', 'Facebook'],
|
||||||
|
['linkedin', 'LinkedIn'],
|
||||||
|
['tiktok', 'TikTok'],
|
||||||
|
['blog', 'Blog'],
|
||||||
|
['other', 'Otros'],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<Field key={key} label={label} hint="Rol del canal para esta marca">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={book.channels[key]}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({
|
||||||
|
channels: { ...book.channels, [key]: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'ads' && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="USPs">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.ads_usps)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ ads_usps: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Objeciones">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.ads_objections)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ ads_objections: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="CTAs">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.ads_ctas)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ ads_ctas: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Ofertas / promos">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.ads_offers)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ ads_offers: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Ángulos creativos">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.ads_angles)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ ads_angles: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'b2b' && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Temas LinkedIn">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.linkedin_themes)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ linkedin_themes: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Formatos LinkedIn">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.linkedin_formats)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ linkedin_formats: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Thought leadership">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.thought_leadership)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ thought_leadership: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Buyer roles">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.buyer_roles)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ buyer_roles: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'blog' && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Temas blog">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.blog_themes)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ blog_themes: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Keywords SEO">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.blog_seo_keywords)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ blog_seo_keywords: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Cadencia">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={book.blog_cadence}
|
||||||
|
onChange={(e) => patch({ blog_cadence: e.target.value })}
|
||||||
|
placeholder="ej. 2 posts/mes"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'compliance' && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Claims prohibidos">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.claims_forbidden)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ claims_forbidden: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Notas legales">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={book.legal_notes}
|
||||||
|
onChange={(e) => patch({ legal_notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Reglas de sector">
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={book.sector_rules}
|
||||||
|
onChange={(e) => patch({ sector_rules: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'fuentes' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-gray-800">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
Subir o pegar brandbook
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
.txt / .md / .csv / .json — o pega el texto de un PDF/DOCX. Se
|
||||||
|
guarda como fuente y alimenta notas libres.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
placeholder="Pega aquí el brandbook o brief…"
|
||||||
|
value={paste}
|
||||||
|
onChange={(e) => setPaste(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<label className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold cursor-pointer hover:bg-gray-50">
|
||||||
|
<BookOpen className="w-3.5 h-3.5" />
|
||||||
|
Elegir archivo
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".txt,.md,.csv,.json,text/plain"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0] || null;
|
||||||
|
void uploadFile(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uploading || !paste.trim()}
|
||||||
|
onClick={() => void uploadFile(null)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-gray-900 px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Añadir texto pegado
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Notas libres">
|
||||||
|
<textarea
|
||||||
|
className={`${areaCls} min-h-[140px]`}
|
||||||
|
value={book.freeform_notes}
|
||||||
|
onChange={(e) => patch({ freeform_notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{(book.source_excerpts || []).length > 0 ? (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{book.source_excerpts.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className="rounded-lg border border-gray-200 p-3 text-xs"
|
||||||
|
>
|
||||||
|
<p className="font-semibold text-gray-800">
|
||||||
|
{s.filename}{' '}
|
||||||
|
<span className="font-normal text-gray-400">
|
||||||
|
{s.added_at?.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-gray-600 line-clamp-4 whitespace-pre-wrap">
|
||||||
|
{s.excerpt.slice(0, 500)}
|
||||||
|
{s.excerpt.length > 500 ? '…' : ''}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500">Aún no hay fuentes subidas.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Surface>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -397,7 +397,7 @@ export default function CalendarCreator({
|
|||||||
) : (
|
) : (
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
Generar preview editorial
|
Generar estrategia del mes
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -422,10 +422,10 @@ export default function CalendarCreator({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Genera un preview según la marca, estacionalidad e histórico
|
Estrategia engagement 2026: tendencias del mes + Zona de
|
||||||
RRSS del cliente, o haz click en un día para añadir
|
marca / brandbook del cliente (crea ángulos nuevos, no
|
||||||
publicaciones a mano (mismos campos que Pipeline
|
copia el histórico). Completa el brandbook en Marca para
|
||||||
creatividades).
|
mejores temas. O haz click en un día para añadir a mano.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
Leaf,
|
Leaf,
|
||||||
|
BookMarked,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import TenantSwitcher from '@/components/TenantSwitcher';
|
import TenantSwitcher from '@/components/TenantSwitcher';
|
||||||
import type { DashboardModuleId } from '@/lib/tenant-modules';
|
import type { DashboardModuleId } from '@/lib/tenant-modules';
|
||||||
@@ -65,6 +66,13 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
iconClass: 'text-emerald-500',
|
iconClass: 'text-emerald-500',
|
||||||
moduleId: 'estrategia',
|
moduleId: 'estrategia',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/dashboard/marca',
|
||||||
|
label: 'Zona de marca',
|
||||||
|
icon: BookMarked,
|
||||||
|
iconClass: 'text-rose-500',
|
||||||
|
moduleId: 'marca',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard/ad-tracker',
|
href: '/dashboard/ad-tracker',
|
||||||
label: 'Ads',
|
label: 'Ads',
|
||||||
|
|||||||
571
src/lib/brandbook.ts
Normal file
571
src/lib/brandbook.ts
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
/**
|
||||||
|
* Brandbook per tenant — source of truth for ads, RRSS calendars,
|
||||||
|
* LinkedIn B2B and corporate blog strategy.
|
||||||
|
* Stored in documents as type=brandbook (one active doc per tenant).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { brandBrief } from '@/lib/calendar-generate-brand';
|
||||||
|
|
||||||
|
export const BRANDBOOK_DOC_TYPE = 'brandbook';
|
||||||
|
export const BRANDBOOK_DOC_TITLE = 'Brandbook';
|
||||||
|
|
||||||
|
export type BrandbookSectionId =
|
||||||
|
| 'identidad'
|
||||||
|
| 'voz'
|
||||||
|
| 'visual'
|
||||||
|
| 'audiencia'
|
||||||
|
| 'oferta'
|
||||||
|
| 'pilares'
|
||||||
|
| 'canales'
|
||||||
|
| 'ads'
|
||||||
|
| 'b2b'
|
||||||
|
| 'blog'
|
||||||
|
| 'compliance'
|
||||||
|
| 'fuentes';
|
||||||
|
|
||||||
|
export type Brandbook = {
|
||||||
|
version: 1;
|
||||||
|
updated_at?: string;
|
||||||
|
/** Identity */
|
||||||
|
brand_name: string;
|
||||||
|
tagline: string;
|
||||||
|
mission: string;
|
||||||
|
vision: string;
|
||||||
|
values: string[];
|
||||||
|
vertical: string;
|
||||||
|
/** Voice */
|
||||||
|
tone: string;
|
||||||
|
personality: string[];
|
||||||
|
voice_do: string[];
|
||||||
|
voice_dont: string[];
|
||||||
|
vocabulary_prefer: string[];
|
||||||
|
vocabulary_avoid: string[];
|
||||||
|
/** Visual */
|
||||||
|
colors_primary: string[];
|
||||||
|
colors_secondary: string[];
|
||||||
|
fonts: string[];
|
||||||
|
visual_notes: string;
|
||||||
|
logo_usage: string;
|
||||||
|
/** Audience */
|
||||||
|
icp: string;
|
||||||
|
personas: Array<{ name: string; description: string; pains: string }>;
|
||||||
|
geo: string;
|
||||||
|
languages: string[];
|
||||||
|
/** Offer */
|
||||||
|
products_services: string[];
|
||||||
|
differentiators: string[];
|
||||||
|
proof_points: string[];
|
||||||
|
pricing_notes: string;
|
||||||
|
/** Content pillars */
|
||||||
|
pillars_rrss: string[];
|
||||||
|
series_recurrentes: string[];
|
||||||
|
formats_priority: string[];
|
||||||
|
/** Channels */
|
||||||
|
channels: {
|
||||||
|
instagram: string;
|
||||||
|
facebook: string;
|
||||||
|
linkedin: string;
|
||||||
|
tiktok: string;
|
||||||
|
blog: string;
|
||||||
|
other: string;
|
||||||
|
};
|
||||||
|
/** Ads */
|
||||||
|
ads_usps: string[];
|
||||||
|
ads_objections: string[];
|
||||||
|
ads_ctas: string[];
|
||||||
|
ads_offers: string[];
|
||||||
|
ads_angles: string[];
|
||||||
|
/** B2B / LinkedIn */
|
||||||
|
linkedin_themes: string[];
|
||||||
|
linkedin_formats: string[];
|
||||||
|
thought_leadership: string[];
|
||||||
|
buyer_roles: string[];
|
||||||
|
/** Blog corporativo */
|
||||||
|
blog_themes: string[];
|
||||||
|
blog_seo_keywords: string[];
|
||||||
|
blog_cadence: string;
|
||||||
|
/** Compliance */
|
||||||
|
claims_forbidden: string[];
|
||||||
|
legal_notes: string;
|
||||||
|
sector_rules: string;
|
||||||
|
/** Uploaded / pasted source material */
|
||||||
|
source_excerpts: Array<{
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
excerpt: string;
|
||||||
|
added_at: string;
|
||||||
|
}>;
|
||||||
|
freeform_notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BRANDBOOK_SECTIONS: {
|
||||||
|
id: BrandbookSectionId;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
id: 'identidad',
|
||||||
|
label: 'Identidad',
|
||||||
|
hint: 'Nombre, misión, visión, valores, vertical',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'voz',
|
||||||
|
label: 'Voz y tono',
|
||||||
|
hint: 'Cómo habla la marca; do/don’t y léxico',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'visual',
|
||||||
|
label: 'Visual',
|
||||||
|
hint: 'Colores, tipografías, logo y notas de arte',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audiencia',
|
||||||
|
label: 'Audiencia',
|
||||||
|
hint: 'ICP, personas, geo e idiomas',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'oferta',
|
||||||
|
label: 'Oferta',
|
||||||
|
hint: 'Productos/servicios, diferenciadores, prueba',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pilares',
|
||||||
|
label: 'Pilares de contenido',
|
||||||
|
hint: 'Series RRSS y formatos prioritarios',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'canales',
|
||||||
|
label: 'Canales',
|
||||||
|
hint: 'Rol de cada red y blog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ads',
|
||||||
|
label: 'Anuncios',
|
||||||
|
hint: 'USPs, objeciones, CTAs, ofertas y ángulos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'b2b',
|
||||||
|
label: 'LinkedIn B2B',
|
||||||
|
hint: 'Temas, formatos y thought leadership',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blog',
|
||||||
|
label: 'Blog corporativo',
|
||||||
|
hint: 'Temas, SEO y cadencia',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compliance',
|
||||||
|
label: 'Compliance',
|
||||||
|
hint: 'Claims prohibidos y notas legales',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fuentes',
|
||||||
|
label: 'Fuentes',
|
||||||
|
hint: 'Textos subidos / pegados del brandbook',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function emptyBrandbook(label = ''): Brandbook {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
brand_name: label,
|
||||||
|
tagline: '',
|
||||||
|
mission: '',
|
||||||
|
vision: '',
|
||||||
|
values: [],
|
||||||
|
vertical: '',
|
||||||
|
tone: '',
|
||||||
|
personality: [],
|
||||||
|
voice_do: [],
|
||||||
|
voice_dont: [],
|
||||||
|
vocabulary_prefer: [],
|
||||||
|
vocabulary_avoid: [],
|
||||||
|
colors_primary: [],
|
||||||
|
colors_secondary: [],
|
||||||
|
fonts: [],
|
||||||
|
visual_notes: '',
|
||||||
|
logo_usage: '',
|
||||||
|
icp: '',
|
||||||
|
personas: [],
|
||||||
|
geo: 'España',
|
||||||
|
languages: ['es'],
|
||||||
|
products_services: [],
|
||||||
|
differentiators: [],
|
||||||
|
proof_points: [],
|
||||||
|
pricing_notes: '',
|
||||||
|
pillars_rrss: [],
|
||||||
|
series_recurrentes: [],
|
||||||
|
formats_priority: ['Reel', 'Carrusel', 'Stories', 'Post'],
|
||||||
|
channels: {
|
||||||
|
instagram: '',
|
||||||
|
facebook: '',
|
||||||
|
linkedin: '',
|
||||||
|
tiktok: '',
|
||||||
|
blog: '',
|
||||||
|
other: '',
|
||||||
|
},
|
||||||
|
ads_usps: [],
|
||||||
|
ads_objections: [],
|
||||||
|
ads_ctas: [],
|
||||||
|
ads_offers: [],
|
||||||
|
ads_angles: [],
|
||||||
|
linkedin_themes: [],
|
||||||
|
linkedin_formats: ['Carrusel', 'Documento', 'Post texto', 'Video corto'],
|
||||||
|
thought_leadership: [],
|
||||||
|
buyer_roles: [],
|
||||||
|
blog_themes: [],
|
||||||
|
blog_seo_keywords: [],
|
||||||
|
blog_cadence: '',
|
||||||
|
claims_forbidden: [],
|
||||||
|
legal_notes: '',
|
||||||
|
sector_rules: '',
|
||||||
|
source_excerpts: [],
|
||||||
|
freeform_notes: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed a usable brandbook from hard-coded brand DNA + sensible defaults. */
|
||||||
|
export function seedBrandbook(slug: string, label: string): Brandbook {
|
||||||
|
const brief = brandBrief(slug, label);
|
||||||
|
const base = emptyBrandbook(label);
|
||||||
|
const seeds: Record<string, Partial<Brandbook>> = {
|
||||||
|
bps: {
|
||||||
|
vertical: 'Escuela de idiomas',
|
||||||
|
tagline: 'Inglés real, cultura pop y certificaciones',
|
||||||
|
mission:
|
||||||
|
'Ayudar a alumnos a hablar inglés con confianza para trabajo, viajes y exámenes.',
|
||||||
|
tone: brief.tone,
|
||||||
|
personality: ['cercano', 'divertido', 'riguroso', 'pop'],
|
||||||
|
voice_do: [
|
||||||
|
'Explicar con ejemplos reales',
|
||||||
|
'Usar series FALSE FRIEND / SLANG / MTW',
|
||||||
|
'Micro-lecciones accionables',
|
||||||
|
],
|
||||||
|
voice_dont: [
|
||||||
|
'Listas genéricas de Black Friday',
|
||||||
|
'Tono académico seco',
|
||||||
|
'Copiar títulos mes a mes',
|
||||||
|
],
|
||||||
|
pillars_rrss: brief.pillars,
|
||||||
|
series_recurrentes: [
|
||||||
|
'FALSE FRIEND',
|
||||||
|
'SLANG',
|
||||||
|
'Same word different level',
|
||||||
|
'MTW',
|
||||||
|
'Canción del día',
|
||||||
|
'English Through TV',
|
||||||
|
'Fix it',
|
||||||
|
],
|
||||||
|
products_services: [
|
||||||
|
'Cursos generales',
|
||||||
|
'Intensivos',
|
||||||
|
'Trinity / IELTS / Cambridge',
|
||||||
|
'Inglés oposiciones',
|
||||||
|
'Madrid / Málaga',
|
||||||
|
],
|
||||||
|
ads_usps: [
|
||||||
|
'Metodología práctica',
|
||||||
|
'Profes nativos / preparados',
|
||||||
|
'Certificaciones oficiales',
|
||||||
|
],
|
||||||
|
ads_ctas: ['Test de nivel', 'Reservar plaza', 'WhatsApp'],
|
||||||
|
ads_objections: [
|
||||||
|
'No tengo tiempo',
|
||||||
|
'Ya probé y no aprendí',
|
||||||
|
'Es caro',
|
||||||
|
],
|
||||||
|
linkedin_themes: [
|
||||||
|
'Inglés profesional',
|
||||||
|
'Employability',
|
||||||
|
'Casos de alumnos',
|
||||||
|
],
|
||||||
|
blog_themes: [
|
||||||
|
'Guías de certificación',
|
||||||
|
'Errores comunes',
|
||||||
|
'Cómo elegir curso',
|
||||||
|
],
|
||||||
|
channels: {
|
||||||
|
instagram: 'Alcance + comunidad (Reels + Stories)',
|
||||||
|
facebook: 'Comunidad local / padres',
|
||||||
|
linkedin: 'B2B ligero / empleabilidad',
|
||||||
|
tiktok: 'Opcional micro-lecciones',
|
||||||
|
blog: 'SEO certificaciones y objeciones',
|
||||||
|
other: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'talento-y-deporte': {
|
||||||
|
vertical: brief.vertical,
|
||||||
|
tone: brief.tone,
|
||||||
|
pillars_rrss: brief.pillars,
|
||||||
|
series_recurrentes: [
|
||||||
|
'Nuevo alumno',
|
||||||
|
'Beca académica',
|
||||||
|
'Beca deportiva',
|
||||||
|
'Campus USA',
|
||||||
|
'Objeciones',
|
||||||
|
'English tips',
|
||||||
|
],
|
||||||
|
products_services: [
|
||||||
|
'Becas deportivas USA',
|
||||||
|
'Acompañamiento académico',
|
||||||
|
'Campus visits',
|
||||||
|
],
|
||||||
|
ads_usps: ['Becas reales', 'Acompañamiento end-to-end', 'Historias de alumnos'],
|
||||||
|
ads_ctas: ['Agenda llamada', 'Evalúa tu perfil'],
|
||||||
|
linkedin_themes: ['Educación internacional', 'Deporte + estudios'],
|
||||||
|
blog_themes: ['Guía becas USA', 'Requisitos TOEFL/SAT', 'Vida en campus'],
|
||||||
|
},
|
||||||
|
'halal-safety': {
|
||||||
|
vertical: brief.vertical,
|
||||||
|
tone: brief.tone,
|
||||||
|
pillars_rrss: brief.pillars,
|
||||||
|
products_services: [
|
||||||
|
'Certificación Halal',
|
||||||
|
'Consultoría exportación',
|
||||||
|
'Formación',
|
||||||
|
],
|
||||||
|
ads_usps: ['Autoridad técnica', 'Mercados export', 'Proceso claro'],
|
||||||
|
ads_ctas: ['Solicitar info', 'Auditoría'],
|
||||||
|
linkedin_themes: [
|
||||||
|
'Exportación Halal',
|
||||||
|
'Normativa',
|
||||||
|
'Casos sector alimentación/cosmética',
|
||||||
|
],
|
||||||
|
thought_leadership: [
|
||||||
|
'Tendencias mercados musulmanes',
|
||||||
|
'Compliance vs marketing Halal',
|
||||||
|
],
|
||||||
|
blog_themes: ['Guías de certificación', 'Mercados clave', 'FAQ técnica'],
|
||||||
|
buyer_roles: ['Quality', 'Export manager', 'CEO pyme alimentaria'],
|
||||||
|
},
|
||||||
|
'brava-dermoestetica': {
|
||||||
|
vertical: brief.vertical,
|
||||||
|
tone: brief.tone,
|
||||||
|
pillars_rrss: brief.pillars,
|
||||||
|
products_services: ['Peelings', 'Tratamientos facial', 'Rutinas'],
|
||||||
|
ads_usps: ['Resultados naturales', 'Equipo médico', 'Protocolos personalizados'],
|
||||||
|
ads_ctas: ['Pedir cita', 'Consulta valoración'],
|
||||||
|
claims_forbidden: [
|
||||||
|
'Promesas milagro sin matiz',
|
||||||
|
'Antes/después sin consentimiento',
|
||||||
|
],
|
||||||
|
sector_rules: 'Publicidad sanitaria: claims prudentes, sin garantías absolutas.',
|
||||||
|
},
|
||||||
|
noberplast: {
|
||||||
|
vertical: brief.vertical,
|
||||||
|
tone: brief.tone,
|
||||||
|
pillars_rrss: brief.pillars,
|
||||||
|
formats_priority: ['Carrusel', 'Post LinkedIn', 'Documento', 'Reel'],
|
||||||
|
linkedin_themes: [
|
||||||
|
'Plásticos técnicos',
|
||||||
|
'Casos industriales',
|
||||||
|
'Innovación materiales',
|
||||||
|
],
|
||||||
|
thought_leadership: [
|
||||||
|
'Sostenibilidad industrial',
|
||||||
|
'Especificaciones técnicas',
|
||||||
|
],
|
||||||
|
buyer_roles: ['Ingeniería', 'Compras', 'Producción'],
|
||||||
|
blog_themes: ['Fichas técnicas', 'Aplicaciones sectoriales', 'FAQ producto'],
|
||||||
|
channels: {
|
||||||
|
instagram: 'Marca / producto visual',
|
||||||
|
facebook: 'Bajo',
|
||||||
|
linkedin: 'Canal principal B2B',
|
||||||
|
tiktok: 'No prioritario',
|
||||||
|
blog: 'SEO técnico + captación inbound',
|
||||||
|
other: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'4ventures-hub': {
|
||||||
|
vertical: brief.vertical,
|
||||||
|
tone: brief.tone,
|
||||||
|
pillars_rrss: brief.pillars,
|
||||||
|
linkedin_themes: ['Ops de agencia', 'Growth', 'Casos clientes'],
|
||||||
|
thought_leadership: ['Centro de mando', 'Atribución', 'Contenido que vende'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedSlug = slug === 'blue-parrot-school' ? 'bps' : slug;
|
||||||
|
|
||||||
|
const extra = seeds[resolvedSlug] || {
|
||||||
|
vertical: brief.vertical,
|
||||||
|
tone: brief.tone,
|
||||||
|
pillars_rrss: brief.pillars,
|
||||||
|
products_services: [],
|
||||||
|
freeform_notes: brief.notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...extra,
|
||||||
|
brand_name: label,
|
||||||
|
vertical: extra.vertical || brief.vertical,
|
||||||
|
tone: extra.tone || brief.tone,
|
||||||
|
pillars_rrss: extra.pillars_rrss?.length ? extra.pillars_rrss : brief.pillars,
|
||||||
|
freeform_notes: extra.freeform_notes || brief.notes,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBrandbook(raw: unknown): Brandbook | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const o = raw as Partial<Brandbook>;
|
||||||
|
if (o.version !== 1 && !o.brand_name && !o.vertical) {
|
||||||
|
// Allow loose objects that look like brandbooks
|
||||||
|
if (!('tone' in o) && !('pillars_rrss' in o) && !('mission' in o)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const empty = emptyBrandbook(String(o.brand_name || ''));
|
||||||
|
return {
|
||||||
|
...empty,
|
||||||
|
...o,
|
||||||
|
version: 1,
|
||||||
|
values: arr(o.values),
|
||||||
|
personality: arr(o.personality),
|
||||||
|
voice_do: arr(o.voice_do),
|
||||||
|
voice_dont: arr(o.voice_dont),
|
||||||
|
vocabulary_prefer: arr(o.vocabulary_prefer),
|
||||||
|
vocabulary_avoid: arr(o.vocabulary_avoid),
|
||||||
|
colors_primary: arr(o.colors_primary),
|
||||||
|
colors_secondary: arr(o.colors_secondary),
|
||||||
|
fonts: arr(o.fonts),
|
||||||
|
personas: Array.isArray(o.personas) ? o.personas : [],
|
||||||
|
languages: arr(o.languages).length ? arr(o.languages) : ['es'],
|
||||||
|
products_services: arr(o.products_services),
|
||||||
|
differentiators: arr(o.differentiators),
|
||||||
|
proof_points: arr(o.proof_points),
|
||||||
|
pillars_rrss: arr(o.pillars_rrss),
|
||||||
|
series_recurrentes: arr(o.series_recurrentes),
|
||||||
|
formats_priority: arr(o.formats_priority),
|
||||||
|
channels: { ...empty.channels, ...(o.channels || {}) },
|
||||||
|
ads_usps: arr(o.ads_usps),
|
||||||
|
ads_objections: arr(o.ads_objections),
|
||||||
|
ads_ctas: arr(o.ads_ctas),
|
||||||
|
ads_offers: arr(o.ads_offers),
|
||||||
|
ads_angles: arr(o.ads_angles),
|
||||||
|
linkedin_themes: arr(o.linkedin_themes),
|
||||||
|
linkedin_formats: arr(o.linkedin_formats),
|
||||||
|
thought_leadership: arr(o.thought_leadership),
|
||||||
|
buyer_roles: arr(o.buyer_roles),
|
||||||
|
blog_themes: arr(o.blog_themes),
|
||||||
|
blog_seo_keywords: arr(o.blog_seo_keywords),
|
||||||
|
claims_forbidden: arr(o.claims_forbidden),
|
||||||
|
source_excerpts: Array.isArray(o.source_excerpts) ? o.source_excerpts : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function arr(v: unknown): string[] {
|
||||||
|
if (!Array.isArray(v)) return [];
|
||||||
|
return v.map((x) => String(x || '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lines / commas → string[] for form fields. */
|
||||||
|
export function linesToList(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split(/\n|,/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listToLines(list: string[]): string {
|
||||||
|
return (list || []).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heuristic: fold uploaded plain text into freeform + optional lists. */
|
||||||
|
export function ingestSourceText(
|
||||||
|
book: Brandbook,
|
||||||
|
filename: string,
|
||||||
|
text: string
|
||||||
|
): Brandbook {
|
||||||
|
const excerpt = text.slice(0, 12000).trim();
|
||||||
|
if (!excerpt) return book;
|
||||||
|
const id = `src_${Date.now().toString(36)}`;
|
||||||
|
const next: Brandbook = {
|
||||||
|
...book,
|
||||||
|
source_excerpts: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
filename: filename || 'pegado.txt',
|
||||||
|
excerpt,
|
||||||
|
added_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
...(book.source_excerpts || []),
|
||||||
|
].slice(0, 20),
|
||||||
|
freeform_notes: [book.freeform_notes, `--- ${filename} ---\n${excerpt.slice(0, 4000)}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n')
|
||||||
|
.slice(0, 50000),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Light keyword pulls into empty fields
|
||||||
|
const lower = excerpt.toLowerCase();
|
||||||
|
if (!next.mission && /misi[oó]n/.test(lower)) {
|
||||||
|
const m = excerpt.match(/misi[oó]n[:\s]+(.{20,200})/i);
|
||||||
|
if (m) next.mission = m[1].trim();
|
||||||
|
}
|
||||||
|
if (!next.tone && /tono[:\s]+(.{10,120})/i.test(excerpt)) {
|
||||||
|
const m = excerpt.match(/tono[:\s]+(.{10,120})/i);
|
||||||
|
if (m) next.tone = m[1].trim();
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact prompt block for strategists / Anthropic. */
|
||||||
|
export function brandbookToPrompt(book: Brandbook): string {
|
||||||
|
const lines = [
|
||||||
|
`Marca: ${book.brand_name}`,
|
||||||
|
book.tagline && `Tagline: ${book.tagline}`,
|
||||||
|
book.vertical && `Vertical: ${book.vertical}`,
|
||||||
|
book.mission && `Misión: ${book.mission}`,
|
||||||
|
book.tone && `Tono: ${book.tone}`,
|
||||||
|
book.personality.length && `Personalidad: ${book.personality.join(', ')}`,
|
||||||
|
book.voice_do.length && `Hacer: ${book.voice_do.join('; ')}`,
|
||||||
|
book.voice_dont.length && `Evitar: ${book.voice_dont.join('; ')}`,
|
||||||
|
book.series_recurrentes.length &&
|
||||||
|
`Series RRSS: ${book.series_recurrentes.join(', ')}`,
|
||||||
|
book.pillars_rrss.length && `Pilares: ${book.pillars_rrss.join(', ')}`,
|
||||||
|
book.products_services.length &&
|
||||||
|
`Oferta: ${book.products_services.join(', ')}`,
|
||||||
|
book.differentiators.length &&
|
||||||
|
`Diferenciadores: ${book.differentiators.join(', ')}`,
|
||||||
|
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.ads_objections.length &&
|
||||||
|
`Objeciones: ${book.ads_objections.join('; ')}`,
|
||||||
|
book.linkedin_themes.length &&
|
||||||
|
`LinkedIn: ${book.linkedin_themes.join(', ')}`,
|
||||||
|
book.thought_leadership.length &&
|
||||||
|
`Thought leadership: ${book.thought_leadership.join(', ')}`,
|
||||||
|
book.blog_themes.length && `Blog: ${book.blog_themes.join(', ')}`,
|
||||||
|
book.blog_seo_keywords.length &&
|
||||||
|
`SEO: ${book.blog_seo_keywords.join(', ')}`,
|
||||||
|
book.claims_forbidden.length &&
|
||||||
|
`Prohibido: ${book.claims_forbidden.join('; ')}`,
|
||||||
|
book.legal_notes && `Legal: ${book.legal_notes}`,
|
||||||
|
book.freeform_notes && `Notas: ${book.freeform_notes.slice(0, 1500)}`,
|
||||||
|
].filter(Boolean);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brandbookCompleteness(book: Brandbook): {
|
||||||
|
score: number;
|
||||||
|
missing: string[];
|
||||||
|
} {
|
||||||
|
const checks: [string, boolean][] = [
|
||||||
|
['Identidad (misión/vertical)', !!(book.mission || book.vertical)],
|
||||||
|
['Voz/tono', !!book.tone],
|
||||||
|
['Audiencia/ICP', !!(book.icp || book.personas.length)],
|
||||||
|
['Oferta', book.products_services.length > 0],
|
||||||
|
['Pilares/series RRSS', !!(book.pillars_rrss.length || book.series_recurrentes.length)],
|
||||||
|
['Ads (USP o CTA)', !!(book.ads_usps.length || book.ads_ctas.length)],
|
||||||
|
['LinkedIn B2B o blog', !!(book.linkedin_themes.length || book.blog_themes.length)],
|
||||||
|
];
|
||||||
|
const missing = checks.filter(([, ok]) => !ok).map(([l]) => l);
|
||||||
|
const score = Math.round(
|
||||||
|
((checks.length - missing.length) / checks.length) * 100
|
||||||
|
);
|
||||||
|
return { score, missing };
|
||||||
|
}
|
||||||
125
src/lib/calendar-generate-brand.ts
Normal file
125
src/lib/calendar-generate-brand.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/** Shared brand DNA + title helpers for calendar strategy / generate. */
|
||||||
|
|
||||||
|
export type TenantBrandBrief = {
|
||||||
|
slug: string;
|
||||||
|
label: string;
|
||||||
|
vertical: string;
|
||||||
|
tone: string;
|
||||||
|
pillars: string[];
|
||||||
|
formats: { post: number; reel: number; stories: number; carrusel: number };
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
|
||||||
|
'talento-y-deporte': {
|
||||||
|
vertical: 'Educación deportiva / becas USA',
|
||||||
|
tone: 'Inspirador, cercano, ambicioso; historias de alumnos',
|
||||||
|
pillars: [
|
||||||
|
'nuevo-alumno',
|
||||||
|
'beca-academica',
|
||||||
|
'beca-deportiva',
|
||||||
|
'campus',
|
||||||
|
'objeciones',
|
||||||
|
'tips-ingles',
|
||||||
|
],
|
||||||
|
formats: { post: 0.35, reel: 0.25, stories: 0.3, carrusel: 0.1 },
|
||||||
|
notes: 'Series Nuevo alumno, objeciones, campus USA, English tips.',
|
||||||
|
},
|
||||||
|
'halal-safety': {
|
||||||
|
vertical: 'Certificación Halal / exportación',
|
||||||
|
tone: 'Profesional, claro, autoridad técnica',
|
||||||
|
pillars: ['certificacion', 'exportacion', 'educativo', 'cifras', 'casos'],
|
||||||
|
formats: { post: 0.4, reel: 0.2, stories: 0.25, carrusel: 0.15 },
|
||||||
|
notes: 'Cifras, certificación, mercados, cosmética/alimentación Halal.',
|
||||||
|
},
|
||||||
|
'brava-dermoestetica': {
|
||||||
|
vertical: 'Clínica dermoestética',
|
||||||
|
tone: 'Cercano, experto, estacional (piel / tratamientos)',
|
||||||
|
pillars: ['tratamiento', 'educativo', 'antes-despues', 'promos', 'equipo'],
|
||||||
|
formats: { post: 0.3, reel: 0.25, stories: 0.3, carrusel: 0.15 },
|
||||||
|
notes: 'Peelings, rutinas, encuestas stories, trends, citas.',
|
||||||
|
},
|
||||||
|
bps: {
|
||||||
|
vertical: 'Escuela de idiomas',
|
||||||
|
tone: 'Educativo, pop culture, series (FALSE FRIEND, SLANG, MTW…)',
|
||||||
|
pillars: [
|
||||||
|
'educativo',
|
||||||
|
'slang',
|
||||||
|
'false-friend',
|
||||||
|
'certificaciones',
|
||||||
|
'cursos',
|
||||||
|
'cultura-pop',
|
||||||
|
],
|
||||||
|
// Target mix for growth 2026 (not legacy 2% reels)
|
||||||
|
formats: { post: 0.2, reel: 0.28, stories: 0.36, carrusel: 0.16 },
|
||||||
|
notes:
|
||||||
|
'ADN BPS: FALSE FRIEND, SLANG, Same word, canción, TV shows, matriculación Madrid/Málaga, Trinity/IELTS — crear ángulos nuevos, no repetir títulos.',
|
||||||
|
},
|
||||||
|
'blue-parrot-school': {
|
||||||
|
vertical: 'Escuela de idiomas',
|
||||||
|
tone: 'Educativo, pop culture, series recurrentes',
|
||||||
|
pillars: [
|
||||||
|
'educativo',
|
||||||
|
'slang',
|
||||||
|
'false-friend',
|
||||||
|
'certificaciones',
|
||||||
|
'cursos',
|
||||||
|
'cultura-pop',
|
||||||
|
],
|
||||||
|
formats: { post: 0.2, reel: 0.28, stories: 0.36, carrusel: 0.16 },
|
||||||
|
notes: 'Misma línea que BPS.',
|
||||||
|
},
|
||||||
|
noberplast: {
|
||||||
|
vertical: 'Plásticos industriales B2B',
|
||||||
|
tone: 'Técnico, B2B, LinkedIn-first',
|
||||||
|
pillars: ['producto', 'casos', 'industria', 'linkedin'],
|
||||||
|
formats: { post: 0.45, reel: 0.15, stories: 0.1, carrusel: 0.3 },
|
||||||
|
notes: 'Más LinkedIn y carruseles técnicos.',
|
||||||
|
},
|
||||||
|
'4ventures-hub': {
|
||||||
|
vertical: 'Agencia marketing',
|
||||||
|
tone: 'Directo, ops, thought leadership',
|
||||||
|
pillars: ['ops', 'casos', 'tips', 'equipo'],
|
||||||
|
formats: { post: 0.35, reel: 0.25, stories: 0.2, carrusel: 0.2 },
|
||||||
|
notes: 'Contenido de agencia.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function brandBrief(slug: string, label: string): TenantBrandBrief {
|
||||||
|
const base = BRANDS[slug] || {
|
||||||
|
vertical: 'Marca / servicios',
|
||||||
|
tone: 'Profesional y claro',
|
||||||
|
pillars: ['educativo', 'producto', 'comunidad', 'promo'],
|
||||||
|
formats: { post: 0.3, reel: 0.25, stories: 0.3, carrusel: 0.15 },
|
||||||
|
notes: 'Ajustar al ADN del cliente.',
|
||||||
|
};
|
||||||
|
return { slug, label, ...base };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanEditorialTitle(raw: string): string {
|
||||||
|
return String(raw || '')
|
||||||
|
.replace(/\s*\([^)]*\b\d+\s*(?:[.,]\d+)?\s*h(?:oras?)?[^)]*\)/gi, '')
|
||||||
|
.replace(/\s*\([^)]*\b\d+\s*min[^)]*\)/gi, '')
|
||||||
|
.replace(/\s*\(\s*0?\s*[hH]\s*\)/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isJunkPreviewTitle(title: string): boolean {
|
||||||
|
const t = title.toLowerCase();
|
||||||
|
if (/^post\s+\d+\s*·/.test(t)) return true;
|
||||||
|
if (/^reel\s*·\s*(feed|stories|nuevo)/.test(t)) return true;
|
||||||
|
if (/^stories\s*·\s*(black friday|puente|cierre|feed|stories)/.test(t)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/black friday|cierre de año comercial|puente$|· puente|· black friday/.test(
|
||||||
|
t
|
||||||
|
) &&
|
||||||
|
/^(post|reel|stories)\b/.test(t)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/carrusel stories|carrusel feed/.test(t)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -1,176 +1,53 @@
|
|||||||
/**
|
/**
|
||||||
* Generate a month editorial preview from tenant history + light seasonality.
|
* Month editorial preview — social strategist (trends + client DNA).
|
||||||
* Priority: same-month prior year → recent months → brand defaults.
|
* Creates new angles; does not copy historical calendars day-for-day.
|
||||||
* Uses Anthropic when ANTHROPIC_API_KEY is set; otherwise history remix.
|
* Uses Anthropic when ANTHROPIC_API_KEY is set; else strategy heuristic.
|
||||||
*/
|
*/
|
||||||
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
||||||
import { emptyMonthPlan, newPieceId } from '@/lib/calendar-documents';
|
import { emptyMonthPlan, newPieceId } from '@/lib/calendar-documents';
|
||||||
import { monthLabel } from '@/lib/calendar-import';
|
import { monthLabel } from '@/lib/calendar-import';
|
||||||
|
import {
|
||||||
|
brandBrief,
|
||||||
|
cleanEditorialTitle,
|
||||||
|
isJunkPreviewTitle,
|
||||||
|
} from '@/lib/calendar-generate-brand';
|
||||||
|
import {
|
||||||
|
extractClientDna,
|
||||||
|
monthTrendBeats,
|
||||||
|
strategistSystemPrompt,
|
||||||
|
strategyMonthPreview,
|
||||||
|
} from '@/lib/calendar-strategy';
|
||||||
|
import type { Brandbook } from '@/lib/brandbook';
|
||||||
|
|
||||||
export type TenantBrandBrief = {
|
export {
|
||||||
|
brandBrief,
|
||||||
|
cleanEditorialTitle,
|
||||||
|
isJunkPreviewTitle,
|
||||||
|
type TenantBrandBrief,
|
||||||
|
} from '@/lib/calendar-generate-brand';
|
||||||
|
|
||||||
|
export type GeneratePreviewInput = {
|
||||||
|
ym: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
label: string;
|
label: string;
|
||||||
vertical: string;
|
history: RrssMonthPlan[];
|
||||||
tone: string;
|
brandbook?: Brandbook | null;
|
||||||
pillars: string[];
|
|
||||||
formats: { post: number; reel: number; stories: number; carrusel: number };
|
|
||||||
notes: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
|
export type GeneratePreviewResult = {
|
||||||
'talento-y-deporte': {
|
plan: RrssMonthPlan;
|
||||||
vertical: 'Educación deportiva / becas USA',
|
rationale: string;
|
||||||
tone: 'Inspirador, cercano, ambicioso; historias de alumnos',
|
source: 'anthropic' | 'strategy' | 'heuristic';
|
||||||
pillars: [
|
|
||||||
'nuevo-alumno',
|
|
||||||
'beca-academica',
|
|
||||||
'beca-deportiva',
|
|
||||||
'campus',
|
|
||||||
'objeciones',
|
|
||||||
'tips-ingles',
|
|
||||||
],
|
|
||||||
formats: { post: 0.45, reel: 0.15, stories: 0.3, carrusel: 0.1 },
|
|
||||||
notes: 'Series Nuevo alumno, objeciones, campus USA, English tips.',
|
|
||||||
},
|
|
||||||
'halal-safety': {
|
|
||||||
vertical: 'Certificación Halal / exportación',
|
|
||||||
tone: 'Profesional, claro, autoridad técnica',
|
|
||||||
pillars: ['certificacion', 'exportacion', 'educativo', 'cifras', 'casos'],
|
|
||||||
formats: { post: 0.5, reel: 0.1, stories: 0.25, carrusel: 0.15 },
|
|
||||||
notes: 'Cifras, certificación, mercados, cosmética/alimentación Halal.',
|
|
||||||
},
|
|
||||||
'brava-dermoestetica': {
|
|
||||||
vertical: 'Clínica dermoestética',
|
|
||||||
tone: 'Cercano, experto, estacional (piel / tratamientos)',
|
|
||||||
pillars: ['tratamiento', 'educativo', 'antes-despues', 'promos', 'equipo'],
|
|
||||||
formats: { post: 0.35, reel: 0.2, stories: 0.3, carrusel: 0.15 },
|
|
||||||
notes: 'Peelings, rutinas, encuestas stories, trends, citas.',
|
|
||||||
},
|
|
||||||
bps: {
|
|
||||||
vertical: 'Escuela de idiomas',
|
|
||||||
tone: 'Educativo, pop culture, series recurrentes (FALSE FRIEND, SLANG, MTW…)',
|
|
||||||
pillars: [
|
|
||||||
'educativo',
|
|
||||||
'slang',
|
|
||||||
'false-friend',
|
|
||||||
'certificaciones',
|
|
||||||
'cursos',
|
|
||||||
'cultura-pop',
|
|
||||||
],
|
|
||||||
formats: { post: 0.42, reel: 0.02, stories: 0.54, carrusel: 0.02 },
|
|
||||||
notes:
|
|
||||||
'Línea BPS: FALSE FRIEND, SLANG, Same word different level, canción del día, TV shows, matriculación Madrid/Málaga, Trinity/IELTS, oposiciones.',
|
|
||||||
},
|
|
||||||
'blue-parrot-school': {
|
|
||||||
vertical: 'Escuela de idiomas',
|
|
||||||
tone: 'Educativo, pop culture, series recurrentes',
|
|
||||||
pillars: [
|
|
||||||
'educativo',
|
|
||||||
'slang',
|
|
||||||
'false-friend',
|
|
||||||
'certificaciones',
|
|
||||||
'cursos',
|
|
||||||
'cultura-pop',
|
|
||||||
],
|
|
||||||
formats: { post: 0.42, reel: 0.02, stories: 0.54, carrusel: 0.02 },
|
|
||||||
notes: 'Misma línea que BPS.',
|
|
||||||
},
|
|
||||||
noberplast: {
|
|
||||||
vertical: 'Plásticos industriales B2B',
|
|
||||||
tone: 'Técnico, B2B, LinkedIn-first',
|
|
||||||
pillars: ['producto', 'casos', 'industria', 'linkedin'],
|
|
||||||
formats: { post: 0.5, reel: 0.1, stories: 0.1, carrusel: 0.3 },
|
|
||||||
notes: 'Más LinkedIn y carruseles técnicos.',
|
|
||||||
},
|
|
||||||
'4ventures-hub': {
|
|
||||||
vertical: 'Agencia marketing',
|
|
||||||
tone: 'Directo, ops, thought leadership',
|
|
||||||
pillars: ['ops', 'casos', 'tips', 'equipo'],
|
|
||||||
formats: { post: 0.4, reel: 0.2, stories: 0.2, carrusel: 0.2 },
|
|
||||||
notes: 'Contenido de agencia.',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function brandBrief(slug: string, label: string): TenantBrandBrief {
|
|
||||||
const base = BRANDS[slug] || {
|
|
||||||
vertical: 'Marca / servicios',
|
|
||||||
tone: 'Profesional y claro',
|
|
||||||
pillars: ['educativo', 'producto', 'comunidad', 'promo'],
|
|
||||||
formats: { post: 0.45, reel: 0.1, stories: 0.35, carrusel: 0.1 },
|
|
||||||
notes: 'Ajustar al histórico del cliente.',
|
|
||||||
};
|
|
||||||
return { slug, label, ...base };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Light season cues — never the main title unless history also used them. */
|
|
||||||
function seasonHints(ym: string): string[] {
|
|
||||||
const m = Number(ym.slice(5, 7));
|
|
||||||
const map: Record<number, string[]> = {
|
|
||||||
1: ['año nuevo', 'propósitos'],
|
|
||||||
2: ['San Valentín'],
|
|
||||||
5: ['Día de la Madre', 'pre-verano'],
|
|
||||||
6: ['verano', 'intensivos'],
|
|
||||||
7: ['verano', 'summer camp'],
|
|
||||||
8: ['verano', 'matriculación'],
|
|
||||||
9: ['vuelta al cole', 'matriculación'],
|
|
||||||
10: ['Halloween', 'otoño'],
|
|
||||||
11: ['Black Friday', 'invierno'],
|
|
||||||
12: ['Navidad'],
|
|
||||||
};
|
|
||||||
return map[m] || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function weekdaysInMonth(ym: string): string[] {
|
|
||||||
const [y, m] = ym.split('-').map(Number);
|
|
||||||
const days: string[] = [];
|
|
||||||
const dim = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
||||||
for (let d = 1; d <= dim; d++) {
|
|
||||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
||||||
const wd = dt.getUTCDay();
|
|
||||||
if (wd >= 1 && wd <= 5) {
|
|
||||||
days.push(`${ym}-${String(d).padStart(2, '0')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return days;
|
|
||||||
}
|
|
||||||
|
|
||||||
function colorFor(format: string, kind: string, platform: string): string {
|
function colorFor(format: string, kind: string, platform: string): string {
|
||||||
if (kind === 'story' || /stor/i.test(format)) return '#374151';
|
if (kind === 'story' || /stor/i.test(format)) return '#374151';
|
||||||
if (/linkedin/i.test(platform)) return '#7C66B8';
|
if (/linkedin/i.test(platform)) return '#7C66B8';
|
||||||
if (/reel/i.test(format)) return '#7C66B8';
|
if (/reel/i.test(format)) return '#7C66B8';
|
||||||
|
if (/carrusel/i.test(format)) return '#0F9D8F';
|
||||||
return '#2071cf';
|
return '#2071cf';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip production hour notes: (2h), (30min), OH, etc. */
|
|
||||||
export function cleanEditorialTitle(raw: string): string {
|
|
||||||
return String(raw || '')
|
|
||||||
.replace(/\s*\([^)]*\b\d+\s*(?:[.,]\d+)?\s*h(?:oras?)?[^)]*\)/gi, '')
|
|
||||||
.replace(/\s*\([^)]*\b\d+\s*min[^)]*\)/gi, '')
|
|
||||||
.replace(/\s*\(\s*0?\s*[hH]\s*\)/g, '')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Detect titles from the old broken heuristic (season placeholders). */
|
|
||||||
export function isJunkPreviewTitle(title: string): boolean {
|
|
||||||
const t = title.toLowerCase();
|
|
||||||
if (/^post\s+\d+\s*·/.test(t)) return true;
|
|
||||||
if (/^reel\s*·\s*(feed|stories|nuevo)/.test(t)) return true;
|
|
||||||
if (/^stories\s*·\s*(black friday|puente|cierre|feed|stories)/.test(t)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
/black friday|cierre de año comercial|puente$|· puente|· black friday/.test(
|
|
||||||
t
|
|
||||||
) &&
|
|
||||||
/^(post|reel|stories)\b/.test(t)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (/carrusel stories|carrusel feed/.test(t)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function kindOf(format?: string, kind?: string): string {
|
function kindOf(format?: string, kind?: string): string {
|
||||||
const hay = `${format || ''} ${kind || ''}`.toLowerCase();
|
const hay = `${format || ''} ${kind || ''}`.toLowerCase();
|
||||||
if (hay.includes('stor')) return 'story';
|
if (hay.includes('stor')) return 'story';
|
||||||
@@ -179,349 +56,42 @@ function kindOf(format?: string, kind?: string): string {
|
|||||||
return 'post';
|
return 'post';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLabel(kind: string): string {
|
async function anthropicStrategist(
|
||||||
if (kind === 'story') return 'Stories';
|
|
||||||
if (kind === 'reel') return 'Reel';
|
|
||||||
if (kind === 'carrusel') return 'Carrusel';
|
|
||||||
return 'Post';
|
|
||||||
}
|
|
||||||
|
|
||||||
type HistPiece = {
|
|
||||||
title: string;
|
|
||||||
format: string;
|
|
||||||
kind: string;
|
|
||||||
pillar: string;
|
|
||||||
platform: string;
|
|
||||||
time?: string;
|
|
||||||
hook?: string;
|
|
||||||
body?: string;
|
|
||||||
cta?: string;
|
|
||||||
day: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HistoryModel = {
|
|
||||||
avgPerWeek: number;
|
|
||||||
storiesRatio: number;
|
|
||||||
poolByKind: Record<string, HistPiece[]>;
|
|
||||||
sameMonth: HistPiece[];
|
|
||||||
sampleTitles: string[];
|
|
||||||
sourceMonths: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function collectHistory(plans: RrssMonthPlan[], targetYm: string): HistoryModel {
|
|
||||||
const targetMonth = targetYm.slice(5, 7);
|
|
||||||
const poolByKind: Record<string, HistPiece[]> = {
|
|
||||||
post: [],
|
|
||||||
story: [],
|
|
||||||
reel: [],
|
|
||||||
carrusel: [],
|
|
||||||
};
|
|
||||||
const sameMonth: HistPiece[] = [];
|
|
||||||
const sourceMonths: string[] = [];
|
|
||||||
let total = 0;
|
|
||||||
let stories = 0;
|
|
||||||
|
|
||||||
const sorted = [...plans].sort((a, b) =>
|
|
||||||
String(b.month).localeCompare(String(a.month))
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const plan of sorted) {
|
|
||||||
if (!plan.pieces?.length) continue;
|
|
||||||
let used = 0;
|
|
||||||
for (const p of plan.pieces) {
|
|
||||||
const title = cleanEditorialTitle(p.title || '');
|
|
||||||
if (!title || isJunkPreviewTitle(title)) continue;
|
|
||||||
// Skip pure season-only junk that sneaked in as body-less placeholders
|
|
||||||
if (/^(black friday|puente|cierre de año)/i.test(title)) continue;
|
|
||||||
const kind = kindOf(p.format, p.kind);
|
|
||||||
const day = Number(String(p.date || '').slice(8, 10)) || 0;
|
|
||||||
const row: HistPiece = {
|
|
||||||
title,
|
|
||||||
format: p.format || formatLabel(kind),
|
|
||||||
kind,
|
|
||||||
pillar: (p.pillar || (kind === 'story' ? 'stories' : 'feed')).toLowerCase(),
|
|
||||||
platform: String(p.platform || 'instagram').toLowerCase(),
|
|
||||||
time: p.time,
|
|
||||||
hook: p.hook ? cleanEditorialTitle(p.hook) : undefined,
|
|
||||||
body: p.body,
|
|
||||||
cta: p.cta,
|
|
||||||
day,
|
|
||||||
};
|
|
||||||
poolByKind[kind] = poolByKind[kind] || [];
|
|
||||||
poolByKind[kind].push(row);
|
|
||||||
total += 1;
|
|
||||||
if (kind === 'story') stories += 1;
|
|
||||||
used += 1;
|
|
||||||
if (String(plan.month).slice(5, 7) === targetMonth) {
|
|
||||||
sameMonth.push(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (used > 0) sourceMonths.push(plan.month);
|
|
||||||
}
|
|
||||||
|
|
||||||
const months = Math.max(1, sourceMonths.length);
|
|
||||||
const avgPerWeek = Math.max(
|
|
||||||
3,
|
|
||||||
Math.min(8, Math.round(total / months / 4) || 5)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
avgPerWeek,
|
|
||||||
storiesRatio: total ? stories / total : 0.5,
|
|
||||||
poolByKind,
|
|
||||||
sameMonth: sameMonth.sort((a, b) => a.day - b.day),
|
|
||||||
sampleTitles: [
|
|
||||||
...sameMonth.map((p) => p.title),
|
|
||||||
...poolByKind.post.map((p) => p.title),
|
|
||||||
...poolByKind.story.map((p) => p.title),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 24),
|
|
||||||
sourceMonths,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickFromPool(
|
|
||||||
pool: HistPiece[],
|
|
||||||
used: Set<string>,
|
|
||||||
i: number
|
|
||||||
): HistPiece | null {
|
|
||||||
if (!pool.length) return null;
|
|
||||||
for (let k = 0; k < pool.length; k++) {
|
|
||||||
const cand = pool[(i + k) % pool.length];
|
|
||||||
const key = cand.title.toLowerCase();
|
|
||||||
if (!used.has(key)) {
|
|
||||||
used.add(key);
|
|
||||||
return cand;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Allow reuse if pool exhausted
|
|
||||||
return pool[i % pool.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultSeries(brand: TenantBrandBrief, kind: string, i: number): string {
|
|
||||||
const series =
|
|
||||||
brand.slug === 'bps' || brand.slug === 'blue-parrot-school'
|
|
||||||
? kind === 'story'
|
|
||||||
? [
|
|
||||||
'FALSE FRIEND',
|
|
||||||
'SLANG',
|
|
||||||
'Cancion del día',
|
|
||||||
'English Through TV Shows',
|
|
||||||
'Encuesta esto o lo otro',
|
|
||||||
'Mind the word',
|
|
||||||
'Spelling',
|
|
||||||
'Story error',
|
|
||||||
'Nuevo curso Madrid y Málaga',
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
'Same word different level',
|
|
||||||
'Vocabulario temático',
|
|
||||||
'Expresiones útiles',
|
|
||||||
'Cultura pop / series',
|
|
||||||
'Certificaciones (Trinity / IELTS)',
|
|
||||||
'Nuevo curso Madrid y Málaga',
|
|
||||||
'Errores comunes al aprender inglés',
|
|
||||||
'Idioms',
|
|
||||||
]
|
|
||||||
: brand.pillars.map((p) => p.replace(/-/g, ' '));
|
|
||||||
return series[i % series.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
function pieceFromHist(
|
|
||||||
date: string,
|
|
||||||
src: HistPiece,
|
|
||||||
brand: TenantBrandBrief
|
|
||||||
): RrssPiece {
|
|
||||||
const kind = src.kind;
|
|
||||||
const format = src.format || formatLabel(kind);
|
|
||||||
return {
|
|
||||||
id: newPieceId(date, src.title),
|
|
||||||
date,
|
|
||||||
time: src.time || (kind === 'story' ? '09:00' : '10:30'),
|
|
||||||
platform: src.platform.includes('linkedin') ? 'linkedin' : 'instagram',
|
|
||||||
format,
|
|
||||||
pillar: src.pillar,
|
|
||||||
title: src.title.slice(0, 160),
|
|
||||||
hook: (src.hook || src.title).slice(0, 180),
|
|
||||||
body: [
|
|
||||||
`Propuesta editorial · ${brand.label}`,
|
|
||||||
`Serie / ángulo: ${src.title}`,
|
|
||||||
'Escribe aquí el copy final. Sin notas internas (WhatsApp, PENDIENTE, nombres).',
|
|
||||||
].join('\n\n'),
|
|
||||||
cta:
|
|
||||||
src.cta ||
|
|
||||||
(kind === 'story'
|
|
||||||
? 'Responde a la story'
|
|
||||||
: 'Enlace en la bio / test de nivel'),
|
|
||||||
status: 'planned',
|
|
||||||
kind,
|
|
||||||
color: colorFor(format, kind, src.platform),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeneratePreviewInput = {
|
|
||||||
ym: string;
|
|
||||||
slug: string;
|
|
||||||
label: string;
|
|
||||||
history: RrssMonthPlan[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GeneratePreviewResult = {
|
|
||||||
plan: RrssMonthPlan;
|
|
||||||
rationale: string;
|
|
||||||
source: 'anthropic' | 'heuristic';
|
|
||||||
};
|
|
||||||
|
|
||||||
function heuristicPreview(input: GeneratePreviewInput): GeneratePreviewResult {
|
|
||||||
const brand = brandBrief(input.slug, input.label);
|
|
||||||
const hist = collectHistory(input.history, input.ym);
|
|
||||||
const season = seasonHints(input.ym);
|
|
||||||
const weekdays = weekdaysInMonth(input.ym);
|
|
||||||
const usedTitles = new Set<string>();
|
|
||||||
const pieces: RrssPiece[] = [];
|
|
||||||
|
|
||||||
// 1) Prefer same-month prior year as skeleton (remap day → new month dates)
|
|
||||||
if (hist.sameMonth.length >= 6) {
|
|
||||||
for (const src of hist.sameMonth) {
|
|
||||||
const day = Math.min(
|
|
||||||
src.day || 1,
|
|
||||||
Number(weekdays[weekdays.length - 1]?.slice(8, 10) || 28)
|
|
||||||
);
|
|
||||||
const date = `${input.ym}-${String(day).padStart(2, '0')}`;
|
|
||||||
// Only weekdays
|
|
||||||
if (!weekdays.includes(date)) {
|
|
||||||
// snap to nearest weekday in month
|
|
||||||
const near =
|
|
||||||
weekdays.find((d) => Number(d.slice(8, 10)) >= day) ||
|
|
||||||
weekdays[weekdays.length - 1];
|
|
||||||
if (!near) continue;
|
|
||||||
pieces.push(pieceFromHist(near, src, brand));
|
|
||||||
} else {
|
|
||||||
pieces.push(pieceFromHist(date, src, brand));
|
|
||||||
}
|
|
||||||
usedTitles.add(src.title.toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) If thin history, build cadence from recent pools (Stories/Post mix)
|
|
||||||
if (pieces.length < hist.avgPerWeek * 3) {
|
|
||||||
const target = Math.min(
|
|
||||||
weekdays.length,
|
|
||||||
Math.max(hist.avgPerWeek * 4, pieces.length + 8)
|
|
||||||
);
|
|
||||||
const storiesTarget = Math.round(target * (hist.storiesRatio || brand.formats.stories));
|
|
||||||
let storiesPlaced = pieces.filter((p) => p.kind === 'story').length;
|
|
||||||
let i = 0;
|
|
||||||
for (const date of weekdays) {
|
|
||||||
if (pieces.length >= target) break;
|
|
||||||
if (pieces.some((p) => p.date === date)) continue;
|
|
||||||
const wantStory = storiesPlaced < storiesTarget;
|
|
||||||
const kind = wantStory ? 'story' : 'post';
|
|
||||||
const pool =
|
|
||||||
hist.poolByKind[kind]?.length > 0
|
|
||||||
? hist.poolByKind[kind]
|
|
||||||
: hist.poolByKind.post || [];
|
|
||||||
let src = pickFromPool(pool, usedTitles, i);
|
|
||||||
if (!src) {
|
|
||||||
const title = defaultSeries(brand, kind, i);
|
|
||||||
src = {
|
|
||||||
title,
|
|
||||||
format: formatLabel(kind),
|
|
||||||
kind,
|
|
||||||
pillar: kind === 'story' ? 'stories' : 'feed',
|
|
||||||
platform: 'instagram',
|
|
||||||
day: Number(date.slice(8, 10)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
pieces.push(pieceFromHist(date, src, brand));
|
|
||||||
if (kind === 'story') storiesPlaced += 1;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Never invent season titles. History already carries real seasonal beats
|
|
||||||
// (e.g. BPS Nov → invierno / Navidad / Trinity). Do not append "Black Friday".
|
|
||||||
void season;
|
|
||||||
|
|
||||||
pieces.sort((a, b) => a.date.localeCompare(b.date));
|
|
||||||
// Dedupe same day+title
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const uniq = pieces.filter((p) => {
|
|
||||||
const k = `${p.date}|${p.title.toLowerCase()}`;
|
|
||||||
if (seen.has(k)) return false;
|
|
||||||
seen.add(k);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const plan = emptyMonthPlan(input.ym, input.label);
|
|
||||||
plan.hero.lede = hist.sameMonth.length
|
|
||||||
? `Remix del histórico ${monthLabel(input.ym).split(' ')[0]} (años anteriores) + series recurrentes de ${brand.label}.`
|
|
||||||
: `Remix del histórico reciente de ${brand.label}. Ajusta títulos y copy.`;
|
|
||||||
plan.hero.chips = [
|
|
||||||
`${uniq.length} piezas`,
|
|
||||||
hist.sameMonth.length
|
|
||||||
? `base ${hist.sourceMonths.find((m) => m.slice(5, 7) === input.ym.slice(5, 7)) || 'mismo mes'}`
|
|
||||||
: 'histórico reciente',
|
|
||||||
brand.vertical,
|
|
||||||
];
|
|
||||||
plan.pieces = uniq;
|
|
||||||
|
|
||||||
const rationale = [
|
|
||||||
`Línea editorial de ${brand.label}: ${brand.notes}`,
|
|
||||||
hist.sameMonth.length
|
|
||||||
? `Plantilla principal = mismo mes de años anteriores (${hist.sameMonth.length} piezas base).`
|
|
||||||
: hist.sampleTitles.length
|
|
||||||
? `Sin mismo mes previo → remix de ${hist.sourceMonths.slice(0, 4).join(', ')}.`
|
|
||||||
: 'Sin histórico útil → series por defecto de la vertical.',
|
|
||||||
`Cadencia ~${hist.avgPerWeek} piezas/semana · ~${Math.round(
|
|
||||||
(hist.storiesRatio || 0) * 100
|
|
||||||
)}% stories.`,
|
|
||||||
hist.sampleTitles.length
|
|
||||||
? `Ejemplos históricos: ${hist.sampleTitles.slice(0, 6).join(' · ')}.`
|
|
||||||
: '',
|
|
||||||
'La estacionalidad solo aparece si ya estaba en el histórico del mismo mes (no se inventa Black Friday genérico).',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
return { plan, rationale, source: 'heuristic' };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function anthropicPreview(
|
|
||||||
input: GeneratePreviewInput,
|
input: GeneratePreviewInput,
|
||||||
fallback: GeneratePreviewResult
|
fallback: GeneratePreviewResult
|
||||||
): Promise<GeneratePreviewResult> {
|
): Promise<GeneratePreviewResult> {
|
||||||
const key = process.env.ANTHROPIC_API_KEY?.trim();
|
const key = process.env.ANTHROPIC_API_KEY?.trim();
|
||||||
if (!key || key.startsWith('replace-')) return fallback;
|
if (!key || key.startsWith('replace-')) return fallback;
|
||||||
|
|
||||||
const brand = brandBrief(input.slug, input.label);
|
const cleanHistory = input.history
|
||||||
const hist = collectHistory(input.history, input.ym);
|
.map((plan) => ({
|
||||||
const season = seasonHints(input.ym);
|
...plan,
|
||||||
const prompt = `Eres planner RRSS de 4Ventures para ${brand.label} (${input.ym}).
|
pieces: (plan.pieces || []).filter(
|
||||||
Vertical: ${brand.vertical}. Tono: ${brand.tone}.
|
(p) => p.title && !isJunkPreviewTitle(p.title)
|
||||||
Línea editorial: ${brand.notes}
|
),
|
||||||
Cadencia histórica: ~${hist.avgPerWeek} piezas/semana, ~${Math.round(
|
}))
|
||||||
(hist.storiesRatio || 0.5) * 100
|
.filter((p) => (p.pieces?.length || 0) >= 3);
|
||||||
)}% stories.
|
|
||||||
Títulos REALES del histórico (úsalos como plantilla; remix, no inventes Black Friday genérico):
|
const dna = extractClientDna(
|
||||||
${hist.sampleTitles.slice(0, 20).join('\n') || '(sin histórico)'}
|
input.slug,
|
||||||
Estacionalidad opcional (máx 1-2 piezas si encaja con la marca): ${
|
input.label,
|
||||||
season.join(', ') || 'ninguna'
|
cleanHistory,
|
||||||
}.
|
input.brandbook
|
||||||
Devuelve SOLO JSON: { "rationale": string, "pieces": [{ "date":"YYYY-MM-DD", "time":"HH:MM", "platform":"instagram", "format":"Post|Stories|Reel|Carrusel", "pillar":"feed|stories", "title":string, "hook":string, "body":string, "cta":string }] }
|
);
|
||||||
Reglas:
|
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
|
||||||
- Títulos al estilo del histórico (FALSE FRIEND, SLANG, Same word…, canción, matriculación, etc. si es BPS).
|
const prompt = strategistSystemPrompt({
|
||||||
- PROHIBIDO títulos tipo "POST 1 · Black Friday" o "STORIES · puente".
|
ym: input.ym,
|
||||||
- 14-28 piezas en laborables; body = notas de producción del ángulo, no caption final largo.
|
label: input.label,
|
||||||
- Español.`;
|
dna,
|
||||||
|
trends,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const Anthropic = (await import('@anthropic-ai/sdk')).default;
|
const Anthropic = (await import('@anthropic-ai/sdk')).default;
|
||||||
const client = new Anthropic({ apiKey: key });
|
const client = new Anthropic({ apiKey: key });
|
||||||
const msg = await client.messages.create({
|
const msg = await client.messages.create({
|
||||||
model: 'claude-sonnet-4-20250514',
|
model: 'claude-sonnet-4-20250514',
|
||||||
max_tokens: 4000,
|
max_tokens: 4500,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
});
|
});
|
||||||
const text = msg.content
|
const text = msg.content
|
||||||
@@ -531,9 +101,11 @@ Reglas:
|
|||||||
if (!jsonMatch) return fallback;
|
if (!jsonMatch) return fallback;
|
||||||
const parsed = JSON.parse(jsonMatch[0]) as {
|
const parsed = JSON.parse(jsonMatch[0]) as {
|
||||||
rationale?: string;
|
rationale?: string;
|
||||||
pieces?: Array<Partial<RrssPiece>>;
|
pieces?: Array<Partial<RrssPiece> & { objective_metric?: string }>;
|
||||||
};
|
};
|
||||||
if (!Array.isArray(parsed.pieces) || !parsed.pieces.length) return fallback;
|
if (!Array.isArray(parsed.pieces) || parsed.pieces.length < 8) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
const pieces: RrssPiece[] = parsed.pieces
|
const pieces: RrssPiece[] = parsed.pieces
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
@@ -549,7 +121,7 @@ Reglas:
|
|||||||
return {
|
return {
|
||||||
id: newPieceId(date, title),
|
id: newPieceId(date, title),
|
||||||
date,
|
date,
|
||||||
time: String(p.time || '10:30').slice(0, 5),
|
time: String(p.time || '13:00').slice(0, 5),
|
||||||
platform,
|
platform,
|
||||||
format,
|
format,
|
||||||
pillar: String(p.pillar || (kind === 'story' ? 'stories' : 'feed')),
|
pillar: String(p.pillar || (kind === 'story' ? 'stories' : 'feed')),
|
||||||
@@ -557,6 +129,9 @@ Reglas:
|
|||||||
hook: String(p.hook || title).slice(0, 180),
|
hook: String(p.hook || title).slice(0, 180),
|
||||||
body: String(p.body || title),
|
body: String(p.body || title),
|
||||||
cta: String(p.cta || ''),
|
cta: String(p.cta || ''),
|
||||||
|
objective_metric: p.objective_metric
|
||||||
|
? String(p.objective_metric)
|
||||||
|
: undefined,
|
||||||
status: 'planned',
|
status: 'planned',
|
||||||
kind,
|
kind,
|
||||||
color: colorFor(format, kind, platform),
|
color: colorFor(format, kind, platform),
|
||||||
@@ -564,10 +139,14 @@ Reglas:
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as RrssPiece[];
|
.filter(Boolean) as RrssPiece[];
|
||||||
|
|
||||||
if (pieces.length < 6) return fallback;
|
if (pieces.length < 8) return fallback;
|
||||||
const plan = emptyMonthPlan(input.ym, input.label);
|
const plan = emptyMonthPlan(input.ym, input.label);
|
||||||
plan.hero.lede = 'Preview IA anclado al histórico del cliente.';
|
plan.hero.lede = `Estrategia IA · ${monthLabel(input.ym)} · engagement 2026 + ADN ${input.label}.`;
|
||||||
plan.hero.chips = [`${pieces.length} piezas`, 'IA + histórico', brand.vertical];
|
plan.hero.chips = [
|
||||||
|
`${pieces.length} piezas`,
|
||||||
|
`${pieces.filter((p) => /reel/i.test(p.format || '')).length} Reels`,
|
||||||
|
'IA strategist',
|
||||||
|
];
|
||||||
plan.pieces = pieces.sort((a, b) => a.date.localeCompare(b.date));
|
plan.pieces = pieces.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
return {
|
return {
|
||||||
plan,
|
plan,
|
||||||
@@ -582,7 +161,6 @@ Reglas:
|
|||||||
export async function generateMonthPreview(
|
export async function generateMonthPreview(
|
||||||
input: GeneratePreviewInput
|
input: GeneratePreviewInput
|
||||||
): Promise<GeneratePreviewResult> {
|
): Promise<GeneratePreviewResult> {
|
||||||
// Drop junk months (previous bad previews) from history
|
|
||||||
const cleanHistory = input.history
|
const cleanHistory = input.history
|
||||||
.map((plan) => ({
|
.map((plan) => ({
|
||||||
...plan,
|
...plan,
|
||||||
@@ -592,8 +170,18 @@ export async function generateMonthPreview(
|
|||||||
}))
|
}))
|
||||||
.filter((p) => (p.pieces?.length || 0) >= 3);
|
.filter((p) => (p.pieces?.length || 0) >= 3);
|
||||||
|
|
||||||
const fallback = heuristicPreview({ ...input, history: cleanHistory });
|
const strategist = strategyMonthPreview({
|
||||||
return anthropicPreview({ ...input, history: cleanHistory }, fallback);
|
...input,
|
||||||
|
history: cleanHistory,
|
||||||
|
brandbook: input.brandbook,
|
||||||
|
});
|
||||||
|
const fallback: GeneratePreviewResult = {
|
||||||
|
plan: strategist.plan,
|
||||||
|
rationale: strategist.rationale,
|
||||||
|
source: 'strategy',
|
||||||
|
};
|
||||||
|
|
||||||
|
return anthropicStrategist({ ...input, history: cleanHistory }, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergePreviewIntoPlan(
|
export function mergePreviewIntoPlan(
|
||||||
@@ -605,7 +193,9 @@ export function mergePreviewIntoPlan(
|
|||||||
return preview;
|
return preview;
|
||||||
}
|
}
|
||||||
const occupied = new Set(existing.pieces.map((p) => p.date.slice(0, 10)));
|
const occupied = new Set(existing.pieces.map((p) => p.date.slice(0, 10)));
|
||||||
const extras = preview.pieces.filter((p) => !occupied.has(p.date.slice(0, 10)));
|
const extras = preview.pieces.filter(
|
||||||
|
(p) => !occupied.has(p.date.slice(0, 10))
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...existing,
|
...existing,
|
||||||
pieces: [...existing.pieces, ...extras].sort((a, b) =>
|
pieces: [...existing.pieces, ...extras].sort((a, b) =>
|
||||||
|
|||||||
755
src/lib/calendar-strategy.ts
Normal file
755
src/lib/calendar-strategy.ts
Normal file
@@ -0,0 +1,755 @@
|
|||||||
|
/**
|
||||||
|
* Social media strategist for editorial month previews.
|
||||||
|
* Creates NEW angles from: client DNA + month trends + 2026 engagement playbook.
|
||||||
|
* Does NOT copy historical titles day-for-day.
|
||||||
|
*/
|
||||||
|
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
||||||
|
import { emptyMonthPlan, newPieceId } from '@/lib/calendar-documents';
|
||||||
|
import { monthLabel } from '@/lib/calendar-import';
|
||||||
|
import {
|
||||||
|
brandBrief,
|
||||||
|
cleanEditorialTitle,
|
||||||
|
type TenantBrandBrief,
|
||||||
|
} from '@/lib/calendar-generate-brand';
|
||||||
|
import {
|
||||||
|
brandbookToPrompt,
|
||||||
|
type Brandbook,
|
||||||
|
} from '@/lib/brandbook';
|
||||||
|
|
||||||
|
export type ClientDna = {
|
||||||
|
brand: TenantBrandBrief;
|
||||||
|
/** Recurring series names inferred from history (as DNA, not to copy). */
|
||||||
|
signatureSeries: string[];
|
||||||
|
storiesRatio: number;
|
||||||
|
avgPerWeek: number;
|
||||||
|
conversionPillars: string[];
|
||||||
|
voiceNotes: string;
|
||||||
|
/** Full brandbook prompt block when available. */
|
||||||
|
brandbookPrompt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrendBeat = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
why: string;
|
||||||
|
formats: Array<'Reel' | 'Carrusel' | 'Stories' | 'Post'>;
|
||||||
|
kpi: 'alcance' | 'guardados' | 'comentarios' | 'compartidos' | 'conversion';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Spain / IG 2026 playbook (IAB Spain 2026 + education Reels research). */
|
||||||
|
export const PLAYBOOK_2026 = {
|
||||||
|
principle:
|
||||||
|
'Menos volumen, más relevancia (marcas ES −30% posts / +70% eficiencia). Reels = alcance; Carrusel = guardados; Stories = conversación.',
|
||||||
|
mix: {
|
||||||
|
/** Target share of pieces */
|
||||||
|
reel: 0.28,
|
||||||
|
carrusel: 0.18,
|
||||||
|
stories: 0.38,
|
||||||
|
post: 0.16,
|
||||||
|
},
|
||||||
|
hours: {
|
||||||
|
reel: '19:30',
|
||||||
|
carrusel: '11:00',
|
||||||
|
stories: '09:30',
|
||||||
|
post: '13:00',
|
||||||
|
} as Record<string, string>,
|
||||||
|
engagementHooks: [
|
||||||
|
'Test your friend / envía esto a…',
|
||||||
|
'Error que todos cometen',
|
||||||
|
'Micro-lección completa en <30s',
|
||||||
|
'Pronunciación nativa + caption SEO',
|
||||||
|
'Encuesta / caja de respuestas',
|
||||||
|
'Antes vs después / nivel A2→C1',
|
||||||
|
'Lo que nadie te cuenta de…',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function monthTrendBeats(ym: string, vertical: string): TrendBeat[] {
|
||||||
|
const m = Number(ym.slice(5, 7));
|
||||||
|
const edu = /idioma|colegio|educaci|beca|alumno/i.test(vertical);
|
||||||
|
const clinic = /dermo|clínic|estetic/i.test(vertical);
|
||||||
|
const b2b = /b2b|industrial|plástic|certificaci[oó]n halal/i.test(vertical);
|
||||||
|
|
||||||
|
const commonNov: TrendBeat[] = [
|
||||||
|
{
|
||||||
|
id: 'reels-micro',
|
||||||
|
label: 'Micro-lección Reel (hook 3s + payoff)',
|
||||||
|
why: 'Reels lideran engagement 2026 en IG ES (+31% likes vs estático).',
|
||||||
|
formats: ['Reel'],
|
||||||
|
kpi: 'alcance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'carousel-save',
|
||||||
|
label: 'Carrusel “guarda esto” / test a un amigo',
|
||||||
|
why: 'Carruseles multiplican guardados; algoritmo premia saves y shares.',
|
||||||
|
formats: ['Carrusel'],
|
||||||
|
kpi: 'guardados',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stories-chat',
|
||||||
|
label: 'Stories conversación (encuesta / caja)',
|
||||||
|
why: 'Stories 2026: más replies (+88%), menos alcance → comunidad y DM.',
|
||||||
|
formats: ['Stories'],
|
||||||
|
kpi: 'comentarios',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (m === 11) {
|
||||||
|
const nov: TrendBeat[] = [
|
||||||
|
...commonNov,
|
||||||
|
{
|
||||||
|
id: 'nov-cold-travel',
|
||||||
|
label: edu
|
||||||
|
? 'Frases para viajar / trabajar en invierno (EN/idioma)'
|
||||||
|
: 'Temporada fría / rutina noviembre',
|
||||||
|
why: 'Intención estacional real (viajes, puentes, indoor) sin forzar Black Friday genérico.',
|
||||||
|
formats: ['Reel', 'Carrusel', 'Stories'],
|
||||||
|
kpi: 'compartidos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nov-bf-value',
|
||||||
|
label: edu
|
||||||
|
? 'Oferta formación con valor (no spam BF): packs / matriculación inteligente'
|
||||||
|
: b2b
|
||||||
|
? 'Cierre de año B2B / presupuesto 2027'
|
||||||
|
: 'Black Friday con utilidad (checklist, no solo descuento)',
|
||||||
|
why: 'Noviembre comercial; conversión solo si aporta utilidad y prueba social.',
|
||||||
|
formats: ['Carrusel', 'Stories', 'Reel'],
|
||||||
|
kpi: 'conversion',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nov-thanks-us',
|
||||||
|
label: edu
|
||||||
|
? 'Cultura US: Thanksgiving vocab / false friends festivos'
|
||||||
|
: 'Gratitud / comunidad de noviembre',
|
||||||
|
why: 'Anglo culture beat mid-late Nov → alto share en escuelas de idiomas.',
|
||||||
|
formats: ['Reel', 'Stories', 'Post'],
|
||||||
|
kpi: 'compartidos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nov-xmas-soft',
|
||||||
|
label: edu
|
||||||
|
? 'Soft Navidad: tradiciones por país / spelling festivo'
|
||||||
|
: clinic
|
||||||
|
? 'Piel en frío + prep fiestas'
|
||||||
|
: 'Cierre de mes / preview diciembre',
|
||||||
|
why: 'Última semana Nov: puente emocional a Navidad sin saturar.',
|
||||||
|
formats: ['Carrusel', 'Stories', 'Reel'],
|
||||||
|
kpi: 'guardados',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nov-search-seo',
|
||||||
|
label: 'Contenido buscable (keywords en caption + texto en Reel)',
|
||||||
|
why: 'IG 2026 prioriza SEO interno: “cómo se dice…”, “diferencia entre…”.',
|
||||||
|
formats: ['Reel', 'Carrusel'],
|
||||||
|
kpi: 'alcance',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return nov;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic month beats
|
||||||
|
return [
|
||||||
|
...commonNov,
|
||||||
|
{
|
||||||
|
id: 'season',
|
||||||
|
label: `Ángulo estacional ${monthLabel(ym).split(' ')[0]}`,
|
||||||
|
why: 'Anclar el mes al contexto del usuario sin clichés vacíos.',
|
||||||
|
formats: ['Reel', 'Stories', 'Carrusel'],
|
||||||
|
kpi: 'compartidos',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdaysInMonth(ym: string): string[] {
|
||||||
|
const [y, m] = ym.split('-').map(Number);
|
||||||
|
const days: string[] = [];
|
||||||
|
const dim = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||||
|
for (let d = 1; d <= dim; d++) {
|
||||||
|
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||||
|
if (dt.getUTCDay() >= 1 && dt.getUTCDay() <= 5) {
|
||||||
|
days.push(`${ym}-${String(d).padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorFor(format: string, kind: string): string {
|
||||||
|
if (kind === 'story' || /stor/i.test(format)) return '#374151';
|
||||||
|
if (/reel/i.test(format)) return '#7C66B8';
|
||||||
|
if (/carrusel/i.test(format)) return '#0F9D8F';
|
||||||
|
return '#2071cf';
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindOf(format: string): string {
|
||||||
|
const f = format.toLowerCase();
|
||||||
|
if (f.includes('stor')) return 'story';
|
||||||
|
if (f.includes('reel')) return 'reel';
|
||||||
|
if (f.includes('carrusel')) return 'carrusel';
|
||||||
|
return 'post';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build DNA from history without copying titles. */
|
||||||
|
export function extractClientDna(
|
||||||
|
slug: string,
|
||||||
|
label: string,
|
||||||
|
history: RrssMonthPlan[],
|
||||||
|
brandbook?: Brandbook | null
|
||||||
|
): ClientDna {
|
||||||
|
const brand = brandBrief(slug, label);
|
||||||
|
if (brandbook) {
|
||||||
|
if (brandbook.vertical) brand.vertical = brandbook.vertical;
|
||||||
|
if (brandbook.tone) brand.tone = brandbook.tone;
|
||||||
|
if (brandbook.pillars_rrss?.length) brand.pillars = brandbook.pillars_rrss;
|
||||||
|
if (brandbook.freeform_notes || brandbook.mission) {
|
||||||
|
brand.notes = [
|
||||||
|
brandbook.mission && `Misión: ${brandbook.mission}`,
|
||||||
|
brandbook.voice_do?.length && `Hacer: ${brandbook.voice_do.join('; ')}`,
|
||||||
|
brandbook.voice_dont?.length &&
|
||||||
|
`Evitar: ${brandbook.voice_dont.join('; ')}`,
|
||||||
|
brandbook.ads_usps?.length && `USPs: ${brandbook.ads_usps.join('; ')}`,
|
||||||
|
brandbook.claims_forbidden?.length &&
|
||||||
|
`No claims: ${brandbook.claims_forbidden.join('; ')}`,
|
||||||
|
brandbook.freeform_notes?.slice(0, 800),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const seriesCount = new Map<string, number>();
|
||||||
|
let total = 0;
|
||||||
|
let stories = 0;
|
||||||
|
const months = Math.max(1, history.length);
|
||||||
|
|
||||||
|
const classify = (title: string): string | null => {
|
||||||
|
const x = cleanEditorialTitle(title)
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '');
|
||||||
|
const rules: [RegExp, string][] = [
|
||||||
|
[/false\s*friend/, 'FALSE FRIEND'],
|
||||||
|
[/\bslang\b/, 'SLANG'],
|
||||||
|
[/mind the word|\bmtw\b/, 'MTW'],
|
||||||
|
[/same word/, 'Same word different level'],
|
||||||
|
[/cancion|canción/, 'Canción del día'],
|
||||||
|
[/tv show|english through|prison break|how i met/, 'English Through TV'],
|
||||||
|
[/encuesta|interactiva|verdadero/, 'Encuesta / interactiva'],
|
||||||
|
[/spelling/, 'Spelling'],
|
||||||
|
[/fix it|error|errores/, 'Errores / Fix it'],
|
||||||
|
[/nuevo curso|matricul|intensivo/, 'Matriculación'],
|
||||||
|
[/trinity|ielts|cambridge|aptis|certific/, 'Certificaciones'],
|
||||||
|
[/oposic|policia|renfe/, 'Oposiciones'],
|
||||||
|
[/erasmus/, 'Erasmus'],
|
||||||
|
[/gym talk/, 'Gym talk'],
|
||||||
|
[/idiom/, 'Idioms'],
|
||||||
|
[/vocab|survival|expresion|expresión/, 'Vocab / expresiones'],
|
||||||
|
[/nuevo alumno|alumno nuevo/, 'Nuevo alumno'],
|
||||||
|
[/peeling|tratamiento|piel/, 'Tratamiento'],
|
||||||
|
[/halal|certificacion|export/, 'Certificación / export'],
|
||||||
|
];
|
||||||
|
for (const [re, labelS] of rules) if (re.test(x)) return labelS;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const plan of history) {
|
||||||
|
for (const p of plan.pieces || []) {
|
||||||
|
if (!p.title) continue;
|
||||||
|
total += 1;
|
||||||
|
if (/stor/i.test(p.format || '') || p.kind === 'story') stories += 1;
|
||||||
|
const s = classify(p.title);
|
||||||
|
if (s) seriesCount.set(s, (seriesCount.get(s) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferred = [...seriesCount.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([k]) => k);
|
||||||
|
|
||||||
|
// Seed brand-line series so DNA isn't only whatever appeared most recently
|
||||||
|
const seedBySlug: Record<string, string[]> = {
|
||||||
|
bps: [
|
||||||
|
'FALSE FRIEND',
|
||||||
|
'SLANG',
|
||||||
|
'Same word different level',
|
||||||
|
'MTW',
|
||||||
|
'Canción del día',
|
||||||
|
'English Through TV',
|
||||||
|
'Errores / Fix it',
|
||||||
|
'Idioms',
|
||||||
|
'Spelling',
|
||||||
|
'Vocab / expresiones',
|
||||||
|
'Certificaciones',
|
||||||
|
'Matriculación',
|
||||||
|
],
|
||||||
|
'blue-parrot-school': [
|
||||||
|
'FALSE FRIEND',
|
||||||
|
'SLANG',
|
||||||
|
'Same word different level',
|
||||||
|
'MTW',
|
||||||
|
'Canción del día',
|
||||||
|
'English Through TV',
|
||||||
|
'Errores / Fix it',
|
||||||
|
'Idioms',
|
||||||
|
'Certificaciones',
|
||||||
|
'Matriculación',
|
||||||
|
],
|
||||||
|
'talento-y-deporte': [
|
||||||
|
'Nuevo alumno',
|
||||||
|
'Vocab / expresiones',
|
||||||
|
'Campus',
|
||||||
|
'Errores / Fix it',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const fromBook = brandbook?.series_recurrentes?.length
|
||||||
|
? brandbook.series_recurrentes
|
||||||
|
: brandbook?.pillars_rrss || [];
|
||||||
|
const seed = fromBook.length ? fromBook : seedBySlug[slug] || [];
|
||||||
|
const signatureSeries = [
|
||||||
|
...seed,
|
||||||
|
...inferred.filter((s) => !seed.includes(s)),
|
||||||
|
].slice(0, 12);
|
||||||
|
|
||||||
|
const conversionPillars = [
|
||||||
|
...inferred,
|
||||||
|
...signatureSeries,
|
||||||
|
].filter(
|
||||||
|
(s, i, arr) =>
|
||||||
|
arr.indexOf(s) === i &&
|
||||||
|
/matricul|certific|oposic|nuevo alumno|tratamiento|export|promo/i.test(s)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
brand,
|
||||||
|
signatureSeries:
|
||||||
|
signatureSeries.length > 0 ? signatureSeries : brand.pillars.map((p) => p),
|
||||||
|
storiesRatio: total ? stories / total : brand.formats.stories,
|
||||||
|
avgPerWeek: Math.max(4, Math.min(8, Math.round(total / months / 4) || 5)),
|
||||||
|
conversionPillars:
|
||||||
|
conversionPillars.length > 0
|
||||||
|
? conversionPillars
|
||||||
|
: ['Matriculación', 'Certificaciones'],
|
||||||
|
voiceNotes: brand.notes,
|
||||||
|
brandbookPrompt: brandbook ? brandbookToPrompt(brandbook) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Slot = {
|
||||||
|
date: string;
|
||||||
|
format: 'Reel' | 'Carrusel' | 'Stories' | 'Post';
|
||||||
|
role: 'reach' | 'save' | 'chat' | 'convert' | 'authority';
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildMonthSlots(ym: string, dna: ClientDna): Slot[] {
|
||||||
|
const weekdays = weekdaysInMonth(ym);
|
||||||
|
const target = Math.min(
|
||||||
|
weekdays.length,
|
||||||
|
Math.max(16, Math.round(dna.avgPerWeek * 4 * 0.85)) // quality > quantity (−15% vs legacy)
|
||||||
|
);
|
||||||
|
const mix = PLAYBOOK_2026.mix;
|
||||||
|
const counts = {
|
||||||
|
Reel: Math.round(target * mix.reel),
|
||||||
|
Carrusel: Math.round(target * mix.carrusel),
|
||||||
|
Stories: Math.round(target * mix.stories),
|
||||||
|
Post: 0,
|
||||||
|
};
|
||||||
|
counts.Post = Math.max(0, target - counts.Reel - counts.Carrusel - counts.Stories);
|
||||||
|
|
||||||
|
// Round-robin interleave so the month isn't "all Reels then all Stories"
|
||||||
|
const bags: Record<Slot['format'], number> = { ...counts };
|
||||||
|
const formats: Slot['format'][] = ['Reel', 'Carrusel', 'Stories', 'Post'];
|
||||||
|
const queue: Array<Slot['format']> = [];
|
||||||
|
while (queue.length < target) {
|
||||||
|
let added = false;
|
||||||
|
for (const f of formats) {
|
||||||
|
if (bags[f] > 0) {
|
||||||
|
queue.push(f);
|
||||||
|
bags[f] -= 1;
|
||||||
|
added = true;
|
||||||
|
if (queue.length >= target) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!added) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertEvery = Math.max(4, Math.floor(target / 3));
|
||||||
|
const slots: Slot[] = [];
|
||||||
|
const step = Math.max(1, Math.floor(weekdays.length / Math.max(1, queue.length)));
|
||||||
|
let dayIdx = 0;
|
||||||
|
for (let qi = 0; qi < queue.length; qi++) {
|
||||||
|
const format = queue[qi];
|
||||||
|
const date = weekdays[Math.min(dayIdx, weekdays.length - 1)];
|
||||||
|
dayIdx += step;
|
||||||
|
if (dayIdx >= weekdays.length) dayIdx = qi % weekdays.length;
|
||||||
|
// Prefer unused weekdays
|
||||||
|
let d = date;
|
||||||
|
if (slots.some((s) => s.date === d)) {
|
||||||
|
d = weekdays.find((w) => !slots.some((s) => s.date === w)) || date;
|
||||||
|
}
|
||||||
|
const isConvert =
|
||||||
|
qi > 0 &&
|
||||||
|
qi % convertEvery === 0 &&
|
||||||
|
(format === 'Stories' || format === 'Carrusel' || format === 'Post');
|
||||||
|
const role: Slot['role'] = isConvert
|
||||||
|
? 'convert'
|
||||||
|
: format === 'Reel'
|
||||||
|
? 'reach'
|
||||||
|
: format === 'Carrusel'
|
||||||
|
? 'save'
|
||||||
|
: format === 'Stories'
|
||||||
|
? 'chat'
|
||||||
|
: 'authority';
|
||||||
|
slots.push({ date: d, format, role });
|
||||||
|
}
|
||||||
|
return slots.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Concrete creative banks — rotate by index so each month feels fresh. */
|
||||||
|
const EDU_ANGLES: Record<string, string[]> = {
|
||||||
|
'FALSE FRIEND': [
|
||||||
|
'FALSE FRIEND · “actually” no significa “actualmente” (y te delata)',
|
||||||
|
'FALSE FRIEND · “library” vs librería: el error clásico en Madrid',
|
||||||
|
'FALSE FRIEND · Thanksgiving: “grateful” no es “gratefulidad”',
|
||||||
|
'FALSE FRIEND · “exit” no es éxito (puerta vs logro)',
|
||||||
|
'FALSE FRIEND · “carpet” no es carpeta — mini test 10s',
|
||||||
|
],
|
||||||
|
SLANG: [
|
||||||
|
'SLANG · “I’m down” = me apunto (no “estoy abajo”)',
|
||||||
|
'SLANG · “lowkey / highkey” en 20 segundos',
|
||||||
|
'SLANG · “spill the tea” sin sonar raro',
|
||||||
|
'SLANG · “ghosting” en inglés real de oficina',
|
||||||
|
'SLANG · “bet” = ok / trato hecho',
|
||||||
|
],
|
||||||
|
MTW: [
|
||||||
|
'MTW · “get” tiene 12 vidas: las 5 que sí usas',
|
||||||
|
'MTW · “set” en el gym vs en la oficina',
|
||||||
|
'MTW · “run” no es solo correr',
|
||||||
|
'MTW · “make” vs “do” en 20s (sin tabla aburrida)',
|
||||||
|
'MTW · “take” en aeropuerto, oficina y cita',
|
||||||
|
],
|
||||||
|
'Same word different level': [
|
||||||
|
'Same word · “nice” A2 vs C1 (suena a robot o a nativo)',
|
||||||
|
'Same word · “sorry” 3 niveles de disculpa',
|
||||||
|
'Same word · “interesting” cuando NO quieres decir interesante',
|
||||||
|
'Same word · “good” → alternativas que suenan nativas',
|
||||||
|
],
|
||||||
|
'Canción del día': [
|
||||||
|
'Canción del día · lyric drop + 1 expresión para guardar',
|
||||||
|
'Canción del día · pronunciación del coro (slow + nativo)',
|
||||||
|
'Canción del día · false friend escondido en el estribillo',
|
||||||
|
'Canción del día · shadowing 15s (copia al cantante)',
|
||||||
|
],
|
||||||
|
'English Through TV': [
|
||||||
|
'English Through TV · 1 escena, 3 frases que sí se usan',
|
||||||
|
'English Through TV · cómo suena el sarcasm en sitcoms',
|
||||||
|
'English Through TV · fillers nativos (like, you know) sin abusar',
|
||||||
|
'English Through TV · “I’ll be there” vs “I’m coming”',
|
||||||
|
],
|
||||||
|
Spelling: [
|
||||||
|
'Spelling · palabras de noviembre que todo el mundo escribe mal',
|
||||||
|
'Spelling bee · 4 letras vs 1 error (challenge)',
|
||||||
|
'Spelling · “definitely” y otras trampas de examen',
|
||||||
|
],
|
||||||
|
'Errores / Fix it': [
|
||||||
|
'Fix it · “I have 25 years” en 8 segundos',
|
||||||
|
'Errores · “depend of” → la forma que sí funciona',
|
||||||
|
'Fix it · “I am agree” — corrección + frase alternativa',
|
||||||
|
'Errores · “people is” vs “people are” (rápido)',
|
||||||
|
],
|
||||||
|
Idioms: [
|
||||||
|
'Idiom · “break the ice” en networking (no en neveras)',
|
||||||
|
'Idiom · “under the weather” = flojo, no meteorólogo',
|
||||||
|
'Idiom · “hit the books” antes del examen',
|
||||||
|
'Idiom · “piece of cake” cuando el speaking sale bien',
|
||||||
|
],
|
||||||
|
'Vocab / expresiones': [
|
||||||
|
'Vocab invierno · cold, chilly, freezing (y cuándo cada uno)',
|
||||||
|
'Expresiones · pedir un favor sin sonar agresivo',
|
||||||
|
'Vocab · frases para el puente / viaje de noviembre',
|
||||||
|
'Expresiones · cómo decir “no me apetece” con educación',
|
||||||
|
],
|
||||||
|
Matriculación: [
|
||||||
|
'Matriculación · 3 señales de que este es tu mes para empezar',
|
||||||
|
'Intensivos nov-dic · quién debería (y quién no)',
|
||||||
|
],
|
||||||
|
Certificaciones: [
|
||||||
|
'Trinity/IELTS · el error #1 en speaking (y cómo arreglarlo)',
|
||||||
|
'Certificado · checklist 7 días antes del examen',
|
||||||
|
],
|
||||||
|
Oposiciones: [
|
||||||
|
'Inglés oposiciones · 1 hábito de 12 min que sí suma',
|
||||||
|
],
|
||||||
|
Erasmus: [
|
||||||
|
'Erasmus · frases de supervivencia la primera semana',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const GENERIC_CONVERT = [
|
||||||
|
'Por qué noviembre es el mes smart para matricularte (sin FOMO barato)',
|
||||||
|
'Test de nivel + plan: deja de “estudiar cuando pueda”',
|
||||||
|
'Madrid vs Málaga: qué grupo te encaja este trimestre',
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickAngleTitle(
|
||||||
|
series: string,
|
||||||
|
beat: TrendBeat,
|
||||||
|
index: number,
|
||||||
|
format: Slot['format']
|
||||||
|
): string {
|
||||||
|
const bank = EDU_ANGLES[series];
|
||||||
|
let core: string;
|
||||||
|
if (bank?.length) {
|
||||||
|
core = bank[index % bank.length];
|
||||||
|
} else if (beat.id.includes('thanks')) {
|
||||||
|
core = `${series} · Thanksgiving vocab que no sale en el libro`;
|
||||||
|
} else if (beat.id.includes('cold')) {
|
||||||
|
core = `${series} · frases de frío / viaje que sí se usan`;
|
||||||
|
} else if (beat.id.includes('bf') || beat.kpi === 'conversion') {
|
||||||
|
core = GENERIC_CONVERT[index % GENERIC_CONVERT.length];
|
||||||
|
} else if (/encuesta|interactiva/i.test(series)) {
|
||||||
|
const polls = [
|
||||||
|
'Encuesta · ¿“actually” o “currently”? (elige y pelea)',
|
||||||
|
'Quiz · true/false: “I’m boring” vs “I’m bored”',
|
||||||
|
'Caja de respuestas · ¿qué false friend te delató?',
|
||||||
|
'Encuesta · ¿prefieres slang UK o US?',
|
||||||
|
];
|
||||||
|
core = polls[index % polls.length];
|
||||||
|
} else {
|
||||||
|
const fallbacks = [
|
||||||
|
`${series} · el detalle que cambia cómo te perciben`,
|
||||||
|
`${series} · ángulo ${beat.label.split('(')[0].trim().slice(0, 40)}`,
|
||||||
|
`${series} · lo que tu audiencia guarda (no scrollea)`,
|
||||||
|
`${series} · prueba social + micro-historia`,
|
||||||
|
`${series} · objeción resuelta en 20s`,
|
||||||
|
`${series} · checklist accionable`,
|
||||||
|
];
|
||||||
|
core = fallbacks[index % fallbacks.length];
|
||||||
|
}
|
||||||
|
const prefix =
|
||||||
|
format === 'Reel'
|
||||||
|
? 'REEL'
|
||||||
|
: format === 'Carrusel'
|
||||||
|
? 'CARRUSEL'
|
||||||
|
: format === 'Stories'
|
||||||
|
? 'STORIES'
|
||||||
|
: 'POST';
|
||||||
|
// Avoid double prefix if bank already has series label only
|
||||||
|
if (/^(REEL|CARRUSEL|STORIES|POST)\b/i.test(core)) return core.slice(0, 140);
|
||||||
|
return `${prefix} · ${core}`.slice(0, 140);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creative angle factory — NEW titles, DNA-aligned. */
|
||||||
|
function inventAngle(
|
||||||
|
dna: ClientDna,
|
||||||
|
slot: Slot,
|
||||||
|
beat: TrendBeat,
|
||||||
|
index: number,
|
||||||
|
/** Extra offset only for title bank rotation (dedupe). */
|
||||||
|
bankOffset = 0
|
||||||
|
): { title: string; hook: string; body: string; cta: string; pillar: string } {
|
||||||
|
const series =
|
||||||
|
dna.signatureSeries[index % Math.max(1, dna.signatureSeries.length)] ||
|
||||||
|
'Educativo';
|
||||||
|
const brand = dna.brand.label;
|
||||||
|
const edu = /idioma|colegio|educaci/i.test(dna.brand.vertical);
|
||||||
|
const hooks = PLAYBOOK_2026.engagementHooks;
|
||||||
|
const hookStyle = hooks[index % hooks.length];
|
||||||
|
const bankIndex = index + bankOffset;
|
||||||
|
|
||||||
|
if (slot.role === 'convert') {
|
||||||
|
const pillar = dna.conversionPillars[index % dna.conversionPillars.length];
|
||||||
|
const title = edu
|
||||||
|
? pickAngleTitle(pillar, beat, bankIndex, slot.format)
|
||||||
|
: `${slot.format.toUpperCase()} · ${pillar}: beneficio claro + prueba social`;
|
||||||
|
if (slot.format === 'Stories') {
|
||||||
|
return {
|
||||||
|
title: title.startsWith('STORIES')
|
||||||
|
? title
|
||||||
|
: `STORIES · ${pillar}: encuesta “¿este mes o enero?”`,
|
||||||
|
hook: 'Una pregunta. Tu respuesta abre el DM.',
|
||||||
|
body: `Conversión ${brand}. ADN: ${pillar}. Sticker encuesta/caja → DM. KPI: mensajes. Sin spam BF genérico.`,
|
||||||
|
cta: 'Responde y te orientamos',
|
||||||
|
pillar: 'conversion',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
hook: 'La diferencia entre “lo pienso” y “ya estoy dentro”.',
|
||||||
|
body: `Conversión útil. 3 motivos + prueba social + CTA test/matricula. ADN: ${pillar}. Tendencia: ${beat.why}`,
|
||||||
|
cta: 'Link en bio / test de nivel',
|
||||||
|
pillar: 'conversion',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = pickAngleTitle(series, beat, bankIndex, slot.format);
|
||||||
|
|
||||||
|
if (slot.format === 'Reel') {
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
hook: `${hookStyle}`,
|
||||||
|
body: [
|
||||||
|
`Objetivo: ALCANCE (Reels 2026 ES).`,
|
||||||
|
`ADN: ${series}. Tendencia: ${beat.label} — ${beat.why}`,
|
||||||
|
`Estructura: hook 3s → micro-lección → CTA share/save.`,
|
||||||
|
`Texto en pantalla + caption con keyword buscable.`,
|
||||||
|
].join('\n'),
|
||||||
|
cta: 'Comparte con alguien que lo necesite',
|
||||||
|
pillar: 'reach',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slot.format === 'Carrusel') {
|
||||||
|
return {
|
||||||
|
title: title.replace(/^REEL ·/, 'CARRUSEL ·').replace(/^POST ·/, 'CARRUSEL ·'),
|
||||||
|
hook: 'Guarda esto antes de que se te olvide.',
|
||||||
|
body: [
|
||||||
|
`Objetivo: GUARDADOS + shares.`,
|
||||||
|
`ADN: ${series}. ${beat.why}`,
|
||||||
|
`5 slides: hook polémico → valor → test “envía a un amigo”.`,
|
||||||
|
].join('\n'),
|
||||||
|
cta: 'Guarda + envía a tu compañero de estudio',
|
||||||
|
pillar: 'saves',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slot.format === 'Stories') {
|
||||||
|
return {
|
||||||
|
title: title.replace(/^(REEL|CARRUSEL|POST) ·/, 'STORIES ·'),
|
||||||
|
hook: 'Tú eliges: A o B',
|
||||||
|
body: [
|
||||||
|
`Objetivo: REPLIES (+88% conversación Stories 2026).`,
|
||||||
|
`ADN: ${series}. Sticker encuesta / quiz / caja.`,
|
||||||
|
beat.label,
|
||||||
|
].join('\n'),
|
||||||
|
cta: 'Responde a la story',
|
||||||
|
pillar: 'stories',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title.replace(/^(REEL|CARRUSEL|STORIES) ·/, 'POST ·'),
|
||||||
|
hook: `Lo que nadie te explica de ${series.toLowerCase()}.`,
|
||||||
|
body: [
|
||||||
|
`Feed autoridad + SEO caption (“cómo se dice…”, “diferencia entre…”).`,
|
||||||
|
`ADN ${brand}: ${series}.`,
|
||||||
|
`Tendencia: ${beat.why}`,
|
||||||
|
].join('\n'),
|
||||||
|
cta: 'Comenta tu caso',
|
||||||
|
pillar: 'feed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StrategyPreviewResult = {
|
||||||
|
plan: RrssMonthPlan;
|
||||||
|
rationale: string;
|
||||||
|
source: 'strategy';
|
||||||
|
trends: TrendBeat[];
|
||||||
|
dna: ClientDna;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function strategyMonthPreview(input: {
|
||||||
|
ym: string;
|
||||||
|
slug: string;
|
||||||
|
label: string;
|
||||||
|
history: RrssMonthPlan[];
|
||||||
|
brandbook?: Brandbook | null;
|
||||||
|
}): StrategyPreviewResult {
|
||||||
|
const dna = extractClientDna(
|
||||||
|
input.slug,
|
||||||
|
input.label,
|
||||||
|
input.history,
|
||||||
|
input.brandbook
|
||||||
|
);
|
||||||
|
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
|
||||||
|
const slots = buildMonthSlots(input.ym, dna);
|
||||||
|
const pieces: RrssPiece[] = [];
|
||||||
|
|
||||||
|
const usedCores = new Set<string>();
|
||||||
|
const coreKey = (t: string) =>
|
||||||
|
t.replace(/^(REEL|CARRUSEL|STORIES|POST)\s*·\s*/i, '').toLowerCase();
|
||||||
|
slots.forEach((slot, i) => {
|
||||||
|
const beat = trends[i % trends.length];
|
||||||
|
const useBeat =
|
||||||
|
slot.role === 'convert'
|
||||||
|
? trends.find((t) => t.kpi === 'conversion') || beat
|
||||||
|
: Number(slot.date.slice(8, 10)) >= 24
|
||||||
|
? trends.find((t) => t.id.includes('xmas')) || beat
|
||||||
|
: beat;
|
||||||
|
let angle = inventAngle(dna, slot, useBeat, i, 0);
|
||||||
|
let bump = 0;
|
||||||
|
while (usedCores.has(coreKey(angle.title)) && bump < 20) {
|
||||||
|
bump += 1;
|
||||||
|
angle = inventAngle(dna, slot, useBeat, i, bump);
|
||||||
|
}
|
||||||
|
if (usedCores.has(coreKey(angle.title))) {
|
||||||
|
angle = {
|
||||||
|
...angle,
|
||||||
|
title: `${angle.title} · ángulo ${i + 1}`.slice(0, 140),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
usedCores.add(coreKey(angle.title));
|
||||||
|
const kind = kindOf(slot.format);
|
||||||
|
const time =
|
||||||
|
PLAYBOOK_2026.hours[slot.format.toLowerCase()] ||
|
||||||
|
PLAYBOOK_2026.hours.post;
|
||||||
|
pieces.push({
|
||||||
|
id: newPieceId(slot.date, angle.title),
|
||||||
|
date: slot.date,
|
||||||
|
time,
|
||||||
|
platform: 'instagram',
|
||||||
|
format: slot.format,
|
||||||
|
pillar: angle.pillar,
|
||||||
|
title: angle.title,
|
||||||
|
hook: angle.hook,
|
||||||
|
body: angle.body,
|
||||||
|
cta: angle.cta,
|
||||||
|
status: 'planned',
|
||||||
|
kind,
|
||||||
|
color: colorFor(slot.format, kind),
|
||||||
|
objective_metric: useBeat.kpi,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = emptyMonthPlan(input.ym, input.label);
|
||||||
|
plan.hero.lede = `Estrategia ${monthLabel(input.ym)} · ${dna.brand.vertical}. Creado para engagement 2026 (no copia del histórico).`;
|
||||||
|
plan.hero.chips = [
|
||||||
|
`${pieces.length} piezas`,
|
||||||
|
`${pieces.filter((p) => p.format === 'Reel').length} Reels`,
|
||||||
|
`${pieces.filter((p) => p.format === 'Carrusel').length} carruseles`,
|
||||||
|
'strategist',
|
||||||
|
];
|
||||||
|
plan.pieces = pieces;
|
||||||
|
|
||||||
|
const rationale = [
|
||||||
|
`Estrategia (no copia): DNA de ${dna.brand.label} = series ${dna.signatureSeries.slice(0, 5).join(', ') || 'vertical'}.`,
|
||||||
|
`Playbook ES 2026 (IAB): menos posts, más eficiencia · Reels alcance · Carrusel guardados · Stories conversación.`,
|
||||||
|
`Tendencias ${monthLabel(input.ym)}: ${trends
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((t) => t.label)
|
||||||
|
.join(' · ')}.`,
|
||||||
|
`Horas: Reels ${PLAYBOOK_2026.hours.reel} · Carrusel ${PLAYBOOK_2026.hours.carrusel} · Stories ${PLAYBOOK_2026.hours.stories} · Post ${PLAYBOOK_2026.hours.post}.`,
|
||||||
|
`Conversión (~${pieces.filter((p) => p.pillar === 'conversion').length} piezas) alineada a ${dna.conversionPillars.join('/')}, no spam genérico.`,
|
||||||
|
dna.brandbookPrompt
|
||||||
|
? 'Brandbook activo como constraint creativo.'
|
||||||
|
: dna.voiceNotes,
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
return { plan, rationale, source: 'strategy', trends, dna };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strategistSystemPrompt(input: {
|
||||||
|
ym: string;
|
||||||
|
label: string;
|
||||||
|
dna: ClientDna;
|
||||||
|
trends: TrendBeat[];
|
||||||
|
}): string {
|
||||||
|
return `Eres Head of Social de 4Ventures. Crea un calendario NUEVO para ${input.label} (${input.ym}).
|
||||||
|
NO copies títulos viejos. USA el ADN / brandbook como constraint creativo.
|
||||||
|
ADN series: ${input.dna.signatureSeries.join(', ')}.
|
||||||
|
Voz: ${input.dna.brand.tone}. Vertical: ${input.dna.brand.vertical}.
|
||||||
|
${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''}
|
||||||
|
Playbook 2026 ES: ${PLAYBOOK_2026.principle}
|
||||||
|
Tendencias del mes:
|
||||||
|
${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')}
|
||||||
|
Devuelve SOLO JSON { rationale, pieces:[{date,time,platform,format,pillar,title,hook,body,cta,objective_metric}] }
|
||||||
|
Reglas: títulos creativos y específicos; mix ~28% Reel, 18% Carrusel, 38% Stories, resto Post; horas engagement (Reels noche ~19:30, Stories mañana, Carrusel mediodía); KPI por pieza; español; sin "POST 1 · Black Friday" vacío; respeta claims prohibidos del brandbook.`;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export type DashboardModuleId =
|
|||||||
| 'crm'
|
| 'crm'
|
||||||
| 'seguimiento'
|
| 'seguimiento'
|
||||||
| 'estrategia'
|
| 'estrategia'
|
||||||
|
| 'marca'
|
||||||
| 'calendario'
|
| 'calendario'
|
||||||
| 'ad-tracker'
|
| 'ad-tracker'
|
||||||
| 'social'
|
| 'social'
|
||||||
@@ -51,6 +52,13 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
|
|||||||
providers: [],
|
providers: [],
|
||||||
hint: 'Targets y presupuesto',
|
hint: 'Targets y presupuesto',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'marca',
|
||||||
|
href: '/dashboard/marca',
|
||||||
|
label: 'Zona de marca',
|
||||||
|
providers: [],
|
||||||
|
hint: 'Brandbook: voz, oferta, ads, LinkedIn, blog',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'calendario',
|
id: 'calendario',
|
||||||
href: '/dashboard/calendario',
|
href: '/dashboard/calendario',
|
||||||
|
|||||||
Reference in New Issue
Block a user