feat: preview de placement en pipeline de creatividades
Cada pieza muestra mock Meta/Google Ads o RRSS (feed, Stories, LinkedIn) como el Ad Tracker, con copy, media y canal editables. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
92
scripts/dokploy_apply_creatives_preview.py
Normal file
92
scripts/dokploy_apply_creatives_preview.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/creatives_preview.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||
SQL = ROOT / "sql" / "creatives_preview.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
v = os.environ.get(key, "").strip()
|
||||
if v:
|
||||
return v
|
||||
p = BPS / ".env"
|
||||
if not p.is_file():
|
||||
return ""
|
||||
for line in p.read_text().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(800)
|
||||
if "/dashboard" not in page.url:
|
||||
page.locator('input[type="email"], input[name="email"]').first.fill(email)
|
||||
page.locator('input[type="password"]').first.fill(password)
|
||||
page.locator('button[type="submit"]').first.click()
|
||||
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
|
||||
print("login ok", flush=True)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/cc.b64 /tmp/cc.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/cc.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/cc.b64 > /tmp/cc.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cc.sql; echo EXIT:$?",
|
||||
14000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
print(text[-600:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
89
sql/creatives_preview.sql
Normal file
89
sql/creatives_preview.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
-- Preview fields for creatives pipeline (ads + RRSS placement mocks)
|
||||
-- Idempotent.
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists channel text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists placement text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists format text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists headline text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists primary_text text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists description text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists cta text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists media_url text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists brand_name text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists landing_url text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists meta jsonb not null default '{}'::jsonb;
|
||||
|
||||
-- Soft checks (nullable for legacy rows)
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint where conname = 'creatives_channel_check'
|
||||
) then
|
||||
alter table public.creatives
|
||||
add constraint creatives_channel_check
|
||||
check (
|
||||
channel is null
|
||||
or channel in (
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'instagram',
|
||||
'facebook',
|
||||
'linkedin',
|
||||
'tiktok',
|
||||
'stories',
|
||||
'other'
|
||||
)
|
||||
);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint where conname = 'creatives_format_check'
|
||||
) then
|
||||
alter table public.creatives
|
||||
add constraint creatives_format_check
|
||||
check (
|
||||
format is null
|
||||
or format in (
|
||||
'image',
|
||||
'video',
|
||||
'carousel',
|
||||
'reel',
|
||||
'story',
|
||||
'rsa',
|
||||
'pmax',
|
||||
'other'
|
||||
)
|
||||
);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
comment on column public.creatives.channel is
|
||||
'Surface: meta_ads | google_ads | instagram | facebook | linkedin | stories…';
|
||||
comment on column public.creatives.placement is
|
||||
'Placement mock: feed | stories | reels | search | display…';
|
||||
comment on column public.creatives.media_url is
|
||||
'Public or signed URL for creative media preview';
|
||||
@@ -10,6 +10,31 @@ import {
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
|
||||
const SELECT =
|
||||
'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,mime_type,bytes,created_at,updated_at';
|
||||
|
||||
const CHANNELS = new Set([
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'instagram',
|
||||
'facebook',
|
||||
'linkedin',
|
||||
'tiktok',
|
||||
'stories',
|
||||
'other',
|
||||
]);
|
||||
const FORMATS = new Set([
|
||||
'image',
|
||||
'video',
|
||||
'carousel',
|
||||
'reel',
|
||||
'story',
|
||||
'rsa',
|
||||
'pmax',
|
||||
'other',
|
||||
]);
|
||||
const ALLOWED_STATUS = new Set(['draft', 'in_review', 'approved', 'rejected']);
|
||||
|
||||
async function resolveTenantId(user: {
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
@@ -23,6 +48,30 @@ async function resolveTenantId(user: {
|
||||
return { tenantId: homeTenantId(user), viaView: false };
|
||||
}
|
||||
|
||||
function str(v: unknown, max = 2000): string | null {
|
||||
if (typeof v !== 'string') return null;
|
||||
const t = v.trim();
|
||||
if (!t) return null;
|
||||
return t.slice(0, max);
|
||||
}
|
||||
|
||||
function previewFields(body: Record<string, unknown>) {
|
||||
const channel = str(body.channel, 40);
|
||||
const format = str(body.format, 40);
|
||||
return {
|
||||
channel: channel && CHANNELS.has(channel) ? channel : null,
|
||||
placement: str(body.placement, 40),
|
||||
format: format && FORMATS.has(format) ? format : null,
|
||||
headline: str(body.headline, 500),
|
||||
primary_text: str(body.primary_text, 8000),
|
||||
description: str(body.description, 2000),
|
||||
cta: str(body.cta, 120),
|
||||
media_url: str(body.media_url, 2000),
|
||||
brand_name: str(body.brand_name, 200),
|
||||
landing_url: str(body.landing_url, 2000),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
@@ -43,10 +92,23 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=id,title,status,mime_type,bytes,created_at,updated_at&order=updated_at.desc`,
|
||||
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=${SELECT}&order=updated_at.desc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) {
|
||||
// Fallback if preview columns not migrated yet
|
||||
const fallback = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=id,title,status,mime_type,bytes,created_at,updated_at&order=updated_at.desc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (fallback.ok) {
|
||||
return NextResponse.json({
|
||||
creatives: await fallback.json(),
|
||||
viewTenantId: tenantId,
|
||||
warning:
|
||||
'Aplica sql/creatives_preview.sql para canales, copy y media preview',
|
||||
});
|
||||
}
|
||||
return NextResponse.json({
|
||||
creatives: [],
|
||||
warning: await r.text(),
|
||||
@@ -61,14 +123,25 @@ export async function GET() {
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.select('id,title,status,mime_type,bytes,created_at,updated_at')
|
||||
.select(SELECT)
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ creatives: [], warning: error.message },
|
||||
{ status: 200 }
|
||||
);
|
||||
const { data: basic, error: e2 } = await supabase
|
||||
.from('creatives')
|
||||
.select('id,title,status,mime_type,bytes,created_at,updated_at')
|
||||
.order('updated_at', { ascending: false });
|
||||
if (e2) {
|
||||
return NextResponse.json(
|
||||
{ creatives: [], warning: error.message },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
creatives: basic ?? [],
|
||||
warning:
|
||||
'Aplica sql/creatives_preview.sql para canales, copy y media preview',
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ creatives: data ?? [] });
|
||||
@@ -88,8 +161,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Usuario sin tenant_id' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
||||
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const title = str(body.title, 300);
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
|
||||
}
|
||||
@@ -99,6 +172,7 @@ export async function POST(req: NextRequest) {
|
||||
title,
|
||||
status: 'draft',
|
||||
created_by: user.id,
|
||||
...previewFields(body),
|
||||
};
|
||||
|
||||
if (viaView) {
|
||||
@@ -131,18 +205,36 @@ export async function POST(req: NextRequest) {
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.insert(row)
|
||||
.select('id,title,status,created_at')
|
||||
.select(SELECT)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
// Retry without preview columns if migration pending
|
||||
const { data: basic, error: e2 } = await supabase
|
||||
.from('creatives')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
title,
|
||||
status: 'draft',
|
||||
created_by: user.id,
|
||||
})
|
||||
.select('id,title,status,created_at')
|
||||
.single();
|
||||
if (e2) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
creative: basic,
|
||||
warning: 'Preview columns missing — apply sql/creatives_preview.sql',
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ creative: data }, { status: 201 });
|
||||
}
|
||||
|
||||
const ALLOWED = new Set(['draft', 'in_review', 'approved', 'rejected']);
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
@@ -152,11 +244,23 @@ export async function PATCH(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const id = typeof body.id === 'string' ? body.id.trim() : '';
|
||||
const status = typeof body.status === 'string' ? body.status.trim() : '';
|
||||
if (!id || !ALLOWED.has(status)) {
|
||||
return NextResponse.json({ error: 'id y status válidos requeridos' }, { status: 400 });
|
||||
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const id = str(body.id, 80);
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const clean: Record<string, unknown> = {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
if (typeof body.status === 'string' && ALLOWED_STATUS.has(body.status)) {
|
||||
clean.status = body.status;
|
||||
}
|
||||
const title = str(body.title, 300);
|
||||
if (title) clean.title = title;
|
||||
|
||||
for (const [k, v] of Object.entries(previewFields(body))) {
|
||||
if (k in body) clean[k] = v;
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
@@ -172,10 +276,7 @@ export async function PATCH(req: NextRequest) {
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status,
|
||||
updated_at: new Date().toISOString(),
|
||||
}),
|
||||
body: JSON.stringify(clean),
|
||||
}
|
||||
);
|
||||
if (!r.ok) {
|
||||
@@ -187,9 +288,9 @@ export async function PATCH(req: NextRequest) {
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.update({ status, updated_at: new Date().toISOString() })
|
||||
.update(clean)
|
||||
.eq('id', id)
|
||||
.select('id,title,status')
|
||||
.select(SELECT)
|
||||
.single();
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
@@ -1,191 +1,11 @@
|
||||
/**
|
||||
* Pipeline de creatividades publicitarias del cliente en contexto.
|
||||
* No es la librería de assets (documentos) ni el sync live de Meta — es la cola de aprobación.
|
||||
* Pipeline de creatividades — cola de aprobación con preview de placement
|
||||
* (Meta/Google Ads + RRSS), estilo Ad Tracker.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import React, { FormEvent, useEffect, useState } from 'react';
|
||||
import { Image as ImageIcon, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
type Creative = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
const STATUSES = [
|
||||
{ id: 'draft', label: 'Borrador' },
|
||||
{ id: 'in_review', label: 'En review' },
|
||||
{ id: 'approved', label: 'Aprobado' },
|
||||
{ id: 'rejected', label: 'Rechazado' },
|
||||
] as const;
|
||||
import CreativesPipeline from '@/components/CreativesPipeline';
|
||||
|
||||
export default function CreativesPage() {
|
||||
const [creatives, setCreatives] = useState<Creative[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
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);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/creatives');
|
||||
const data = await res.json();
|
||||
setCreatives(data.creatives ?? []);
|
||||
setWarning(data.warning ?? null);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
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({ title }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setErr(data.error ?? 'Error al crear');
|
||||
return;
|
||||
}
|
||||
setTitle('');
|
||||
await load();
|
||||
}
|
||||
|
||||
async function setStatus(id: string, status: string) {
|
||||
setBusyId(id);
|
||||
setErr(null);
|
||||
const res = await fetch('/api/creatives', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, status }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) setErr(data.error ?? 'No se pudo actualizar');
|
||||
await load();
|
||||
setBusyId(null);
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm('¿Borrar esta creatividad?')) return;
|
||||
setBusyId(id);
|
||||
setErr(null);
|
||||
const res = await fetch(`/api/creatives?id=${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) setErr(data.error ?? 'No se pudo borrar');
|
||||
await load();
|
||||
setBusyId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||
Pipeline de creatividades
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-1 max-w-2xl">
|
||||
Cola de aprobación de piezas ads del <strong className="text-gray-300">cliente en contexto</strong> (selector arriba):
|
||||
borrador → review → aprobado. No sustituye Meta Ads Manager; el sync
|
||||
automático de anuncios live llegará con la integración Meta.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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>}
|
||||
|
||||
<form onSubmit={create} className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-[11px] uppercase text-gray-500">
|
||||
Nueva pieza
|
||||
</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-sm text-white"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Video Meta · testimonial ES"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-500/90 hover:bg-brand-500 px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Añadir
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500">Cargando…</p>
|
||||
) : creatives.length === 0 ? (
|
||||
<div className="bg-[#111827] border border-dashed border-gray-700 rounded-xl p-12 text-center">
|
||||
<ImageIcon className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-300 font-medium">Pipeline vacío</p>
|
||||
<p className="text-xs text-gray-500 mt-2 max-w-md mx-auto">
|
||||
Úsalo para piezas en producción creativa por cliente. La biblioteca
|
||||
de activos guarda archivos validados (PDF, Excel, packs…); esto es
|
||||
el flow de ads.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{STATUSES.map((col) => {
|
||||
const items = creatives.filter((c) => c.status === col.id);
|
||||
return (
|
||||
<div key={col.id} className="space-y-2">
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-500 font-semibold px-1">
|
||||
{col.label} ({items.length})
|
||||
</p>
|
||||
<ul className="space-y-2 min-h-[80px]">
|
||||
{items.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="rounded-lg border border-gray-800 bg-[#111827] p-3 space-y-2"
|
||||
>
|
||||
<p className="text-sm text-white font-medium">{c.title}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="flex-1 text-[11px] rounded bg-gray-900 border border-gray-700 text-gray-300 px-2 py-1"
|
||||
value={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-1.5 rounded text-gray-500 hover:text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <CreativesPipeline />;
|
||||
}
|
||||
|
||||
458
src/components/CreativePlacementPreview.tsx
Normal file
458
src/components/CreativePlacementPreview.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Facebook,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Megaphone,
|
||||
Play,
|
||||
ImageIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export type CreativeChannel =
|
||||
| 'meta_ads'
|
||||
| 'google_ads'
|
||||
| 'instagram'
|
||||
| 'facebook'
|
||||
| 'linkedin'
|
||||
| 'tiktok'
|
||||
| 'stories'
|
||||
| 'other';
|
||||
|
||||
export type CreativeFormat =
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'carousel'
|
||||
| 'reel'
|
||||
| 'story'
|
||||
| 'rsa'
|
||||
| 'pmax'
|
||||
| 'other';
|
||||
|
||||
export type CreativePreviewData = {
|
||||
title: string;
|
||||
channel?: string | null;
|
||||
placement?: string | null;
|
||||
format?: string | null;
|
||||
headline?: string | null;
|
||||
primary_text?: string | null;
|
||||
description?: string | null;
|
||||
cta?: string | null;
|
||||
media_url?: string | null;
|
||||
brand_name?: string | null;
|
||||
landing_url?: string | null;
|
||||
};
|
||||
|
||||
function hostOf(url?: string | null): string {
|
||||
if (!url) return 'ejemplo.com';
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, '').split('/')[0] || 'ejemplo.com';
|
||||
}
|
||||
}
|
||||
|
||||
function MediaBlock({
|
||||
data,
|
||||
tall,
|
||||
dark,
|
||||
}: {
|
||||
data: CreativePreviewData;
|
||||
tall?: boolean;
|
||||
dark?: boolean;
|
||||
}) {
|
||||
const url = data.media_url?.trim();
|
||||
const isVideo =
|
||||
(data.format || '').toLowerCase() === 'video' ||
|
||||
(data.format || '').toLowerCase() === 'reel' ||
|
||||
Boolean(url && /\.(mp4|webm|mov)(\?|$)/i.test(url));
|
||||
|
||||
if (url && isVideo) {
|
||||
return (
|
||||
<video
|
||||
src={url}
|
||||
className={`w-full object-cover ${tall ? 'h-full min-h-[220px]' : 'aspect-[4/5]'}`}
|
||||
muted
|
||||
playsInline
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (url) {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={data.headline || data.title}
|
||||
className={`w-full object-cover ${tall ? 'h-full min-h-[220px]' : 'aspect-[4/5]'}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full flex flex-col items-center justify-center gap-2 ${
|
||||
tall ? 'h-full min-h-[220px]' : 'aspect-[4/5]'
|
||||
} ${dark ? 'bg-gradient-to-br from-[#16204A] to-[#0B0F19]' : 'bg-gradient-to-br from-gray-800 to-gray-900'}`}
|
||||
>
|
||||
{(data.format || '').toLowerCase() === 'video' ||
|
||||
(data.format || '').toLowerCase() === 'reel' ? (
|
||||
<Play className="w-10 h-10 text-white/50" />
|
||||
) : (
|
||||
<ImageIcon className="w-10 h-10 text-white/40" />
|
||||
)}
|
||||
<p className="text-[11px] text-white/60 px-4 text-center line-clamp-3">
|
||||
{data.headline || data.title}
|
||||
</p>
|
||||
<p className="text-[10px] text-white/35 uppercase tracking-wider">
|
||||
Sin media · añade URL
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneChrome({
|
||||
pill,
|
||||
children,
|
||||
stories,
|
||||
}: {
|
||||
pill: string;
|
||||
children: React.ReactNode;
|
||||
stories?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl overflow-hidden border border-gray-700 bg-black shadow-lg shadow-black/40 ${
|
||||
stories ? 'aspect-[9/16] max-h-[420px]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-[#111827] border-b border-gray-800">
|
||||
<span className="text-[10px] font-bold tracking-wide text-sky-300 uppercase">
|
||||
{pill}
|
||||
</span>
|
||||
<span className="w-8 h-1 rounded-full bg-gray-600" />
|
||||
</div>
|
||||
<div className={stories ? 'h-[calc(100%-36px)]' : ''}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaFeedPreview({ data }: { data: CreativePreviewData }) {
|
||||
const brand = data.brand_name || 'Marca';
|
||||
return (
|
||||
<PhoneChrome pill="Meta Ads · feed">
|
||||
<div className="bg-white text-gray-900">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="w-8 h-8 rounded-full bg-[#7C66B8] text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{brand.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold truncate">{brand}</p>
|
||||
<p className="text-[10px] text-gray-500">Patrocinado · Meta</p>
|
||||
</div>
|
||||
<Megaphone className="w-3.5 h-3.5 text-gray-400" />
|
||||
</div>
|
||||
{data.primary_text ? (
|
||||
<p className="px-3 pb-2 text-[12px] leading-snug line-clamp-3">
|
||||
{data.primary_text}
|
||||
</p>
|
||||
) : null}
|
||||
<MediaBlock data={data} />
|
||||
<div className="px-3 py-2.5 border-t border-gray-100 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wide truncate">
|
||||
{hostOf(data.landing_url)}
|
||||
</p>
|
||||
<p className="text-sm font-semibold line-clamp-2">
|
||||
{data.headline || data.title}
|
||||
</p>
|
||||
{data.description ? (
|
||||
<p className="text-[11px] text-gray-600 line-clamp-2 mt-0.5">
|
||||
{data.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-bold px-2.5 py-1.5 rounded bg-gray-100 border border-gray-200">
|
||||
{data.cta || 'Más info'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PhoneChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function InstagramFeedPreview({ data }: { data: CreativePreviewData }) {
|
||||
const brand = data.brand_name || 'cuenta';
|
||||
return (
|
||||
<PhoneChrome pill="Instagram · feed">
|
||||
<div className="bg-white text-gray-900">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-tr from-amber-400 via-pink-500 to-violet-600 p-[2px]">
|
||||
<div className="w-full h-full rounded-full bg-white flex items-center justify-center text-[9px] font-bold text-pink-600">
|
||||
{brand.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs font-bold flex-1 truncate">{brand}</p>
|
||||
<Instagram className="w-3.5 h-3.5 text-pink-500" />
|
||||
</div>
|
||||
<MediaBlock data={data} />
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
<p className="text-[12px] leading-snug">
|
||||
<span className="font-bold mr-1">{brand}</span>
|
||||
<span className="line-clamp-4">
|
||||
{data.primary_text || data.headline || data.title}
|
||||
</span>
|
||||
</p>
|
||||
{data.cta ? (
|
||||
<p className="text-[11px] text-sky-600 font-semibold">{data.cta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</PhoneChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function StoriesPreview({ data }: { data: CreativePreviewData }) {
|
||||
const brand = data.brand_name || 'cuenta';
|
||||
return (
|
||||
<PhoneChrome pill="Stories · 9:16" stories>
|
||||
<div className="relative h-full bg-black text-white">
|
||||
<div className="absolute inset-0">
|
||||
<MediaBlock data={data} tall dark />
|
||||
</div>
|
||||
<div className="absolute inset-x-0 top-0 p-3 bg-gradient-to-b from-black/70 to-transparent">
|
||||
<div className="flex gap-1 mb-2">
|
||||
<div className="h-0.5 flex-1 rounded bg-white" />
|
||||
<div className="h-0.5 flex-1 rounded bg-white/30" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-white/20 text-[9px] font-bold flex items-center justify-center">
|
||||
{brand.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<p className="text-xs font-bold">{brand}</p>
|
||||
<span className="text-[10px] text-white/60">ahora</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 p-4 bg-gradient-to-t from-black/80 to-transparent space-y-2">
|
||||
<p className="text-sm font-semibold line-clamp-3">
|
||||
{data.headline || data.primary_text || data.title}
|
||||
</p>
|
||||
{data.cta ? (
|
||||
<span className="inline-block text-[11px] font-bold px-3 py-1.5 rounded-full bg-white text-black">
|
||||
{data.cta}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-block text-[10px] text-white/70">
|
||||
Sticker / encuesta
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PhoneChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedInPreview({ data }: { data: CreativePreviewData }) {
|
||||
const brand = data.brand_name || 'Empresa';
|
||||
return (
|
||||
<PhoneChrome pill="LinkedIn · feed">
|
||||
<div className="bg-white text-gray-900">
|
||||
<div className="flex items-start gap-2 px-3 py-2.5">
|
||||
<div className="w-9 h-9 rounded bg-[#0A66C2] text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{brand.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold truncate">{brand}</p>
|
||||
<p className="text-[10px] text-gray-500">Promocionado · LinkedIn</p>
|
||||
</div>
|
||||
<Linkedin className="w-3.5 h-3.5 text-[#0A66C2]" />
|
||||
</div>
|
||||
<p className="px-3 pb-2 text-[12px] leading-snug whitespace-pre-wrap line-clamp-5">
|
||||
{data.primary_text || data.headline || data.title}
|
||||
</p>
|
||||
{(data.media_url || data.format === 'image' || data.format === 'video') && (
|
||||
<MediaBlock data={data} />
|
||||
)}
|
||||
<div className="px-3 py-2 border-t border-gray-100 flex items-center justify-between">
|
||||
<p className="text-[11px] text-gray-500 truncate">
|
||||
{data.headline || 'Ver más'}
|
||||
</p>
|
||||
<span className="text-[10px] font-bold text-[#0A66C2]">
|
||||
{data.cta || 'Saber más'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PhoneChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function FacebookFeedPreview({ data }: { data: CreativePreviewData }) {
|
||||
const brand = data.brand_name || 'Página';
|
||||
return (
|
||||
<PhoneChrome pill="Facebook · feed">
|
||||
<div className="bg-white text-gray-900">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="w-8 h-8 rounded-full bg-[#1877F2] text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{brand.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold truncate">{brand}</p>
|
||||
<p className="text-[10px] text-gray-500">Patrocinado</p>
|
||||
</div>
|
||||
<Facebook className="w-3.5 h-3.5 text-[#1877F2]" />
|
||||
</div>
|
||||
{data.primary_text ? (
|
||||
<p className="px-3 pb-2 text-[12px] leading-snug line-clamp-3">
|
||||
{data.primary_text}
|
||||
</p>
|
||||
) : null}
|
||||
<MediaBlock data={data} />
|
||||
<div className="px-3 py-2 bg-gray-50 border-t border-gray-100 flex justify-between gap-2 items-center">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] text-gray-500 uppercase truncate">
|
||||
{hostOf(data.landing_url)}
|
||||
</p>
|
||||
<p className="text-sm font-semibold line-clamp-2">
|
||||
{data.headline || data.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-bold px-2.5 py-1.5 rounded bg-gray-200">
|
||||
{data.cta || 'Más información'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PhoneChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleSearchPreview({ data }: { data: CreativePreviewData }) {
|
||||
const host = hostOf(data.landing_url);
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-700 bg-white text-gray-900 p-4 shadow-lg">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-sky-600 mb-2">
|
||||
Google Ads · búsqueda
|
||||
</p>
|
||||
<p className="text-[11px] text-emerald-800">
|
||||
Anuncio · <span className="text-gray-600">{host}</span>
|
||||
</p>
|
||||
<p className="text-lg text-[#1a0dab] font-medium leading-snug mt-0.5">
|
||||
{data.headline || data.title}
|
||||
</p>
|
||||
<p className="text-[13px] text-gray-700 mt-1 line-clamp-3">
|
||||
{data.description || data.primary_text || 'Descripción del anuncio…'}
|
||||
</p>
|
||||
{data.cta ? (
|
||||
<p className="text-[11px] text-sky-700 mt-2 font-semibold">{data.cta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleDisplayPreview({ data }: { data: CreativePreviewData }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-700 bg-[#111827] overflow-hidden">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-sky-400 px-3 py-2 border-b border-gray-800">
|
||||
Google Ads · display / PMax
|
||||
</p>
|
||||
<MediaBlock data={data} />
|
||||
<div className="p-3 bg-white text-gray-900">
|
||||
<p className="text-sm font-bold line-clamp-2">
|
||||
{data.headline || data.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-600 line-clamp-2 mt-1">
|
||||
{data.description || data.primary_text}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 mt-2">{hostOf(data.landing_url)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a placement mock like Ad Tracker phone / Google RSA cards. */
|
||||
export default function CreativePlacementPreview({
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
data: CreativePreviewData;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const channel = (data.channel || '').toLowerCase();
|
||||
const placement = (data.placement || '').toLowerCase();
|
||||
const format = (data.format || '').toLowerCase();
|
||||
|
||||
let body: React.ReactNode;
|
||||
|
||||
if (channel === 'google_ads' || format === 'rsa') {
|
||||
body =
|
||||
format === 'pmax' || placement === 'display' ? (
|
||||
<GoogleDisplayPreview data={data} />
|
||||
) : (
|
||||
<GoogleSearchPreview data={data} />
|
||||
);
|
||||
} else if (
|
||||
channel === 'stories' ||
|
||||
placement === 'stories' ||
|
||||
format === 'story'
|
||||
) {
|
||||
body = <StoriesPreview data={data} />;
|
||||
} else if (channel === 'linkedin') {
|
||||
body = <LinkedInPreview data={data} />;
|
||||
} else if (channel === 'instagram') {
|
||||
body =
|
||||
placement === 'reels' || format === 'reel' ? (
|
||||
<StoriesPreview data={data} />
|
||||
) : (
|
||||
<InstagramFeedPreview data={data} />
|
||||
);
|
||||
} else if (channel === 'facebook') {
|
||||
body = <FacebookFeedPreview data={data} />;
|
||||
} else if (channel === 'meta_ads' || channel === 'tiktok' || !channel) {
|
||||
body =
|
||||
placement === 'stories' || format === 'story' ? (
|
||||
<StoriesPreview data={data} />
|
||||
) : (
|
||||
<MetaFeedPreview data={data} />
|
||||
);
|
||||
} else {
|
||||
body = <MetaFeedPreview data={data} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? 'max-w-[280px]' : 'w-full max-w-[320px]'}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CHANNEL_OPTIONS: { id: CreativeChannel; label: string }[] = [
|
||||
{ id: 'meta_ads', label: 'Meta Ads' },
|
||||
{ id: 'google_ads', label: 'Google Ads' },
|
||||
{ id: 'instagram', label: 'Instagram (orgánico)' },
|
||||
{ id: 'facebook', label: 'Facebook (orgánico)' },
|
||||
{ id: 'linkedin', label: 'LinkedIn' },
|
||||
{ id: 'stories', label: 'Stories' },
|
||||
{ id: 'tiktok', label: 'TikTok' },
|
||||
{ id: 'other', label: 'Otro' },
|
||||
];
|
||||
|
||||
export const FORMAT_OPTIONS: { id: CreativeFormat; label: string }[] = [
|
||||
{ id: 'image', label: 'Imagen' },
|
||||
{ id: 'video', label: 'Vídeo' },
|
||||
{ id: 'carousel', label: 'Carrusel' },
|
||||
{ id: 'reel', label: 'Reel' },
|
||||
{ id: 'story', label: 'Story' },
|
||||
{ id: 'rsa', label: 'RSA (búsqueda)' },
|
||||
{ id: 'pmax', label: 'PMax / Display' },
|
||||
{ id: 'other', label: 'Otro' },
|
||||
];
|
||||
|
||||
export const PLACEMENT_OPTIONS = [
|
||||
{ id: 'feed', label: 'Feed' },
|
||||
{ id: 'stories', label: 'Stories' },
|
||||
{ id: 'reels', label: 'Reels' },
|
||||
{ id: 'search', label: 'Búsqueda' },
|
||||
{ id: 'display', label: 'Display' },
|
||||
{ id: 'other', label: 'Otro' },
|
||||
];
|
||||
681
src/components/CreativesPipeline.tsx
Normal file
681
src/components/CreativesPipeline.tsx
Normal file
@@ -0,0 +1,681 @@
|
||||
'use client';
|
||||
|
||||
import React, { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Image as ImageIcon,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import CreativePlacementPreview, {
|
||||
CHANNEL_OPTIONS,
|
||||
FORMAT_OPTIONS,
|
||||
PLACEMENT_OPTIONS,
|
||||
type CreativePreviewData,
|
||||
} from '@/components/CreativePlacementPreview';
|
||||
|
||||
export type Creative = CreativePreviewData & {
|
||||
id: string;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
const STATUSES = [
|
||||
{ id: 'draft', label: 'Borrador' },
|
||||
{ id: 'in_review', label: 'En review' },
|
||||
{ id: 'approved', label: 'Aprobado' },
|
||||
{ id: 'rejected', label: 'Rechazado' },
|
||||
] as const;
|
||||
|
||||
const inputCls =
|
||||
'mt-1 w-full rounded-lg bg-gray-900 border border-gray-700 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;
|
||||
};
|
||||
|
||||
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: '',
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/creatives');
|
||||
const data = await res.json();
|
||||
setCreatives(data.creatives ?? []);
|
||||
setWarning(data.warning ?? null);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === 'all') return creatives;
|
||||
if (filter === 'ads') {
|
||||
return creatives.filter((c) =>
|
||||
['meta_ads', 'google_ads'].includes(c.channel || '')
|
||||
);
|
||||
}
|
||||
if (filter === 'rrss') {
|
||||
return creatives.filter((c) =>
|
||||
['instagram', 'facebook', 'linkedin', 'stories', 'tiktok'].includes(
|
||||
c.channel || ''
|
||||
)
|
||||
);
|
||||
}
|
||||
return creatives.filter((c) => c.status === filter);
|
||||
}, [creatives, filter]);
|
||||
|
||||
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 res = await fetch('/api/creatives', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, status }),
|
||||
});
|
||||
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;
|
||||
setBusyId(selected.id);
|
||||
setErr(null);
|
||||
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 res = await fetch(`/api/creatives?id=${encodeURIComponent(id)}`, {
|
||||
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">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-violet-400">
|
||||
Pipeline creatividades
|
||||
</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white mt-1">
|
||||
Preview como en Ad Tracker
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-1 max-w-2xl">
|
||||
Cada pieza muestra cómo se verá en Meta/Google Ads o en RRSS
|
||||
(feed, Stories, LinkedIn) antes de aprobar. Cliente en contexto
|
||||
arriba.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{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-white text-gray-900'
|
||||
: 'bg-gray-800/70 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] 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-white">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="Agua Miia"
|
||||
/>
|
||||
</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 /media/…"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Landing">
|
||||
<input
|
||||
value={draft.landing_url}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, landing_url: e.target.value }))
|
||||
}
|
||||
className={inputCls}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</Field>
|
||||
</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-800 text-gray-300 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-[#111827] border border-dashed border-gray-700 rounded-xl p-12 text-center">
|
||||
<ImageIcon className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-300 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-800 bg-[#111827] 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-white">
|
||||
{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-700 px-2 py-0.5 text-[10px] tracking-wide text-gray-400 uppercase">
|
||||
{c.format}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="border border-gray-700 px-2 py-1 font-mono text-[10px] tracking-wide uppercase text-gray-300">
|
||||
{STATUSES.find((s) => s.id === c.status)?.label || c.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(c.headline || c.primary_text) && (
|
||||
<div className="rounded-xl border border-gray-800 bg-black/25 p-3 space-y-1.5">
|
||||
{c.headline ? (
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{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()}
|
||||
>
|
||||
<select
|
||||
className="flex-1 text-[11px] rounded-lg bg-gray-900 border border-gray-700 text-gray-300 px-2 py-1.5"
|
||||
value={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>
|
||||
</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-800 bg-[#0D121F] px-3 py-2"
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
||||
{col.label}
|
||||
</p>
|
||||
<p className="text-xl font-bold text-white 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-[#0D121F] border border-gray-800 rounded-2xl w-full max-w-4xl max-h-[92vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-gray-800 flex justify-between gap-3 bg-[#111827]">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">{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>
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-800 bg-[#111827] flex flex-wrap justify-between gap-2">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user