'use client'; import React, { FormEvent, useEffect, useMemo, useState } from 'react'; import { Image as ImageIcon, Plus, Trash2, X, Eye, Upload, } from 'lucide-react'; import CreativePlacementPreview, { CHANNEL_OPTIONS, FORMAT_OPTIONS, PLACEMENT_OPTIONS, type CreativePreviewData, } from '@/components/CreativePlacementPreview'; import { PageHeader } from '@/components/ui'; export type Creative = CreativePreviewData & { id: string; status: string; created_at?: string; updated_at?: string; source?: 'ads' | 'editorial'; month?: string | null; notes?: string | null; instagram_url?: string | null; caption?: string | null; }; const STATUSES = [ { id: 'draft', label: 'Propuesta' }, { id: 'in_review', label: 'Revisión interna' }, { id: 'approved', label: 'Aprobado' }, { id: 'rejected', label: 'Rechazado' }, { id: 'published', label: 'Publicado' }, ] as const; const EDITORIAL_STATUS: Record = { propuesta: 'draft', en_diseno: 'draft', revision_interna: 'in_review', aprobado: 'approved', rechazado: 'rejected', publicado: 'published', draft: 'draft', in_review: 'in_review', approved: 'approved', rejected: 'rejected', published: 'published', }; function statusLabel(status: string): string { const mapped = EDITORIAL_STATUS[status] || status; return STATUSES.find((s) => s.id === mapped)?.label || status; } const inputCls = 'mt-1 w-full rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-sm text-white'; type Draft = { title: string; channel: string; placement: string; format: string; headline: string; primary_text: string; description: string; cta: string; media_url: string; brand_name: string; landing_url: string; month: string; notes: string; instagram_url: string; }; const emptyDraft = (): Draft => ({ title: '', channel: 'meta_ads', placement: 'feed', format: 'image', headline: '', primary_text: '', description: '', cta: 'Más información', media_url: '', brand_name: '', landing_url: '', month: '', notes: '', instagram_url: '', }); function channelLabel(id?: string | null) { return CHANNEL_OPTIONS.find((c) => c.id === id)?.label || id || '—'; } export default function CreativesPipeline() { const [creatives, setCreatives] = useState([]); const [draft, setDraft] = useState(emptyDraft); const [warning, setWarning] = useState(null); const [err, setErr] = useState(null); const [loading, setLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [selected, setSelected] = useState(null); const [showForm, setShowForm] = useState(false); const [filter, setFilter] = useState<'all' | string>('all'); const [uploading, setUploading] = useState(false); const [uploadMsg, setUploadMsg] = useState(null); const [isAgency, setIsAgency] = useState(true); const [monthFilter, setMonthFilter] = useState('all'); function metaOf(c: Creative): { month?: string | null; notes?: string | null; instagram_url?: string | null; } { const m = (c as Creative & { meta?: Record }).meta; if (m && typeof m === 'object') { return { month: typeof m.month === 'string' ? m.month : null, notes: typeof m.notes === 'string' ? m.notes : null, instagram_url: typeof m.instagram_url === 'string' ? m.instagram_url : null, }; } return {}; } async function load() { setLoading(true); const [adsRes, edRes] = await Promise.all([ fetch('/api/creatives'), fetch('/api/creative-pipeline'), ]); const adsData = await adsRes.json(); const edData = await edRes.json().catch(() => ({})); setIsAgency(Boolean(adsData.isAgency ?? edData.isAgency ?? true)); const ads: Creative[] = (adsData.creatives ?? []).map((c: Creative) => { const extra = metaOf(c); return { ...c, source: 'ads' as const, month: extra.month, notes: extra.notes, instagram_url: extra.instagram_url || c.landing_url, }; }); const editorial: Creative[] = (edData.items ?? []).map( (it: { id: string; title: string; status: string; format?: string; month?: string | null; caption?: string | null; notes?: string | null; cover_path?: string | null; created_at?: string; updated_at?: string; }) => { const ig = (it.notes || '').match(/https?:\/\/(?:www\.)?instagram\.com\S+/i)?.[0] || null; const fmt = it.format === 'carrusel' ? 'carousel' : it.format === 'post' ? 'image' : it.format || 'image'; return { id: it.id, title: it.title, status: EDITORIAL_STATUS[it.status] || it.status, source: 'editorial' as const, channel: 'instagram', placement: it.format === 'story' ? 'stories' : 'feed', format: fmt, headline: it.title, primary_text: it.caption || '', description: '', cta: '', media_url: it.cover_path ? `/api/creative-pipeline/media?path=${encodeURIComponent(it.cover_path)}` : '', brand_name: '', landing_url: ig || '', month: it.month, notes: it.notes, instagram_url: ig, caption: it.caption, created_at: it.created_at, updated_at: it.updated_at, } as Creative; } ); setCreatives([...editorial, ...ads]); setWarning( [adsData.warning, edData.warning].filter(Boolean).join(' · ') || null ); setLoading(false); } useEffect(() => { void load(); }, []); const filtered = useMemo(() => { return creatives.filter((c) => { if (monthFilter !== 'all' && (c.month || 'sin-mes') !== monthFilter) { return false; } if (filter === 'all') return true; if (filter === 'ads') { return ( c.source === 'ads' || ['meta_ads', 'google_ads'].includes(c.channel || '') ); } if (filter === 'rrss') { return ( c.source === 'editorial' || ['instagram', 'facebook', 'linkedin', 'stories', 'tiktok'].includes( c.channel || '' ) ); } const mapped = EDITORIAL_STATUS[c.status] || c.status; return mapped === filter || c.status === filter; }); }, [creatives, filter, monthFilter]); async function uploadFile( file: File, creativeId?: string ): Promise { setUploading(true); setUploadMsg(null); setErr(null); try { const fd = new FormData(); fd.append('file', file); if (creativeId) fd.append('creativeId', creativeId); if (draft.title) fd.append('title', draft.title); const res = await fetch('/api/creatives/upload', { method: 'POST', body: fd, }); const data = await res.json(); if (!res.ok) { setErr(data.error ?? 'Error al subir'); return null; } const url = data.media_url || data.creative?.media_url || null; if (url) { setDraft((d) => ({ ...d, media_url: url, title: d.title || data.creative?.title || d.title, format: file.type.startsWith('video/') && !d.format.startsWith('pmax') ? 'video' : d.format, })); setUploadMsg('Archivo subido · preview actualizada'); } await load(); return data.creative?.id || creativeId || null; } finally { setUploading(false); } } async function create(e: FormEvent) { e.preventDefault(); setErr(null); const res = await fetch('/api/creatives', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft), }); const data = await res.json(); if (!res.ok) { setErr(data.error ?? 'Error al crear'); return; } setDraft(emptyDraft()); setShowForm(false); await load(); } async function setStatus(id: string, status: string) { setBusyId(id); setErr(null); const item = creatives.find((c) => c.id === id); const endpoint = item?.source === 'editorial' ? '/api/creative-pipeline' : '/api/creatives'; const editorialStatus = item?.source === 'editorial' ? status === 'draft' ? 'propuesta' : status === 'in_review' ? 'revision_interna' : status === 'approved' ? 'aprobado' : status === 'rejected' ? 'rechazado' : status === 'published' ? 'publicado' : status : status === 'published' ? 'approved' : status; const res = await fetch(endpoint, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, status: editorialStatus }), }); const data = await res.json(); if (!res.ok) setErr(data.error ?? 'No se pudo actualizar'); await load(); setBusyId(null); if (selected?.id === id) { setSelected((prev) => (prev ? { ...prev, status } : prev)); } } async function saveSelected() { if (!selected) return; if (!isAgency) return; setBusyId(selected.id); setErr(null); if (selected.source === 'editorial') { const res = await fetch('/api/creative-pipeline', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: selected.id, title: selected.title, caption: selected.primary_text, notes: selected.notes || selected.landing_url, }), }); const data = await res.json(); if (!res.ok) setErr(data.error ?? 'No se pudo guardar'); await load(); setBusyId(null); return; } const res = await fetch('/api/creatives', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: selected.id, status: selected.status, title: selected.title, channel: selected.channel, placement: selected.placement, format: selected.format, headline: selected.headline, primary_text: selected.primary_text, description: selected.description, cta: selected.cta, media_url: selected.media_url, brand_name: selected.brand_name, landing_url: selected.landing_url, }), }); const data = await res.json(); if (!res.ok) setErr(data.error ?? 'No se pudo guardar'); await load(); setBusyId(null); } async function remove(id: string) { if (!confirm('¿Borrar esta creatividad?')) return; setBusyId(id); setErr(null); const item = creatives.find((c) => c.id === id); const url = item?.source === 'editorial' ? `/api/creative-pipeline?id=${encodeURIComponent(id)}` : `/api/creatives?id=${encodeURIComponent(id)}`; const res = await fetch(url, { method: 'DELETE', }); const data = await res.json().catch(() => ({})); if (!res.ok) setErr(data.error ?? 'No se pudo borrar'); if (selected?.id === id) setSelected(null); await load(); setBusyId(null); } const livePreview: CreativePreviewData = showForm ? draft : draft; return (
setShowForm((v) => !v)} className="inline-flex items-center gap-2 rounded-xl bg-brand-500/90 hover:bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white" > Nueva pieza ) : null } /> {warning && (

{warning}

)} {err &&

{err}

}
{[ { id: 'all', label: 'Todas' }, { id: 'ads', label: 'Campañas ads' }, { id: 'rrss', label: 'Redes sociales' }, ...STATUSES.map((s) => ({ id: s.id, label: s.label })), ].map((f) => ( ))}
{Array.from( new Set(creatives.map((c) => c.month).filter((m): m is string => Boolean(m))) ) .sort() .map((m) => ( ))}
{showForm && isAgency && (

Nueva creatividad

setDraft((d) => ({ ...d, title: e.target.value })) } className={inputCls} placeholder="VIDEO 8 · botellas 9:16" /> setDraft((d) => ({ ...d, brand_name: e.target.value })) } className={inputCls} placeholder="Marca del cliente" /> setDraft((d) => ({ ...d, month: e.target.value })) } className={inputCls} /> setDraft((d) => ({ ...d, cta: e.target.value })) } className={inputCls} placeholder="Más información" />
setDraft((d) => ({ ...d, headline: e.target.value })) } className={inputCls} placeholder="Titular visible en el anuncio / post" />