Wire Meta IG/FB publish from pipeline with feed view and delete.

Agency can publish now or on schedule, remove live posts from Meta, and browse pieces newest-first like an IG board.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 18:18:15 +02:00
parent c2bbbfcd06
commit ade2762b01
4 changed files with 1197 additions and 67 deletions

View File

@@ -14,6 +14,8 @@ import {
ChevronLeft,
ChevronRight,
Filter,
LayoutGrid,
Send,
} from 'lucide-react';
import { PageHeader, Surface } from '@/components/ui';
import {
@@ -66,6 +68,9 @@ type PipelineItem = {
publish_status?: string | null;
publish_error?: string | null;
publish_attempts?: number | null;
published_at?: string | null;
meta_ig_media_id?: string | null;
meta_fb_post_id?: string | null;
calendar_piece_id?: string | null;
};
@@ -151,6 +156,7 @@ export default function CreativePipelineBoard() {
const [selectedMonth, setSelectedMonth] = useState<string>('all');
const [selectedFormat, setSelectedFormat] = useState<string>('all');
const [previewItem, setPreviewItem] = useState<PipelineItem | null>(null);
const [viewMode, setViewMode] = useState<'produccion' | 'tablon'>('produccion');
async function load() {
setLoading(true);
@@ -260,6 +266,19 @@ export default function CreativePipelineBoard() {
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
}, [filteredItems]);
/** Tablón tipo IG: más reciente → más antiguo */
const feedItems = useMemo(() => {
const score = (it: PipelineItem) => {
const t =
Date.parse(it.published_at || '') ||
Date.parse(it.scheduled_at || '') ||
Date.parse(it.created_at || '') ||
0;
return t;
};
return [...filteredItems].sort((a, b) => score(b) - score(a));
}, [filteredItems]);
async function createItem(e: React.FormEvent) {
e.preventDefault();
if (!newTitle.trim()) return;
@@ -378,10 +397,29 @@ export default function CreativePipelineBoard() {
}
}
async function removeItem(id: string) {
async function removeItem(id: string, alsoMeta = false) {
setBusyId(id);
setErr(null);
try {
await fetch(`/api/creative-pipeline?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
const target = items.find((x) => x.id === id);
if (
alsoMeta &&
(target?.meta_ig_media_id || target?.meta_fb_post_id)
) {
const del = await fetch('/api/creative-pipeline/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, action: 'delete_meta' }),
});
const delData = await del.json();
if (!del.ok) {
setErr(delData.error || 'No se pudo borrar en Meta');
return;
}
}
await fetch(`/api/creative-pipeline?id=${encodeURIComponent(id)}`, {
method: 'DELETE',
});
if (previewItem?.id === id) setPreviewItem(null);
await load();
} finally {
@@ -389,6 +427,65 @@ export default function CreativePipelineBoard() {
}
}
async function publishNow(id: string) {
if (!isAgency) return;
setBusyId(id);
setErr(null);
try {
const res = await fetch('/api/creative-pipeline/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, action: 'publish_now' }),
});
const data = await res.json();
if (!res.ok) {
setErr(data.error || 'Error al publicar en Meta');
if (data.item && previewItem?.id === id) {
setPreviewItem({ ...previewItem, ...data.item });
}
await load();
return;
}
if (data.item && previewItem?.id === id) {
setPreviewItem({ ...previewItem, ...data.item });
}
await load();
} finally {
setBusyId(null);
}
}
async function deleteOnMeta(id: string) {
if (!isAgency) return;
if (
!window.confirm(
'¿Borrar esta publicación en Instagram/Facebook? La pieza seguirá en el pipeline.'
)
) {
return;
}
setBusyId(id);
setErr(null);
try {
const res = await fetch('/api/creative-pipeline/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, action: 'delete_meta' }),
});
const data = await res.json();
if (!res.ok) {
setErr(data.error || 'No se pudo borrar en Meta');
return;
}
if (data.item && previewItem?.id === id) {
setPreviewItem({ ...previewItem, ...data.item });
}
await load();
} finally {
setBusyId(null);
}
}
async function uploadImage(
itemId: string,
file: File,
@@ -422,7 +519,7 @@ export default function CreativePipelineBoard() {
title="Panel de Creatividades"
description={
isAgency
? 'Producción editorial. Programa IG/FB solo desde aquí (vista agencia).'
? 'Producción editorial. Publica o programa IG/FB desde aquí (vista agencia).'
: 'Revisa y aprueba las creatividades propuestas por 4Ventures.'
}
actions={
@@ -451,10 +548,31 @@ export default function CreativePipelineBoard() {
{/* Barra de Filtros por Mes y Formato */}
<div className="mb-6 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Meses ({items.length} piezas):
</span>
<div className="inline-flex rounded-lg border border-gray-200 bg-white p-0.5">
<button
type="button"
onClick={() => setViewMode('produccion')}
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
viewMode === 'produccion'
? 'bg-gray-900 text-white'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
<LayoutGrid className="h-3.5 w-3.5" />
Producción
</button>
<button
type="button"
onClick={() => setViewMode('tablon')}
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
viewMode === 'tablon'
? 'bg-gray-900 text-white'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
<Instagram className="h-3.5 w-3.5" />
Tablón IG
</button>
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Filter className="h-3.5 w-3.5" />
@@ -517,6 +635,12 @@ export default function CreativePipelineBoard() {
);
})}
</div>
{viewMode === 'tablon' ? (
<p className="text-[11px] text-gray-500">
Orden: más reciente → más antiguo (fecha de publicación, programación o
creación). Simula el feed de Instagram.
</p>
) : null}
</div>
{showForm && isAgency ? (
@@ -695,6 +819,30 @@ export default function CreativePipelineBoard() {
No hay piezas con los filtros seleccionados (Mes: {selectedMonth}, Formato: {selectedFormat}).
</p>
</Surface>
) : viewMode === 'tablon' ? (
<div className="mx-auto max-w-xl space-y-6">
{feedItems.map((it) => (
<PieceCard
key={it.id}
item={it}
isAgency={isAgency}
busy={busyId === it.id}
feedStyle
onStatus={(s) => setStatus(it.id, s)}
onDelete={() => {
const hasMeta = Boolean(it.meta_ig_media_id || it.meta_fb_post_id);
const alsoMeta =
hasMeta &&
window.confirm(
'Esta pieza está en redes. ¿Borrar también el post en Instagram/Facebook?'
);
void removeItem(it.id, alsoMeta);
}}
onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)}
onPreview={() => setPreviewItem(it)}
/>
))}
</div>
) : (
<div className="space-y-8">
{grouped.map(([month, monthItems]) => (
@@ -715,8 +863,20 @@ export default function CreativePipelineBoard() {
isAgency={isAgency}
busy={busyId === it.id}
onStatus={(s) => setStatus(it.id, s)}
onDelete={() => removeItem(it.id)}
onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)}
onDelete={() => {
const hasMeta = Boolean(
it.meta_ig_media_id || it.meta_fb_post_id
);
const alsoMeta =
hasMeta &&
window.confirm(
'Esta pieza está en redes. ¿Borrar también el post en Instagram/Facebook?'
);
void removeItem(it.id, alsoMeta);
}}
onUpload={(file, slot, index) =>
uploadImage(it.id, file, slot, index)
}
onPreview={() => setPreviewItem(it)}
/>
))}
@@ -735,6 +895,8 @@ export default function CreativePipelineBoard() {
onClose={() => setPreviewItem(null)}
onStatus={(s) => setStatus(previewItem.id, s)}
onSaveSchedule={(payload) => saveSchedule(previewItem.id, payload)}
onPublishNow={() => publishNow(previewItem.id)}
onDeleteMeta={() => deleteOnMeta(previewItem.id)}
/>
)}
</div>
@@ -745,6 +907,7 @@ function PieceCard({
item,
isAgency,
busy,
feedStyle = false,
onStatus,
onDelete,
onUpload,
@@ -753,6 +916,7 @@ function PieceCard({
item: PipelineItem;
isAgency: boolean;
busy: boolean;
feedStyle?: boolean;
onStatus: (status: string) => void;
onDelete: () => void;
onUpload: (file: File, slot: 'cover' | 'slide', index: number) => void;
@@ -768,11 +932,48 @@ function PieceCard({
const igUrl = getInstagramUrl(item.notes);
const clientCanDecide = !isAgency && item.status === 'revision_interna';
const when =
item.published_at || item.scheduled_at || item.created_at;
return (
<Surface padded={false} className="overflow-hidden flex flex-col group transition-shadow hover:shadow-md">
<Surface
padded={false}
className={`overflow-hidden flex flex-col group transition-shadow hover:shadow-md ${
feedStyle ? 'rounded-none sm:rounded-xl' : ''
}`}
>
{feedStyle ? (
<div className="flex items-center justify-between gap-2 border-b border-gray-100 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900">
{item.title}
</p>
<p className="text-[11px] text-gray-500">
{when
? new Date(when).toLocaleString('es-ES', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})
: '—'}
{item.publish_status === 'published' ? ' · en redes' : ''}
</p>
</div>
<span
className={`shrink-0 rounded-md px-2 py-0.5 text-[10px] font-bold uppercase border ${formatInfo.badgeCls}`}
>
{formatInfo.label}
</span>
</div>
) : null}
{/* Portada / Imagen */}
<div className="relative aspect-[4/5] bg-gray-100 overflow-hidden cursor-pointer" onClick={onPreview}>
<div
className={`relative bg-gray-100 overflow-hidden cursor-pointer ${
feedStyle ? 'aspect-square' : 'aspect-[4/5]'
}`}
onClick={onPreview}
>
{cover ? (
// eslint-disable-next-line @next/next/no-img-element
<img
@@ -997,6 +1198,8 @@ function DetailModal({
onClose,
onStatus,
onSaveSchedule,
onPublishNow,
onDeleteMeta,
}: {
item: PipelineItem;
isAgency: boolean;
@@ -1007,6 +1210,8 @@ function DetailModal({
scheduled_at: string | null;
channels: PublishChannel[];
}) => void;
onPublishNow: () => void;
onDeleteMeta: () => void;
}) {
const slides = item.slide_paths || [];
const allImages = [item.cover_path, ...slides].filter(Boolean) as string[];
@@ -1224,36 +1429,67 @@ function DetailModal({
{item.publish_error}
</p>
) : null}
<div className="flex gap-2">
<div className="flex flex-col gap-2">
<button
type="button"
disabled={busy}
onClick={submitSchedule}
className="flex-1 rounded-lg bg-sky-700 px-3 py-2 text-xs font-semibold text-white hover:bg-sky-800 disabled:opacity-60"
disabled={
busy ||
!item.cover_path ||
item.publish_status === 'publishing' ||
item.publish_status === 'published'
}
onClick={onPublishNow}
className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-gradient-to-r from-purple-600 via-pink-600 to-amber-500 px-3 py-2 text-xs font-bold text-white shadow-sm hover:opacity-95 disabled:opacity-50"
>
Guardar programación
<Send className="h-3.5 w-3.5" />
{item.publish_status === 'publishing'
? 'Publicando…'
: item.publish_status === 'published'
? 'Ya publicado en redes'
: 'Publicar ahora (IG + FB)'}
</button>
{schedLocal ? (
{(item.meta_ig_media_id || item.meta_fb_post_id) && (
<button
type="button"
disabled={busy}
onClick={() => {
setSchedLocal('');
onSaveSchedule({
scheduled_at: null,
channels: ['instagram', 'facebook'],
});
}}
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700"
onClick={onDeleteMeta}
className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 py-2 text-xs font-semibold text-red-700 hover:bg-red-50 disabled:opacity-60"
>
Quitar
<Trash2 className="h-3.5 w-3.5" />
Borrar en Instagram / Facebook
</button>
) : null}
)}
<div className="flex gap-2">
<button
type="button"
disabled={busy}
onClick={submitSchedule}
className="flex-1 rounded-lg bg-sky-700 px-3 py-2 text-xs font-semibold text-white hover:bg-sky-800 disabled:opacity-60"
>
Guardar programación
</button>
{schedLocal ? (
<button
type="button"
disabled={busy}
onClick={() => {
setSchedLocal('');
onSaveSchedule({
scheduled_at: null,
channels: ['instagram', 'facebook'],
});
}}
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700"
>
Quitar
</button>
) : null}
</div>
</div>
<p className="text-[10px] text-sky-900/80 leading-relaxed">
Requiere estado <strong>aprobado</strong> para entrar en cola.
Publica IG+FB al unísono vía cron (Meta se conecta en el
siguiente paso).
<strong>Publicar ahora</strong> sale al momento. Con fecha futura +
estado <strong>aprobado</strong>, el cron publica IG+FB al unísono.
Si ya está en redes y está mal: bórralo aquí y vuelve a publicar.
</p>
</div>
) : null}