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:
4Ventures
2026-10-10 01:05:15 +02:00
parent 59f42eafe0
commit e49111f63e
13 changed files with 2907 additions and 500 deletions

View 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,
});
}

View 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,
});
}

View File

@@ -8,6 +8,10 @@ import { isAgencyRole, userRole } from '@/lib/agency';
import { asPlanObject } from '@/lib/calendar-documents';
import { generateMonthPreview } from '@/lib/calendar-generate';
import type { RrssMonthPlan } from '@/components/RrssMonthView';
import {
BRANDBOOK_DOC_TYPE,
parseBrandbook,
} from '@/lib/brandbook';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
@@ -64,11 +68,19 @@ export async function POST(req: NextRequest) {
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({
ym: month,
slug,
label: ctx.label,
history,
brandbook,
});
return NextResponse.json({
@@ -77,5 +89,6 @@ export async function POST(req: NextRequest) {
rationale: result.rationale,
source: result.source,
brand: slug,
brandbook: !!brandbook,
});
}

View 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} />;
}

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

View File

@@ -397,7 +397,7 @@ export default function CalendarCreator({
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
Generar preview editorial
Generar estrategia del mes
</button>
<button
type="button"
@@ -422,10 +422,10 @@ export default function CalendarCreator({
</div>
) : (
<p className="text-xs text-gray-500">
Genera un preview según la marca, estacionalidad e histórico
RRSS del cliente, o haz click en un día para añadir
publicaciones a mano (mismos campos que Pipeline
creatividades).
Estrategia engagement 2026: tendencias del mes + Zona de
marca / brandbook del cliente (crea ángulos nuevos, no
copia el histórico). Completa el brandbook en Marca para
mejores temas. O haz click en un día para añadir a mano.
</p>
)}

View File

@@ -21,6 +21,7 @@ import {
Menu,
X,
Leaf,
BookMarked,
} from 'lucide-react';
import TenantSwitcher from '@/components/TenantSwitcher';
import type { DashboardModuleId } from '@/lib/tenant-modules';
@@ -65,6 +66,13 @@ const NAV_ITEMS: NavItem[] = [
iconClass: 'text-emerald-500',
moduleId: 'estrategia',
},
{
href: '/dashboard/marca',
label: 'Zona de marca',
icon: BookMarked,
iconClass: 'text-rose-500',
moduleId: 'marca',
},
{
href: '/dashboard/ad-tracker',
label: 'Ads',

571
src/lib/brandbook.ts Normal file
View 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 };
}

View 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;
}

View File

