feat: LinkedIn editorial, accesos agencia estables y deshacer en historial

Integra estrategias LinkedIn en calendarios (backlog publicado vs pendiente), unifica roles/cookies de vista agencia y permite deshacer cambios de brandbook dejando constancia en el historial.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-16 04:15:51 +02:00
parent 3b57cdce53
commit 4a1abd3318
54 changed files with 6400 additions and 527 deletions

View File

@@ -1,9 +1,12 @@
'use client';
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
CalendarPlus,
ExternalLink,
Instagram,
Linkedin,
Loader2,
Plus,
Sparkles,
@@ -138,20 +141,67 @@ export default function CalendarCreator({
...DEFAULT_GEN_OPTIONS,
});
const [showGenPanel, setShowGenPanel] = useState(true);
/** Pestaña del creador: IG/FB vs LinkedIn */
const [creatorTab, setCreatorTab] = useState<'ig-fb' | 'linkedin'>('ig-fb');
const [liStrategyMeta, setLiStrategyMeta] = useState<{
url: string | null;
posts_count: number;
themes: string[];
} | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
try {
const r = await fetch('/api/calendar/linkedin-strategy');
const j = await r.json();
if (!r.ok || cancelled) return;
setLiStrategyMeta({
url: j.url || j.brandbook_url || null,
posts_count: j.posts_count || 0,
themes: j.themes || [],
});
if (j.url || j.brandbook_url) {
setGenOptions((o) =>
o.linkedinStrategyUrl
? o
: {
...o,
linkedinStrategyUrl: String(j.url || j.brandbook_url || ''),
}
);
}
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
}, [open]);
const monthChoices = useMemo(() => {
const base = ymNow();
return Array.from({ length: 6 }, (_, i) => addMonths(base, i));
}, []);
const filteredPieces = useMemo(() => {
const all = plan?.pieces || [];
if (creatorTab === 'linkedin') {
return all.filter((p) => /linkedin/i.test(p.platform || ''));
}
return all.filter((p) => !/linkedin/i.test(p.platform || ''));
}, [plan, creatorTab]);
const byDate = useMemo(() => {
const m = new Map<string, RrssPiece[]>();
for (const p of plan?.pieces || []) {
for (const p of filteredPieces) {
const k = p.date.slice(0, 10);
(m.get(k) || m.set(k, []).get(k)!).push(p);
}
return m;
}, [plan]);
}, [filteredPieces]);
const grid = useMemo(() => {
if (!month) return { pad: 0, days: 0 };
@@ -213,7 +263,21 @@ export default function CalendarCreator({
setBusy(true);
setErr(null);
try {
const opts = normalizeGenOptions(genOptions);
let opts = normalizeGenOptions(genOptions);
// Pestaña LinkedIn: prioriza LI (default 8) y no regenera IG/FB en este preview
if (creatorTab === 'linkedin') {
if (opts.linkedinPosts <= 0) {
opts = { ...opts, linkedinPosts: 8 };
setGenOptions((o) => ({ ...o, linkedinPosts: 8 }));
}
opts = {
...opts,
posts: 0,
stories: 0,
reels: 0,
carruseles: 0,
};
}
const keepIds =
mode === 'regen_selected' && plan?.pieces?.length
? new Set(
@@ -264,6 +328,26 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
setSource(`${j.source || 'heuristic'}+regen`);
setRegenIds(new Set());
}
} else if (creatorTab === 'linkedin' && plan?.pieces?.length) {
// Conserva IG/FB existentes; sustituye solo LinkedIn
const keepIg = (plan.pieces || []).filter(
(p) => !/linkedin/i.test(p.platform || '')
);
const newLi = (j.plan?.pieces || []).filter((p: RrssPiece) =>
/linkedin/i.test(p.platform || '')
);
const merged: RrssMonthPlan = {
...(j.plan || plan),
pieces: [...keepIg, ...newLi].sort((a, b) =>
a.date.localeCompare(b.date)
),
};
setPlan(merged);
setRationale(
`${j.rationale || ''}\n\nLinkedIn regenerado (${newLi.length}); IG/FB conservados (${keepIg.length}).`
);
setSource(`${j.source || 'heuristic'}+li`);
setRegenIds(new Set());
} else {
setPlan(j.plan);
setRationale(j.rationale || '');
@@ -440,7 +524,11 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
}
}
const dayPieces = dayZoom ? byDate.get(dayZoom) || [] : [];
const dayPieces = useMemo(() => {
if (!dayZoom) return [] as RrssPiece[];
// En el drawer del día mostramos todas las redes; el tab solo filtra el grid
return (plan?.pieces || []).filter((p) => p.date.slice(0, 10) === dayZoom);
}, [dayZoom, plan]);
return (
<>
@@ -523,6 +611,52 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex rounded-xl border border-gray-200 bg-gray-50 p-0.5">
<button
type="button"
onClick={() => setCreatorTab('ig-fb')}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold ${
creatorTab === 'ig-fb'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500'
}`}
>
<Instagram className="h-3.5 w-3.5" />
IG / FB
<span className="text-[10px] text-gray-400">
{
(plan?.pieces || []).filter(
(p) => !/linkedin/i.test(p.platform || '')
).length
}
</span>
</button>
<button
type="button"
onClick={() => setCreatorTab('linkedin')}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold ${
creatorTab === 'linkedin'
? 'bg-[#7C66B8] text-white shadow-sm'
: 'text-gray-500'
}`}
>
<Linkedin className="h-3.5 w-3.5" />
LinkedIn
<span
className={`text-[10px] ${
creatorTab === 'linkedin'
? 'text-white/70'
: 'text-gray-400'
}`}
>
{
(plan?.pieces || []).filter((p) =>
/linkedin/i.test(p.platform || '')
).length
}
</span>
</button>
</div>
<button
type="button"
onClick={() => setStep('pick')}
@@ -601,6 +735,7 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
Ocultar
</button>
</div>
{creatorTab === 'ig-fb' ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{(
[
@@ -628,6 +763,124 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
</label>
))}
</div>
) : (
<div className="space-y-3 rounded-xl border border-[#7C66B8]/30 bg-white/80 p-3">
<p className="text-[11px] font-bold uppercase tracking-wider text-[#7C66B8]">
Generación LinkedIn B2B
</p>
<label className="text-[11px] text-gray-700 block">
Nº de posts LinkedIn
<input
type="number"
min={0}
max={40}
value={genOptions.linkedinPosts}
onChange={(e) =>
setGenOptions((o) => ({
...o,
linkedinPosts: Number(e.target.value) || 0,
}))
}
className="mt-0.5 w-full max-w-[120px] rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm"
/>
</label>
<div>
<p className="text-[11px] font-semibold text-gray-700 mb-1">
Fuente del copy
</p>
<div className="flex flex-wrap gap-1.5">
{(
[
['from_strategy', 'Estrategia (copie listo)'],
['ideas', 'Ideas nuevas'],
['mixed', 'Mixto'],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() =>
setGenOptions((o) => ({
...o,
linkedinMode: mode,
}))
}
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold border ${
genOptions.linkedinMode === mode
? 'bg-[#7C66B8] text-white border-[#7C66B8]'
: 'bg-white text-gray-600 border-gray-200'
}`}
>
{label}
</button>
))}
</div>
</div>
<label className="block text-[11px] text-gray-700">
URL estrategia LinkedIn
<input
type="url"
value={genOptions.linkedinStrategyUrl || ''}
onChange={(e) =>
setGenOptions((o) => ({
...o,
linkedinStrategyUrl: e.target.value,
}))
}
placeholder="https://4ventures.es/bps/estrategia-linkedin-bps.html"
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs font-mono"
/>
</label>
{liStrategyMeta?.url ? (
<p className="text-[11px] text-gray-600">
Detectada:{' '}
<a
href={liStrategyMeta.url}
target="_blank"
rel="noopener noreferrer"
className="text-[#7C66B8] underline inline-flex items-center gap-0.5"
>
{liStrategyMeta.url.replace(/^https?:\/\//, '').slice(0, 56)}
<ExternalLink className="h-3 w-3" />
</a>
{liStrategyMeta.posts_count
? ` · ${liStrategyMeta.posts_count} posts en estrategia`
: ''}
</p>
) : (
<p className="text-[11px] text-amber-800">
Sin estrategia en brandbook: pega la URL o guárdala en Zona de marca → LinkedIn B2B.
</p>
)}
{liStrategyMeta?.themes?.length ? (
<p className="text-[11px] text-gray-500">
Temas: {liStrategyMeta.themes.slice(0, 5).join(' · ')}
</p>
) : null}
<label className="block text-[11px] text-gray-700">
Instrucciones LinkedIn
<textarea
value={genOptions.linkedinInstructions || ''}
onChange={(e) =>
setGenOptions((o) => ({
...o,
linkedinInstructions: e.target.value,
}))
}
rows={2}
placeholder="Ej: 8 posts desde la estrategia; tono C-level; CTA a demo…"
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs"
/>
</label>
<p className="text-[10px] text-gray-500 leading-relaxed">
Con URL: revisa qué posts de la estrategia ya están publicados o
agendados y programa los pendientes (copie listo). Sin URL o
backlog agotado: crea ideas nuevas siguiendo temas/CTAs del
brandbook y el histórico LinkedIn. Publicación API cuando configures{' '}
<code className="bg-gray-100 px-1 rounded">LINKEDIN_ACCESS_TOKEN</code>.
</p>
</div>
)}
<div>
<p className="text-[11px] font-semibold text-gray-700 mb-1">
Días de la semana
@@ -866,11 +1119,22 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
<>
<button
type="button"
onClick={() => setForm(emptyForm(dayZoom))}
onClick={() => {
const f = emptyForm(dayZoom);
if (creatorTab === 'linkedin') {
f.platform = 'linkedin';
f.format = 'Post';
f.pillar = 'b2b';
f.cta = 'Comenta tu caso · DM abierto';
}
setForm(f);
}}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-brand-600 px-3 py-2.5 text-sm font-semibold text-white hover:bg-brand-700"
>
<Plus className="h-4 w-4" />
Añadir publicación
{creatorTab === 'linkedin'
? 'Añadir post LinkedIn'
: 'Añadir publicación'}
</button>
{dayPieces.length === 0 ? (
<p className="text-xs text-gray-500">
@@ -906,9 +1170,40 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
style={{ background: colorFor(p) }}
/>
</div>
<p className="text-xs text-gray-600 line-clamp-3 whitespace-pre-wrap">
<p className="text-xs text-gray-600 line-clamp-6 whitespace-pre-wrap">
{p.body || p.hook}
</p>
{/linkedin/i.test(p.platform || '') ? (
<div className="rounded-lg border border-[#7C66B8]/25 bg-[#7C66B8]/5 p-2 space-y-1.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-[#7C66B8] inline-flex items-center gap-1">
<Linkedin className="h-3 w-3" /> Estrategia LinkedIn
</p>
{p.strategy_angle ? (
<p className="text-[11px] text-gray-700">
<span className="font-semibold">Ángulo:</span>{' '}
{p.strategy_angle}
</p>
) : null}
{(p.strategy_url || p.meta_line) && (
<a
href={
(p.meta_line?.includes('http')
? p.meta_line
: p.strategy_url) || '#'
}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] text-[#7C66B8] underline inline-flex items-center gap-1"
>
Ver en página de estrategia
{p.strategy_post_id
? ` (#${p.strategy_post_id})`
: ''}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
) : null}
{p.interactive_story &&
p.interactive_story !== 'none' ? (
<p className="text-[10px] font-semibold text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1">