Absorb client approval, month filters and editorial pieces into Pipeline; redirect /panel-cliente and remove duplicate menu entry. Co-authored-by: Cursor <cursoragent@cursor.com>
1054 lines
37 KiB
TypeScript
1054 lines
37 KiB
TypeScript
'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<string, string> = {
|
|
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<Creative[]>([]);
|
|
const [draft, setDraft] = useState<Draft>(emptyDraft);
|
|
const [warning, setWarning] = useState<string | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [busyId, setBusyId] = useState<string | null>(null);
|
|
const [selected, setSelected] = useState<Creative | null>(null);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [filter, setFilter] = useState<'all' | string>('all');
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadMsg, setUploadMsg] = useState<string | null>(null);
|
|
const [isAgency, setIsAgency] = useState(true);
|
|
const [monthFilter, setMonthFilter] = useState<string>('all');
|
|
|
|
function metaOf(c: Creative): {
|
|
month?: string | null;
|
|
notes?: string | null;
|
|
instagram_url?: string | null;
|
|
} {
|
|
const m = (c as Creative & { meta?: Record<string, unknown> }).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<string | null> {
|
|
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 (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
eyebrow="Contenido"
|
|
title="Pipeline"
|
|
description="El cliente aprueba aquí. La agencia sube piezas (ads y RRSS) con preview de placement."
|
|
actions={
|
|
isAgency ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => 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"
|
|
>
|
|
<Plus className="w-4 h-4" /> Nueva pieza
|
|
</button>
|
|
) : null
|
|
}
|
|
/>
|
|
|
|
{warning && (
|
|
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
|
{warning}
|
|
</p>
|
|
)}
|
|
{err && <p className="text-xs text-red-400">{err}</p>}
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{[
|
|
{ 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) => (
|
|
<button
|
|
key={f.id}
|
|
type="button"
|
|
onClick={() => setFilter(f.id)}
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
|
|
filter === f.id
|
|
? 'bg-gray-900 text-white'
|
|
: 'bg-gray-100 text-gray-600'
|
|
}`}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMonthFilter('all')}
|
|
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
|
monthFilter === 'all'
|
|
? 'bg-brand-600 text-white'
|
|
: 'bg-gray-100 text-gray-600'
|
|
}`}
|
|
>
|
|
Todos los meses
|
|
</button>
|
|
{Array.from(
|
|
new Set(creatives.map((c) => c.month).filter((m): m is string => Boolean(m)))
|
|
)
|
|
.sort()
|
|
.map((m) => (
|
|
<button
|
|
key={m}
|
|
type="button"
|
|
onClick={() => setMonthFilter(m)}
|
|
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
|
monthFilter === m
|
|
? 'bg-brand-600 text-white'
|
|
: 'bg-gray-100 text-gray-600'
|
|
}`}
|
|
>
|
|
{m}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{showForm && isAgency && (
|
|
<section className="rounded-2xl border border-gray-200 bg-white p-4 md:p-5 grid lg:grid-cols-[1fr_300px] gap-5">
|
|
<form onSubmit={create} className="space-y-3">
|
|
<p className="text-sm font-bold text-gray-900">Nueva creatividad</p>
|
|
<div className="grid sm:grid-cols-2 gap-3">
|
|
<Field label="Nombre interno">
|
|
<input
|
|
required
|
|
value={draft.title}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, title: e.target.value }))
|
|
}
|
|
className={inputCls}
|
|
placeholder="VIDEO 8 · botellas 9:16"
|
|
/>
|
|
</Field>
|
|
<Field label="Marca / cuenta">
|
|
<input
|
|
value={draft.brand_name}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, brand_name: e.target.value }))
|
|
}
|
|
className={inputCls}
|
|
placeholder="Marca del cliente"
|
|
/>
|
|
</Field>
|
|
<Field label="Mes (YYYY-MM)">
|
|
<input
|
|
type="month"
|
|
value={draft.month}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, month: e.target.value }))
|
|
}
|
|
className={inputCls}
|
|
/>
|
|
</Field>
|
|
<Field label="Canal">
|
|
<select
|
|
value={draft.channel}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, channel: e.target.value }))
|
|
}
|
|
className={inputCls}
|
|
>
|
|
{CHANNEL_OPTIONS.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="Placement">
|
|
<select
|
|
value={draft.placement}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, placement: e.target.value }))
|
|
}
|
|
className={inputCls}
|
|
>
|
|
{PLACEMENT_OPTIONS.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="Formato">
|
|
<select
|
|
value={draft.format}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, format: e.target.value }))
|
|
}
|
|
className={inputCls}
|
|
>
|
|
{FORMAT_OPTIONS.map((f) => (
|
|
<option key={f.id} value={f.id}>
|
|
{f.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="CTA">
|
|
<input
|
|
value={draft.cta}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, cta: e.target.value }))
|
|
}
|
|
className={inputCls}
|
|
placeholder="Más información"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Field label="Headline">
|
|
<input
|
|
value={draft.headline}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, headline: e.target.value }))
|
|
}
|
|
className={inputCls}
|
|
placeholder="Titular visible en el anuncio / post"
|
|
/>
|
|
</Field>
|
|
<Field label="Primary text / copy">
|
|
<textarea
|
|
value={draft.primary_text}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, primary_text: e.target.value }))
|
|
}
|
|
className={`${inputCls} min-h-[88px]`}
|
|
placeholder="Texto principal del anuncio o caption RRSS"
|
|
/>
|
|
</Field>
|
|
<Field label="Descripción (ads)">
|
|
<input
|
|
value={draft.description}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, description: e.target.value }))
|
|
}
|
|
className={inputCls}
|
|
/>
|
|
</Field>
|
|
<div className="grid sm:grid-cols-2 gap-3">
|
|
<Field label="URL media (imagen / vídeo)">
|
|
<input
|
|
value={draft.media_url}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, media_url: e.target.value }))
|
|
}
|
|
className={inputCls}
|
|
placeholder="https://… o /api/creatives/media?id=…"
|
|
/>
|
|
</Field>
|
|
<Field label="Landing / Instagram">
|
|
<input
|
|
value={draft.landing_url}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({
|
|
...d,
|
|
landing_url: e.target.value,
|
|
instagram_url: /instagram\.com/i.test(e.target.value)
|
|
? e.target.value
|
|
: d.instagram_url,
|
|
}))
|
|
}
|
|
className={inputCls}
|
|
placeholder="https://… o enlace de Instagram"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Field label="Notas internas">
|
|
<textarea
|
|
className={`${inputCls} min-h-[72px]`}
|
|
value={draft.notes}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, notes: e.target.value }))
|
|
}
|
|
placeholder="Contexto para el cliente o el equipo"
|
|
/>
|
|
</Field>
|
|
<div className="rounded-xl border border-dashed border-violet-500/40 bg-violet-500/5 p-4">
|
|
<p className="text-[11px] uppercase tracking-wider text-violet-300 font-semibold mb-2 flex items-center gap-1.5">
|
|
<Upload className="w-3.5 h-3.5" /> Subir creativo (sin URL)
|
|
</p>
|
|
<input
|
|
type="file"
|
|
accept="image/*,video/mp4,video/webm,video/quicktime"
|
|
disabled={uploading}
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) void uploadFile(f);
|
|
e.target.value = '';
|
|
}}
|
|
className="block w-full text-xs text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-violet-500/20 file:px-3 file:py-1.5 file:text-violet-200 file:text-xs file:font-semibold"
|
|
/>
|
|
<p className="text-[10px] text-gray-500 mt-2">
|
|
JPG, PNG, WebP, GIF o MP4/WebM. Se guarda en el storage del
|
|
tenant y alimenta la preview.
|
|
</p>
|
|
{uploading ? (
|
|
<p className="text-xs text-violet-300 mt-2">Subiendo…</p>
|
|
) : null}
|
|
{uploadMsg ? (
|
|
<p className="text-xs text-emerald-400 mt-2">{uploadMsg}</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex gap-2 pt-1">
|
|
<button
|
|
type="submit"
|
|
className="rounded-xl bg-emerald-500/20 border border-emerald-500/40 text-emerald-300 px-4 py-2 text-sm font-semibold"
|
|
>
|
|
Guardar en pipeline
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowForm(false)}
|
|
className="rounded-xl bg-gray-100 text-gray-600 px-4 py-2 text-sm"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<div>
|
|
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2 flex items-center gap-1.5">
|
|
<Eye className="w-3.5 h-3.5" /> Preview en vivo
|
|
</p>
|
|
<CreativePlacementPreview data={livePreview} />
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{loading ? (
|
|
<p className="text-sm text-gray-500">Cargando…</p>
|
|
) : filtered.length === 0 ? (
|
|
<div className="bg-white border border-dashed border-gray-200 rounded-xl p-12 text-center">
|
|
<ImageIcon className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
|
<p className="text-gray-600 font-medium">Pipeline vacío</p>
|
|
<p className="text-xs text-gray-500 mt-2 max-w-md mx-auto">
|
|
Añade una pieza con canal (Meta, Google, IG, LinkedIn…) y verás la
|
|
preview de publicación como en el Ad Tracker.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{filtered.map((c) => (
|
|
<article
|
|
key={c.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => setSelected(c)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
setSelected(c);
|
|
}
|
|
}}
|
|
className="grid cursor-pointer gap-4 rounded-2xl border border-gray-200 bg-white p-4 transition hover:border-violet-500/40 md:grid-cols-[minmax(240px,300px)_1fr]"
|
|
>
|
|
<CreativePlacementPreview data={c} compact />
|
|
<div className="min-w-0 space-y-3">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900">
|
|
{c.title}
|
|
</h3>
|
|
<p className="text-sm text-gray-400 mt-0.5">
|
|
{channelLabel(c.channel)}
|
|
{c.placement ? ` · ${c.placement}` : ''}
|
|
{c.format ? ` · ${c.format}` : ''}
|
|
</p>
|
|
<div className="flex flex-wrap gap-1.5 pt-2">
|
|
<span className="rounded-full border border-violet-500/40 px-2 py-0.5 text-[10px] tracking-wide text-violet-300 uppercase">
|
|
{channelLabel(c.channel)}
|
|
</span>
|
|
{c.format ? (
|
|
<span className="rounded-full border border-gray-200 px-2 py-0.5 text-[10px] tracking-wide text-gray-400 uppercase">
|
|
{c.format}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<span className="border border-gray-200 px-2 py-1 font-mono text-[10px] tracking-wide uppercase text-gray-600">
|
|
{STATUSES.find((s) => s.id === (EDITORIAL_STATUS[c.status] || c.status))?.label ||
|
|
statusLabel(c.status)}
|
|
</span>
|
|
</div>
|
|
|
|
{(c.headline || c.primary_text) && (
|
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 space-y-1.5">
|
|
{c.headline ? (
|
|
<p className="text-sm font-semibold text-gray-900">
|
|
{c.headline}
|
|
</p>
|
|
) : null}
|
|
{c.primary_text ? (
|
|
<p className="text-xs text-gray-400 line-clamp-3 whitespace-pre-wrap">
|
|
{c.primary_text}
|
|
</p>
|
|
) : null}
|
|
{c.cta ? (
|
|
<p className="text-[11px] text-sky-400 font-semibold">
|
|
CTA: {c.cta}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className="flex items-center gap-2"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{isAgency ? (
|
|
<>
|
|
<select
|
|
className="flex-1 text-[11px] rounded-lg bg-gray-900 border border-gray-200 text-gray-600 px-2 py-1.5"
|
|
value={EDITORIAL_STATUS[c.status] || c.status}
|
|
disabled={busyId === c.id}
|
|
onChange={(e) => setStatus(c.id, e.target.value)}
|
|
>
|
|
{STATUSES.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
title="Borrar"
|
|
disabled={busyId === c.id}
|
|
onClick={() => remove(c.id)}
|
|
className="p-2 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-500/10"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</>
|
|
) : (EDITORIAL_STATUS[c.status] || c.status) === 'in_review' ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
disabled={busyId === c.id}
|
|
onClick={() => setStatus(c.id, 'approved')}
|
|
className="flex-1 rounded-lg bg-emerald-600 px-2 py-1.5 text-xs font-medium text-white"
|
|
>
|
|
Aprobar
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busyId === c.id}
|
|
onClick={() => setStatus(c.id, 'rejected')}
|
|
className="flex-1 rounded-lg bg-red-600 px-2 py-1.5 text-xs font-medium text-white"
|
|
>
|
|
Rechazar
|
|
</button>
|
|
</>
|
|
) : (
|
|
<p className="text-xs text-gray-400">
|
|
{(EDITORIAL_STATUS[c.status] || c.status) === 'approved'
|
|
? 'Aprobada por ti'
|
|
: (EDITORIAL_STATUS[c.status] || c.status) === 'published'
|
|
? 'Publicado'
|
|
: 'Pendiente del equipo de 4Ventures'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && creatives.length > 0 && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 pt-2">
|
|
{STATUSES.map((col) => {
|
|
const n = creatives.filter((c) => c.status === col.id).length;
|
|
return (
|
|
<div
|
|
key={col.id}
|
|
className="rounded-xl border border-gray-200 bg-white px-3 py-2"
|
|
>
|
|
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
|
{col.label}
|
|
</p>
|
|
<p className="text-xl font-bold text-gray-900 tabular-nums">{n}</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{selected && (
|
|
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<div className="bg-white border border-gray-200 rounded-2xl w-full max-w-4xl max-h-[92vh] overflow-hidden flex flex-col">
|
|
<div className="p-4 border-b border-gray-200 flex justify-between gap-3 bg-white">
|
|
<div>
|
|
<p className="text-sm font-bold text-gray-900">{selected.title}</p>
|
|
<p className="text-[11px] text-gray-500 mt-0.5">
|
|
Preview · {channelLabel(selected.channel)}
|
|
</p>
|
|
</div>
|
|
<button type="button" onClick={() => setSelected(null)}>
|
|
<X className="w-5 h-5 text-gray-400" />
|
|
</button>
|
|
</div>
|
|
<div className="p-5 overflow-y-auto grid md:grid-cols-[300px_1fr] gap-5">
|
|
<CreativePlacementPreview data={selected} />
|
|
<div className="space-y-3">
|
|
<Field label="Headline">
|
|
<input
|
|
className={inputCls}
|
|
value={selected.headline || ''}
|
|
onChange={(e) =>
|
|
setSelected({ ...selected, headline: e.target.value })
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label="Primary text">
|
|
<textarea
|
|
className={`${inputCls} min-h-[100px]`}
|
|
value={selected.primary_text || ''}
|
|
onChange={(e) =>
|
|
setSelected({
|
|
...selected,
|
|
primary_text: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</Field>
|
|
<div className="grid sm:grid-cols-2 gap-3">
|
|
<Field label="Canal">
|
|
<select
|
|
className={inputCls}
|
|
value={selected.channel || 'meta_ads'}
|
|
onChange={(e) =>
|
|
setSelected({ ...selected, channel: e.target.value })
|
|
}
|
|
>
|
|
{CHANNEL_OPTIONS.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="Placement">
|
|
<select
|
|
className={inputCls}
|
|
value={selected.placement || 'feed'}
|
|
onChange={(e) =>
|
|
setSelected({ ...selected, placement: e.target.value })
|
|
}
|
|
>
|
|
{PLACEMENT_OPTIONS.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="Formato">
|
|
<select
|
|
className={inputCls}
|
|
value={selected.format || 'image'}
|
|
onChange={(e) =>
|
|
setSelected({ ...selected, format: e.target.value })
|
|
}
|
|
>
|
|
{FORMAT_OPTIONS.map((f) => (
|
|
<option key={f.id} value={f.id}>
|
|
{f.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="CTA">
|
|
<input
|
|
className={inputCls}
|
|
value={selected.cta || ''}
|
|
onChange={(e) =>
|
|
setSelected({ ...selected, cta: e.target.value })
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label="Media URL">
|
|
<input
|
|
className={inputCls}
|
|
value={selected.media_url || ''}
|
|
onChange={(e) =>
|
|
setSelected({ ...selected, media_url: e.target.value })
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label="Landing">
|
|
<input
|
|
className={inputCls}
|
|
value={selected.landing_url || ''}
|
|
onChange={(e) =>
|
|
setSelected({
|
|
...selected,
|
|
landing_url: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<div className="rounded-xl border border-dashed border-violet-500/30 p-3">
|
|
<p className="text-[10px] uppercase text-violet-300 mb-2">
|
|
Reemplazar archivo
|
|
</p>
|
|
<input
|
|
type="file"
|
|
accept="image/*,video/mp4,video/webm"
|
|
disabled={uploading || busyId === selected.id}
|
|
onChange={async (e) => {
|
|
const f = e.target.files?.[0];
|
|
if (!f || !selected) return;
|
|
const id = await uploadFile(f, selected.id);
|
|
if (id) {
|
|
setSelected((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
media_url: `/api/creatives/media?id=${encodeURIComponent(prev.id)}&t=${Date.now()}`,
|
|
}
|
|
: prev
|
|
);
|
|
}
|
|
e.target.value = '';
|
|
}}
|
|
className="block w-full text-xs text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-violet-500/20 file:px-3 file:py-1.5 file:text-violet-200"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="p-4 border-t border-gray-200 bg-white flex flex-wrap justify-between gap-2">
|
|
{isAgency ? (
|
|
<button
|
|
type="button"
|
|
disabled={busyId === selected.id}
|
|
onClick={() => remove(selected.id)}
|
|
className="px-3 py-2 rounded-xl text-xs text-red-300 border border-red-500/30"
|
|
>
|
|
Borrar
|
|
</button>
|
|
) : (
|
|
<span />
|
|
)}
|
|
{isAgency ? (
|
|
<button
|
|
type="button"
|
|
disabled={busyId === selected.id}
|
|
onClick={saveSelected}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40"
|
|
>
|
|
Guardar cambios
|
|
</button>
|
|
) : (EDITORIAL_STATUS[selected.status] || selected.status) ===
|
|
'in_review' ? (
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={busyId === selected.id}
|
|
onClick={() => setStatus(selected.id, 'approved')}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-emerald-600 text-white"
|
|
>
|
|
Aprobar
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busyId === selected.id}
|
|
onClick={() => setStatus(selected.id, 'rejected')}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-red-600 text-white"
|
|
>
|
|
Rechazar
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<label className="block text-[11px] uppercase tracking-wider text-gray-500">
|
|
{label}
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|