@@ -1,176 +1,53 @@
/**
* Generate a month editorial preview from tenant history + light seasonality.
* Priority: same-month prior year → recent months → brand defaults.
* Uses Anthropic when ANTHROPIC_API_KEY is set; otherwise history remix.
* Month editorial preview — social strategist (trends + client DNA).
* Creates new angles; does not copy historical calendars day-for-day.
* Uses Anthropic when ANTHROPIC_API_KEY is set; else strategy heuristic.
*/
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import { emptyMonthPlan, newPieceId } from '@/lib/calendar-documents';
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;
label: string;
vertical: string;
tone: string;
pillars: string[];
formats: { post: number; reel: number; stories: number; carrusel: number };
notes: string;
history: RrssMonthPlan[];
brandbook?: Brandbook | null;
};
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.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 type GeneratePreviewResult = {
plan: RrssMonthPlan;
rationale: string;
source: 'anthropic' | 'strategy' | 'heuristic';
};
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 {
if (kind === 'story' || /stor/i.test(format)) return '#374151';
if (/linkedin/i.test(platform)) return '#7C66B8';
if (/reel/i.test(format)) return '#7C66B8';
if (/carrusel/i.test(format)) return '#0F9D8F';
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 {
const hay = `${format || ''} ${kind || ''}`.toLowerCase();
if (hay.includes('stor')) return 'story';
@@ -179,349 +56,42 @@ function kindOf(format?: string, kind?: string): string {
return 'post';
}
function formatLabel(kind: string): string {
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(
async function anthropicStrategist(
input: GeneratePreviewInput,
fallback: GeneratePreviewResult
): Promise<GeneratePreviewResult> {
const key = process.env.ANTHROPIC_API_KEY?.trim();
if (!key || key.startsWith('replace-')) return fallback;
const brand = brandBrief(input.slug, input.label);
const hist = collectHistory(input.history, input.ym);
const season = seasonHints(input.ym);
const prompt = `Eres planner RRSS de 4Ventures para ${brand.label} (${input.ym}).
Vertical: ${brand.vertical}. Tono: ${brand.tone}.
Línea editorial: ${brand.notes}
Cadencia histórica: ~${hist.avgPerWeek} piezas/semana, ~${Math.round(
(hist.storiesRatio || 0.5) * 100
)}% stories.
Títulos REALES del histórico (úsalos como plantilla; remix, no inventes Black Friday genérico):
${hist.sampleTitles.slice(0, 20).join('\n') || '(sin histórico)'}
Estacionalidad opcional (máx 1-2 piezas si encaja con la marca): ${
season.join(', ') || 'ninguna'
}.
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:
- Títulos al estilo del histórico (FALSE FRIEND, SLANG, Same word…, canción, matriculación, etc. si es BPS).
- PROHIBIDO títulos tipo "POST 1 · Black Friday" o "STORIES · puente".
- 14-28 piezas en laborables; body = notas de producción del ángulo, no caption final largo.
- Español.`;
const cleanHistory = input.history
.map((plan) => ({
...plan,
pieces: (plan.pieces || []).filter(
(p) => p.title && !isJunkPreviewTitle(p.title)
),
}))
.filter((p) => (p.pieces?.length || 0) >= 3);
const dna = extractClientDna(
input.slug,
input.label,
cleanHistory,
input.brandbook
);
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
const prompt = strategistSystemPrompt({
ym: input.ym,
label: input.label,
dna,
trends,
});
try {
const Anthropic = (await import('@anthropic-ai/sdk')).default;
const client = new Anthropic({ apiKey: key });
const msg = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4000,
max_tokens: 4500,
messages: [{ role: 'user', content: prompt }],
});
const text = msg.content
@@ -531,9 +101,11 @@ Reglas:
if (!jsonMatch) return fallback;
const parsed = JSON.parse(jsonMatch[0]) as {
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
.map((p) => {
@@ -549,7 +121,7 @@ Reglas:
return {
id: newPieceId(date, title),
date,
time: String(p.time || '10:30').slice(0, 5),
time: String(p.time || '13:00').slice(0, 5),
platform,
format,
pillar: String(p.pillar || (kind === 'story' ? 'stories' : 'feed')),
@@ -557,6 +129,9 @@ Reglas:
hook: String(p.hook || title).slice(0, 180),
body: String(p.body || title),
cta: String(p.cta || ''),
objective_metric: p.objective_metric
? String(p.objective_metric)
: undefined,
status: 'planned',
kind,
color: colorFor(format, kind, platform),
@@ -564,10 +139,14 @@ Reglas:
})
.filter(Boolean) as RrssPiece[];
if (pieces.length < 6) return fallback;
if (pieces.length < 8) return fallback;
const plan = emptyMonthPlan(input.ym, input.label);
plan.hero.lede = 'Preview IA anclado al histórico del cliente.';
plan.hero.chips = [`${pieces.length} piezas`, 'IA + histórico', brand.vertical];
plan.hero.lede = `Estrategia IA · ${monthLabel(input.ym)} · engagement 2026 + ADN ${input.label}.`;
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));
return {
plan,
@@ -582,7 +161,6 @@ Reglas:
export async function generateMonthPreview(
input: GeneratePreviewInput
): Promise<GeneratePreviewResult> {
// Drop junk months (previous bad previews) from history
const cleanHistory = input.history
.map((plan) => ({
...plan,
@@ -592,8 +170,18 @@ export async function generateMonthPreview(
}))
.filter((p) => (p.pieces?.length || 0) >= 3);
const fallback = heuristicPreview({ ...input, history: cleanHistory });
return anthropicPreview({ ...input, history: cleanHistory }, fallback);
const strategist = strategyMonthPreview({
...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(
@@ -605,7 +193,9 @@ export function mergePreviewIntoPlan(
return preview;
}
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 {
...existing,
pieces: [...existing.pieces, ...extras].sort((a, b) =>

View 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.`;
}

View File

@@ -5,6 +5,7 @@ export type DashboardModuleId =
| 'crm'
| 'seguimiento'
| 'estrategia'
| 'marca'
| 'calendario'
| 'ad-tracker'
| 'social'
@@ -51,6 +52,13 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
providers: [],
hint: 'Targets y presupuesto',
},
{
id: 'marca',
href: '/dashboard/marca',
label: 'Zona de marca',
providers: [],
hint: 'Brandbook: voz, oferta, ads, LinkedIn, blog',
},
{
id: 'calendario',
href: '/dashboard/calendario',