Files
4ventures-client-portal/src/components/BrandZone.tsx

2297 lines
91 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import {
BookOpen,
Loader2,
Save,
Sparkles,
Upload,
CheckCircle2,
Plus,
Trash2,
ImageIcon,
Type,
Palette,
} from 'lucide-react';
import { PageHeader, Surface, SectionTitle, SegmentedControl } from '@/components/ui';
import {
ASSET_KIND_LABELS,
BRANDBOOK_SECTIONS,
emptyBrandbook,
linesToList,
listToLines,
newId,
normalizeHex,
primaryLogo,
type BrandAssetKind,
type Brandbook,
type BrandbookSectionId,
type ColorToken,
type FontToken,
type CreativeB2BPersona,
type CreativeB2CPersona,
type CreativeObjection,
} from '@/lib/brandbook';
import GoogleFontsGallery from '@/components/GoogleFontsGallery';
import BrandOverview from '@/components/BrandOverview';
function Field({
label,
hint,
children,
className = '',
}: {
label: string;
hint?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<label className={`block space-y-1 ${className}`}>
<span className="text-xs font-semibold text-gray-800">{label}</span>
{hint ? (
<span className="block text-[11px] text-gray-500 leading-snug">
{hint}
</span>
) : null}
{children}
</label>
);
}
const inputCls =
'w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-200';
const areaCls = `${inputCls} min-h-[88px] resize-y`;
const selectCls = inputCls;
function KitThumb({ assetFileId, alt }: { assetFileId: string; alt: string }) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(
`/api/assets/download?id=${encodeURIComponent(assetFileId)}`
);
const j = await r.json();
if (!cancelled && r.ok && j.url) setUrl(j.url);
})();
return () => {
cancelled = true;
};
}, [assetFileId]);
if (!url) {
return (
<div className="w-full h-20 rounded-lg bg-gray-100 flex items-center justify-center text-[10px] text-gray-400">
…
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={alt}
className="w-full h-20 object-contain rounded-lg border border-gray-200 bg-white"
/>
);
}
export default function BrandZone({
tenantId,
tenantLabel,
tenantSlug,
}: {
tenantId?: string;
tenantLabel: string;
tenantSlug?: string;
}) {
const [viewMode, setViewMode] = useState<'overview' | 'edit'>('overview');
const [section, setSection] = useState<BrandbookSectionId>('identidad');
const [book, setBook] = useState<Brandbook>(() => emptyBrandbook(tenantLabel));
const [slug, setSlug] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [seeding, setSeeding] = useState(false);
const [uploading, setUploading] = useState(false);
const [kitUploading, setKitUploading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [okMsg, setOkMsg] = useState<string | null>(null);
const [score, setScore] = useState(0);
const [missing, setMissing] = useState<string[]>([]);
const [paste, setPaste] = useState('');
const [exists, setExists] = useState(false);
const [kitKind, setKitKind] = useState<BrandAssetKind>('logo');
const [kitRole, setKitRole] = useState('primary');
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const query = tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : '';
const r = await fetch(`/api/brandbook${query}`);
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo cargar');
setBook(j.brandbook || emptyBrandbook(tenantLabel));
setSlug(j.slug || tenantSlug || '');
setScore(j.completeness?.score ?? 0);
setMissing(j.completeness?.missing || []);
setExists(!!j.exists);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error');
} finally {
setLoading(false);
}
}, [tenantId, tenantLabel, tenantSlug]);
useEffect(() => {
void load();
}, [load]);
function patch(partial: Partial<Brandbook>) {
setBook((b) => ({ ...b, ...partial }));
setOkMsg(null);
}
async function save() {
setSaving(true);
setErr(null);
setOkMsg(null);
try {
const query = tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : '';
const r = await fetch(`/api/brandbook${query}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ brandbook: book, tenant_id: tenantId }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo guardar');
setBook(j.brandbook);
setScore(j.completeness?.score ?? 0);
setMissing(j.completeness?.missing || []);
setExists(true);
setOkMsg('Brand kit guardado y sincronizado');
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error al guardar');
} finally {
setSaving(false);
}
}
async function seed() {
setSeeding(true);
setErr(null);
try {
const query = tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : '';
const r = await fetch(`/api/brandbook${query}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seed: true, tenant_id: tenantId }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo sembrar');
setBook(j.brandbook);
setScore(j.completeness?.score ?? 0);
setMissing(j.completeness?.missing || []);
setExists(true);
setOkMsg('Base experta creada — sube logos/fuentes y completa huecos');
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error');
} finally {
setSeeding(false);
}
}
async function uploadSource(file: File | null) {
if (!file && !paste.trim()) return;
setUploading(true);
setErr(null);
try {
const fd = new FormData();
if (file) fd.append('file', file);
if (paste.trim()) {
fd.append('text', paste);
fd.append('filename', 'pegado.txt');
}
const r = await fetch('/api/brandbook/upload', { method: 'POST', body: fd });
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'Upload falló');
setBook(j.brandbook);
setScore(j.completeness?.score ?? 0);
setMissing(j.completeness?.missing || []);
setExists(true);
setPaste('');
setOkMsg(`Fuente añadida: ${j.ingested}`);
setSection('fuentes');
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error upload');
} finally {
setUploading(false);
}
}
async function uploadKit(file: File) {
setKitUploading(true);
setErr(null);
try {
const fd = new FormData();
fd.append('file', file);
fd.append('kind', kitKind);
fd.append('role', kitRole);
fd.append('label', file.name);
const r = await fetch('/api/brandbook/kit', { method: 'POST', body: fd });
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo subir al kit');
setBook(j.brandbook);
setScore(j.completeness?.score ?? 0);
setMissing(j.completeness?.missing || []);
setExists(true);
setOkMsg(`${ASSET_KIND_LABELS[kitKind]} añadido: ${file.name}`);
setSection('visual');
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error kit');
} finally {
setKitUploading(false);
}
}
async function removeKit(kitId: string) {
setErr(null);
try {
const r = await fetch(
`/api/brandbook/kit?kitId=${encodeURIComponent(kitId)}`,
{ method: 'DELETE' }
);
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo quitar');
setBook(j.brandbook);
setScore(j.completeness?.score ?? 0);
setMissing(j.completeness?.missing || []);
setOkMsg('Archivo quitado del kit');
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error');
}
}
function addColor() {
const c: ColorToken = {
id: newId('col'),
name: 'Nuevo color',
hex: '#2071CF',
role: 'primary',
usage: '',
};
patch({ colors: [...book.colors, c] });
}
function updateColor(id: string, partial: Partial<ColorToken>) {
patch({
colors: book.colors.map((c) => (c.id === id ? { ...c, ...partial } : c)),
});
}
function addFont() {
const f: FontToken = {
id: newId('font'),
family: 'Nueva fuente',
role: 'body',
weights: '400,700',
source: 'google',
url_or_notes: '',
};
patch({ fonts_kit: [...book.fonts_kit, f] });
}
function updateFont(id: string, partial: Partial<FontToken>) {
patch({
fonts_kit: book.fonts_kit.map((f) =>
f.id === id ? { ...f, ...partial } : f
),
});
}
const logo = primaryLogo(book);
const secMeta = BRANDBOOK_SECTIONS.find((s) => s.id === section);
if (loading) {
return (
<div className="flex items-center gap-2 text-sm text-gray-500 py-12">
<Loader2 className="w-4 h-4 animate-spin" />
Cargando zona de marca…
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
eyebrow="Marca"
title="Zona de marca"
description={`${tenantLabel}${slug ? ` · ${slug}` : ''} · Brand Overview + kit editable para ads, calendarios, LinkedIn y blog.`}
actions={
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void seed()}
disabled={seeding}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 disabled:opacity-50"
>
{seeding ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Sparkles className="w-3.5 h-3.5 text-amber-500" />
)}
{exists ? 'Resembrar base experta' : 'Crear base experta'}
</button>
<button
type="button"
onClick={() => void save()}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-xs font-semibold text-white hover:bg-brand-700 disabled:opacity-50"
>
{saving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Guardar brand kit
</button>
</div>
}
/>
<SegmentedControl
value={viewMode}
onChange={setViewMode}
options={[
{ id: 'overview', label: 'Brand Overview' },
{ id: 'edit', label: 'Detalles del kit' },
]}
/>
{err ? (
<p className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-3 py-2">
{err}
</p>
) : null}
{okMsg ? (
<p className="text-sm text-emerald-700 bg-emerald-50 border border-emerald-100 rounded-lg px-3 py-2">
{okMsg}
</p>
) : null}
{viewMode === 'overview' ? (
<BrandOverview
book={book}
tenantLabel={tenantLabel}
score={score}
missing={missing}
onContinueEdit={() => setViewMode('edit')}
onEditSection={(id) => {
setSection(id);
setViewMode('edit');
}}
/>
) : null}
{viewMode === 'edit' ? (
<>
{/* Identity strip */}
<Surface className="!p-4">
<div className="flex flex-col sm:flex-row gap-4 items-start">
<div className="w-28 shrink-0">
{logo ? (
<KitThumb assetFileId={logo.asset_file_id} alt={logo.label} />
) : (
<div className="h-20 rounded-lg border border-dashed border-gray-300 bg-gray-50 flex flex-col items-center justify-center text-[10px] text-gray-500 gap-1">
<ImageIcon className="w-4 h-4" />
Sin logo
</div>
)}
</div>
<div className="flex-1 min-w-0 space-y-2">
<p className="text-lg font-semibold text-gray-900 truncate">
{book.brand_name || tenantLabel}
</p>
<p className="text-sm text-gray-600">
{book.tagline || book.elevator_pitch || 'Sin tagline todavía'}
</p>
<div className="flex flex-wrap gap-1.5">
{book.colors.slice(0, 8).map((c) => (
<span
key={c.id}
title={`${c.name} ${c.hex}`}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 px-2 py-0.5 text-[10px] text-gray-700"
>
<span
className="w-3 h-3 rounded-full border border-black/10"
style={{ background: c.hex }}
/>
{c.hex}
</span>
))}
{book.fonts_kit.slice(0, 3).map((f) => (
<span
key={f.id}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 px-2 py-0.5 text-[10px] text-gray-700"
>
<Type className="w-3 h-3" />
{f.family}
</span>
))}
</div>
</div>
<div className="sm:w-40 shrink-0">
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">
Completitud
</p>
<p className="text-2xl font-semibold text-gray-900 mt-0.5">{score}%</p>
<div className="mt-2 h-1.5 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full bg-brand-500 transition-all"
style={{ width: `${score}%` }}
/>
</div>
{missing.length ? (
<p className="text-[10px] text-amber-700 mt-2 leading-snug">
Falta: {missing.slice(0, 3).join(' · ')}
</p>
) : (
<p className="text-[10px] text-emerald-700 mt-2 inline-flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Kit sólido
</p>
)}
</div>
</div>
</Surface>
{/* Quick upload bar */}
<Surface className="!p-3">
<div className="flex flex-col md:flex-row md:items-end gap-3">
<div className="flex items-center gap-2 text-xs font-semibold text-gray-800 shrink-0">
<Upload className="w-4 h-4 text-rose-500" />
Añadir al kit
</div>
<Field label="Tipo" className="md:w-36">
<select
className={selectCls}
value={kitKind}
onChange={(e) => {
const k = e.target.value as BrandAssetKind;
setKitKind(k);
setKitRole(
k === 'logo'
? 'primary'
: k === 'font'
? 'body'
: k === 'image'
? 'moodboard'
: 'asset'
);
}}
>
{Object.entries(ASSET_KIND_LABELS).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</Field>
<Field label="Rol" className="md:w-40" hint="ej. primary, dark, display">
<input
className={inputCls}
value={kitRole}
onChange={(e) => setKitRole(e.target.value)}
/>
</Field>
<label className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-4 py-2.5 text-xs font-semibold text-white cursor-pointer hover:bg-gray-800 disabled:opacity-50">
{kitUploading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Plus className="w-3.5 h-3.5" />
)}
Subir archivo
<input
type="file"
className="hidden"
accept={
kitKind === 'logo' || kitKind === 'image' || kitKind === 'icon'
? 'image/*,.svg,.pdf'
: kitKind === 'font'
? '.ttf,.otf,.woff,.woff2,.zip'
: kitKind === 'video'
? 'video/*'
: '*/*'
}
disabled={kitUploading}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void uploadKit(f);
e.target.value = '';
}}
/>
</label>
<p className="text-[11px] text-gray-500 md:ml-auto max-w-xs">
Logos, tipografías, patrones, iconos, PDFs de brandbook, moodboards…
</p>
</div>
</Surface>
<div className="flex flex-col lg:flex-row gap-4">
<nav className="lg:w-56 shrink-0 flex lg:flex-col gap-1 overflow-x-auto pb-1">
{BRANDBOOK_SECTIONS.map((s) => (
<button
key={s.id}
type="button"
onClick={() => setSection(s.id)}
className={`text-left rounded-lg px-3 py-2 text-xs font-medium whitespace-nowrap transition-colors ${
section === s.id
? 'bg-brand-50 text-gray-900 border border-brand-200'
: 'text-gray-600 border border-transparent hover:bg-gray-50'
}`}
>
{s.label}
</button>
))}
</nav>
<Surface className="flex-1 min-w-0 space-y-4">
<SectionTitle
title={secMeta?.label || ''}
description={secMeta?.expert || secMeta?.hint}
/>
{section === 'identidad' && (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Nombre de marca">
<input
className={inputCls}
value={book.brand_name}
onChange={(e) => patch({ brand_name: e.target.value })}
/>
</Field>
<Field label="Tagline">
<input
className={inputCls}
value={book.tagline}
onChange={(e) => patch({ tagline: e.target.value })}
/>
</Field>
<Field
label="Elevator pitch (30s)"
hint="Una frase que un desconocido entiende"
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={book.elevator_pitch}
onChange={(e) => patch({ elevator_pitch: e.target.value })}
/>
</Field>
<Field label="Promesa de marca">
<textarea
className={areaCls}
value={book.brand_promise}
onChange={(e) => patch({ brand_promise: e.target.value })}
/>
</Field>
<Field label="Posicionamiento">
<textarea
className={areaCls}
value={book.positioning}
onChange={(e) => patch({ positioning: e.target.value })}
/>
</Field>
<Field label="Arquetipo" hint="Jung / marca (Sabio, Héroe…)">
<input
className={inputCls}
value={book.archetype}
onChange={(e) => patch({ archetype: e.target.value })}
/>
</Field>
<Field label="Vertical / sector">
<input
className={inputCls}
value={book.vertical}
onChange={(e) => patch({ vertical: e.target.value })}
/>
</Field>
<Field label="Misión">
<textarea
className={areaCls}
value={book.mission}
onChange={(e) => patch({ mission: e.target.value })}
/>
</Field>
<Field label="Visión">
<textarea
className={areaCls}
value={book.vision}
onChange={(e) => patch({ vision: e.target.value })}
/>
</Field>
<Field label="Valores (uno por línea)">
<textarea
className={areaCls}
value={listToLines(book.values)}
onChange={(e) =>
patch({ values: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Historia / origen">
<textarea
className={areaCls}
value={book.origin_story}
onChange={(e) => patch({ origin_story: e.target.value })}
/>
</Field>
<Field label="Sitio Web">
<input
className={inputCls}
value={book.website || ''}
placeholder="ej. blueparrotschool.com"
onChange={(e) => patch({ website: e.target.value })}
/>
</Field>
<Field
label="Oferta y programas B2B (empresas)"
hint="Programas in-company, formación equipos, acreditaciones oficiales"
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={book.b2b_offering || ''}
onChange={(e) => patch({ b2b_offering: e.target.value })}
/>
</Field>
<Field
label="Oferta y cursos B2C (particulares)"
hint="Cursos adultos, intensivos, certificaciones Cambridge/Aptis, 1to1"
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={book.b2c_offering || ''}
onChange={(e) => patch({ b2c_offering: e.target.value })}
/>
</Field>
<Field
label="Diferenciación clave (USPs frente a competencia)"
hint="Profesor fijo, sesiones completas, acreditación directa FUNDAE..."
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={book.usp_differentiators || ''}
onChange={(e) => patch({ usp_differentiators: e.target.value })}
/>
</Field>
<Field
label="Prioridades actuales y foco comercial"
hint="Foco de captación prioritario para este periodo"
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={book.current_priorities || ''}
onChange={(e) => patch({ current_priorities: e.target.value })}
/>
</Field>
</div>
)}
{section === 'voz' && (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Tono" className="sm:col-span-2">
<textarea
className={areaCls}
value={book.tone}
onChange={(e) => patch({ tone: e.target.value })}
/>
</Field>
<Field label="Personalidad">
<textarea
className={areaCls}
value={listToLines(book.personality)}
onChange={(e) =>
patch({ personality: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Pilares de mensaje">
<textarea
className={areaCls}
value={listToLines(book.messaging_pillars)}
onChange={(e) =>
patch({ messaging_pillars: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Sí hacer">
<textarea
className={areaCls}
value={listToLines(book.voice_do)}
onChange={(e) =>
patch({ voice_do: linesToList(e.target.value) })
}
/>
</Field>
<Field label="No hacer">
<textarea
className={areaCls}
value={listToLines(book.voice_dont)}
onChange={(e) =>
patch({ voice_dont: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Léxico preferido">
<textarea
className={areaCls}
value={listToLines(book.vocabulary_prefer)}
onChange={(e) =>
patch({ vocabulary_prefer: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Léxico a evitar">
<textarea
className={areaCls}
value={listToLines(book.vocabulary_avoid)}
onChange={(e) =>
patch({ vocabulary_avoid: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Ejemplos de frases BUENAS">
<textarea
className={areaCls}
value={listToLines(book.example_good)}
onChange={(e) =>
patch({ example_good: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Ejemplos de frases MALAS">
<textarea
className={areaCls}
value={listToLines(book.example_bad)}
onChange={(e) =>
patch({ example_bad: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Variantes de slogan" className="sm:col-span-2">
<textarea
className={areaCls}
value={listToLines(book.slogan_variants)}
onChange={(e) =>
patch({ slogan_variants: linesToList(e.target.value) })
}
/>
</Field>
<Field
label="Estructura y reglas de redacción (Copy Ads & Web)"
hint="Longitud, número de frases, estructura hook -> beneficio -> CTA"
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={book.copy_structure_rules || ''}
placeholder="ej. Frases cortas. Un dato concreto vale más que un adjetivo. En primary_text: 2-4 frases, máx. 300 caracteres..."
onChange={(e) => patch({ copy_structure_rules: e.target.value })}
/>
</Field>
<Field
label="Política de términos en inglés y anglicismos"
hint="Términos admitidos vs anglicismos prohibidos tipo 'boost' o 'level up'"
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={book.english_terms_policy || ''}
placeholder="ej. Inglés en el copy: solo nombres propios de exámenes o términos sin traducción natural..."
onChange={(e) => patch({ english_terms_policy: e.target.value })}
/>
</Field>
<Field label="Conceptos fuerza (Power Words)" hint="Uno por línea">
<textarea
className={areaCls}
value={listToLines(book.power_words || [])}
onChange={(e) =>
patch({ power_words: linesToList(e.target.value) })
}
/>
</Field>
<Field label="CTAs permitidos para Ads / Web" hint="Uno por línea">
<textarea
className={areaCls}
value={listToLines(book.allowed_ctas || [])}
onChange={(e) =>
patch({ allowed_ctas: linesToList(e.target.value) })
}
/>
</Field>
</div>
)}
{section === 'visual' && (
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-gray-900 inline-flex items-center gap-1.5">
<Palette className="w-4 h-4 text-rose-500" />
Colores
</p>
<button
type="button"
onClick={addColor}
className="text-xs font-semibold text-brand-700 inline-flex items-center gap-1"
>
<Plus className="w-3.5 h-3.5" /> Añadir color
</button>
</div>
<div className="space-y-2">
{book.colors.length === 0 ? (
<p className="text-xs text-gray-500">
Añade tokens de color con picker o pega un #hex.
</p>
) : null}
{book.colors.map((c) => (
<div
key={c.id}
className="grid grid-cols-[40px_1fr_1fr_100px_1fr_auto] gap-2 items-center"
>
<input
type="color"
value={normalizeHex(c.hex) || '#000000'}
onChange={(e) =>
updateColor(c.id, {
hex: e.target.value.toUpperCase(),
})
}
className="w-10 h-10 rounded cursor-pointer border border-gray-200 p-0"
/>
<input
className={inputCls}
value={c.name}
onChange={(e) =>
updateColor(c.id, { name: e.target.value })
}
placeholder="Nombre"
/>
<input
className={inputCls}
value={c.hex}
onChange={(e) => {
const n = normalizeHex(e.target.value);
updateColor(c.id, {
hex: n || e.target.value,
});
}}
placeholder="#000000"
/>
<select
className={selectCls}
value={c.role}
onChange={(e) =>
updateColor(c.id, {
role: e.target.value as ColorToken['role'],
})
}
>
{(
[
'primary',
'secondary',
'accent',
'neutral',
'success',
'warning',
'error',
'other',
] as const
).map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
className={inputCls}
value={c.usage}
onChange={(e) =>
updateColor(c.id, { usage: e.target.value })
}
placeholder="Uso"
/>
<button
type="button"
onClick={() =>
patch({
colors: book.colors.filter((x) => x.id !== c.id),
})
}
className="p-2 text-gray-400 hover:text-red-600"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
<div className="space-y-3">
<GoogleFontsGallery
selectedFamilies={book.fonts_kit.map((f) => f.family)}
onPick={(font, pickRole) => {
const exists = book.fonts_kit.some(
(f) =>
f.family.toLowerCase() === font.family.toLowerCase()
);
if (exists) {
setOkMsg(`${font.family} ya está en el kit`);
return;
}
const weights = font.variants
.filter((v) => /^\d+$/.test(v))
.slice(0, 6)
.join(',');
const token: FontToken = {
id: newId('font'),
family: font.family,
role: pickRole,
weights: weights || '400,700',
source: 'google',
url_or_notes: `https://fonts.google.com/specimen/${encodeURIComponent(
font.family.replace(/ /g, '+')
)}`,
};
patch({ fonts_kit: [...book.fonts_kit, token] });
setOkMsg(`${font.family} añadida al kit (${pickRole})`);
}}
/>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-gray-900 inline-flex items-center gap-1.5">
<Type className="w-4 h-4 text-rose-500" />
Tipografías del kit
</p>
<button
type="button"
onClick={addFont}
className="text-xs font-semibold text-brand-700 inline-flex items-center gap-1"
>
<Plus className="w-3.5 h-3.5" /> Manual
</button>
</div>
<div className="space-y-2">
{book.fonts_kit.map((f) => (
<div
key={f.id}
className="grid grid-cols-1 sm:grid-cols-[1fr_120px_100px_100px_1fr_auto] gap-2 items-center"
>
<input
className={inputCls}
value={f.family}
onChange={(e) =>
updateFont(f.id, { family: e.target.value })
}
placeholder="Familia"
/>
<select
className={selectCls}
value={f.role}
onChange={(e) =>
updateFont(f.id, {
role: e.target.value as FontToken['role'],
})
}
>
{(
[
'display',
'headline',
'body',
'ui',
'mono',
'other',
] as const
).map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
className={inputCls}
value={f.weights}
onChange={(e) =>
updateFont(f.id, { weights: e.target.value })
}
placeholder="400,700"
/>
<select
className={selectCls}
value={f.source}
onChange={(e) =>
updateFont(f.id, {
source: e.target.value as FontToken['source'],
})
}
>
{(
[
'google',
'adobe',
'file',
'system',
'other',
] as const
).map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
className={inputCls}
value={f.url_or_notes}
onChange={(e) =>
updateFont(f.id, { url_or_notes: e.target.value })
}
placeholder="URL Google Fonts / notas"
/>
<button
type="button"
onClick={() =>
patch({
fonts_kit: book.fonts_kit.filter(
(x) => x.id !== f.id
),
})
}
className="p-2 text-gray-400 hover:text-red-600"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<p className="text-[11px] text-gray-500 mt-2">
Para archivos .ttf/.otf/.woff usa la barra «Añadir al kit» con
tipo Tipografía.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Uso de logo">
<textarea
className={areaCls}
value={book.logo_usage}
onChange={(e) => patch({ logo_usage: e.target.value })}
/>
</Field>
<Field label="Notas visuales / arte">
<textarea
className={areaCls}
value={book.visual_notes}
onChange={(e) => patch({ visual_notes: e.target.value })}
/>
</Field>
<Field label="Estilo fotografía">
<textarea
className={areaCls}
value={book.photography_style}
onChange={(e) =>
patch({ photography_style: e.target.value })
}
/>
</Field>
<Field label="Ilustración / iconos">
<textarea
className={areaCls}
value={[book.illustration_style, book.icon_style]
.filter(Boolean)
.join('\n')}
onChange={(e) => {
const [illustration_style = '', icon_style = ''] =
e.target.value.split('\n');
patch({ illustration_style, icon_style });
}}
/>
</Field>
<Field label="Imágenes: sí">
<textarea
className={areaCls}
value={listToLines(book.imagery_do)}
onChange={(e) =>
patch({ imagery_do: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Imágenes: no">
<textarea
className={areaCls}
value={listToLines(book.imagery_dont)}
onChange={(e) =>
patch({ imagery_dont: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Uso de superficies y combinaciones" className="sm:col-span-2">
<textarea
className={areaCls}
value={book.surface_rules || ''}
placeholder="ej. Azul (#2A4D9C) y blanco (#FFFFFF) como base. Amarillo (#FBC02D) solo para resaltar una palabra o CTA..."
onChange={(e) => patch({ surface_rules: e.target.value })}
/>
</Field>
<Field label="Tipografía titulares (Hook)">
<input
className={inputCls}
value={book.typography_headline || ''}
placeholder="ej. Poppins Extrabold"
onChange={(e) => patch({ typography_headline: e.target.value })}
/>
</Field>
<Field label="Tipografía cuerpo / apoyo (Support)">
<input
className={inputCls}
value={book.typography_body || ''}
placeholder="ej. Poppins Regular"
onChange={(e) => patch({ typography_body: e.target.value })}
/>
</Field>
<Field label="Reglas de texto en imagen" className="sm:col-span-2">
<textarea
className={areaCls}
value={book.image_text_rules || ''}
placeholder="ej. Máximo 2 niveles (titular ≤ 8 palabras + apoyo ≤ 12 palabras). Margen mínimo del 8% en todos los lados..."
onChange={(e) => patch({ image_text_rules: e.target.value })}
/>
</Field>
</div>
<div>
<p className="text-sm font-semibold text-gray-900 mb-2">
Archivos del kit ({book.kit_assets.length})
</p>
{book.kit_assets.length === 0 ? (
<p className="text-xs text-gray-500">
Todavía no hay logos ni archivos. Usa «Añadir al kit»
arriba.
</p>
) : (
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{book.kit_assets.map((a) => (
<li
key={a.id}
className="rounded-xl border border-gray-200 p-2 space-y-2"
>
{a.mime_type?.startsWith('image/') ||
a.kind === 'logo' ||
a.kind === 'image' ||
a.kind === 'icon' ? (
<KitThumb
assetFileId={a.asset_file_id}
alt={a.label}
/>
) : (
<div className="h-20 rounded-lg bg-gray-50 border border-gray-100 flex items-center justify-center text-xs text-gray-500">
{ASSET_KIND_LABELS[a.kind]}
</div>
)}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-semibold text-gray-800 truncate">
{a.label}
</p>
<p className="text-[10px] text-gray-500">
{ASSET_KIND_LABELS[a.kind]} · {a.role}
</p>
</div>
<button
type="button"
onClick={() => void removeKit(a.id)}
className="p-1 text-gray-400 hover:text-red-600"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)}
{section === 'audiencia' && (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="ICP" className="sm:col-span-2">
<textarea
className={areaCls}
value={book.icp}
onChange={(e) => patch({ icp: e.target.value })}
/>
</Field>
<Field label="Geo">
<input
className={inputCls}
value={book.geo}
onChange={(e) => patch({ geo: e.target.value })}
/>
</Field>
<Field label="Idiomas">
<textarea
className={areaCls}
value={listToLines(book.languages)}
onChange={(e) =>
patch({ languages: linesToList(e.target.value) })
}
/>
</Field>
<Field
label="Personas"
hint="Nombre | descripción | pains | goals"
className="sm:col-span-2"
>
<textarea
className={`${areaCls} min-h-[120px]`}
value={book.personas
.map(
(p) =>
`${p.name} | ${p.description} | ${p.pains}${
p.goals ? ` | ${p.goals}` : ''
}`
)
.join('\n')}
onChange={(e) => {
const personas = e.target.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name, description, pains, goals] = line
.split('|')
.map((s) => s.trim());
return {
name: name || 'Persona',
description: description || '',
pains: pains || '',
goals: goals || '',
};
});
patch({ personas });
}}
/>
</Field>
<Field label="Jobs to be done">
<textarea
className={areaCls}
value={listToLines(book.jobs_to_be_done)}
onChange={(e) =>
patch({ jobs_to_be_done: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Triggers de compra">
<textarea
className={areaCls}
value={listToLines(book.buying_triggers)}
onChange={(e) =>
patch({ buying_triggers: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Barreras / objeciones de audiencia">
<textarea
className={areaCls}
value={listToLines(book.barriers)}
onChange={(e) =>
patch({ barriers: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Customer journey (notas)">
<textarea
className={areaCls}
value={book.customer_journey}
onChange={(e) => patch({ customer_journey: e.target.value })}
/>
</Field>
<Field
label="Frustración principal previa de la audiencia"
hint="Por qué han fracasado con otras alternativas o en qué punto están atascados"
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={book.customer_frustrations || ''}
placeholder="ej. Estancamiento en el nivel intermedio eterno por apps de 15 minutos sin profesor fijo..."
onChange={(e) => patch({ customer_frustrations: e.target.value })}
/>
</Field>
<Field
label="Transformación deseada (Antes vs Después)"
hint="De qué estado evitado pasan a qué resultado concreto"
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={book.desired_transformation || ''}
placeholder="ej. Pasar de evitar hablar en inglés o depender de traductores a negociar con seguridad..."
onChange={(e) => patch({ desired_transformation: e.target.value })}
/>
</Field>
{/* B2B Personas Card Editor */}
<div className="sm:col-span-2 space-y-3 pt-3 border-t border-gray-200">
<div className="flex items-center justify-between">
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-800">
Buyer Personas B2B (Empresas / Directivos)
</h4>
<p className="text-[11px] text-gray-500">
Cargos, dolores de gestión y disparadores de compra corporativos.
</p>
</div>
<button
type="button"
onClick={() => {
const list = [...(book.b2b_personas || [])];
list.push({
name: `Persona B2B #${list.length + 1}`,
role: '',
goal: '',
pain_point: '',
buying_trigger: '',
});
patch({ b2b_personas: list });
}}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-2.5 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 shadow-sm"
>
<Plus className="w-3.5 h-3.5 text-gray-500" />
Añadir B2B
</button>
</div>
{(!book.b2b_personas || book.b2b_personas.length === 0) ? (
<p className="text-xs text-gray-400 italic">No hay buyer personas B2B definidas.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{book.b2b_personas.map((p, idx) => (
<div key={idx} className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 space-y-2">
<div className="flex items-center justify-between">
<input
type="text"
value={p.name}
onChange={(e) => {
const list = [...book.b2b_personas!];
list[idx] = { ...list[idx], name: e.target.value };
patch({ b2b_personas: list });
}}
placeholder="Nombre / Identificador"
className="font-bold text-xs text-gray-900 bg-transparent border-b border-dashed border-gray-300 focus:outline-none focus:border-brand-500 px-1 py-0.5"
/>
<button
type="button"
onClick={() => {
const list = book.b2b_personas!.filter((_, i) => i !== idx);
patch({ b2b_personas: list });
}}
className="text-gray-400 hover:text-red-500 p-1"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-500 block">Cargo / Rol</label>
<input
type="text"
value={p.role}
onChange={(e) => {
const list = [...book.b2b_personas!];
list[idx] = { ...list[idx], role: e.target.value };
patch({ b2b_personas: list });
}}
placeholder="ej. Responsable de RRHH o Talento"
className="w-full text-xs rounded border border-gray-200 bg-white px-2 py-1"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-500 block">Objetivo</label>
<input
type="text"
value={p.goal}
onChange={(e) => {
const list = [...book.b2b_personas!];
list[idx] = { ...list[idx], goal: e.target.value };
patch({ b2b_personas: list });
}}
placeholder="ej. Proveedor llave en mano sin carga administrativa"
className="w-full text-xs rounded border border-gray-200 bg-white px-2 py-1"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-500 block">Dolor / Frustración</label>
<textarea
value={p.pain_point}
onChange={(e) => {
const list = [...book.b2b_personas!];
list[idx] = { ...list[idx], pain_point: e.target.value };
patch({ b2b_personas: list });
}}
placeholder="ej. Papeleo FUNDAE, quejas por rotación de profes..."
className="w-full text-xs rounded border border-gray-200 bg-white px-2 py-1 min-h-[50px]"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-500 block">Disparador de Compra</label>
<input
type="text"
value={p.buying_trigger}
onChange={(e) => {
const list = [...book.b2b_personas!];
list[idx] = { ...list[idx], buying_trigger: e.target.value };
patch({ b2b_personas: list });
}}
placeholder="ej. Gestión FUNDAE directa 100% resuelta"
className="w-full text-xs rounded border border-gray-200 bg-white px-2 py-1"
/>
</div>
</div>
))}
</div>
)}
</div>
{/* B2C Personas Card Editor */}
<div className="sm:col-span-2 space-y-3 pt-3 border-t border-gray-200">
<div className="flex items-center justify-between">
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-800">
Buyer Personas B2C (Particulares / Alumnos)
</h4>
<p className="text-[11px] text-gray-500">
Segmentos de particulares, edad, situación vital, dolor y meta.
</p>
</div>
<button
type="button"
onClick={() => {
const list = [...(book.b2c_personas || [])];
list.push({
name: `Persona B2C #${list.length + 1}`,
age_range: '',
situation: '',
pain_point: '',
goal: '',
});
patch({ b2c_personas: list });
}}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-2.5 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 shadow-sm"
>
<Plus className="w-3.5 h-3.5 text-gray-500" />
Añadir B2C
</button>
</div>
{(!book.b2c_personas || book.b2c_personas.length === 0) ? (
<p className="text-xs text-gray-400 italic">No hay buyer personas B2C definidas.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{book.b2c_personas.map((p, idx) => (
<div key={idx} className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 space-y-2">
<div className="flex items-center justify-between">
<input
type="text"
value={p.name}
onChange={(e) => {
const list = [...book.b2c_personas!];
list[idx] = { ...list[idx], name: e.target.value };
patch({ b2c_personas: list });
}}
placeholder="Perfil / Segmento"
className="font-bold text-xs text-gray-900 bg-transparent border-b border-dashed border-gray-300 focus:outline-none focus:border-brand-500 px-1 py-0.5"
/>
<button
type="button"
onClick={() => {
const list = book.b2c_personas!.filter((_, i) => i !== idx);
patch({ b2c_personas: list });
}}
className="text-gray-400 hover:text-red-500 p-1"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[10px] font-semibold text-gray-500 block">Rango de Edad</label>
<input
type="text"
value={p.age_range}
onChange={(e) => {
const list = [...book.b2c_personas!];
list[idx] = { ...list[idx], age_range: e.target.value };
patch({ b2c_personas: list });
}}
placeholder="ej. 25-45 años"
className="w-full text-xs rounded border border-gray-200 bg-white px-2 py-1"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-500 block">Situación</label>
<input
type="text"
value={p.situation}
onChange={(e) => {
const list = [...book.b2c_personas!];
list[idx] = { ...list[idx], situation: e.target.value };
patch({ b2c_personas: list });
}}
placeholder="ej. Opositor / Empleado"
className="w-full text-xs rounded border border-gray-200 bg-white px-2 py-1"
/>
</div>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-500 block">Dolor / Frustración</label>
<textarea
value={p.pain_point}
onChange={(e) => {
const list = [...book.b2c_personas!];
list[idx] = { ...list[idx], pain_point: e.target.value };
patch({ b2c_personas: list });
}}
placeholder="ej. Ha fracasado con apps sin feedback ni estructura..."
className="w-full text-xs rounded border border-gray-200 bg-white px-2 py-1 min-h-[50px]"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-500 block">Meta / Objetivo</label>
<input
type="text"
value={p.goal}
onChange={(e) => {
const list = [...book.b2c_personas!];
list[idx] = { ...list[idx], goal: e.target.value };
patch({ b2c_personas: list });
}}
placeholder="ej. Certificar B2/C1 Cambridge en 6 meses"
className="w-full text-xs rounded border border-gray-200 bg-white px-2 py-1"
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{section === 'oferta' && (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Propuesta de valor" className="sm:col-span-2">
<textarea
className={areaCls}
value={book.value_proposition}
onChange={(e) =>
patch({ value_proposition: e.target.value })
}
/>
</Field>
<Field label="Productos / servicios">
<textarea
className={areaCls}
value={listToLines(book.products_services)}
onChange={(e) =>
patch({ products_services: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Packs / tiers">
<textarea
className={areaCls}
value={listToLines(book.packages)}
onChange={(e) =>
patch({ packages: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Diferenciadores">
<textarea
className={areaCls}
value={listToLines(book.differentiators)}
onChange={(e) =>
patch({ differentiators: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Prueba social / cifras">
<textarea
className={areaCls}
value={listToLines(book.proof_points)}
onChange={(e) =>
patch({ proof_points: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Alternativas competitivas">
<textarea
className={areaCls}
value={listToLines(book.competitive_alternatives)}
onChange={(e) =>
patch({
competitive_alternatives: linesToList(e.target.value),
})
}
/>
</Field>
<Field label="Garantía / riesgo inverso">
<textarea
className={areaCls}
value={book.guarantee}
onChange={(e) => patch({ guarantee: e.target.value })}
/>
</Field>
<Field label="Pricing / notas comerciales" className="sm:col-span-2">
<textarea
className={areaCls}
value={book.pricing_notes}
onChange={(e) => patch({ pricing_notes: e.target.value })}
/>
</Field>
<Field
label="Enfoque de precio / oferta para Ads y Funnel"
hint="Coste neto, bonificación FUNDAE, evitar descuentos agresivos..."
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={book.price_and_offer_framing || ''}
placeholder="ej. En B2B destacar el coste bonificado vía FUNDAE... En B2C destacar el valor del programa sin descuentos agresivos..."
onChange={(e) => patch({ price_and_offer_framing: e.target.value })}
/>
</Field>
</div>
)}
{section === 'pilares' && (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Pilares RRSS">
<textarea
className={areaCls}
value={listToLines(book.pillars_rrss)}
onChange={(e) =>
patch({ pillars_rrss: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Series recurrentes (ADN)">
<textarea
className={areaCls}
value={listToLines(book.series_recurrentes)}
onChange={(e) =>
patch({ series_recurrentes: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Formatos prioritarios">
<textarea
className={areaCls}
value={listToLines(book.formats_priority)}
onChange={(e) =>
patch({ formats_priority: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Hooks estacionales">
<textarea
className={areaCls}
value={listToLines(book.seasonal_hooks)}
onChange={(e) =>
patch({ seasonal_hooks: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Temas tabú">
<textarea
className={areaCls}
value={listToLines(book.taboo_topics)}
onChange={(e) =>
patch({ taboo_topics: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Matriz de contenido" className="sm:col-span-2">
<textarea
className={`${areaCls} min-h-[120px]`}
value={book.content_matrix}
onChange={(e) => patch({ content_matrix: e.target.value })}
placeholder="Pilar × formato × KPI…"
/>
</Field>
</div>
)}
{section === 'canales' && (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
{(
[
['instagram', 'Instagram'],
['facebook', 'Facebook'],
['linkedin', 'LinkedIn'],
['tiktok', 'TikTok'],
['youtube', 'YouTube'],
['blog', 'Blog'],
['email', 'Email'],
['other', 'Otros'],
] as const
).map(([key, label]) => (
<Field key={key} label={`${label} — rol`}>
<textarea
className={areaCls}
value={book.channels[key]}
onChange={(e) =>
patch({
channels: { ...book.channels, [key]: e.target.value },
})
}
/>
</Field>
))}
</div>
<div className="grid gap-3 sm:grid-cols-2">
{(
[
['instagram', 'Handle IG'],
['facebook', 'URL Facebook'],
['linkedin', 'URL LinkedIn'],
['tiktok', 'Handle TikTok'],
['youtube', 'URL YouTube'],
['blog', 'URL Blog'],
] as const
).map(([key, label]) => (
<Field key={key} label={label}>
<input
className={inputCls}
value={book.channel_handles[key]}
onChange={(e) =>
patch({
channel_handles: {
...book.channel_handles,
[key]: e.target.value,
},
})
}
/>
</Field>
))}
<Field label="Cadencia global">
<input
className={inputCls}
value={book.channel_cadence}
onChange={(e) => patch({ channel_cadence: e.target.value })}
placeholder="ej. 4 Reels/sem + Stories diarias"
/>
</Field>
<Field label="KPIs por canal">
<textarea
className={areaCls}
value={listToLines(book.channel_kpis)}
onChange={(e) =>
patch({ channel_kpis: linesToList(e.target.value) })
}
/>
</Field>
</div>
</div>
)}
{section === 'ads' && (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="USPs">
<textarea
className={areaCls}
value={listToLines(book.ads_usps)}
onChange={(e) =>
patch({ ads_usps: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Objeciones">
<textarea
className={areaCls}
value={listToLines(book.ads_objections)}
onChange={(e) =>
patch({ ads_objections: linesToList(e.target.value) })
}
/>
</Field>
<Field label="CTAs">
<textarea
className={areaCls}
value={listToLines(book.ads_ctas)}
onChange={(e) =>
patch({ ads_ctas: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Ofertas / promos">
<textarea
className={areaCls}
value={listToLines(book.ads_offers)}
onChange={(e) =>
patch({ ads_offers: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Ángulos creativos">
<textarea
className={areaCls}
value={listToLines(book.ads_angles)}
onChange={(e) =>
patch({ ads_angles: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Embudo (TOFU/MOFU/BOFU)">
<textarea
className={areaCls}
value={listToLines(book.ads_funnel)}
onChange={(e) =>
patch({ ads_funnel: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Formatos ads">
<textarea
className={areaCls}
value={listToLines(book.ads_formats)}
onChange={(e) =>
patch({ ads_formats: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Remarketing">
<textarea
className={areaCls}
value={listToLines(book.retargeting_angles)}
onChange={(e) =>
patch({ retargeting_angles: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Notas landing" className="sm:col-span-2">
<textarea
className={areaCls}
value={book.landing_notes}
onChange={(e) => patch({ landing_notes: e.target.value })}
/>
</Field>
<Field
label="Hooks ganadores de referencia"
hint="Ejemplos reales que han tenido buen CTR / conversión (uno por línea, para referencia de estilo)"
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={listToLines(book.winning_hooks || [])}
placeholder="ej. Tu equipo no necesita más inglés. Necesita el inglés de su trabajo..."
onChange={(e) =>
patch({ winning_hooks: linesToList(e.target.value) })
}
/>
</Field>
<Field
label="Ángulos recomendados para rotar en campañas"
hint="Ángulos prioritarios para Claude (uno por línea)"
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={listToLines(book.preferred_angles || [])}
placeholder="ej. objeción tiempo, coste FUNDAE, contraste app vs programa real, especialización sectorial..."
onChange={(e) =>
patch({ preferred_angles: linesToList(e.target.value) })
}
/>
</Field>
{/* Objeciones a desactivar con Rebuttal */}
<div className="sm:col-span-2 space-y-3 pt-3 border-t border-gray-200">
<div className="flex items-center justify-between">
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-800">
Objeciones a Desactivar (Objeción → Argumento de Refutación)
</h4>
<p className="text-[11px] text-gray-500">
Utilizadas por Claude para redactar copys de consideración y remarketing que resuelvan dudas del lead.
</p>
</div>
<button
type="button"
onClick={() => {
const list = [...(book.objections || [])];
list.push({ objection: '', rebuttal: '' });
patch({ objections: list });
}}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-2.5 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 shadow-sm"
>
<Plus className="w-3.5 h-3.5 text-gray-500" />
Añadir Objeción
</button>
</div>
{(!book.objections || book.objections.length === 0) ? (
<p className="text-xs text-gray-400 italic">No hay objeciones estructuradas definidas.</p>
) : (
<div className="space-y-2">
{book.objections.map((o, idx) => (
<div
key={idx}
className="flex flex-col sm:flex-row items-start sm:items-center gap-2 rounded-xl border border-gray-200 bg-gray-50/60 p-2.5"
>
<div className="flex-1 w-full sm:w-auto">
<label className="text-[10px] font-semibold text-gray-500 block mb-0.5">
Objeción del cliente
</label>
<input
type="text"
value={o.objection}
onChange={(e) => {
const list = [...book.objections!];
list[idx] = { ...list[idx], objection: e.target.value };
patch({ objections: list });
}}
placeholder='ej. "Ya usamos una app"'
className="w-full text-xs rounded border border-gray-200 bg-white px-2 py-1.5"
/>
</div>
<div className="flex-[2] w-full sm:w-auto">
<label className="text-[10px] font-semibold text-gray-500 block mb-0.5">
Respuesta / Refutación
</label>
<input
type="text"
value={o.rebuttal}
onChange={(e) => {
const list = [...book.objections!];
list[idx] = { ...list[idx], rebuttal: e.target.value };
patch({ objections: list });
}}
placeholder='ej. "La app mantiene, no avanza. Sin profesor fijo ni sesiones de 60 min no se sale del nivel intermedio."'
className="w-full text-xs rounded border border-gray-200 bg-white px-2 py-1.5"
/>
</div>
<button
type="button"
onClick={() => {
const list = book.objections!.filter((_, i) => i !== idx);
patch({ objections: list });
}}
className="self-end sm:self-center mt-2 sm:mt-4 text-gray-400 hover:text-red-500 p-1"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
{section === 'b2b' && (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Temas LinkedIn">
<textarea
className={areaCls}
value={listToLines(book.linkedin_themes)}
onChange={(e) =>
patch({ linkedin_themes: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Formatos LinkedIn">
<textarea
className={areaCls}
value={listToLines(book.linkedin_formats)}
onChange={(e) =>
patch({ linkedin_formats: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Thought leadership">
<textarea
className={areaCls}
value={listToLines(book.thought_leadership)}
onChange={(e) =>
patch({ thought_leadership: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Buyer roles">
<textarea
className={areaCls}
value={listToLines(book.buyer_roles)}
onChange={(e) =>
patch({ buyer_roles: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Ángulos casos de estudio">
<textarea
className={areaCls}
value={listToLines(book.case_study_angles)}
onChange={(e) =>
patch({ case_study_angles: linesToList(e.target.value) })
}
/>
</Field>
<Field label="CTAs LinkedIn">
<textarea
className={areaCls}
value={listToLines(book.linkedin_cta)}
onChange={(e) =>
patch({ linkedin_cta: linesToList(e.target.value) })
}
/>
</Field>
<Field label="ABM / cuentas objetivo" className="sm:col-span-2">
<textarea
className={areaCls}
value={book.abm_notes}
onChange={(e) => patch({ abm_notes: e.target.value })}
/>
</Field>
</div>
)}
{section === 'blog' && (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Temas blog">
<textarea
className={areaCls}
value={listToLines(book.blog_themes)}
onChange={(e) =>
patch({ blog_themes: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Keywords SEO">
<textarea
className={areaCls}
value={listToLines(book.blog_seo_keywords)}
onChange={(e) =>
patch({ blog_seo_keywords: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Clusters de contenido">
<textarea
className={areaCls}
value={listToLines(book.content_clusters)}
onChange={(e) =>
patch({ content_clusters: linesToList(e.target.value) })
}
/>
</Field>
<Field label="CTAs del blog">
<textarea
className={areaCls}
value={listToLines(book.blog_cta)}
onChange={(e) =>
patch({ blog_cta: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Cadencia">
<input
className={inputCls}
value={book.blog_cadence}
onChange={(e) => patch({ blog_cadence: e.target.value })}
placeholder="ej. 2 posts/mes"
/>
</Field>
</div>
)}
{section === 'compliance' && (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Claims prohibidos">
<textarea
className={areaCls}
value={listToLines(book.claims_forbidden)}
onChange={(e) =>
patch({ claims_forbidden: linesToList(e.target.value) })
}
/>
</Field>
<Field label="Disclaimers obligatorios">
<textarea
className={areaCls}
value={listToLines(book.required_disclaimers)}
onChange={(e) =>
patch({
required_disclaimers: linesToList(e.target.value),
})
}
/>
</Field>
<Field label="Notas legales">
<textarea
className={areaCls}
value={book.legal_notes}
onChange={(e) => patch({ legal_notes: e.target.value })}
/>
</Field>
<Field label="Reglas de sector">
<textarea
className={areaCls}
value={book.sector_rules}
onChange={(e) => patch({ sector_rules: e.target.value })}
/>
</Field>
<Field label="Derechos de imagen" className="sm:col-span-2">
<textarea
className={areaCls}
value={book.image_rights}
onChange={(e) => patch({ image_rights: e.target.value })}
/>
</Field>
<Field
label="Competidores que NUNCA se deben nombrar"
hint="Marcas de la competencia totalmente vetadas en copy y claims (uno por línea)"
className="sm:col-span-2"
>
<textarea
className={areaCls}
value={listToLines(book.competitors_never_mention || [])}
placeholder="ej. Twenix, Preply, Italki, Open English, What's Up!..."
onChange={(e) =>
patch({ competitors_never_mention: linesToList(e.target.value) })
}
/>
</Field>
<Field
label="Política de Emojis"
hint="Normas sobre el uso de emojis en creatividades y copies"
>
<textarea
className={areaCls}
value={book.emoji_policy || ''}
placeholder="ej. Prohibidos los emojis en creatividades de imagen. En copy, como mucho uno al inicio solo en B2C..."
onChange={(e) => patch({ emoji_policy: e.target.value })}
/>
</Field>
<Field
label="Política de Testimonios y Casos de Estudio"
hint="Normas para evitar testimonios falsos o no autorizados"
>
<textarea
className={areaCls}
value={book.testimonials_policy || ''}
placeholder="ej. Prohibidos testimonios inventados. Si no hay un caso real verificado, no se usa..."
onChange={(e) => patch({ testimonials_policy: e.target.value })}
/>
</Field>
</div>
)}
{section === 'fuentes' && (
<div className="space-y-4">
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-800">
<Upload className="w-4 h-4" />
Subir o pegar brandbook / brief
</div>
<p className="text-xs text-gray-500">
.txt / .md / .csv / .json — o pega texto de PDF/DOCX. Detecta
colores #hex automáticamente. Logos y tipografías: barra
«Añadir al kit».
</p>
<textarea
className={areaCls}
placeholder="Pega aquí el brandbook completo…"
value={paste}
onChange={(e) => setPaste(e.target.value)}
/>
<div className="flex flex-wrap gap-2">
<label className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold cursor-pointer hover:bg-gray-50">
<BookOpen className="w-3.5 h-3.5" />
Elegir archivo
<input
type="file"
accept=".txt,.md,.csv,.json,text/plain"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0] || null;
void uploadSource(f);
e.target.value = '';
}}
/>
</label>
<button
type="button"
disabled={uploading || !paste.trim()}
onClick={() => void uploadSource(null)}
className="inline-flex items-center gap-1.5 rounded-lg bg-gray-900 px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
>
{uploading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
Añadir texto pegado
</button>
</div>
</div>
<Field label="Notas libres">
<textarea
className={`${areaCls} min-h-[140px]`}
value={book.freeform_notes}
onChange={(e) => patch({ freeform_notes: e.target.value })}
/>
</Field>
{(book.source_excerpts || []).length > 0 ? (
<ul className="space-y-2">
{book.source_excerpts.map((s) => (
<li
key={s.id}
className="rounded-lg border border-gray-200 p-3 text-xs"
>
<p className="font-semibold text-gray-800">
{s.filename}{' '}
<span className="font-normal text-gray-400">
{s.added_at?.slice(0, 10)}
</span>
</p>
<p className="mt-1 text-gray-600 line-clamp-4 whitespace-pre-wrap">
{s.excerpt.slice(0, 500)}
{s.excerpt.length > 500 ? '…' : ''}
</p>
</li>
))}
</ul>
) : (
<p className="text-xs text-gray-500">
Aún no hay fuentes de texto.
</p>
)}
</div>
)}
</Surface>
</div>
</>
) : null}
</div>
);
}