Add Zona de marca brandbooks and strategist calendar generation.

Per-tenant brandbooks power ads, RRSS calendars, LinkedIn B2B and blog; Nov calendars now create from trends + client DNA instead of copying history.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-10 01:05:15 +02:00
parent 59f42eafe0
commit e49111f63e
13 changed files with 2907 additions and 500 deletions

571
src/lib/brandbook.ts Normal file
View File

@@ -0,0 +1,571 @@
/**
* Brandbook per tenant — source of truth for ads, RRSS calendars,
* LinkedIn B2B and corporate blog strategy.
* Stored in documents as type=brandbook (one active doc per tenant).
*/
import { brandBrief } from '@/lib/calendar-generate-brand';
export const BRANDBOOK_DOC_TYPE = 'brandbook';
export const BRANDBOOK_DOC_TITLE = 'Brandbook';
export type BrandbookSectionId =
| 'identidad'
| 'voz'
| 'visual'
| 'audiencia'
| 'oferta'
| 'pilares'
| 'canales'
| 'ads'
| 'b2b'
| 'blog'
| 'compliance'
| 'fuentes';
export type Brandbook = {
version: 1;
updated_at?: string;
/** Identity */
brand_name: string;
tagline: string;
mission: string;
vision: string;
values: string[];
vertical: string;
/** Voice */
tone: string;
personality: string[];
voice_do: string[];
voice_dont: string[];
vocabulary_prefer: string[];
vocabulary_avoid: string[];
/** Visual */
colors_primary: string[];
colors_secondary: string[];
fonts: string[];
visual_notes: string;
logo_usage: string;
/** Audience */
icp: string;
personas: Array<{ name: string; description: string; pains: string }>;
geo: string;
languages: string[];
/** Offer */
products_services: string[];
differentiators: string[];
proof_points: string[];
pricing_notes: string;
/** Content pillars */
pillars_rrss: string[];
series_recurrentes: string[];
formats_priority: string[];
/** Channels */
channels: {
instagram: string;
facebook: string;
linkedin: string;
tiktok: string;
blog: string;
other: string;
};
/** Ads */
ads_usps: string[];
ads_objections: string[];
ads_ctas: string[];
ads_offers: string[];
ads_angles: string[];
/** B2B / LinkedIn */
linkedin_themes: string[];
linkedin_formats: string[];
thought_leadership: string[];
buyer_roles: string[];
/** Blog corporativo */
blog_themes: string[];
blog_seo_keywords: string[];
blog_cadence: string;
/** Compliance */
claims_forbidden: string[];
legal_notes: string;
sector_rules: string;
/** Uploaded / pasted source material */
source_excerpts: Array<{
id: string;
filename: string;
excerpt: string;
added_at: string;
}>;
freeform_notes: string;
};
export const BRANDBOOK_SECTIONS: {
id: BrandbookSectionId;
label: string;
hint: string;
}[] = [
{
id: 'identidad',
label: 'Identidad',
hint: 'Nombre, misión, visión, valores, vertical',
},
{
id: 'voz',
label: 'Voz y tono',
hint: 'Cómo habla la marca; do/don’t y léxico',
},
{
id: 'visual',
label: 'Visual',
hint: 'Colores, tipografías, logo y notas de arte',
},
{
id: 'audiencia',
label: 'Audiencia',
hint: 'ICP, personas, geo e idiomas',
},
{
id: 'oferta',
label: 'Oferta',
hint: 'Productos/servicios, diferenciadores, prueba',
},
{
id: 'pilares',
label: 'Pilares de contenido',
hint: 'Series RRSS y formatos prioritarios',
},
{
id: 'canales',
label: 'Canales',
hint: 'Rol de cada red y blog',
},
{
id: 'ads',
label: 'Anuncios',
hint: 'USPs, objeciones, CTAs, ofertas y ángulos',
},
{
id: 'b2b',
label: 'LinkedIn B2B',
hint: 'Temas, formatos y thought leadership',
},
{
id: 'blog',
label: 'Blog corporativo',
hint: 'Temas, SEO y cadencia',
},
{
id: 'compliance',
label: 'Compliance',
hint: 'Claims prohibidos y notas legales',
},
{
id: 'fuentes',
label: 'Fuentes',
hint: 'Textos subidos / pegados del brandbook',
},
];
export function emptyBrandbook(label = ''): Brandbook {
return {
version: 1,
brand_name: label,
tagline: '',
mission: '',
vision: '',
values: [],
vertical: '',
tone: '',
personality: [],
voice_do: [],
voice_dont: [],
vocabulary_prefer: [],
vocabulary_avoid: [],
colors_primary: [],
colors_secondary: [],
fonts: [],
visual_notes: '',
logo_usage: '',
icp: '',
personas: [],
geo: 'España',
languages: ['es'],
products_services: [],
differentiators: [],
proof_points: [],
pricing_notes: '',
pillars_rrss: [],
series_recurrentes: [],
formats_priority: ['Reel', 'Carrusel', 'Stories', 'Post'],
channels: {
instagram: '',
facebook: '',
linkedin: '',
tiktok: '',
blog: '',
other: '',
},
ads_usps: [],
ads_objections: [],
ads_ctas: [],
ads_offers: [],
ads_angles: [],
linkedin_themes: [],
linkedin_formats: ['Carrusel', 'Documento', 'Post texto', 'Video corto'],
thought_leadership: [],
buyer_roles: [],
blog_themes: [],
blog_seo_keywords: [],
blog_cadence: '',
claims_forbidden: [],
legal_notes: '',
sector_rules: '',
source_excerpts: [],
freeform_notes: '',
};
}
/** Seed a usable brandbook from hard-coded brand DNA + sensible defaults. */
export function seedBrandbook(slug: string, label: string): Brandbook {
const brief = brandBrief(slug, label);
const base = emptyBrandbook(label);
const seeds: Record<string, Partial<Brandbook>> = {
bps: {
vertical: 'Escuela de idiomas',
tagline: 'Inglés real, cultura pop y certificaciones',
mission:
'Ayudar a alumnos a hablar inglés con confianza para trabajo, viajes y exámenes.',
tone: brief.tone,
personality: ['cercano', 'divertido', 'riguroso', 'pop'],
voice_do: [
'Explicar con ejemplos reales',
'Usar series FALSE FRIEND / SLANG / MTW',
'Micro-lecciones accionables',
],
voice_dont: [
'Listas genéricas de Black Friday',
'Tono académico seco',
'Copiar títulos mes a mes',
],
pillars_rrss: brief.pillars,
series_recurrentes: [
'FALSE FRIEND',
'SLANG',
'Same word different level',
'MTW',
'Canción del día',
'English Through TV',
'Fix it',
],
products_services: [
'Cursos generales',
'Intensivos',
'Trinity / IELTS / Cambridge',
'Inglés oposiciones',
'Madrid / Málaga',
],
ads_usps: [
'Metodología práctica',
'Profes nativos / preparados',
'Certificaciones oficiales',
],
ads_ctas: ['Test de nivel', 'Reservar plaza', 'WhatsApp'],
ads_objections: [
'No tengo tiempo',
'Ya probé y no aprendí',
'Es caro',
],
linkedin_themes: [
'Inglés profesional',
'Employability',
'Casos de alumnos',
],
blog_themes: [
'Guías de certificación',
'Errores comunes',
'Cómo elegir curso',
],
channels: {
instagram: 'Alcance + comunidad (Reels + Stories)',
facebook: 'Comunidad local / padres',
linkedin: 'B2B ligero / empleabilidad',
tiktok: 'Opcional micro-lecciones',
blog: 'SEO certificaciones y objeciones',
other: '',
},
},
'talento-y-deporte': {
vertical: brief.vertical,
tone: brief.tone,
pillars_rrss: brief.pillars,
series_recurrentes: [
'Nuevo alumno',
'Beca académica',
'Beca deportiva',
'Campus USA',
'Objeciones',
'English tips',
],
products_services: [
'Becas deportivas USA',
'Acompañamiento académico',
'Campus visits',
],
ads_usps: ['Becas reales', 'Acompañamiento end-to-end', 'Historias de alumnos'],
ads_ctas: ['Agenda llamada', 'Evalúa tu perfil'],
linkedin_themes: ['Educación internacional', 'Deporte + estudios'],
blog_themes: ['Guía becas USA', 'Requisitos TOEFL/SAT', 'Vida en campus'],
},
'halal-safety': {
vertical: brief.vertical,
tone: brief.tone,
pillars_rrss: brief.pillars,
products_services: [
'Certificación Halal',
'Consultoría exportación',
'Formación',
],
ads_usps: ['Autoridad técnica', 'Mercados export', 'Proceso claro'],
ads_ctas: ['Solicitar info', 'Auditoría'],
linkedin_themes: [
'Exportación Halal',
'Normativa',
'Casos sector alimentación/cosmética',
],
thought_leadership: [
'Tendencias mercados musulmanes',
'Compliance vs marketing Halal',
],
blog_themes: ['Guías de certificación', 'Mercados clave', 'FAQ técnica'],
buyer_roles: ['Quality', 'Export manager', 'CEO pyme alimentaria'],
},
'brava-dermoestetica': {
vertical: brief.vertical,
tone: brief.tone,
pillars_rrss: brief.pillars,
products_services: ['Peelings', 'Tratamientos facial', 'Rutinas'],
ads_usps: ['Resultados naturales', 'Equipo médico', 'Protocolos personalizados'],
ads_ctas: ['Pedir cita', 'Consulta valoración'],
claims_forbidden: [
'Promesas milagro sin matiz',
'Antes/después sin consentimiento',
],
sector_rules: 'Publicidad sanitaria: claims prudentes, sin garantías absolutas.',
},
noberplast: {
vertical: brief.vertical,
tone: brief.tone,
pillars_rrss: brief.pillars,
formats_priority: ['Carrusel', 'Post LinkedIn', 'Documento', 'Reel'],
linkedin_themes: [
'Plásticos técnicos',
'Casos industriales',
'Innovación materiales',
],
thought_leadership: [
'Sostenibilidad industrial',
'Especificaciones técnicas',
],
buyer_roles: ['Ingeniería', 'Compras', 'Producción'],
blog_themes: ['Fichas técnicas', 'Aplicaciones sectoriales', 'FAQ producto'],
channels: {
instagram: 'Marca / producto visual',
facebook: 'Bajo',
linkedin: 'Canal principal B2B',
tiktok: 'No prioritario',
blog: 'SEO técnico + captación inbound',
other: '',
},
},
'4ventures-hub': {
vertical: brief.vertical,
tone: brief.tone,
pillars_rrss: brief.pillars,
linkedin_themes: ['Ops de agencia', 'Growth', 'Casos clientes'],
thought_leadership: ['Centro de mando', 'Atribución', 'Contenido que vende'],
},
};
const resolvedSlug = slug === 'blue-parrot-school' ? 'bps' : slug;
const extra = seeds[resolvedSlug] || {
vertical: brief.vertical,
tone: brief.tone,
pillars_rrss: brief.pillars,
products_services: [],
freeform_notes: brief.notes,
};
return {
...base,
...extra,
brand_name: label,
vertical: extra.vertical || brief.vertical,
tone: extra.tone || brief.tone,
pillars_rrss: extra.pillars_rrss?.length ? extra.pillars_rrss : brief.pillars,
freeform_notes: extra.freeform_notes || brief.notes,
updated_at: new Date().toISOString(),
};
}
export function parseBrandbook(raw: unknown): Brandbook | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Partial<Brandbook>;
if (o.version !== 1 && !o.brand_name && !o.vertical) {
// Allow loose objects that look like brandbooks
if (!('tone' in o) && !('pillars_rrss' in o) && !('mission' in o)) {
return null;
}
}
const empty = emptyBrandbook(String(o.brand_name || ''));
return {
...empty,
...o,
version: 1,
values: arr(o.values),
personality: arr(o.personality),
voice_do: arr(o.voice_do),
voice_dont: arr(o.voice_dont),
vocabulary_prefer: arr(o.vocabulary_prefer),
vocabulary_avoid: arr(o.vocabulary_avoid),
colors_primary: arr(o.colors_primary),
colors_secondary: arr(o.colors_secondary),
fonts: arr(o.fonts),
personas: Array.isArray(o.personas) ? o.personas : [],
languages: arr(o.languages).length ? arr(o.languages) : ['es'],
products_services: arr(o.products_services),
differentiators: arr(o.differentiators),
proof_points: arr(o.proof_points),
pillars_rrss: arr(o.pillars_rrss),
series_recurrentes: arr(o.series_recurrentes),
formats_priority: arr(o.formats_priority),
channels: { ...empty.channels, ...(o.channels || {}) },
ads_usps: arr(o.ads_usps),
ads_objections: arr(o.ads_objections),
ads_ctas: arr(o.ads_ctas),
ads_offers: arr(o.ads_offers),
ads_angles: arr(o.ads_angles),
linkedin_themes: arr(o.linkedin_themes),
linkedin_formats: arr(o.linkedin_formats),
thought_leadership: arr(o.thought_leadership),
buyer_roles: arr(o.buyer_roles),
blog_themes: arr(o.blog_themes),
blog_seo_keywords: arr(o.blog_seo_keywords),
claims_forbidden: arr(o.claims_forbidden),
source_excerpts: Array.isArray(o.source_excerpts) ? o.source_excerpts : [],
};
}
function arr(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.map((x) => String(x || '').trim()).filter(Boolean);
}
/** Lines / commas → string[] for form fields. */
export function linesToList(text: string): string[] {
return text
.split(/\n|,/)
.map((s) => s.trim())
.filter(Boolean);
}
export function listToLines(list: string[]): string {
return (list || []).join('\n');
}
/** Heuristic: fold uploaded plain text into freeform + optional lists. */
export function ingestSourceText(
book: Brandbook,
filename: string,
text: string
): Brandbook {
const excerpt = text.slice(0, 12000).trim();
if (!excerpt) return book;
const id = `src_${Date.now().toString(36)}`;
const next: Brandbook = {
...book,
source_excerpts: [
{
id,
filename: filename || 'pegado.txt',
excerpt,
added_at: new Date().toISOString(),
},
...(book.source_excerpts || []),
].slice(0, 20),
freeform_notes: [book.freeform_notes, `--- ${filename} ---\n${excerpt.slice(0, 4000)}`]
.filter(Boolean)
.join('\n\n')
.slice(0, 50000),
updated_at: new Date().toISOString(),
};
// Light keyword pulls into empty fields
const lower = excerpt.toLowerCase();
if (!next.mission && /misi[oó]n/.test(lower)) {
const m = excerpt.match(/misi[oó]n[:\s]+(.{20,200})/i);
if (m) next.mission = m[1].trim();
}
if (!next.tone && /tono[:\s]+(.{10,120})/i.test(excerpt)) {
const m = excerpt.match(/tono[:\s]+(.{10,120})/i);
if (m) next.tone = m[1].trim();
}
return next;
}
/** Compact prompt block for strategists / Anthropic. */
export function brandbookToPrompt(book: Brandbook): string {
const lines = [
`Marca: ${book.brand_name}`,
book.tagline && `Tagline: ${book.tagline}`,
book.vertical && `Vertical: ${book.vertical}`,
book.mission && `Misión: ${book.mission}`,
book.tone && `Tono: ${book.tone}`,
book.personality.length && `Personalidad: ${book.personality.join(', ')}`,
book.voice_do.length && `Hacer: ${book.voice_do.join('; ')}`,
book.voice_dont.length && `Evitar: ${book.voice_dont.join('; ')}`,
book.series_recurrentes.length &&
`Series RRSS: ${book.series_recurrentes.join(', ')}`,
book.pillars_rrss.length && `Pilares: ${book.pillars_rrss.join(', ')}`,
book.products_services.length &&
`Oferta: ${book.products_services.join(', ')}`,
book.differentiators.length &&
`Diferenciadores: ${book.differentiators.join(', ')}`,
book.icp && `ICP: ${book.icp}`,
book.ads_usps.length && `USPs ads: ${book.ads_usps.join('; ')}`,
book.ads_ctas.length && `CTAs: ${book.ads_ctas.join('; ')}`,
book.ads_objections.length &&
`Objeciones: ${book.ads_objections.join('; ')}`,
book.linkedin_themes.length &&
`LinkedIn: ${book.linkedin_themes.join(', ')}`,
book.thought_leadership.length &&
`Thought leadership: ${book.thought_leadership.join(', ')}`,
book.blog_themes.length && `Blog: ${book.blog_themes.join(', ')}`,
book.blog_seo_keywords.length &&
`SEO: ${book.blog_seo_keywords.join(', ')}`,
book.claims_forbidden.length &&
`Prohibido: ${book.claims_forbidden.join('; ')}`,
book.legal_notes && `Legal: ${book.legal_notes}`,
book.freeform_notes && `Notas: ${book.freeform_notes.slice(0, 1500)}`,
].filter(Boolean);
return lines.join('\n');
}
export function brandbookCompleteness(book: Brandbook): {
score: number;
missing: string[];
} {
const checks: [string, boolean][] = [
['Identidad (misión/vertical)', !!(book.mission || book.vertical)],
['Voz/tono', !!book.tone],
['Audiencia/ICP', !!(book.icp || book.personas.length)],
['Oferta', book.products_services.length > 0],
['Pilares/series RRSS', !!(book.pillars_rrss.length || book.series_recurrentes.length)],
['Ads (USP o CTA)', !!(book.ads_usps.length || book.ads_ctas.length)],
['LinkedIn B2B o blog', !!(book.linkedin_themes.length || book.blog_themes.length)],
];
const missing = checks.filter(([, ok]) => !ok).map(([l]) => l);
const score = Math.round(
((checks.length - missing.length) / checks.length) * 100
);
return { score, missing };
}