Add Zona de marca brandbooks and strategist calendar generation.
Per-tenant brandbooks power ads, RRSS calendars, LinkedIn B2B and blog; Nov calendars now create from trends + client DNA instead of copying history. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
824
src/components/BrandZone.tsx
Normal file
824
src/components/BrandZone.tsx
Normal file
@@ -0,0 +1,824 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
BookOpen,
|
||||
Loader2,
|
||||
Save,
|
||||
Sparkles,
|
||||
Upload,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader, Surface, SectionTitle } from '@/components/ui';
|
||||
import {
|
||||
BRANDBOOK_SECTIONS,
|
||||
emptyBrandbook,
|
||||
linesToList,
|
||||
listToLines,
|
||||
type Brandbook,
|
||||
type BrandbookSectionId,
|
||||
} from '@/lib/brandbook';
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-semibold text-gray-800">{label}</span>
|
||||
{hint ? <span className="block text-[11px] text-gray-500">{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`;
|
||||
|
||||
export default function BrandZone({ tenantLabel }: { tenantLabel: string }) {
|
||||
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 [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 load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await fetch('/api/brandbook');
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.error || 'No se pudo cargar');
|
||||
setBook(j.brandbook || emptyBrandbook(tenantLabel));
|
||||
setSlug(j.slug || '');
|
||||
setScore(j.completeness?.score ?? 0);
|
||||
setMissing(j.completeness?.missing || []);
|
||||
setExists(!!j.exists);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tenantLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function patch(partial: Partial<Brandbook>) {
|
||||
setBook((b) => ({ ...b, ...partial }));
|
||||
setOkMsg(null);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setErr(null);
|
||||
setOkMsg(null);
|
||||
try {
|
||||
const r = await fetch('/api/brandbook', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ brandbook: book }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.error || 'No se pudo guardar');
|
||||
setBook(j.brandbook);
|
||||
setScore(j.completeness?.score ?? 0);
|
||||
setMissing(j.completeness?.missing || []);
|
||||
setExists(true);
|
||||
setOkMsg('Brandbook guardado');
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
setSeeding(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await fetch('/api/brandbook', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seed: true }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.error || 'No se pudo sembrar');
|
||||
setBook(j.brandbook);
|
||||
setScore(j.completeness?.score ?? 0);
|
||||
setMissing(j.completeness?.missing || []);
|
||||
setExists(true);
|
||||
setOkMsg('Base de marca creada — completa y guarda lo que falte');
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setSeeding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(file: File | null) {
|
||||
if (!file && !paste.trim()) return;
|
||||
setUploading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
if (file) fd.append('file', file);
|
||||
if (paste.trim()) {
|
||||
fd.append('text', paste);
|
||||
fd.append('filename', 'pegado.txt');
|
||||
}
|
||||
const r = await fetch('/api/brandbook/upload', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.error || 'Upload falló');
|
||||
setBook(j.brandbook);
|
||||
setScore(j.completeness?.score ?? 0);
|
||||
setMissing(j.completeness?.missing || []);
|
||||
setExists(true);
|
||||
setPaste('');
|
||||
setOkMsg(`Fuente añadida: ${j.ingested}`);
|
||||
setSection('fuentes');
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Error upload');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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}` : ''} · Brandbook para anuncios, calendarios RRSS, LinkedIn B2B y blog corporativo.`}
|
||||
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' : 'Crear base de marca'}
|
||||
</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 brandbook
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Surface className="!p-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
||||
Completitud
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-gray-900 mt-1">{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>
|
||||
</Surface>
|
||||
<Surface className="!p-3 sm:col-span-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
||||
Usos conectados
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 mt-1 leading-relaxed">
|
||||
Calendario editorial · Pipeline creatividades / ads · Estrategia
|
||||
LinkedIn B2B · Blog corporativo. Cuanto más completo el brandbook,
|
||||
mejores los temas que genera la IA.
|
||||
</p>
|
||||
{missing.length ? (
|
||||
<p className="text-xs text-amber-700 mt-2">
|
||||
Falta: {missing.join(' · ')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-emerald-700 mt-2 inline-flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Base mínima completa
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<nav className="lg:w-52 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={BRANDBOOK_SECTIONS.find((s) => s.id === section)?.label || ''}
|
||||
description={
|
||||
BRANDBOOK_SECTIONS.find((s) => s.id === section)?.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="Vertical / sector">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={book.vertical}
|
||||
onChange={(e) => patch({ vertical: 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="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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'voz' && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Tono">
|
||||
<textarea
|
||||
className={areaCls}
|
||||
value={book.tone}
|
||||
onChange={(e) => patch({ tone: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Personalidad (líneas)">
|
||||
<textarea
|
||||
className={areaCls}
|
||||
value={listToLines(book.personality)}
|
||||
onChange={(e) =>
|
||||
patch({ personality: 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'visual' && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Colores primarios (#hex o nombre)">
|
||||
<textarea
|
||||
className={areaCls}
|
||||
value={listToLines(book.colors_primary)}
|
||||
onChange={(e) =>
|
||||
patch({ colors_primary: linesToList(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Colores secundarios">
|
||||
<textarea
|
||||
className={areaCls}
|
||||
value={listToLines(book.colors_secondary)}
|
||||
onChange={(e) =>
|
||||
patch({ colors_secondary: linesToList(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Tipografías">
|
||||
<textarea
|
||||
className={areaCls}
|
||||
value={listToLines(book.fonts)}
|
||||
onChange={(e) =>
|
||||
patch({ fonts: linesToList(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<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} sm:col-span-2`}
|
||||
value={book.visual_notes}
|
||||
onChange={(e) => patch({ visual_notes: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'audiencia' && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="ICP">
|
||||
<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 (JSON o notas libres en Notas)"
|
||||
hint="Formato: Nombre — descripción — pains (una por línea, sep. |)"
|
||||
>
|
||||
<textarea
|
||||
className={areaCls}
|
||||
value={book.personas
|
||||
.map((p) => `${p.name} | ${p.description} | ${p.pains}`)
|
||||
.join('\n')}
|
||||
onChange={(e) => {
|
||||
const personas = e.target.value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [name, description, pains] = line
|
||||
.split('|')
|
||||
.map((s) => s.trim());
|
||||
return {
|
||||
name: name || 'Persona',
|
||||
description: description || '',
|
||||
pains: pains || '',
|
||||
};
|
||||
});
|
||||
patch({ personas });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'oferta' && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Productos / servicios">
|
||||
<textarea
|
||||
className={areaCls}
|
||||
value={listToLines(book.products_services)}
|
||||
onChange={(e) =>
|
||||
patch({ products_services: 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="Notas de pricing / packs">
|
||||
<textarea
|
||||
className={areaCls}
|
||||
value={book.pricing_notes}
|
||||
onChange={(e) => patch({ pricing_notes: 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'canales' && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
['instagram', 'Instagram'],
|
||||
['facebook', 'Facebook'],
|
||||
['linkedin', 'LinkedIn'],
|
||||
['tiktok', 'TikTok'],
|
||||
['blog', 'Blog'],
|
||||
['other', 'Otros'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<Field key={key} label={label} hint="Rol del canal para esta marca">
|
||||
<textarea
|
||||
className={areaCls}
|
||||
value={book.channels[key]}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
channels: { ...book.channels, [key]: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</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>
|
||||
</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>
|
||||
</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="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="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>
|
||||
</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
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
.txt / .md / .csv / .json — o pega el texto de un PDF/DOCX. Se
|
||||
guarda como fuente y alimenta notas libres.
|
||||
</p>
|
||||
<textarea
|
||||
className={areaCls}
|
||||
placeholder="Pega aquí el brandbook o brief…"
|
||||
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 uploadFile(f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading || !paste.trim()}
|
||||
onClick={() => void uploadFile(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 subidas.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Surface>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user