diff --git a/scripts/seed_brandbooks_regen_nov.ts b/scripts/seed_brandbooks_regen_nov.ts
new file mode 100644
index 0000000..9130c22
--- /dev/null
+++ b/scripts/seed_brandbooks_regen_nov.ts
@@ -0,0 +1,143 @@
+/**
+ * Seed brandbook for every tenant + regenerate Nov 2026 editorial strategy.
+ * Usage: npx tsx scripts/seed_brandbooks_regen_nov.ts
+ * Optional: MONTH=2026-11 FORCE_BRANDBOOK=1
+ */
+import { config } from 'dotenv';
+import { resolve } from 'path';
+config({ path: resolve(process.cwd(), '.env.local') });
+
+import type { RrssMonthPlan } from '../src/components/RrssMonthView';
+import { generateMonthPreview } from '../src/lib/calendar-generate';
+import {
+ BRANDBOOK_DOC_TITLE,
+ BRANDBOOK_DOC_TYPE,
+ parseBrandbook,
+ seedBrandbook,
+} from '../src/lib/brandbook';
+import { monthLabel } from '../src/lib/calendar-import';
+
+const ym = process.env.MONTH || '2026-11';
+const forceBook = process.env.FORCE_BRANDBOOK === '1';
+
+async function main() {
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+ const h = {
+ apikey: key,
+ Authorization: `Bearer ${key}`,
+ 'Content-Type': 'application/json',
+ };
+
+ const tenants = (await (
+ await fetch(
+ `${url}/rest/v1/tenants?select=id,slug,name&order=name.asc`,
+ { headers: h }
+ )
+ ).json()) as { id: string; slug: string; name: string }[];
+
+ console.log(`Tenants: ${tenants.length} · month ${ym} · forceBook=${forceBook}`);
+
+ for (const t of tenants) {
+ // --- brandbook ---
+ const bbRes = await fetch(
+ `${url}/rest/v1/documents?tenant_id=eq.${t.id}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`,
+ { headers: h }
+ );
+ const bbRows = (await bbRes.json()) as { id: string; content: unknown }[];
+ const existing = parseBrandbook(bbRows[0]?.content);
+ let book = existing;
+ if (!existing || forceBook) {
+ book = seedBrandbook(t.slug, t.name);
+ if (bbRows[0]?.id) {
+ await fetch(`${url}/rest/v1/documents?id=eq.${bbRows[0].id}`, {
+ method: 'PATCH',
+ headers: { ...h, Prefer: 'return=minimal' },
+ body: JSON.stringify({
+ content: book,
+ title: BRANDBOOK_DOC_TITLE,
+ updated_at: new Date().toISOString(),
+ }),
+ });
+ console.log(`[${t.slug}] brandbook updated`);
+ } else {
+ await fetch(`${url}/rest/v1/documents`, {
+ method: 'POST',
+ headers: { ...h, Prefer: 'return=minimal' },
+ body: JSON.stringify({
+ tenant_id: t.id,
+ type: BRANDBOOK_DOC_TYPE,
+ title: BRANDBOOK_DOC_TITLE,
+ status: 'approved',
+ content: book,
+ }),
+ });
+ console.log(`[${t.slug}] brandbook created`);
+ }
+ } else {
+ console.log(`[${t.slug}] brandbook exists (skip)`);
+ }
+
+ // --- calendars ---
+ const calRes = await fetch(
+ `${url}/rest/v1/documents?tenant_id=eq.${t.id}&type=eq.calendar&select=id,content&order=created_at.desc&limit=48`,
+ { headers: h }
+ );
+ const docs = (await calRes.json()) as { id: string; content: unknown }[];
+ const history: RrssMonthPlan[] = [];
+ let novId: string | null = null;
+ for (const d of docs) {
+ const c =
+ typeof d.content === 'string' ? JSON.parse(d.content) : d.content;
+ const plan = c as RrssMonthPlan;
+ if (plan?.month === ym) novId = d.id;
+ else if (plan?.month && Array.isArray(plan.pieces)) history.push(plan);
+ }
+
+ const result = await generateMonthPreview({
+ ym,
+ slug: t.slug,
+ label: t.name,
+ history,
+ brandbook: book,
+ });
+
+ const title = `RRSS ${monthLabel(ym)}`;
+ if (novId) {
+ const patch = await fetch(`${url}/rest/v1/documents?id=eq.${novId}`, {
+ method: 'PATCH',
+ headers: { ...h, Prefer: 'return=minimal' },
+ body: JSON.stringify({
+ content: result.plan,
+ title,
+ updated_at: new Date().toISOString(),
+ }),
+ });
+ console.log(
+ `[${t.slug}] ${ym} patched ${patch.status} · ${result.plan.pieces.length} pcs · ${result.source}`
+ );
+ } else {
+ const ins = await fetch(`${url}/rest/v1/documents`, {
+ method: 'POST',
+ headers: { ...h, Prefer: 'return=minimal' },
+ body: JSON.stringify({
+ tenant_id: t.id,
+ type: 'calendar',
+ title,
+ status: 'draft',
+ content: result.plan,
+ }),
+ });
+ console.log(
+ `[${t.slug}] ${ym} created ${ins.status} · ${result.plan.pieces.length} pcs · ${result.source}`
+ );
+ }
+ }
+
+ console.log('Done.');
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/src/app/api/brandbook/route.ts b/src/app/api/brandbook/route.ts
new file mode 100644
index 0000000..7d5fd52
--- /dev/null
+++ b/src/app/api/brandbook/route.ts
@@ -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,
+ });
+}
diff --git a/src/app/api/brandbook/upload/route.ts b/src/app/api/brandbook/upload/route.ts
new file mode 100644
index 0000000..ac699e4
--- /dev/null
+++ b/src/app/api/brandbook/upload/route.ts
@@ -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,
+ });
+}
diff --git a/src/app/api/calendar/preview/route.ts b/src/app/api/calendar/preview/route.ts
index f269be4..1ca34a7 100644
--- a/src/app/api/calendar/preview/route.ts
+++ b/src/app/api/calendar/preview/route.ts
@@ -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,
});
}
diff --git a/src/app/dashboard/marca/page.tsx b/src/app/dashboard/marca/page.tsx
new file mode 100644
index 0000000..2d33ec2
--- /dev/null
+++ b/src/app/dashboard/marca/page.tsx
@@ -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 (
+
+ );
+ }
+
+ return ;
+}
diff --git a/src/components/BrandZone.tsx b/src/components/BrandZone.tsx
new file mode 100644
index 0000000..fc63948
--- /dev/null
+++ b/src/components/BrandZone.tsx
@@ -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}
+ {hint ? {hint} : null}
+ {children}
+
+ );
+}
+
+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('identidad');
+ const [book, setBook] = useState(() => 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(null);
+ const [okMsg, setOkMsg] = useState(null);
+ const [score, setScore] = useState(0);
+ const [missing, setMissing] = useState([]);
+ 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) {
+ 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 (
+
+
+ Cargando zona de marca…
+
+ );
+ }
+
+ return (
+
+
+ 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 ? (
+
+ ) : (
+
+ )}
+ {exists ? 'Resembrar base' : 'Crear base de marca'}
+
+ 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 ? (
+
+ ) : (
+
+ )}
+ Guardar brandbook
+
+
+ }
+ />
+
+
+
+
+ Completitud
+
+ {score}%
+
+
+
+
+ Usos conectados
+
+
+ Calendario editorial · Pipeline creatividades / ads · Estrategia
+ LinkedIn B2B · Blog corporativo. Cuanto más completo el brandbook,
+ mejores los temas que genera la IA.
+
+ {missing.length ? (
+
+ Falta: {missing.join(' · ')}
+
+ ) : (
+
+ Base mínima completa
+
+ )}
+
+
+
+ {err ? (
+
+ {err}
+
+ ) : null}
+ {okMsg ? (
+
+ {okMsg}
+
+ ) : null}
+
+
+
+ {BRANDBOOK_SECTIONS.map((s) => (
+ 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}
+
+ ))}
+
+
+
+ s.id === section)?.label || ''}
+ description={
+ BRANDBOOK_SECTIONS.find((s) => s.id === section)?.hint
+ }
+ />
+
+ {section === 'identidad' && (
+
+
+ patch({ brand_name: e.target.value })}
+ />
+
+
+ patch({ tagline: e.target.value })}
+ />
+
+
+ patch({ vertical: e.target.value })}
+ />
+
+
+
+
+
+
+
+
+ )}
+
+ {section === 'voz' && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {section === 'visual' && (
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {section === 'audiencia' && (
+
+
+
+
+ patch({ geo: e.target.value })}
+ />
+
+
+
+
+
+
+ )}
+
+ {section === 'oferta' && (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {section === 'pilares' && (
+
+
+
+
+
+
+
+
+ )}
+
+ {section === 'canales' && (
+
+ {(
+ [
+ ['instagram', 'Instagram'],
+ ['facebook', 'Facebook'],
+ ['linkedin', 'LinkedIn'],
+ ['tiktok', 'TikTok'],
+ ['blog', 'Blog'],
+ ['other', 'Otros'],
+ ] as const
+ ).map(([key, label]) => (
+
+
+ ))}
+
+ )}
+
+ {section === 'ads' && (
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {section === 'b2b' && (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {section === 'blog' && (
+
+
+
+
+
+
+ patch({ blog_cadence: e.target.value })}
+ placeholder="ej. 2 posts/mes"
+ />
+
+
+ )}
+
+ {section === 'compliance' && (
+
+
+
+
+
+
+
+
+ )}
+
+ {section === 'fuentes' && (
+
+
+
+
+ Subir o pegar brandbook
+
+
+ .txt / .md / .csv / .json — o pega el texto de un PDF/DOCX. Se
+ guarda como fuente y alimenta notas libres.
+
+
+
+
+
+
+ {(book.source_excerpts || []).length > 0 ? (
+
+ {book.source_excerpts.map((s) => (
+
+
+ {s.filename}{' '}
+
+ {s.added_at?.slice(0, 10)}
+
+
+
+ {s.excerpt.slice(0, 500)}
+ {s.excerpt.length > 500 ? '…' : ''}
+
+
+ ))}
+
+ ) : (
+
Aún no hay fuentes subidas.
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/CalendarCreator.tsx b/src/components/CalendarCreator.tsx
index b087b03..6eddbeb 100644
--- a/src/components/CalendarCreator.tsx
+++ b/src/components/CalendarCreator.tsx
@@ -397,7 +397,7 @@ export default function CalendarCreator({
) : (
)}
- Generar preview editorial
+ Generar estrategia del mes
) : (
- 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.
)}
diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx
index 486abcf..ea6cc08 100644
--- a/src/components/DashboardShell.tsx
+++ b/src/components/DashboardShell.tsx
@@ -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',
diff --git a/src/lib/brandbook.ts b/src/lib/brandbook.ts
new file mode 100644
index 0000000..f469cb0
--- /dev/null
+++ b/src/lib/brandbook.ts
@@ -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> = {
+ 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;
+ 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 };
+}
diff --git a/src/lib/calendar-generate-brand.ts b/src/lib/calendar-generate-brand.ts
new file mode 100644
index 0000000..ba8a38f
--- /dev/null
+++ b/src/lib/calendar-generate-brand.ts
@@ -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> = {
+ '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;
+}
diff --git a/src/lib/calendar-generate.ts b/src/lib/calendar-generate.ts
index 17cb6c8..85e2fe6 100644
--- a/src/lib/calendar-generate.ts
+++ b/src/lib/calendar-generate.ts
@@ -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> = {
- '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 = {
- 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;
- sameMonth: HistPiece[];
- sampleTitles: string[];
- sourceMonths: string[];
-};
-
-function collectHistory(plans: RrssMonthPlan[], targetYm: string): HistoryModel {
- const targetMonth = targetYm.slice(5, 7);
- const poolByKind: Record = {
- 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,
- 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();
- 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();
- 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 {
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>;
+ pieces?: Array & { 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 {
- // 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) =>
diff --git a/src/lib/calendar-strategy.ts b/src/lib/calendar-strategy.ts
new file mode 100644
index 0000000..be22404
--- /dev/null
+++ b/src/lib/calendar-strategy.ts
@@ -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,
+ 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();
+ 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 = {
+ 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 = { ...counts };
+ const formats: Slot['format'][] = ['Reel', 'Carrusel', 'Stories', 'Post'];
+ const queue: Array = [];
+ 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 = {
+ '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();
+ 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.`;
+}
diff --git a/src/lib/tenant-modules.ts b/src/lib/tenant-modules.ts
index e5f2b4f..a3feb34 100644
--- a/src/lib/tenant-modules.ts
+++ b/src/lib/tenant-modules.ts
@@ -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',