Files
4ventures-client-portal/src/components/CreativesPipeline.tsx
4Ventures ace73c2699 fix: unify creatives into Pipeline and hide Panel nav
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>
2026-10-06 10:16:15 +02:00

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>
);
}