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