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:
571
src/lib/brandbook.ts
Normal file
571
src/lib/brandbook.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* Brandbook per tenant — source of truth for ads, RRSS calendars,
|
||||
* LinkedIn B2B and corporate blog strategy.
|
||||
* Stored in documents as type=brandbook (one active doc per tenant).
|
||||
*/
|
||||
|
||||
import { brandBrief } from '@/lib/calendar-generate-brand';
|
||||
|
||||
export const BRANDBOOK_DOC_TYPE = 'brandbook';
|
||||
export const BRANDBOOK_DOC_TITLE = 'Brandbook';
|
||||
|
||||
export type BrandbookSectionId =
|
||||
| 'identidad'
|
||||
| 'voz'
|
||||
| 'visual'
|
||||
| 'audiencia'
|
||||
| 'oferta'
|
||||
| 'pilares'
|
||||
| 'canales'
|
||||
| 'ads'
|
||||
| 'b2b'
|
||||
| 'blog'
|
||||
| 'compliance'
|
||||
| 'fuentes';
|
||||
|
||||
export type Brandbook = {
|
||||
version: 1;
|
||||
updated_at?: string;
|
||||
/** Identity */
|
||||
brand_name: string;
|
||||
tagline: string;
|
||||
mission: string;
|
||||
vision: string;
|
||||
values: string[];
|
||||
vertical: string;
|
||||
/** Voice */
|
||||
tone: string;
|
||||
personality: string[];
|
||||
voice_do: string[];
|
||||
voice_dont: string[];
|
||||
vocabulary_prefer: string[];
|
||||
vocabulary_avoid: string[];
|
||||
/** Visual */
|
||||
colors_primary: string[];
|
||||
colors_secondary: string[];
|
||||
fonts: string[];
|
||||
visual_notes: string;
|
||||
logo_usage: string;
|
||||
/** Audience */
|
||||
icp: string;
|
||||
personas: Array<{ name: string; description: string; pains: string }>;
|
||||
geo: string;
|
||||
languages: string[];
|
||||
/** Offer */
|
||||
products_services: string[];
|
||||
differentiators: string[];
|
||||
proof_points: string[];
|
||||
pricing_notes: string;
|
||||
/** Content pillars */
|
||||
pillars_rrss: string[];
|
||||
series_recurrentes: string[];
|
||||
formats_priority: string[];
|
||||
/** Channels */
|
||||
channels: {
|
||||
instagram: string;
|
||||
facebook: string;
|
||||
linkedin: string;
|
||||
tiktok: string;
|
||||
blog: string;
|
||||
other: string;
|
||||
};
|
||||
/** Ads */
|
||||
ads_usps: string[];
|
||||
ads_objections: string[];
|
||||
ads_ctas: string[];
|
||||
ads_offers: string[];
|
||||
ads_angles: string[];
|
||||
/** B2B / LinkedIn */
|
||||
linkedin_themes: string[];
|
||||
linkedin_formats: string[];
|
||||
thought_leadership: string[];
|
||||
buyer_roles: string[];
|
||||
/** Blog corporativo */
|
||||
blog_themes: string[];
|
||||
blog_seo_keywords: string[];
|
||||
blog_cadence: string;
|
||||
/** Compliance */
|
||||
claims_forbidden: string[];
|
||||
legal_notes: string;
|
||||
sector_rules: string;
|
||||
/** Uploaded / pasted source material */
|
||||
source_excerpts: Array<{
|
||||
id: string;
|
||||
filename: string;
|
||||
excerpt: string;
|
||||
added_at: string;
|
||||
}>;
|
||||
freeform_notes: string;
|
||||
};
|
||||
|
||||
export const BRANDBOOK_SECTIONS: {
|
||||
id: BrandbookSectionId;
|
||||
label: string;
|
||||
hint: string;
|
||||
}[] = [
|
||||
{
|
||||
id: 'identidad',
|
||||
label: 'Identidad',
|
||||
hint: 'Nombre, misión, visión, valores, vertical',
|
||||
},
|
||||
{
|
||||
id: 'voz',
|
||||
label: 'Voz y tono',
|
||||
hint: 'Cómo habla la marca; do/don’t y léxico',
|
||||
},
|
||||
{
|
||||
id: 'visual',
|
||||
label: 'Visual',
|
||||
hint: 'Colores, tipografías, logo y notas de arte',
|
||||
},
|
||||
{
|
||||
id: 'audiencia',
|
||||
label: 'Audiencia',
|
||||
hint: 'ICP, personas, geo e idiomas',
|
||||
},
|
||||
{
|
||||
id: 'oferta',
|
||||
label: 'Oferta',
|
||||
hint: 'Productos/servicios, diferenciadores, prueba',
|
||||
},
|
||||
{
|
||||
id: 'pilares',
|
||||
label: 'Pilares de contenido',
|
||||
hint: 'Series RRSS y formatos prioritarios',
|
||||
},
|
||||
{
|
||||
id: 'canales',
|
||||
label: 'Canales',
|
||||
hint: 'Rol de cada red y blog',
|
||||
},
|
||||
{
|
||||
id: 'ads',
|
||||
label: 'Anuncios',
|
||||
hint: 'USPs, objeciones, CTAs, ofertas y ángulos',
|
||||
},
|
||||
{
|
||||
id: 'b2b',
|
||||
label: 'LinkedIn B2B',
|
||||
hint: 'Temas, formatos y thought leadership',
|
||||
},
|
||||
{
|
||||
id: 'blog',
|
||||
label: 'Blog corporativo',
|
||||
hint: 'Temas, SEO y cadencia',
|
||||
},
|
||||
{
|
||||
id: 'compliance',
|
||||
label: 'Compliance',
|
||||
hint: 'Claims prohibidos y notas legales',
|
||||
},
|
||||
{
|
||||
id: 'fuentes',
|
||||
label: 'Fuentes',
|
||||
hint: 'Textos subidos / pegados del brandbook',
|
||||
},
|
||||
];
|
||||
|
||||
export function emptyBrandbook(label = ''): Brandbook {
|
||||
return {
|
||||
version: 1,
|
||||
brand_name: label,
|
||||
tagline: '',
|
||||
mission: '',
|
||||
vision: '',
|
||||
values: [],
|
||||
vertical: '',
|
||||
tone: '',
|
||||
personality: [],
|
||||
voice_do: [],
|
||||
voice_dont: [],
|
||||
vocabulary_prefer: [],
|
||||
vocabulary_avoid: [],
|
||||
colors_primary: [],
|
||||
colors_secondary: [],
|
||||
fonts: [],
|
||||
visual_notes: '',
|
||||
logo_usage: '',
|
||||
icp: '',
|
||||
personas: [],
|
||||
geo: 'España',
|
||||
languages: ['es'],
|
||||
products_services: [],
|
||||
differentiators: [],
|
||||
proof_points: [],
|
||||
pricing_notes: '',
|
||||
pillars_rrss: [],
|
||||
series_recurrentes: [],
|
||||
formats_priority: ['Reel', 'Carrusel', 'Stories', 'Post'],
|
||||
channels: {
|
||||
instagram: '',
|
||||
facebook: '',
|
||||
linkedin: '',
|
||||
tiktok: '',
|
||||
blog: '',
|
||||
other: '',
|
||||
},
|
||||
ads_usps: [],
|
||||
ads_objections: [],
|
||||
ads_ctas: [],
|
||||
ads_offers: [],
|
||||
ads_angles: [],
|
||||
linkedin_themes: [],
|
||||
linkedin_formats: ['Carrusel', 'Documento', 'Post texto', 'Video corto'],
|
||||
thought_leadership: [],
|
||||
buyer_roles: [],
|
||||
blog_themes: [],
|
||||
blog_seo_keywords: [],
|
||||
blog_cadence: '',
|
||||
claims_forbidden: [],
|
||||
legal_notes: '',
|
||||
sector_rules: '',
|
||||
source_excerpts: [],
|
||||
freeform_notes: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Seed a usable brandbook from hard-coded brand DNA + sensible defaults. */
|
||||
export function seedBrandbook(slug: string, label: string): Brandbook {
|
||||
const brief = brandBrief(slug, label);
|
||||
const base = emptyBrandbook(label);
|
||||
const seeds: Record<string, Partial<Brandbook>> = {
|
||||
bps: {
|
||||
vertical: 'Escuela de idiomas',
|
||||
tagline: 'Inglés real, cultura pop y certificaciones',
|
||||
mission:
|
||||
'Ayudar a alumnos a hablar inglés con confianza para trabajo, viajes y exámenes.',
|
||||
tone: brief.tone,
|
||||
personality: ['cercano', 'divertido', 'riguroso', 'pop'],
|
||||
voice_do: [
|
||||
'Explicar con ejemplos reales',
|
||||
'Usar series FALSE FRIEND / SLANG / MTW',
|
||||
'Micro-lecciones accionables',
|
||||
],
|
||||
voice_dont: [
|
||||
'Listas genéricas de Black Friday',
|
||||
'Tono académico seco',
|
||||
'Copiar títulos mes a mes',
|
||||
],
|
||||
pillars_rrss: brief.pillars,
|
||||
series_recurrentes: [
|
||||
'FALSE FRIEND',
|
||||
'SLANG',
|
||||
'Same word different level',
|
||||
'MTW',
|
||||
'Canción del día',
|
||||
'English Through TV',
|
||||
'Fix it',
|
||||
],
|
||||
products_services: [
|
||||
'Cursos generales',
|
||||
'Intensivos',
|
||||
'Trinity / IELTS / Cambridge',
|
||||
'Inglés oposiciones',
|
||||
'Madrid / Málaga',
|
||||
],
|
||||
ads_usps: [
|
||||
'Metodología práctica',
|
||||
'Profes nativos / preparados',
|
||||
'Certificaciones oficiales',
|
||||
],
|
||||
ads_ctas: ['Test de nivel', 'Reservar plaza', 'WhatsApp'],
|
||||
ads_objections: [
|
||||
'No tengo tiempo',
|
||||
'Ya probé y no aprendí',
|
||||
'Es caro',
|
||||
],
|
||||
linkedin_themes: [
|
||||
'Inglés profesional',
|
||||
'Employability',
|
||||
'Casos de alumnos',
|
||||
],
|
||||
blog_themes: [
|
||||
'Guías de certificación',
|
||||
'Errores comunes',
|
||||
'Cómo elegir curso',
|
||||
],
|
||||
channels: {
|
||||
instagram: 'Alcance + comunidad (Reels + Stories)',
|
||||
facebook: 'Comunidad local / padres',
|
||||
linkedin: 'B2B ligero / empleabilidad',
|
||||
tiktok: 'Opcional micro-lecciones',
|
||||
blog: 'SEO certificaciones y objeciones',
|
||||
other: '',
|
||||
},
|
||||
},
|
||||
'talento-y-deporte': {
|
||||
vertical: brief.vertical,
|
||||
tone: brief.tone,
|
||||
pillars_rrss: brief.pillars,
|
||||
series_recurrentes: [
|
||||
'Nuevo alumno',
|
||||
'Beca académica',
|
||||
'Beca deportiva',
|
||||
'Campus USA',
|
||||
'Objeciones',
|
||||
'English tips',
|
||||
],
|
||||
products_services: [
|
||||
'Becas deportivas USA',
|
||||
'Acompañamiento académico',
|
||||
'Campus visits',
|
||||
],
|
||||
ads_usps: ['Becas reales', 'Acompañamiento end-to-end', 'Historias de alumnos'],
|
||||
ads_ctas: ['Agenda llamada', 'Evalúa tu perfil'],
|
||||
linkedin_themes: ['Educación internacional', 'Deporte + estudios'],
|
||||
blog_themes: ['Guía becas USA', 'Requisitos TOEFL/SAT', 'Vida en campus'],
|
||||
},
|
||||
'halal-safety': {
|
||||
vertical: brief.vertical,
|
||||
tone: brief.tone,
|
||||
pillars_rrss: brief.pillars,
|
||||
products_services: [
|
||||
'Certificación Halal',
|
||||
'Consultoría exportación',
|
||||
'Formación',
|
||||
],
|
||||
ads_usps: ['Autoridad técnica', 'Mercados export', 'Proceso claro'],
|
||||
ads_ctas: ['Solicitar info', 'Auditoría'],
|
||||
linkedin_themes: [
|
||||
'Exportación Halal',
|
||||
'Normativa',
|
||||
'Casos sector alimentación/cosmética',
|
||||
],
|
||||
thought_leadership: [
|
||||
'Tendencias mercados musulmanes',
|
||||
'Compliance vs marketing Halal',
|
||||
],
|
||||
blog_themes: ['Guías de certificación', 'Mercados clave', 'FAQ técnica'],
|
||||
buyer_roles: ['Quality', 'Export manager', 'CEO pyme alimentaria'],
|
||||
},
|
||||
'brava-dermoestetica': {
|
||||
vertical: brief.vertical,
|
||||
tone: brief.tone,
|
||||
pillars_rrss: brief.pillars,
|
||||
products_services: ['Peelings', 'Tratamientos facial', 'Rutinas'],
|
||||
ads_usps: ['Resultados naturales', 'Equipo médico', 'Protocolos personalizados'],
|
||||
ads_ctas: ['Pedir cita', 'Consulta valoración'],
|
||||
claims_forbidden: [
|
||||
'Promesas milagro sin matiz',
|
||||
'Antes/después sin consentimiento',
|
||||
],
|
||||
sector_rules: 'Publicidad sanitaria: claims prudentes, sin garantías absolutas.',
|
||||
},
|
||||
noberplast: {
|
||||
vertical: brief.vertical,
|
||||
tone: brief.tone,
|
||||
pillars_rrss: brief.pillars,
|
||||
formats_priority: ['Carrusel', 'Post LinkedIn', 'Documento', 'Reel'],
|
||||
linkedin_themes: [
|
||||
'Plásticos técnicos',
|
||||
'Casos industriales',
|
||||
'Innovación materiales',
|
||||
],
|
||||
thought_leadership: [
|
||||
'Sostenibilidad industrial',
|
||||
'Especificaciones técnicas',
|
||||
],
|
||||
buyer_roles: ['Ingeniería', 'Compras', 'Producción'],
|
||||
blog_themes: ['Fichas técnicas', 'Aplicaciones sectoriales', 'FAQ producto'],
|
||||
channels: {
|
||||
instagram: 'Marca / producto visual',
|
||||
facebook: 'Bajo',
|
||||
linkedin: 'Canal principal B2B',
|
||||
tiktok: 'No prioritario',
|
||||
blog: 'SEO técnico + captación inbound',
|
||||
other: '',
|
||||
},
|
||||
},
|
||||
'4ventures-hub': {
|
||||
vertical: brief.vertical,
|
||||
tone: brief.tone,
|
||||
pillars_rrss: brief.pillars,
|
||||
linkedin_themes: ['Ops de agencia', 'Growth', 'Casos clientes'],
|
||||
thought_leadership: ['Centro de mando', 'Atribución', 'Contenido que vende'],
|
||||
},
|
||||
};
|
||||
|
||||
const resolvedSlug = slug === 'blue-parrot-school' ? 'bps' : slug;
|
||||
|
||||
const extra = seeds[resolvedSlug] || {
|
||||
vertical: brief.vertical,
|
||||
tone: brief.tone,
|
||||
pillars_rrss: brief.pillars,
|
||||
products_services: [],
|
||||
freeform_notes: brief.notes,
|
||||
};
|
||||
|
||||
return {
|
||||
...base,
|
||||
...extra,
|
||||
brand_name: label,
|
||||
vertical: extra.vertical || brief.vertical,
|
||||
tone: extra.tone || brief.tone,
|
||||
pillars_rrss: extra.pillars_rrss?.length ? extra.pillars_rrss : brief.pillars,
|
||||
freeform_notes: extra.freeform_notes || brief.notes,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBrandbook(raw: unknown): Brandbook | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const o = raw as Partial<Brandbook>;
|
||||
if (o.version !== 1 && !o.brand_name && !o.vertical) {
|
||||
// Allow loose objects that look like brandbooks
|
||||
if (!('tone' in o) && !('pillars_rrss' in o) && !('mission' in o)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const empty = emptyBrandbook(String(o.brand_name || ''));
|
||||
return {
|
||||
...empty,
|
||||
...o,
|
||||
version: 1,
|
||||
values: arr(o.values),
|
||||
personality: arr(o.personality),
|
||||
voice_do: arr(o.voice_do),
|
||||
voice_dont: arr(o.voice_dont),
|
||||
vocabulary_prefer: arr(o.vocabulary_prefer),
|
||||
vocabulary_avoid: arr(o.vocabulary_avoid),
|
||||
colors_primary: arr(o.colors_primary),
|
||||
colors_secondary: arr(o.colors_secondary),
|
||||
fonts: arr(o.fonts),
|
||||
personas: Array.isArray(o.personas) ? o.personas : [],
|
||||
languages: arr(o.languages).length ? arr(o.languages) : ['es'],
|
||||
products_services: arr(o.products_services),
|
||||
differentiators: arr(o.differentiators),
|
||||
proof_points: arr(o.proof_points),
|
||||
pillars_rrss: arr(o.pillars_rrss),
|
||||
series_recurrentes: arr(o.series_recurrentes),
|
||||
formats_priority: arr(o.formats_priority),
|
||||
channels: { ...empty.channels, ...(o.channels || {}) },
|
||||
ads_usps: arr(o.ads_usps),
|
||||
ads_objections: arr(o.ads_objections),
|
||||
ads_ctas: arr(o.ads_ctas),
|
||||
ads_offers: arr(o.ads_offers),
|
||||
ads_angles: arr(o.ads_angles),
|
||||
linkedin_themes: arr(o.linkedin_themes),
|
||||
linkedin_formats: arr(o.linkedin_formats),
|
||||
thought_leadership: arr(o.thought_leadership),
|
||||
buyer_roles: arr(o.buyer_roles),
|
||||
blog_themes: arr(o.blog_themes),
|
||||
blog_seo_keywords: arr(o.blog_seo_keywords),
|
||||
claims_forbidden: arr(o.claims_forbidden),
|
||||
source_excerpts: Array.isArray(o.source_excerpts) ? o.source_excerpts : [],
|
||||
};
|
||||
}
|
||||
|
||||
function arr(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.map((x) => String(x || '').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Lines / commas → string[] for form fields. */
|
||||
export function linesToList(text: string): string[] {
|
||||
return text
|
||||
.split(/\n|,/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function listToLines(list: string[]): string {
|
||||
return (list || []).join('\n');
|
||||
}
|
||||
|
||||
/** Heuristic: fold uploaded plain text into freeform + optional lists. */
|
||||
export function ingestSourceText(
|
||||
book: Brandbook,
|
||||
filename: string,
|
||||
text: string
|
||||
): Brandbook {
|
||||
const excerpt = text.slice(0, 12000).trim();
|
||||
if (!excerpt) return book;
|
||||
const id = `src_${Date.now().toString(36)}`;
|
||||
const next: Brandbook = {
|
||||
...book,
|
||||
source_excerpts: [
|
||||
{
|
||||
id,
|
||||
filename: filename || 'pegado.txt',
|
||||
excerpt,
|
||||
added_at: new Date().toISOString(),
|
||||
},
|
||||
...(book.source_excerpts || []),
|
||||
].slice(0, 20),
|
||||
freeform_notes: [book.freeform_notes, `--- ${filename} ---\n${excerpt.slice(0, 4000)}`]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
.slice(0, 50000),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Light keyword pulls into empty fields
|
||||
const lower = excerpt.toLowerCase();
|
||||
if (!next.mission && /misi[oó]n/.test(lower)) {
|
||||
const m = excerpt.match(/misi[oó]n[:\s]+(.{20,200})/i);
|
||||
if (m) next.mission = m[1].trim();
|
||||
}
|
||||
if (!next.tone && /tono[:\s]+(.{10,120})/i.test(excerpt)) {
|
||||
const m = excerpt.match(/tono[:\s]+(.{10,120})/i);
|
||||
if (m) next.tone = m[1].trim();
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Compact prompt block for strategists / Anthropic. */
|
||||
export function brandbookToPrompt(book: Brandbook): string {
|
||||
const lines = [
|
||||
`Marca: ${book.brand_name}`,
|
||||
book.tagline && `Tagline: ${book.tagline}`,
|
||||
book.vertical && `Vertical: ${book.vertical}`,
|
||||
book.mission && `Misión: ${book.mission}`,
|
||||
book.tone && `Tono: ${book.tone}`,
|
||||
book.personality.length && `Personalidad: ${book.personality.join(', ')}`,
|
||||
book.voice_do.length && `Hacer: ${book.voice_do.join('; ')}`,
|
||||
book.voice_dont.length && `Evitar: ${book.voice_dont.join('; ')}`,
|
||||
book.series_recurrentes.length &&
|
||||
`Series RRSS: ${book.series_recurrentes.join(', ')}`,
|
||||
book.pillars_rrss.length && `Pilares: ${book.pillars_rrss.join(', ')}`,
|
||||
book.products_services.length &&
|
||||
`Oferta: ${book.products_services.join(', ')}`,
|
||||
book.differentiators.length &&
|
||||
`Diferenciadores: ${book.differentiators.join(', ')}`,
|
||||
book.icp && `ICP: ${book.icp}`,
|
||||
book.ads_usps.length && `USPs ads: ${book.ads_usps.join('; ')}`,
|
||||
book.ads_ctas.length && `CTAs: ${book.ads_ctas.join('; ')}`,
|
||||
book.ads_objections.length &&
|
||||
`Objeciones: ${book.ads_objections.join('; ')}`,
|
||||
book.linkedin_themes.length &&
|
||||
`LinkedIn: ${book.linkedin_themes.join(', ')}`,
|
||||
book.thought_leadership.length &&
|
||||
`Thought leadership: ${book.thought_leadership.join(', ')}`,
|
||||
book.blog_themes.length && `Blog: ${book.blog_themes.join(', ')}`,
|
||||
book.blog_seo_keywords.length &&
|
||||
`SEO: ${book.blog_seo_keywords.join(', ')}`,
|
||||
book.claims_forbidden.length &&
|
||||
`Prohibido: ${book.claims_forbidden.join('; ')}`,
|
||||
book.legal_notes && `Legal: ${book.legal_notes}`,
|
||||
book.freeform_notes && `Notas: ${book.freeform_notes.slice(0, 1500)}`,
|
||||
].filter(Boolean);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function brandbookCompleteness(book: Brandbook): {
|
||||
score: number;
|
||||
missing: string[];
|
||||
} {
|
||||
const checks: [string, boolean][] = [
|
||||
['Identidad (misión/vertical)', !!(book.mission || book.vertical)],
|
||||
['Voz/tono', !!book.tone],
|
||||
['Audiencia/ICP', !!(book.icp || book.personas.length)],
|
||||
['Oferta', book.products_services.length > 0],
|
||||
['Pilares/series RRSS', !!(book.pillars_rrss.length || book.series_recurrentes.length)],
|
||||
['Ads (USP o CTA)', !!(book.ads_usps.length || book.ads_ctas.length)],
|
||||
['LinkedIn B2B o blog', !!(book.linkedin_themes.length || book.blog_themes.length)],
|
||||
];
|
||||
const missing = checks.filter(([, ok]) => !ok).map(([l]) => l);
|
||||
const score = Math.round(
|
||||
((checks.length - missing.length) / checks.length) * 100
|
||||
);
|
||||
return { score, missing };
|
||||
}
|
||||
125
src/lib/calendar-generate-brand.ts
Normal file
125
src/lib/calendar-generate-brand.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/** Shared brand DNA + title helpers for calendar strategy / generate. */
|
||||
|
||||
export type TenantBrandBrief = {
|
||||
slug: string;
|
||||
label: string;
|
||||
vertical: string;
|
||||
tone: string;
|
||||
pillars: string[];
|
||||
formats: { post: number; reel: number; stories: number; carrusel: number };
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
|
||||
'talento-y-deporte': {
|
||||
vertical: 'Educación deportiva / becas USA',
|
||||
tone: 'Inspirador, cercano, ambicioso; historias de alumnos',
|
||||
pillars: [
|
||||
'nuevo-alumno',
|
||||
'beca-academica',
|
||||
'beca-deportiva',
|
||||
'campus',
|
||||
'objeciones',
|
||||
'tips-ingles',
|
||||
],
|
||||
formats: { post: 0.35, reel: 0.25, stories: 0.3, carrusel: 0.1 },
|
||||
notes: 'Series Nuevo alumno, objeciones, campus USA, English tips.',
|
||||
},
|
||||
'halal-safety': {
|
||||
vertical: 'Certificación Halal / exportación',
|
||||
tone: 'Profesional, claro, autoridad técnica',
|
||||
pillars: ['certificacion', 'exportacion', 'educativo', 'cifras', 'casos'],
|
||||
formats: { post: 0.4, reel: 0.2, stories: 0.25, carrusel: 0.15 },
|
||||
notes: 'Cifras, certificación, mercados, cosmética/alimentación Halal.',
|
||||
},
|
||||
'brava-dermoestetica': {
|
||||
vertical: 'Clínica dermoestética',
|
||||
tone: 'Cercano, experto, estacional (piel / tratamientos)',
|
||||
pillars: ['tratamiento', 'educativo', 'antes-despues', 'promos', 'equipo'],
|
||||
formats: { post: 0.3, reel: 0.25, stories: 0.3, carrusel: 0.15 },
|
||||
notes: 'Peelings, rutinas, encuestas stories, trends, citas.',
|
||||
},
|
||||
bps: {
|
||||
vertical: 'Escuela de idiomas',
|
||||
tone: 'Educativo, pop culture, series (FALSE FRIEND, SLANG, MTW…)',
|
||||
pillars: [
|
||||
'educativo',
|
||||
'slang',
|
||||
'false-friend',
|
||||
'certificaciones',
|
||||
'cursos',
|
||||
'cultura-pop',
|
||||
],
|
||||
// Target mix for growth 2026 (not legacy 2% reels)
|
||||
formats: { post: 0.2, reel: 0.28, stories: 0.36, carrusel: 0.16 },
|
||||
notes:
|
||||
'ADN BPS: FALSE FRIEND, SLANG, Same word, canción, TV shows, matriculación Madrid/Málaga, Trinity/IELTS — crear ángulos nuevos, no repetir títulos.',
|
||||
},
|
||||
'blue-parrot-school': {
|
||||
vertical: 'Escuela de idiomas',
|
||||
tone: 'Educativo, pop culture, series recurrentes',
|
||||
pillars: [
|
||||
'educativo',
|
||||
'slang',
|
||||
'false-friend',
|
||||
'certificaciones',
|
||||
'cursos',
|
||||
'cultura-pop',
|
||||
],
|
||||
formats: { post: 0.2, reel: 0.28, stories: 0.36, carrusel: 0.16 },
|
||||
notes: 'Misma línea que BPS.',
|
||||
},
|
||||
noberplast: {
|
||||
vertical: 'Plásticos industriales B2B',
|
||||
tone: 'Técnico, B2B, LinkedIn-first',
|
||||
pillars: ['producto', 'casos', 'industria', 'linkedin'],
|
||||
formats: { post: 0.45, reel: 0.15, stories: 0.1, carrusel: 0.3 },
|
||||
notes: 'Más LinkedIn y carruseles técnicos.',
|
||||
},
|
||||
'4ventures-hub': {
|
||||
vertical: 'Agencia marketing',
|
||||
tone: 'Directo, ops, thought leadership',
|
||||
pillars: ['ops', 'casos', 'tips', 'equipo'],
|
||||
formats: { post: 0.35, reel: 0.25, stories: 0.2, carrusel: 0.2 },
|
||||
notes: 'Contenido de agencia.',
|
||||
},
|
||||
};
|
||||
|
||||
export function brandBrief(slug: string, label: string): TenantBrandBrief {
|
||||
const base = BRANDS[slug] || {
|
||||
vertical: 'Marca / servicios',
|
||||
tone: 'Profesional y claro',
|
||||
pillars: ['educativo', 'producto', 'comunidad', 'promo'],
|
||||
formats: { post: 0.3, reel: 0.25, stories: 0.3, carrusel: 0.15 },
|
||||
notes: 'Ajustar al ADN del cliente.',
|
||||
};
|
||||
return { slug, label, ...base };
|
||||
}
|
||||
|
||||
export function cleanEditorialTitle(raw: string): string {
|
||||
return String(raw || '')
|
||||
.replace(/\s*\([^)]*\b\d+\s*(?:[.,]\d+)?\s*h(?:oras?)?[^)]*\)/gi, '')
|
||||
.replace(/\s*\([^)]*\b\d+\s*min[^)]*\)/gi, '')
|
||||
.replace(/\s*\(\s*0?\s*[hH]\s*\)/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function isJunkPreviewTitle(title: string): boolean {
|
||||
const t = title.toLowerCase();
|
||||
if (/^post\s+\d+\s*·/.test(t)) return true;
|
||||
if (/^reel\s*·\s*(feed|stories|nuevo)/.test(t)) return true;
|
||||
if (/^stories\s*·\s*(black friday|puente|cierre|feed|stories)/.test(t)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/black friday|cierre de año comercial|puente$|· puente|· black friday/.test(
|
||||
t
|
||||
) &&
|
||||
/^(post|reel|stories)\b/.test(t)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (/carrusel stories|carrusel feed/.test(t)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -1,176 +1,53 @@
|
||||
/**
|
||||
* Generate a month editorial preview from tenant history + light seasonality.
|
||||
* 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) =>
|
||||
|
||||
755
src/lib/calendar-strategy.ts
Normal file
755
src/lib/calendar-strategy.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
/**
|
||||
* Social media strategist for editorial month previews.
|
||||
* Creates NEW angles from: client DNA + month trends + 2026 engagement playbook.
|
||||
* Does NOT copy historical titles day-for-day.
|
||||
*/
|
||||
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
||||
import { emptyMonthPlan, newPieceId } from '@/lib/calendar-documents';
|
||||
import { monthLabel } from '@/lib/calendar-import';
|
||||
import {
|
||||
brandBrief,
|
||||
cleanEditorialTitle,
|
||||
type TenantBrandBrief,
|
||||
} from '@/lib/calendar-generate-brand';
|
||||
import {
|
||||
brandbookToPrompt,
|
||||
type Brandbook,
|
||||
} from '@/lib/brandbook';
|
||||
|
||||
export type ClientDna = {
|
||||
brand: TenantBrandBrief;
|
||||
/** Recurring series names inferred from history (as DNA, not to copy). */
|
||||
signatureSeries: string[];
|
||||
storiesRatio: number;
|
||||
avgPerWeek: number;
|
||||
conversionPillars: string[];
|
||||
voiceNotes: string;
|
||||
/** Full brandbook prompt block when available. */
|
||||
brandbookPrompt?: string;
|
||||
};
|
||||
|
||||
export type TrendBeat = {
|
||||
id: string;
|
||||
label: string;
|
||||
why: string;
|
||||
formats: Array<'Reel' | 'Carrusel' | 'Stories' | 'Post'>;
|
||||
kpi: 'alcance' | 'guardados' | 'comentarios' | 'compartidos' | 'conversion';
|
||||
};
|
||||
|
||||
/** Spain / IG 2026 playbook (IAB Spain 2026 + education Reels research). */
|
||||
export const PLAYBOOK_2026 = {
|
||||
principle:
|
||||
'Menos volumen, más relevancia (marcas ES −30% posts / +70% eficiencia). Reels = alcance; Carrusel = guardados; Stories = conversación.',
|
||||
mix: {
|
||||
/** Target share of pieces */
|
||||
reel: 0.28,
|
||||
carrusel: 0.18,
|
||||
stories: 0.38,
|
||||
post: 0.16,
|
||||
},
|
||||
hours: {
|
||||
reel: '19:30',
|
||||
carrusel: '11:00',
|
||||
stories: '09:30',
|
||||
post: '13:00',
|
||||
} as Record<string, string>,
|
||||
engagementHooks: [
|
||||
'Test your friend / envía esto a…',
|
||||
'Error que todos cometen',
|
||||
'Micro-lección completa en <30s',
|
||||
'Pronunciación nativa + caption SEO',
|
||||
'Encuesta / caja de respuestas',
|
||||
'Antes vs después / nivel A2→C1',
|
||||
'Lo que nadie te cuenta de…',
|
||||
],
|
||||
};
|
||||
|
||||
export function monthTrendBeats(ym: string, vertical: string): TrendBeat[] {
|
||||
const m = Number(ym.slice(5, 7));
|
||||
const edu = /idioma|colegio|educaci|beca|alumno/i.test(vertical);
|
||||
const clinic = /dermo|clínic|estetic/i.test(vertical);
|
||||
const b2b = /b2b|industrial|plástic|certificaci[oó]n halal/i.test(vertical);
|
||||
|
||||
const commonNov: TrendBeat[] = [
|
||||
{
|
||||
id: 'reels-micro',
|
||||
label: 'Micro-lección Reel (hook 3s + payoff)',
|
||||
why: 'Reels lideran engagement 2026 en IG ES (+31% likes vs estático).',
|
||||
formats: ['Reel'],
|
||||
kpi: 'alcance',
|
||||
},
|
||||
{
|
||||
id: 'carousel-save',
|
||||
label: 'Carrusel “guarda esto” / test a un amigo',
|
||||
why: 'Carruseles multiplican guardados; algoritmo premia saves y shares.',
|
||||
formats: ['Carrusel'],
|
||||
kpi: 'guardados',
|
||||
},
|
||||
{
|
||||
id: 'stories-chat',
|
||||
label: 'Stories conversación (encuesta / caja)',
|
||||
why: 'Stories 2026: más replies (+88%), menos alcance → comunidad y DM.',
|
||||
formats: ['Stories'],
|
||||
kpi: 'comentarios',
|
||||
},
|
||||
];
|
||||
|
||||
if (m === 11) {
|
||||
const nov: TrendBeat[] = [
|
||||
...commonNov,
|
||||
{
|
||||
id: 'nov-cold-travel',
|
||||
label: edu
|
||||
? 'Frases para viajar / trabajar en invierno (EN/idioma)'
|
||||
: 'Temporada fría / rutina noviembre',
|
||||
why: 'Intención estacional real (viajes, puentes, indoor) sin forzar Black Friday genérico.',
|
||||
formats: ['Reel', 'Carrusel', 'Stories'],
|
||||
kpi: 'compartidos',
|
||||
},
|
||||
{
|
||||
id: 'nov-bf-value',
|
||||
label: edu
|
||||
? 'Oferta formación con valor (no spam BF): packs / matriculación inteligente'
|
||||
: b2b
|
||||
? 'Cierre de año B2B / presupuesto 2027'
|
||||
: 'Black Friday con utilidad (checklist, no solo descuento)',
|
||||
why: 'Noviembre comercial; conversión solo si aporta utilidad y prueba social.',
|
||||
formats: ['Carrusel', 'Stories', 'Reel'],
|
||||
kpi: 'conversion',
|
||||
},
|
||||
{
|
||||
id: 'nov-thanks-us',
|
||||
label: edu
|
||||
? 'Cultura US: Thanksgiving vocab / false friends festivos'
|
||||
: 'Gratitud / comunidad de noviembre',
|
||||
why: 'Anglo culture beat mid-late Nov → alto share en escuelas de idiomas.',
|
||||
formats: ['Reel', 'Stories', 'Post'],
|
||||
kpi: 'compartidos',
|
||||
},
|
||||
{
|
||||
id: 'nov-xmas-soft',
|
||||
label: edu
|
||||
? 'Soft Navidad: tradiciones por país / spelling festivo'
|
||||
: clinic
|
||||
? 'Piel en frío + prep fiestas'
|
||||
: 'Cierre de mes / preview diciembre',
|
||||
why: 'Última semana Nov: puente emocional a Navidad sin saturar.',
|
||||
formats: ['Carrusel', 'Stories', 'Reel'],
|
||||
kpi: 'guardados',
|
||||
},
|
||||
{
|
||||
id: 'nov-search-seo',
|
||||
label: 'Contenido buscable (keywords en caption + texto en Reel)',
|
||||
why: 'IG 2026 prioriza SEO interno: “cómo se dice…”, “diferencia entre…”.',
|
||||
formats: ['Reel', 'Carrusel'],
|
||||
kpi: 'alcance',
|
||||
},
|
||||
];
|
||||
return nov;
|
||||
}
|
||||
|
||||
// Generic month beats
|
||||
return [
|
||||
...commonNov,
|
||||
{
|
||||
id: 'season',
|
||||
label: `Ángulo estacional ${monthLabel(ym).split(' ')[0]}`,
|
||||
why: 'Anclar el mes al contexto del usuario sin clichés vacíos.',
|
||||
formats: ['Reel', 'Stories', 'Carrusel'],
|
||||
kpi: 'compartidos',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function weekdaysInMonth(ym: string): string[] {
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
const days: string[] = [];
|
||||
const dim = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
for (let d = 1; d <= dim; d++) {
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
if (dt.getUTCDay() >= 1 && dt.getUTCDay() <= 5) {
|
||||
days.push(`${ym}-${String(d).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function colorFor(format: string, kind: string): string {
|
||||
if (kind === 'story' || /stor/i.test(format)) return '#374151';
|
||||
if (/reel/i.test(format)) return '#7C66B8';
|
||||
if (/carrusel/i.test(format)) return '#0F9D8F';
|
||||
return '#2071cf';
|
||||
}
|
||||
|
||||
function kindOf(format: string): string {
|
||||
const f = format.toLowerCase();
|
||||
if (f.includes('stor')) return 'story';
|
||||
if (f.includes('reel')) return 'reel';
|
||||
if (f.includes('carrusel')) return 'carrusel';
|
||||
return 'post';
|
||||
}
|
||||
|
||||
/** Build DNA from history without copying titles. */
|
||||
export function extractClientDna(
|
||||
slug: string,
|
||||
label: string,
|
||||
history: RrssMonthPlan[],
|
||||
brandbook?: Brandbook | null
|
||||
): ClientDna {
|
||||
const brand = brandBrief(slug, label);
|
||||
if (brandbook) {
|
||||
if (brandbook.vertical) brand.vertical = brandbook.vertical;
|
||||
if (brandbook.tone) brand.tone = brandbook.tone;
|
||||
if (brandbook.pillars_rrss?.length) brand.pillars = brandbook.pillars_rrss;
|
||||
if (brandbook.freeform_notes || brandbook.mission) {
|
||||
brand.notes = [
|
||||
brandbook.mission && `Misión: ${brandbook.mission}`,
|
||||
brandbook.voice_do?.length && `Hacer: ${brandbook.voice_do.join('; ')}`,
|
||||
brandbook.voice_dont?.length &&
|
||||
`Evitar: ${brandbook.voice_dont.join('; ')}`,
|
||||
brandbook.ads_usps?.length && `USPs: ${brandbook.ads_usps.join('; ')}`,
|
||||
brandbook.claims_forbidden?.length &&
|
||||
`No claims: ${brandbook.claims_forbidden.join('; ')}`,
|
||||
brandbook.freeform_notes?.slice(0, 800),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
}
|
||||
const seriesCount = new Map<string, number>();
|
||||
let total = 0;
|
||||
let stories = 0;
|
||||
const months = Math.max(1, history.length);
|
||||
|
||||
const classify = (title: string): string | null => {
|
||||
const x = cleanEditorialTitle(title)
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
const rules: [RegExp, string][] = [
|
||||
[/false\s*friend/, 'FALSE FRIEND'],
|
||||
[/\bslang\b/, 'SLANG'],
|
||||
[/mind the word|\bmtw\b/, 'MTW'],
|
||||
[/same word/, 'Same word different level'],
|
||||
[/cancion|canción/, 'Canción del día'],
|
||||
[/tv show|english through|prison break|how i met/, 'English Through TV'],
|
||||
[/encuesta|interactiva|verdadero/, 'Encuesta / interactiva'],
|
||||
[/spelling/, 'Spelling'],
|
||||
[/fix it|error|errores/, 'Errores / Fix it'],
|
||||
[/nuevo curso|matricul|intensivo/, 'Matriculación'],
|
||||
[/trinity|ielts|cambridge|aptis|certific/, 'Certificaciones'],
|
||||
[/oposic|policia|renfe/, 'Oposiciones'],
|
||||
[/erasmus/, 'Erasmus'],
|
||||
[/gym talk/, 'Gym talk'],
|
||||
[/idiom/, 'Idioms'],
|
||||
[/vocab|survival|expresion|expresión/, 'Vocab / expresiones'],
|
||||
[/nuevo alumno|alumno nuevo/, 'Nuevo alumno'],
|
||||
[/peeling|tratamiento|piel/, 'Tratamiento'],
|
||||
[/halal|certificacion|export/, 'Certificación / export'],
|
||||
];
|
||||
for (const [re, labelS] of rules) if (re.test(x)) return labelS;
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const plan of history) {
|
||||
for (const p of plan.pieces || []) {
|
||||
if (!p.title) continue;
|
||||
total += 1;
|
||||
if (/stor/i.test(p.format || '') || p.kind === 'story') stories += 1;
|
||||
const s = classify(p.title);
|
||||
if (s) seriesCount.set(s, (seriesCount.get(s) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const inferred = [...seriesCount.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k]) => k);
|
||||
|
||||
// Seed brand-line series so DNA isn't only whatever appeared most recently
|
||||
const seedBySlug: Record<string, string[]> = {
|
||||
bps: [
|
||||
'FALSE FRIEND',
|
||||
'SLANG',
|
||||
'Same word different level',
|
||||
'MTW',
|
||||
'Canción del día',
|
||||
'English Through TV',
|
||||
'Errores / Fix it',
|
||||
'Idioms',
|
||||
'Spelling',
|
||||
'Vocab / expresiones',
|
||||
'Certificaciones',
|
||||
'Matriculación',
|
||||
],
|
||||
'blue-parrot-school': [
|
||||
'FALSE FRIEND',
|
||||
'SLANG',
|
||||
'Same word different level',
|
||||
'MTW',
|
||||
'Canción del día',
|
||||
'English Through TV',
|
||||
'Errores / Fix it',
|
||||
'Idioms',
|
||||
'Certificaciones',
|
||||
'Matriculación',
|
||||
],
|
||||
'talento-y-deporte': [
|
||||
'Nuevo alumno',
|
||||
'Vocab / expresiones',
|
||||
'Campus',
|
||||
'Errores / Fix it',
|
||||
],
|
||||
};
|
||||
const fromBook = brandbook?.series_recurrentes?.length
|
||||
? brandbook.series_recurrentes
|
||||
: brandbook?.pillars_rrss || [];
|
||||
const seed = fromBook.length ? fromBook : seedBySlug[slug] || [];
|
||||
const signatureSeries = [
|
||||
...seed,
|
||||
...inferred.filter((s) => !seed.includes(s)),
|
||||
].slice(0, 12);
|
||||
|
||||
const conversionPillars = [
|
||||
...inferred,
|
||||
...signatureSeries,
|
||||
].filter(
|
||||
(s, i, arr) =>
|
||||
arr.indexOf(s) === i &&
|
||||
/matricul|certific|oposic|nuevo alumno|tratamiento|export|promo/i.test(s)
|
||||
);
|
||||
|
||||
return {
|
||||
brand,
|
||||
signatureSeries:
|
||||
signatureSeries.length > 0 ? signatureSeries : brand.pillars.map((p) => p),
|
||||
storiesRatio: total ? stories / total : brand.formats.stories,
|
||||
avgPerWeek: Math.max(4, Math.min(8, Math.round(total / months / 4) || 5)),
|
||||
conversionPillars:
|
||||
conversionPillars.length > 0
|
||||
? conversionPillars
|
||||
: ['Matriculación', 'Certificaciones'],
|
||||
voiceNotes: brand.notes,
|
||||
brandbookPrompt: brandbook ? brandbookToPrompt(brandbook) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
type Slot = {
|
||||
date: string;
|
||||
format: 'Reel' | 'Carrusel' | 'Stories' | 'Post';
|
||||
role: 'reach' | 'save' | 'chat' | 'convert' | 'authority';
|
||||
};
|
||||
|
||||
function buildMonthSlots(ym: string, dna: ClientDna): Slot[] {
|
||||
const weekdays = weekdaysInMonth(ym);
|
||||
const target = Math.min(
|
||||
weekdays.length,
|
||||
Math.max(16, Math.round(dna.avgPerWeek * 4 * 0.85)) // quality > quantity (−15% vs legacy)
|
||||
);
|
||||
const mix = PLAYBOOK_2026.mix;
|
||||
const counts = {
|
||||
Reel: Math.round(target * mix.reel),
|
||||
Carrusel: Math.round(target * mix.carrusel),
|
||||
Stories: Math.round(target * mix.stories),
|
||||
Post: 0,
|
||||
};
|
||||
counts.Post = Math.max(0, target - counts.Reel - counts.Carrusel - counts.Stories);
|
||||
|
||||
// Round-robin interleave so the month isn't "all Reels then all Stories"
|
||||
const bags: Record<Slot['format'], number> = { ...counts };
|
||||
const formats: Slot['format'][] = ['Reel', 'Carrusel', 'Stories', 'Post'];
|
||||
const queue: Array<Slot['format']> = [];
|
||||
while (queue.length < target) {
|
||||
let added = false;
|
||||
for (const f of formats) {
|
||||
if (bags[f] > 0) {
|
||||
queue.push(f);
|
||||
bags[f] -= 1;
|
||||
added = true;
|
||||
if (queue.length >= target) break;
|
||||
}
|
||||
}
|
||||
if (!added) break;
|
||||
}
|
||||
|
||||
const convertEvery = Math.max(4, Math.floor(target / 3));
|
||||
const slots: Slot[] = [];
|
||||
const step = Math.max(1, Math.floor(weekdays.length / Math.max(1, queue.length)));
|
||||
let dayIdx = 0;
|
||||
for (let qi = 0; qi < queue.length; qi++) {
|
||||
const format = queue[qi];
|
||||
const date = weekdays[Math.min(dayIdx, weekdays.length - 1)];
|
||||
dayIdx += step;
|
||||
if (dayIdx >= weekdays.length) dayIdx = qi % weekdays.length;
|
||||
// Prefer unused weekdays
|
||||
let d = date;
|
||||
if (slots.some((s) => s.date === d)) {
|
||||
d = weekdays.find((w) => !slots.some((s) => s.date === w)) || date;
|
||||
}
|
||||
const isConvert =
|
||||
qi > 0 &&
|
||||
qi % convertEvery === 0 &&
|
||||
(format === 'Stories' || format === 'Carrusel' || format === 'Post');
|
||||
const role: Slot['role'] = isConvert
|
||||
? 'convert'
|
||||
: format === 'Reel'
|
||||
? 'reach'
|
||||
: format === 'Carrusel'
|
||||
? 'save'
|
||||
: format === 'Stories'
|
||||
? 'chat'
|
||||
: 'authority';
|
||||
slots.push({ date: d, format, role });
|
||||
}
|
||||
return slots.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
/** Concrete creative banks — rotate by index so each month feels fresh. */
|
||||
const EDU_ANGLES: Record<string, string[]> = {
|
||||
'FALSE FRIEND': [
|
||||
'FALSE FRIEND · “actually” no significa “actualmente” (y te delata)',
|
||||
'FALSE FRIEND · “library” vs librería: el error clásico en Madrid',
|
||||
'FALSE FRIEND · Thanksgiving: “grateful” no es “gratefulidad”',
|
||||
'FALSE FRIEND · “exit” no es éxito (puerta vs logro)',
|
||||
'FALSE FRIEND · “carpet” no es carpeta — mini test 10s',
|
||||
],
|
||||
SLANG: [
|
||||
'SLANG · “I’m down” = me apunto (no “estoy abajo”)',
|
||||
'SLANG · “lowkey / highkey” en 20 segundos',
|
||||
'SLANG · “spill the tea” sin sonar raro',
|
||||
'SLANG · “ghosting” en inglés real de oficina',
|
||||
'SLANG · “bet” = ok / trato hecho',
|
||||
],
|
||||
MTW: [
|
||||
'MTW · “get” tiene 12 vidas: las 5 que sí usas',
|
||||
'MTW · “set” en el gym vs en la oficina',
|
||||
'MTW · “run” no es solo correr',
|
||||
'MTW · “make” vs “do” en 20s (sin tabla aburrida)',
|
||||
'MTW · “take” en aeropuerto, oficina y cita',
|
||||
],
|
||||
'Same word different level': [
|
||||
'Same word · “nice” A2 vs C1 (suena a robot o a nativo)',
|
||||
'Same word · “sorry” 3 niveles de disculpa',
|
||||
'Same word · “interesting” cuando NO quieres decir interesante',
|
||||
'Same word · “good” → alternativas que suenan nativas',
|
||||
],
|
||||
'Canción del día': [
|
||||
'Canción del día · lyric drop + 1 expresión para guardar',
|
||||
'Canción del día · pronunciación del coro (slow + nativo)',
|
||||
'Canción del día · false friend escondido en el estribillo',
|
||||
'Canción del día · shadowing 15s (copia al cantante)',
|
||||
],
|
||||
'English Through TV': [
|
||||
'English Through TV · 1 escena, 3 frases que sí se usan',
|
||||
'English Through TV · cómo suena el sarcasm en sitcoms',
|
||||
'English Through TV · fillers nativos (like, you know) sin abusar',
|
||||
'English Through TV · “I’ll be there” vs “I’m coming”',
|
||||
],
|
||||
Spelling: [
|
||||
'Spelling · palabras de noviembre que todo el mundo escribe mal',
|
||||
'Spelling bee · 4 letras vs 1 error (challenge)',
|
||||
'Spelling · “definitely” y otras trampas de examen',
|
||||
],
|
||||
'Errores / Fix it': [
|
||||
'Fix it · “I have 25 years” en 8 segundos',
|
||||
'Errores · “depend of” → la forma que sí funciona',
|
||||
'Fix it · “I am agree” — corrección + frase alternativa',
|
||||
'Errores · “people is” vs “people are” (rápido)',
|
||||
],
|
||||
Idioms: [
|
||||
'Idiom · “break the ice” en networking (no en neveras)',
|
||||
'Idiom · “under the weather” = flojo, no meteorólogo',
|
||||
'Idiom · “hit the books” antes del examen',
|
||||
'Idiom · “piece of cake” cuando el speaking sale bien',
|
||||
],
|
||||
'Vocab / expresiones': [
|
||||
'Vocab invierno · cold, chilly, freezing (y cuándo cada uno)',
|
||||
'Expresiones · pedir un favor sin sonar agresivo',
|
||||
'Vocab · frases para el puente / viaje de noviembre',
|
||||
'Expresiones · cómo decir “no me apetece” con educación',
|
||||
],
|
||||
Matriculación: [
|
||||
'Matriculación · 3 señales de que este es tu mes para empezar',
|
||||
'Intensivos nov-dic · quién debería (y quién no)',
|
||||
],
|
||||
Certificaciones: [
|
||||
'Trinity/IELTS · el error #1 en speaking (y cómo arreglarlo)',
|
||||
'Certificado · checklist 7 días antes del examen',
|
||||
],
|
||||
Oposiciones: [
|
||||
'Inglés oposiciones · 1 hábito de 12 min que sí suma',
|
||||
],
|
||||
Erasmus: [
|
||||
'Erasmus · frases de supervivencia la primera semana',
|
||||
],
|
||||
};
|
||||
|
||||
const GENERIC_CONVERT = [
|
||||
'Por qué noviembre es el mes smart para matricularte (sin FOMO barato)',
|
||||
'Test de nivel + plan: deja de “estudiar cuando pueda”',
|
||||
'Madrid vs Málaga: qué grupo te encaja este trimestre',
|
||||
];
|
||||
|
||||
function pickAngleTitle(
|
||||
series: string,
|
||||
beat: TrendBeat,
|
||||
index: number,
|
||||
format: Slot['format']
|
||||
): string {
|
||||
const bank = EDU_ANGLES[series];
|
||||
let core: string;
|
||||
if (bank?.length) {
|
||||
core = bank[index % bank.length];
|
||||
} else if (beat.id.includes('thanks')) {
|
||||
core = `${series} · Thanksgiving vocab que no sale en el libro`;
|
||||
} else if (beat.id.includes('cold')) {
|
||||
core = `${series} · frases de frío / viaje que sí se usan`;
|
||||
} else if (beat.id.includes('bf') || beat.kpi === 'conversion') {
|
||||
core = GENERIC_CONVERT[index % GENERIC_CONVERT.length];
|
||||
} else if (/encuesta|interactiva/i.test(series)) {
|
||||
const polls = [
|
||||
'Encuesta · ¿“actually” o “currently”? (elige y pelea)',
|
||||
'Quiz · true/false: “I’m boring” vs “I’m bored”',
|
||||
'Caja de respuestas · ¿qué false friend te delató?',
|
||||
'Encuesta · ¿prefieres slang UK o US?',
|
||||
];
|
||||
core = polls[index % polls.length];
|
||||
} else {
|
||||
const fallbacks = [
|
||||
`${series} · el detalle que cambia cómo te perciben`,
|
||||
`${series} · ángulo ${beat.label.split('(')[0].trim().slice(0, 40)}`,
|
||||
`${series} · lo que tu audiencia guarda (no scrollea)`,
|
||||
`${series} · prueba social + micro-historia`,
|
||||
`${series} · objeción resuelta en 20s`,
|
||||
`${series} · checklist accionable`,
|
||||
];
|
||||
core = fallbacks[index % fallbacks.length];
|
||||
}
|
||||
const prefix =
|
||||
format === 'Reel'
|
||||
? 'REEL'
|
||||
: format === 'Carrusel'
|
||||
? 'CARRUSEL'
|
||||
: format === 'Stories'
|
||||
? 'STORIES'
|
||||
: 'POST';
|
||||
// Avoid double prefix if bank already has series label only
|
||||
if (/^(REEL|CARRUSEL|STORIES|POST)\b/i.test(core)) return core.slice(0, 140);
|
||||
return `${prefix} · ${core}`.slice(0, 140);
|
||||
}
|
||||
|
||||
/** Creative angle factory — NEW titles, DNA-aligned. */
|
||||
function inventAngle(
|
||||
dna: ClientDna,
|
||||
slot: Slot,
|
||||
beat: TrendBeat,
|
||||
index: number,
|
||||
/** Extra offset only for title bank rotation (dedupe). */
|
||||
bankOffset = 0
|
||||
): { title: string; hook: string; body: string; cta: string; pillar: string } {
|
||||
const series =
|
||||
dna.signatureSeries[index % Math.max(1, dna.signatureSeries.length)] ||
|
||||
'Educativo';
|
||||
const brand = dna.brand.label;
|
||||
const edu = /idioma|colegio|educaci/i.test(dna.brand.vertical);
|
||||
const hooks = PLAYBOOK_2026.engagementHooks;
|
||||
const hookStyle = hooks[index % hooks.length];
|
||||
const bankIndex = index + bankOffset;
|
||||
|
||||
if (slot.role === 'convert') {
|
||||
const pillar = dna.conversionPillars[index % dna.conversionPillars.length];
|
||||
const title = edu
|
||||
? pickAngleTitle(pillar, beat, bankIndex, slot.format)
|
||||
: `${slot.format.toUpperCase()} · ${pillar}: beneficio claro + prueba social`;
|
||||
if (slot.format === 'Stories') {
|
||||
return {
|
||||
title: title.startsWith('STORIES')
|
||||
? title
|
||||
: `STORIES · ${pillar}: encuesta “¿este mes o enero?”`,
|
||||
hook: 'Una pregunta. Tu respuesta abre el DM.',
|
||||
body: `Conversión ${brand}. ADN: ${pillar}. Sticker encuesta/caja → DM. KPI: mensajes. Sin spam BF genérico.`,
|
||||
cta: 'Responde y te orientamos',
|
||||
pillar: 'conversion',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title,
|
||||
hook: 'La diferencia entre “lo pienso” y “ya estoy dentro”.',
|
||||
body: `Conversión útil. 3 motivos + prueba social + CTA test/matricula. ADN: ${pillar}. Tendencia: ${beat.why}`,
|
||||
cta: 'Link en bio / test de nivel',
|
||||
pillar: 'conversion',
|
||||
};
|
||||
}
|
||||
|
||||
const title = pickAngleTitle(series, beat, bankIndex, slot.format);
|
||||
|
||||
if (slot.format === 'Reel') {
|
||||
return {
|
||||
title,
|
||||
hook: `${hookStyle}`,
|
||||
body: [
|
||||
`Objetivo: ALCANCE (Reels 2026 ES).`,
|
||||
`ADN: ${series}. Tendencia: ${beat.label} — ${beat.why}`,
|
||||
`Estructura: hook 3s → micro-lección → CTA share/save.`,
|
||||
`Texto en pantalla + caption con keyword buscable.`,
|
||||
].join('\n'),
|
||||
cta: 'Comparte con alguien que lo necesite',
|
||||
pillar: 'reach',
|
||||
};
|
||||
}
|
||||
|
||||
if (slot.format === 'Carrusel') {
|
||||
return {
|
||||
title: title.replace(/^REEL ·/, 'CARRUSEL ·').replace(/^POST ·/, 'CARRUSEL ·'),
|
||||
hook: 'Guarda esto antes de que se te olvide.',
|
||||
body: [
|
||||
`Objetivo: GUARDADOS + shares.`,
|
||||
`ADN: ${series}. ${beat.why}`,
|
||||
`5 slides: hook polémico → valor → test “envía a un amigo”.`,
|
||||
].join('\n'),
|
||||
cta: 'Guarda + envía a tu compañero de estudio',
|
||||
pillar: 'saves',
|
||||
};
|
||||
}
|
||||
|
||||
if (slot.format === 'Stories') {
|
||||
return {
|
||||
title: title.replace(/^(REEL|CARRUSEL|POST) ·/, 'STORIES ·'),
|
||||
hook: 'Tú eliges: A o B',
|
||||
body: [
|
||||
`Objetivo: REPLIES (+88% conversación Stories 2026).`,
|
||||
`ADN: ${series}. Sticker encuesta / quiz / caja.`,
|
||||
beat.label,
|
||||
].join('\n'),
|
||||
cta: 'Responde a la story',
|
||||
pillar: 'stories',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: title.replace(/^(REEL|CARRUSEL|STORIES) ·/, 'POST ·'),
|
||||
hook: `Lo que nadie te explica de ${series.toLowerCase()}.`,
|
||||
body: [
|
||||
`Feed autoridad + SEO caption (“cómo se dice…”, “diferencia entre…”).`,
|
||||
`ADN ${brand}: ${series}.`,
|
||||
`Tendencia: ${beat.why}`,
|
||||
].join('\n'),
|
||||
cta: 'Comenta tu caso',
|
||||
pillar: 'feed',
|
||||
};
|
||||
}
|
||||
|
||||
export type StrategyPreviewResult = {
|
||||
plan: RrssMonthPlan;
|
||||
rationale: string;
|
||||
source: 'strategy';
|
||||
trends: TrendBeat[];
|
||||
dna: ClientDna;
|
||||
};
|
||||
|
||||
export function strategyMonthPreview(input: {
|
||||
ym: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
history: RrssMonthPlan[];
|
||||
brandbook?: Brandbook | null;
|
||||
}): StrategyPreviewResult {
|
||||
const dna = extractClientDna(
|
||||
input.slug,
|
||||
input.label,
|
||||
input.history,
|
||||
input.brandbook
|
||||
);
|
||||
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
|
||||
const slots = buildMonthSlots(input.ym, dna);
|
||||
const pieces: RrssPiece[] = [];
|
||||
|
||||
const usedCores = new Set<string>();
|
||||
const coreKey = (t: string) =>
|
||||
t.replace(/^(REEL|CARRUSEL|STORIES|POST)\s*·\s*/i, '').toLowerCase();
|
||||
slots.forEach((slot, i) => {
|
||||
const beat = trends[i % trends.length];
|
||||
const useBeat =
|
||||
slot.role === 'convert'
|
||||
? trends.find((t) => t.kpi === 'conversion') || beat
|
||||
: Number(slot.date.slice(8, 10)) >= 24
|
||||
? trends.find((t) => t.id.includes('xmas')) || beat
|
||||
: beat;
|
||||
let angle = inventAngle(dna, slot, useBeat, i, 0);
|
||||
let bump = 0;
|
||||
while (usedCores.has(coreKey(angle.title)) && bump < 20) {
|
||||
bump += 1;
|
||||
angle = inventAngle(dna, slot, useBeat, i, bump);
|
||||
}
|
||||
if (usedCores.has(coreKey(angle.title))) {
|
||||
angle = {
|
||||
...angle,
|
||||
title: `${angle.title} · ángulo ${i + 1}`.slice(0, 140),
|
||||
};
|
||||
}
|
||||
usedCores.add(coreKey(angle.title));
|
||||
const kind = kindOf(slot.format);
|
||||
const time =
|
||||
PLAYBOOK_2026.hours[slot.format.toLowerCase()] ||
|
||||
PLAYBOOK_2026.hours.post;
|
||||
pieces.push({
|
||||
id: newPieceId(slot.date, angle.title),
|
||||
date: slot.date,
|
||||
time,
|
||||
platform: 'instagram',
|
||||
format: slot.format,
|
||||
pillar: angle.pillar,
|
||||
title: angle.title,
|
||||
hook: angle.hook,
|
||||
body: angle.body,
|
||||
cta: angle.cta,
|
||||
status: 'planned',
|
||||
kind,
|
||||
color: colorFor(slot.format, kind),
|
||||
objective_metric: useBeat.kpi,
|
||||
});
|
||||
});
|
||||
|
||||
const plan = emptyMonthPlan(input.ym, input.label);
|
||||
plan.hero.lede = `Estrategia ${monthLabel(input.ym)} · ${dna.brand.vertical}. Creado para engagement 2026 (no copia del histórico).`;
|
||||
plan.hero.chips = [
|
||||
`${pieces.length} piezas`,
|
||||
`${pieces.filter((p) => p.format === 'Reel').length} Reels`,
|
||||
`${pieces.filter((p) => p.format === 'Carrusel').length} carruseles`,
|
||||
'strategist',
|
||||
];
|
||||
plan.pieces = pieces;
|
||||
|
||||
const rationale = [
|
||||
`Estrategia (no copia): DNA de ${dna.brand.label} = series ${dna.signatureSeries.slice(0, 5).join(', ') || 'vertical'}.`,
|
||||
`Playbook ES 2026 (IAB): menos posts, más eficiencia · Reels alcance · Carrusel guardados · Stories conversación.`,
|
||||
`Tendencias ${monthLabel(input.ym)}: ${trends
|
||||
.slice(0, 4)
|
||||
.map((t) => t.label)
|
||||
.join(' · ')}.`,
|
||||
`Horas: Reels ${PLAYBOOK_2026.hours.reel} · Carrusel ${PLAYBOOK_2026.hours.carrusel} · Stories ${PLAYBOOK_2026.hours.stories} · Post ${PLAYBOOK_2026.hours.post}.`,
|
||||
`Conversión (~${pieces.filter((p) => p.pillar === 'conversion').length} piezas) alineada a ${dna.conversionPillars.join('/')}, no spam genérico.`,
|
||||
dna.brandbookPrompt
|
||||
? 'Brandbook activo como constraint creativo.'
|
||||
: dna.voiceNotes,
|
||||
].join(' ');
|
||||
|
||||
return { plan, rationale, source: 'strategy', trends, dna };
|
||||
}
|
||||
|
||||
export function strategistSystemPrompt(input: {
|
||||
ym: string;
|
||||
label: string;
|
||||
dna: ClientDna;
|
||||
trends: TrendBeat[];
|
||||
}): string {
|
||||
return `Eres Head of Social de 4Ventures. Crea un calendario NUEVO para ${input.label} (${input.ym}).
|
||||
NO copies títulos viejos. USA el ADN / brandbook como constraint creativo.
|
||||
ADN series: ${input.dna.signatureSeries.join(', ')}.
|
||||
Voz: ${input.dna.brand.tone}. Vertical: ${input.dna.brand.vertical}.
|
||||
${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''}
|
||||
Playbook 2026 ES: ${PLAYBOOK_2026.principle}
|
||||
Tendencias del mes:
|
||||
${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')}
|
||||
Devuelve SOLO JSON { rationale, pieces:[{date,time,platform,format,pillar,title,hook,body,cta,objective_metric}] }
|
||||
Reglas: títulos creativos y específicos; mix ~28% Reel, 18% Carrusel, 38% Stories, resto Post; horas engagement (Reels noche ~19:30, Stories mañana, Carrusel mediodía); KPI por pieza; español; sin "POST 1 · Black Friday" vacío; respeta claims prohibidos del brandbook.`;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export type DashboardModuleId =
|
||||
| 'crm'
|
||||
| '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',
|
||||
|
||||
Reference in New Issue
Block a user