2297 lines
91 KiB
TypeScript
2297 lines
91 KiB
TypeScript
'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>
|
||
);
|
||
}
|