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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user