diff --git a/scripts/sync_bps_instagram_history.ts b/scripts/sync_bps_instagram_history.ts new file mode 100644 index 0000000..3355b06 --- /dev/null +++ b/scripts/sync_bps_instagram_history.ts @@ -0,0 +1,292 @@ +/** + * Extrae todas las publicaciones de Instagram de Blue Parrot School + * (Enero 2026 a Septiembre 2026) vía Meta Graph API: + * 1. Descarga portadas y slides de carruseles. + * 2. Sube los archivos a Supabase Storage (tenant-assets). + * 3. Inserta/actualiza las piezas en public.creative_pipeline (status: 'publicado'). + * 4. Actualiza/sincroniza en public.documents (type: 'calendar') para reflejarlas en el Calendario. + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { createClient } from '@supabase/supabase-js'; + +config({ path: resolve(process.cwd(), '.env.local') }); + +const BPS_TENANT_ID = '8bec977a-6032-4759-aa7f-b5042081f450'; +const BPS_IG_ID = '17841431459801731'; +const BUCKET = 'tenant-assets'; + +const META_TOKEN = process.env.META_ACCESS_TOKEN || ''; + +type IgMedia = { + id: string; + caption?: string; + media_type: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM'; + media_url?: string; + thumbnail_url?: string; + permalink?: string; + timestamp: string; + children?: { + data: Array<{ + id: string; + media_type: string; + media_url: string; + }>; + }; +}; + +function cleanTitle(caption?: string, date?: string): string { + if (!caption) return `Publicación Instagram ${date || ''}`.trim(); + const firstLine = caption.split('\n')[0].trim(); + // Quitar hashtags al inicio si los hay + const clean = firstLine.replace(/#\S+/g, '').trim(); + if (clean.length > 5) { + return clean.slice(0, 100); + } + return caption.slice(0, 100).trim() || `Publicación ${date || ''}`; +} + +async function fetchBuffer(url: string): Promise { + try { + const res = await fetch(url); + if (!res.ok) return null; + const arrayBuffer = await res.arrayBuffer(); + return Buffer.from(arrayBuffer); + } catch (err) { + console.error(`Error descargando ${url}:`, err); + return null; + } +} + +async function main() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim(); + const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY'); + + const admin = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } }); + + console.log('1. Consultando Meta Graph API para Blue Parrot School (@blueparrotschool)...'); + const allMedia: IgMedia[] = []; + let nextUrl: string | null = + `https://graph.facebook.com/v21.0/${BPS_IG_ID}/media?fields=id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,children{id,media_type,media_url}&limit=50&access_token=${META_TOKEN}`; + + while (nextUrl) { + const res = await fetch(nextUrl); + if (!res.ok) { + const err = await res.text(); + throw new Error(`Graph API error: ${err}`); + } + const data = await res.json(); + const items: IgMedia[] = data.data || []; + if (!items.length) break; + + let reachedPast = false; + for (const it of items) { + const date = it.timestamp.slice(0, 10); + if (date >= '2026-01-01' && date <= '2026-09-30') { + allMedia.push(it); + } else if (date < '2026-01-01') { + reachedPast = true; + break; + } + } + if (reachedPast) break; + nextUrl = data.paging?.next || null; + } + + console.log(`Encontradas ${allMedia.length} publicaciones en Instagram entre 2026-01 y 2026-09.`); + + // Ordenar cronológicamente (antiguo a nuevo) + allMedia.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + + // 2. Subir a Supabase Storage y a public.creative_pipeline + console.log('2. Subiendo imágenes a Storage y registrando en public.creative_pipeline...'); + let successCount = 0; + + for (let i = 0; i < allMedia.length; i++) { + const it = allMedia[i]; + const date = it.timestamp.slice(0, 10); + const month = date.slice(0, 7); + const isCarousel = it.media_type === 'CAROUSEL_ALBUM'; + const isReel = it.media_type === 'VIDEO'; + const format = isCarousel ? 'carrusel' : isReel ? 'reel' : 'post'; + const title = cleanTitle(it.caption, date); + + // Obtener URL de portada + const coverUrl = isReel + ? (it.thumbnail_url || it.media_url) + : isCarousel + ? (it.children?.data?.[0]?.media_url || it.media_url) + : it.media_url; + + const extRef = `ig-${it.id}`; + + // 1) Upsert fila base en creative_pipeline + const { data: row, error: upErr } = await admin + .from('creative_pipeline') + .upsert( + { + tenant_id: BPS_TENANT_ID, + external_ref: extRef, + title, + format, + month, + status: 'publicado', + caption: it.caption || null, + notes: it.permalink ? `Instagram: ${it.permalink}` : null, + created_at: it.timestamp, + }, + { onConflict: 'tenant_id,external_ref' } + ) + .select('id') + .single(); + + if (upErr || !row) { + console.error(`[ERROR upsert] ${extRef}: ${upErr?.message}`); + continue; + } + const itemId = row.id as string; + + // 2) Descargar y subir portada + let coverPath: string | null = null; + if (coverUrl) { + const coverBuf = await fetchBuffer(coverUrl); + if (coverBuf) { + coverPath = `${BPS_TENANT_ID}/pipeline/${itemId}/cover.jpg`; + const upRes = await admin.storage.from(BUCKET).upload(coverPath, coverBuf, { + contentType: 'image/jpeg', + upsert: true, + }); + if (upRes.error) { + console.error(`[WARN cover upload] ${extRef}: ${upRes.error.message}`); + coverPath = null; + } + } + } + + // 3) Si es carrusel, descargar y subir slides + const slidePaths: string[] = []; + if (isCarousel && it.children?.data) { + const slides = it.children.data.slice(1); // slides posteriores a la portada + for (let sIdx = 0; sIdx < slides.length; sIdx++) { + const slide = slides[sIdx]; + if (!slide.media_url) continue; + const slideBuf = await fetchBuffer(slide.media_url); + if (slideBuf) { + const sPath = `${BPS_TENANT_ID}/pipeline/${itemId}/slide-${sIdx + 1}.jpg`; + const sUp = await admin.storage.from(BUCKET).upload(sPath, slideBuf, { + contentType: 'image/jpeg', + upsert: true, + }); + if (!sUp.error) { + slidePaths.push(sPath); + } + } + } + } + + // 4) Actualizar registro con las rutas de storage + await admin + .from('creative_pipeline') + .update({ + cover_path: coverPath, + cover_mime: coverPath ? 'image/jpeg' : null, + slide_paths: slidePaths, + }) + .eq('id', itemId); + + successCount++; + console.log(`[${i + 1}/${allMedia.length}] ${month} [${format}] ${title} (${slidePaths.length} slides)`); + } + + console.log(`\nCreatividades cargadas en creative_pipeline: ${successCount} de ${allMedia.length}.`); + + // 3. Sincronizar en public.documents (type = 'calendar') + console.log('\n3. Sincronizando con public.documents para el Calendario...'); + // Agrupar las publicaciones de Instagram por mes + const igByMonth = new Map(); + for (const m of allMedia) { + const ym = m.timestamp.slice(0, 7); + const list = igByMonth.get(ym) || []; + list.push(m); + igByMonth.set(ym, list); + } + + const { data: allDocs } = await admin + .from('documents') + .select('id, title, content') + .eq('tenant_id', BPS_TENANT_ID) + .eq('type', 'calendar'); + + const docsList = allDocs || []; + + for (const [ym, igList] of igByMonth.entries()) { + // Buscar documento del mes + const doc = docsList.find((d) => { + try { + const c = typeof d.content === 'string' ? JSON.parse(d.content) : d.content; + return c?.month === ym; + } catch { + return false; + } + }); + + if (doc) { + let content = typeof doc.content === 'string' ? JSON.parse(doc.content) : doc.content; + const pieces: any[] = content?.pieces || []; + + // Mapear posts de IG en piezas de calendario si no están + for (const ig of igList) { + const date = ig.timestamp.slice(0, 10); + const title = cleanTitle(ig.caption, date); + const isReel = ig.media_type === 'VIDEO'; + const isCarousel = ig.media_type === 'CAROUSEL_ALBUM'; + const format = isCarousel ? 'Carrusel' : isReel ? 'Reel' : 'Post'; + + // Buscar si ya existe una pieza en esa fecha + const match = pieces.find( + (p) => p.date === date && (p.title?.toLowerCase().includes(title.slice(0, 15).toLowerCase()) || p.platform === 'Instagram') + ); + + if (match) { + match.status = 'published'; + match.meta_line = `Instagram: ${ig.permalink || ''}`; + if (!match.body && ig.caption) match.body = ig.caption; + } else { + // Agregar nueva pieza publicada + pieces.push({ + id: `bps-${date}-ig-${ig.id}`, + date, + platform: 'Instagram', + format, + pillar: isReel ? 'reels' : isCarousel ? 'carrusel' : 'feed', + title, + body: ig.caption || undefined, + status: 'published', + kind: isReel ? 'reel' : 'post', + color: isReel ? '#7C66B8' : isCarousel ? '#2071cf' : '#0F9D8F', + meta_line: `Instagram: ${ig.permalink || ''}`, + }); + } + } + + // Ordenar piezas por fecha + pieces.sort((a, b) => a.date.localeCompare(b.date)); + content.pieces = pieces; + + await admin + .from('documents') + .update({ content }) + .eq('id', doc.id); + + console.log(`Mes ${ym} actualizado en Calendario: ${pieces.length} piezas.`); + } + } + + console.log('\nSincronización completada con éxito.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/api/creative-pipeline/route.ts b/src/app/api/creative-pipeline/route.ts index da647d4..40d1767 100644 --- a/src/app/api/creative-pipeline/route.ts +++ b/src/app/api/creative-pipeline/route.ts @@ -65,7 +65,7 @@ export async function GET(req: NextRequest) { let q = `${supabaseUrl()}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent( ctx.tenantId - )}&select=${SELECT}&order=month.asc,created_at.asc`; + )}&select=${SELECT}&order=month.asc,created_at.asc&limit=1000`; if (month) q += `&month=eq.${encodeURIComponent(month)}`; if (ctx.viaView) { @@ -87,7 +87,8 @@ export async function GET(req: NextRequest) { .from('creative_pipeline') .select(SELECT) .order('month', { ascending: true }) - .order('created_at', { ascending: true }); + .order('created_at', { ascending: true }) + .limit(1000); if (month) query = query.eq('month', month); const { data, error } = await query; diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx index bd9fe68..72394f3 100644 --- a/src/components/CreativePipelineBoard.tsx +++ b/src/components/CreativePipelineBoard.tsx @@ -1,7 +1,20 @@ 'use client'; import React, { useEffect, useMemo, useState } from 'react'; -import { ImagePlus, Plus, Trash2, Upload } from 'lucide-react'; +import { + ImagePlus, + Plus, + Trash2, + Upload, + Instagram, + ExternalLink, + X, + Eye, + Layers, + ChevronLeft, + ChevronRight, + Filter, +} from 'lucide-react'; import { PageHeader, Surface } from '@/components/ui'; type PipelineItem = { @@ -31,12 +44,19 @@ const STATUS_LABEL: Record = { }; const STATUS_COLOR: Record = { - propuesta: 'bg-gray-100 text-gray-700', - en_diseno: 'bg-blue-100 text-blue-700', - revision_interna: 'bg-amber-100 text-amber-800', - aprobado: 'bg-emerald-100 text-emerald-700', - rechazado: 'bg-red-100 text-red-700', - publicado: 'bg-purple-100 text-purple-700', + propuesta: 'bg-gray-100 text-gray-700 border-gray-200', + en_diseno: 'bg-blue-100 text-blue-700 border-blue-200', + revision_interna: 'bg-amber-100 text-amber-800 border-amber-200', + aprobado: 'bg-emerald-100 text-emerald-700 border-emerald-200', + rechazado: 'bg-red-100 text-red-700 border-red-200', + publicado: 'bg-purple-100 text-purple-700 border-purple-200', +}; + +const FORMAT_CONFIG: Record = { + post: { label: 'Post', badgeCls: 'bg-blue-50 text-blue-700 border-blue-200' }, + carrusel: { label: 'Carrusel', badgeCls: 'bg-indigo-50 text-indigo-700 border-indigo-200' }, + reel: { label: 'Reel', badgeCls: 'bg-pink-50 text-pink-700 border-pink-200' }, + story: { label: 'Story', badgeCls: 'bg-amber-50 text-amber-700 border-amber-200' }, }; const AGENCY_STATUS_ORDER = [ @@ -60,6 +80,20 @@ function monthLabel(m: string | null) { return d.toLocaleDateString('es-ES', { month: 'long', year: 'numeric' }); } +function monthShortLabel(m: string | null) { + if (!m) return 'Sin mes'; + const [y, mm] = m.split('-'); + const d = new Date(Number(y), Number(mm) - 1, 1); + const name = d.toLocaleDateString('es-ES', { month: 'short' }); + return `${name} ${y.slice(2)}`; +} + +function getInstagramUrl(notes: string | null): string | null { + if (!notes) return null; + const match = notes.match(/https?:\/\/(?:www\.)?instagram\.com\S+/i); + return match ? match[0] : null; +} + export default function CreativePipelineBoard() { const [items, setItems] = useState([]); const [isAgency, setIsAgency] = useState(false); @@ -71,6 +105,9 @@ export default function CreativePipelineBoard() { const [newTitle, setNewTitle] = useState(''); const [newFormat, setNewFormat] = useState('post'); const [newMonth, setNewMonth] = useState(''); + const [selectedMonth, setSelectedMonth] = useState('all'); + const [selectedFormat, setSelectedFormat] = useState('all'); + const [previewItem, setPreviewItem] = useState(null); async function load() { setLoading(true); @@ -89,15 +126,40 @@ export default function CreativePipelineBoard() { void load(); }, []); + const monthCounts = useMemo(() => { + const map = new Map(); + for (const it of items) { + const key = it.month || 'sin-mes'; + map.set(key, (map.get(key) || 0) + 1); + } + return map; + }, [items]); + + const months = useMemo(() => { + const set = new Set(); + for (const it of items) { + if (it.month) set.add(it.month); + } + return Array.from(set).sort(); + }, [items]); + + const filteredItems = useMemo(() => { + return items.filter((it) => { + if (selectedMonth !== 'all' && (it.month || 'sin-mes') !== selectedMonth) return false; + if (selectedFormat !== 'all' && it.format !== selectedFormat) return false; + return true; + }); + }, [items, selectedMonth, selectedFormat]); + const grouped = useMemo(() => { const map = new Map(); - for (const it of items) { + for (const it of filteredItems) { const key = it.month || 'sin-mes'; if (!map.has(key)) map.set(key, []); map.get(key)!.push(it); } return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)); - }, [items]); + }, [filteredItems]); async function createItem(e: React.FormEvent) { e.preventDefault(); @@ -133,6 +195,9 @@ export default function CreativePipelineBoard() { setErr(data.error || 'Error al cambiar estado'); return; } + if (previewItem && previewItem.id === id) { + setPreviewItem({ ...previewItem, status }); + } await load(); } finally { setBusyId(null); @@ -143,6 +208,7 @@ export default function CreativePipelineBoard() { setBusyId(id); try { await fetch(`/api/creative-pipeline?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (previewItem?.id === id) setPreviewItem(null); await load(); } finally { setBusyId(null); @@ -180,7 +246,7 @@ export default function CreativePipelineBoard() { ) : null} + {/* Barra de Filtros por Mes y Formato */} +
+
+
+ + Meses ({items.length} piezas): + +
+
+ + Mostrando {filteredItems.length} de {items.length} +
+
+ + {/* Meses pills */} +
+ + {months.map((m) => { + const count = monthCounts.get(m) || 0; + const active = selectedMonth === m; + return ( + + ); + })} +
+ + {/* Formatos pills */} +
+ Formato: + {['all', 'carrusel', 'reel', 'post', 'story'].map((f) => { + const active = selectedFormat === f; + const label = + f === 'all' + ? 'Todos los formatos' + : FORMAT_CONFIG[f]?.label || f; + return ( + + ); + })} +
+
+ {showForm && isAgency ? (
@@ -240,7 +377,7 @@ export default function CreativePipelineBoard() { @@ -249,18 +386,29 @@ export default function CreativePipelineBoard() { ) : null} {loading ? ( -

Cargando…

+

Cargando creatividades…

) : items.length === 0 ? (

Todavía no hay piezas en el pipeline.

+ ) : filteredItems.length === 0 ? ( + +

+ No hay piezas con los filtros seleccionados (Mes: {selectedMonth}, Formato: {selectedFormat}). +

+
) : (
{grouped.map(([month, monthItems]) => (
-

- {monthLabel(month === 'sin-mes' ? null : month)} -

+
+

+ {monthLabel(month === 'sin-mes' ? null : month)} +

+ + {monthItems.length} {monthItems.length === 1 ? 'pieza' : 'piezas'} + +
{monthItems.map((it) => ( setStatus(it.id, s)} onDelete={() => removeItem(it.id)} onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)} + onPreview={() => setPreviewItem(it)} /> ))}
@@ -278,6 +427,17 @@ export default function CreativePipelineBoard() { ))}
)} + + {/* Modal de Detalle / Preview */} + {previewItem && ( + setPreviewItem(null)} + onStatus={(s) => setStatus(previewItem.id, s)} + /> + )}
); } @@ -289,6 +449,7 @@ function PieceCard({ onStatus, onDelete, onUpload, + onPreview, }: { item: PipelineItem; isAgency: boolean; @@ -296,26 +457,70 @@ function PieceCard({ onStatus: (status: string) => void; onDelete: () => void; onUpload: (file: File, slot: 'cover' | 'slide', index: number) => void; + onPreview: () => void; }) { const cover = mediaUrl(item.cover_path); const slides = item.slide_paths || []; const isCarrusel = item.format === 'carrusel'; + const formatInfo = FORMAT_CONFIG[item.format] || { + label: item.format, + badgeCls: 'bg-gray-100 text-gray-700 border-gray-200', + }; + const igUrl = getInstagramUrl(item.notes); const clientCanDecide = !isAgency && item.status === 'revision_interna'; return ( - -
+ + {/* Portada / Imagen */} +
{cover ? ( // eslint-disable-next-line @next/next/no-img-element - {item.title} + {item.title} ) : (
)} + + {/* Badges superiores */} +
+ + {isCarrusel && } + {formatInfo.label} + + {isCarrusel && slides.length > 0 && ( + + {slides.length} slides + + )} +
+ + {/* Botón de vista rápida */} + + + {/* Botón subir imagen agencia */} {isAgency ? ( -