From a7704567bda6e831f022a0090fb690016e20a92f Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Wed, 23 Sep 2026 03:36:28 +0200 Subject: [PATCH] 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 --- scripts/dokploy_apply_creatives_preview.py | 92 +++ sql/creatives_preview.sql | 89 +++ src/app/api/creatives/route.ts | 147 ++++- src/app/dashboard/creatives/page.tsx | 188 +----- src/components/CreativePlacementPreview.tsx | 458 +++++++++++++ src/components/CreativesPipeline.tsx | 681 ++++++++++++++++++++ 6 files changed, 1448 insertions(+), 207 deletions(-) create mode 100644 scripts/dokploy_apply_creatives_preview.py create mode 100644 sql/creatives_preview.sql create mode 100644 src/components/CreativePlacementPreview.tsx create mode 100644 src/components/CreativesPipeline.tsx diff --git a/scripts/dokploy_apply_creatives_preview.py b/scripts/dokploy_apply_creatives_preview.py new file mode 100644 index 0000000..cadea56 --- /dev/null +++ b/scripts/dokploy_apply_creatives_preview.py @@ -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()) diff --git a/sql/creatives_preview.sql b/sql/creatives_preview.sql new file mode 100644 index 0000000..168eaf1 --- /dev/null +++ b/sql/creatives_preview.sql @@ -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'; diff --git a/src/app/api/creatives/route.ts b/src/app/api/creatives/route.ts index 04840c7..ac145bf 100644 --- a/src/app/api/creatives/route.ts +++ b/src/app/api/creatives/route.ts @@ -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; user_metadata?: Record; @@ -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) { + 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; + 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; + const id = str(body.id, 80); + if (!id) { + return NextResponse.json({ error: 'id requerido' }, { status: 400 }); + } + + const clean: Record = { + 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 }); diff --git a/src/app/dashboard/creatives/page.tsx b/src/app/dashboard/creatives/page.tsx index 57953e5..b4ba898 100644 --- a/src/app/dashboard/creatives/page.tsx +++ b/src/app/dashboard/creatives/page.tsx @@ -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([]); - const [title, setTitle] = useState(''); - const [warning, setWarning] = useState(null); - const [err, setErr] = useState(null); - const [loading, setLoading] = useState(true); - const [busyId, setBusyId] = useState(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 ( -
-
-

- Pipeline de creatividades -

-

- Cola de aprobación de piezas ads del cliente en contexto (selector arriba): - borrador → review → aprobado. No sustituye Meta Ads Manager; el sync - automático de anuncios live llegará con la integración Meta. -

-
- - {warning && ( -

- {warning} -

- )} - {err &&

{err}

} - -
-
- - setTitle(e.target.value)} - placeholder="Video Meta · testimonial ES" - required - /> -
- -
- - {loading ? ( -

Cargando…

- ) : creatives.length === 0 ? ( -
- -

Pipeline vacío

-

- Ú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. -

-
- ) : ( -
- {STATUSES.map((col) => { - const items = creatives.filter((c) => c.status === col.id); - return ( -
-

- {col.label} ({items.length}) -

-
    - {items.map((c) => ( -
  • -

    {c.title}

    -
    - - -
    -
  • - ))} -
-
- ); - })} -
- )} -
- ); + return ; } diff --git a/src/components/CreativePlacementPreview.tsx b/src/components/CreativePlacementPreview.tsx new file mode 100644 index 0000000..5ecde79 --- /dev/null +++ b/src/components/CreativePlacementPreview.tsx @@ -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 ( +