From 0d286dc66c1d03eac5a4ce2d99666efa84dc3ac3 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Wed, 23 Sep 2026 09:33:22 +0200 Subject: [PATCH] feat: upload creativos, PMax formats, portada IG/FB y seguimiento con notas Permite subir media sin URL; previews Google PMax por ratio; calendario anual estilo Miia con filtros IG/FB y LinkedIn; seguimiento con selector de fechas, KPIs y cuadro de notas. Co-authored-by: Cursor --- .../dokploy_apply_creatives_upload_pmax.py | 92 +++++ sql/creatives_upload_pmax.sql | 35 ++ src/app/api/creatives/media/route.ts | 87 +++++ src/app/api/creatives/route.ts | 8 +- src/app/api/creatives/upload/route.ts | 197 ++++++++++ src/app/api/seguimiento/route.ts | 132 ++++++- src/app/dashboard/calendario/page.tsx | 55 +-- src/components/CreativePlacementPreview.tsx | 114 +++++- src/components/CreativesPipeline.tsx | 99 ++++- src/components/RrssArchiveYear.tsx | 218 +++++++++++ src/components/RrssMonthView.tsx | 54 ++- src/components/SeguimientoBoard.tsx | 354 ++++++++++++++---- 12 files changed, 1295 insertions(+), 150 deletions(-) create mode 100644 scripts/dokploy_apply_creatives_upload_pmax.py create mode 100644 sql/creatives_upload_pmax.sql create mode 100644 src/app/api/creatives/media/route.ts create mode 100644 src/app/api/creatives/upload/route.ts create mode 100644 src/components/RrssArchiveYear.tsx diff --git a/scripts/dokploy_apply_creatives_upload_pmax.py b/scripts/dokploy_apply_creatives_upload_pmax.py new file mode 100644 index 0000000..2903c78 --- /dev/null +++ b/scripts/dokploy_apply_creatives_upload_pmax.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Apply sql/creatives_upload_pmax.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_upload_pmax.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_upload_pmax.sql b/sql/creatives_upload_pmax.sql new file mode 100644 index 0000000..9eca942 --- /dev/null +++ b/sql/creatives_upload_pmax.sql @@ -0,0 +1,35 @@ +-- Expand creatives.format for Google Performance Max asset ratios + upload fields +-- Idempotent. + +alter table public.creatives drop constraint if exists creatives_format_check; + +alter table public.creatives + add constraint creatives_format_check + check ( + format is null + or format in ( + 'image', + 'video', + 'carousel', + 'reel', + 'story', + 'rsa', + 'pmax', + 'pmax_landscape', + 'pmax_square', + 'pmax_portrait', + 'pmax_vertical', + 'pmax_logo', + 'youtube', + 'other' + ) + ); + +alter table public.creatives + add column if not exists storage_path text; + +alter table public.creatives + add column if not exists mime_type text; + +comment on column public.creatives.storage_path is + 'Path in tenant-assets bucket for uploaded creative media'; diff --git a/src/app/api/creatives/media/route.ts b/src/app/api/creatives/media/route.ts new file mode 100644 index 0000000..1a1d703 --- /dev/null +++ b/src/app/api/creatives/media/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets'; + +export const dynamic = 'force-dynamic'; + +/** GET ?id= — redirect to signed URL for creative media */ +export async function GET(req: NextRequest) { + const id = req.nextUrl.searchParams.get('id')?.trim() || ''; + if (!id) { + return NextResponse.json({ error: 'id requerido' }, { status: 400 }); + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json({ error: 'Sin tenant' }, { status: 400 }); + } + + const agency = isAgencyRole(userRole(user)); + const headers = serviceHeaders(); + let storagePath: string | null = null; + + if (agency && headers) { + const r = await fetch( + `${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=storage_path,media_url`, + { headers, cache: 'no-store' } + ); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 502 }); + } + const rows = await r.json(); + storagePath = rows?.[0]?.storage_path || null; + const mediaUrl = rows?.[0]?.media_url as string | undefined; + if (!storagePath && mediaUrl && !mediaUrl.startsWith('/api/creatives/media')) { + return NextResponse.redirect(mediaUrl); + } + } else { + const { data } = await supabase + .from('creatives') + .select('storage_path,media_url') + .eq('id', id) + .maybeSingle(); + storagePath = data?.storage_path || null; + if ( + !storagePath && + data?.media_url && + !String(data.media_url).startsWith('/api/creatives/media') + ) { + return NextResponse.redirect(data.media_url); + } + } + + if (!storagePath) { + return NextResponse.json({ error: 'Sin media' }, { status: 404 }); + } + + const svc = serviceSupabase(); + if (!svc) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + + const signed = await svc.storage + .from(ASSETS_BUCKET) + .createSignedUrl(storagePath, 300); + if (signed.error || !signed.data?.signedUrl) { + return NextResponse.json( + { error: signed.error?.message || 'No signed URL' }, + { status: 500 } + ); + } + return NextResponse.redirect(signed.data.signedUrl); +} diff --git a/src/app/api/creatives/route.ts b/src/app/api/creatives/route.ts index ac145bf..f610633 100644 --- a/src/app/api/creatives/route.ts +++ b/src/app/api/creatives/route.ts @@ -11,7 +11,7 @@ import { } 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'; + 'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,created_at,updated_at'; const CHANNELS = new Set([ 'meta_ads', @@ -31,6 +31,12 @@ const FORMATS = new Set([ 'story', 'rsa', 'pmax', + 'pmax_landscape', + 'pmax_square', + 'pmax_portrait', + 'pmax_vertical', + 'pmax_logo', + 'youtube', 'other', ]); const ALLOWED_STATUS = new Set(['draft', 'in_review', 'approved', 'rejected']); diff --git a/src/app/api/creatives/upload/route.ts b/src/app/api/creatives/upload/route.ts new file mode 100644 index 0000000..a01d2a4 --- /dev/null +++ b/src/app/api/creatives/upload/route.ts @@ -0,0 +1,197 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { + ASSETS_BUCKET, + MAX_ASSET_BYTES, + ensureAssetsBucket, + sanitizeFileName, + serviceSupabase, +} from '@/lib/assets'; +import { randomUUID } from 'crypto'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +const ALLOWED_MIME = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'video/mp4', + 'video/webm', + 'video/quicktime', +]); + +/** + * POST multipart: upload creative media and attach to a creative row. + * fields: file, creativeId? (optional — creates draft if missing), title? + */ +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente en Contexto' }, + { status: 400 } + ); + } + + const form = await req.formData(); + const file = form.get('file'); + if (!(file instanceof File)) { + return NextResponse.json({ error: 'file requerido' }, { status: 400 }); + } + if (file.size <= 0 || file.size > MAX_ASSET_BYTES) { + return NextResponse.json( + { error: `Archivo vacío o > ${MAX_ASSET_BYTES / (1024 * 1024)} MB` }, + { status: 400 } + ); + } + const mime = file.type || 'application/octet-stream'; + if (!ALLOWED_MIME.has(mime) && !mime.startsWith('image/') && !mime.startsWith('video/')) { + return NextResponse.json( + { error: 'Solo imagen o vídeo (jpg/png/webp/gif/mp4/webm)' }, + { status: 400 } + ); + } + + const bucketOk = await ensureAssetsBucket(); + if (!bucketOk.ok) { + return NextResponse.json( + { error: bucketOk.error || 'No se pudo asegurar el bucket' }, + { status: 500 } + ); + } + + const svc = serviceSupabase(); + if (!svc) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + + let creativeId = + typeof form.get('creativeId') === 'string' + ? String(form.get('creativeId')).trim() + : ''; + const title = + typeof form.get('title') === 'string' + ? String(form.get('title')).trim() + : file.name.replace(/\.[^.]+$/, '') || 'Creativo subido'; + + const headers = serviceHeaders(); + const agency = isAgencyRole(userRole(user)); + + if (!creativeId) { + const row = { + tenant_id: ctx.tenantId, + title: title.slice(0, 300), + status: 'draft', + channel: 'meta_ads', + format: mime.startsWith('video/') ? 'video' : 'image', + created_by: user.id, + }; + if (agency && headers) { + const r = await fetch(`${supabaseUrl()}/rest/v1/creatives`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(row), + }); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + const data = await r.json(); + creativeId = Array.isArray(data) ? data[0]?.id : data?.id; + } else { + const { data, error } = await supabase + .from('creatives') + .insert(row) + .select('id') + .single(); + if (error || !data) { + return NextResponse.json( + { error: error?.message || 'No se pudo crear' }, + { status: 500 } + ); + } + creativeId = data.id; + } + } + + if (!creativeId) { + return NextResponse.json({ error: 'creativeId inválido' }, { status: 400 }); + } + + const safeName = sanitizeFileName(file.name || 'creative.bin'); + const storagePath = `${ctx.tenantId}/creatives/${creativeId}/${randomUUID()}-${safeName}`; + const buf = Buffer.from(await file.arrayBuffer()); + + const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, { + contentType: mime, + upsert: false, + }); + if (up.error) { + return NextResponse.json({ error: up.error.message }, { status: 500 }); + } + + const mediaUrl = `/api/creatives/media?id=${encodeURIComponent(creativeId)}`; + const patch = { + storage_path: storagePath, + mime_type: mime, + bytes: file.size, + media_url: mediaUrl, + updated_at: new Date().toISOString(), + }; + + if (agency && headers) { + const r = await fetch( + `${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(patch), + } + ); + if (!r.ok) { + await svc.storage.from(ASSETS_BUCKET).remove([storagePath]); + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + const data = await r.json(); + return NextResponse.json({ + creative: Array.isArray(data) ? data[0] : data, + media_url: mediaUrl, + }); + } + + const { data, error } = await supabase + .from('creatives') + .update(patch) + .eq('id', creativeId) + .select('*') + .single(); + if (error) { + await svc.storage.from(ASSETS_BUCKET).remove([storagePath]); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + return NextResponse.json({ creative: data, media_url: mediaUrl }); +} diff --git a/src/app/api/seguimiento/route.ts b/src/app/api/seguimiento/route.ts index 4ae479a..3e145cc 100644 --- a/src/app/api/seguimiento/route.ts +++ b/src/app/api/seguimiento/route.ts @@ -1,12 +1,20 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; -import { isAgencyRole, userRole } from '@/lib/agency'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; import { AdDailyRow, KpiWeeklyRow, buildChannelSeries, + sumDaily, + sumWeekly, } from '@/lib/metrics'; +import { randomUUID } from 'crypto'; export const dynamic = 'force-dynamic'; @@ -21,9 +29,10 @@ export type SeguimientoEntry = { metrics?: Record; occurred_at?: string | null; synced_at?: string; + source?: string; }; -/** GET seguimiento timeline + channel evolution for active tenant. */ +/** GET seguimiento timeline + metrics series for active tenant. */ export async function GET() { const supabase = await createClient(); const { @@ -41,8 +50,10 @@ export async function GET() { label: ctx.label, portfolioWide: true, entries: [], + weekly: [], + daily: [], channelSeries: { weeks: [], channels: [], points: [] }, - hint: 'Elige un cliente en el selector para ver su seguimiento y evolución de canales.', + hint: 'Elige un cliente en el selector para ver su seguimiento y evolución.', }); } @@ -59,13 +70,13 @@ export async function GET() { const tid = encodeURIComponent(tenantId); const [e, w, d] = await Promise.all([ restGet( - `/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=80` + `/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=120` ), restGet( `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc` ), restGet( - `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.asc&limit=400` + `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.asc&limit=800` ), ]); entries = e.data || []; @@ -77,13 +88,13 @@ export async function GET() { .from('seguimiento_entries') .select('*') .order('occurred_at', { ascending: false, nullsFirst: false }) - .limit(80), + .limit(120), supabase.from('kpi_weekly').select('*').order('week', { ascending: true }), supabase .from('ad_daily') .select('*') .order('date', { ascending: true }) - .limit(400), + .limit(800), ]); entries = (e as SeguimientoEntry[]) || []; weekly = (w as KpiWeeklyRow[]) || []; @@ -91,17 +102,118 @@ export async function GET() { } const channelSeries = buildChannelSeries(weekly); + const totalsWeekly = sumWeekly(weekly); + const totalsDaily = sumDaily(daily); return NextResponse.json({ label: ctx.label, portfolioWide: false, tenantId, entries, + weekly, + daily, channelSeries, + totals: { + spend: totalsWeekly.spend || totalsDaily.spend, + leads: totalsWeekly.leads || totalsDaily.leads, + cpl: totalsWeekly.cpl || (totalsDaily.leads > 0 ? totalsDaily.spend / totalsDaily.leads : 0), + cac: totalsWeekly.cac, + revenue: totalsWeekly.revenue, + roas: totalsWeekly.roas, + impressions: totalsDaily.impressions, + clicks: totalsDaily.clicks, + }, dailySummary: { days: daily.length, - spend: daily.reduce((a, r) => a + Number(r.spend || 0), 0), - leads: daily.reduce((a, r) => a + Number(r.platform_leads || 0), 0), + spend: totalsDaily.spend, + leads: totalsDaily.leads, }, }); } + +/** + * POST — add a manual note / comentario to seguimiento. + * { title?, body, occurred_at? } + */ +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente en Contexto' }, + { status: 400 } + ); + } + + const body = (await req.json().catch(() => ({}))) as Record; + const text = + typeof body.body === 'string' + ? body.body.trim() + : typeof body.note === 'string' + ? body.note.trim() + : ''; + if (!text) { + return NextResponse.json({ error: 'Texto de la nota requerido' }, { status: 400 }); + } + const title = + typeof body.title === 'string' && body.title.trim() + ? body.title.trim().slice(0, 200) + : `Nota · ${new Date().toLocaleDateString('es-ES')}`; + const occurredAt = + typeof body.occurred_at === 'string' && body.occurred_at + ? body.occurred_at + : new Date().toISOString(); + + const row = { + tenant_id: ctx.tenantId, + source: 'manual', + entry_type: 'note', + external_id: `manual-${randomUUID()}`, + title, + summary: text.slice(0, 280), + body_md: text, + occurred_at: occurredAt, + synced_at: new Date().toISOString(), + metrics: {}, + }; + + const agency = isAgencyRole(userRole(user)); + const headers = serviceHeaders(); + + if (agency && headers) { + const r = await fetch(`${supabaseUrl()}/rest/v1/seguimiento_entries`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(row), + }); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + const data = await r.json(); + return NextResponse.json( + { entry: Array.isArray(data) ? data[0] : data }, + { status: 201 } + ); + } + + const { data, error } = await supabase + .from('seguimiento_entries') + .insert(row) + .select('*') + .single(); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + return NextResponse.json({ entry: data }, { status: 201 }); +} diff --git a/src/app/dashboard/calendario/page.tsx b/src/app/dashboard/calendario/page.tsx index 1c1461b..bcee47b 100644 --- a/src/app/dashboard/calendario/page.tsx +++ b/src/app/dashboard/calendario/page.tsx @@ -7,6 +7,7 @@ import { isAgencyRole, userRole } from '@/lib/agency'; import RrssMonthView, { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView'; import CalendarClient from './CalendarClient'; import PublishRrssButton from './PublishRrssButton'; +import RrssArchiveYear from '@/components/RrssArchiveYear'; import octPlan from '@/data/rrss-octubre-2026.json'; import { Instagram, Linkedin, CalendarDays } from 'lucide-react'; @@ -88,11 +89,12 @@ export default async function CalendarioPage({ const monthParam = sp.month || plans[0]?.month; const active = plans.find((p) => p.month === monthParam) || plans[0]; - const view = sp.view || 'hub'; + // Portada = archivo anual estilo /rrss/ig-fb/ + const view = sp.view || 'archive'; const initialPlatform = sp.tab === 'linkedin' ? 'linkedin' - : sp.tab === 'instagram' + : sp.tab === 'instagram' || sp.tab === 'ig-fb' ? 'instagram' : 'all'; @@ -133,49 +135,14 @@ export default async function CalendarioPage({ // Archive of months (like /rrss/ig-fb/) if (view === 'archive') { + const year = Number((active?.month || '2026-10').slice(0, 4)) || 2026; return ( -
-
-
- - ← Hub RRSS - -

- Calendario IG / FB -

-

- Archivo mensual renovable · {ctx.label} -

-
-
-
- {plans.map((p) => ( - -
- - {p.label} - - - Live - -
-
-

- {(p.pieces || []).length} piezas · clic para abrir el mes - completo (reparto + calendario + copy) -

-
- - ))} -
-
+ : null} + /> ); } diff --git a/src/components/CreativePlacementPreview.tsx b/src/components/CreativePlacementPreview.tsx index 5ecde79..dccb4c0 100644 --- a/src/components/CreativePlacementPreview.tsx +++ b/src/components/CreativePlacementPreview.tsx @@ -28,6 +28,12 @@ export type CreativeFormat = | 'story' | 'rsa' | 'pmax' + | 'pmax_landscape' + | 'pmax_square' + | 'pmax_portrait' + | 'pmax_vertical' + | 'pmax_logo' + | 'youtube' | 'other'; export type CreativePreviewData = { @@ -350,13 +356,45 @@ function GoogleSearchPreview({ data }: { data: CreativePreviewData }) { ); } -function GoogleDisplayPreview({ data }: { data: CreativePreviewData }) { +function GoogleDisplayPreview({ + data, + aspect = 'landscape', +}: { + data: CreativePreviewData; + aspect?: 'landscape' | 'square' | 'portrait' | 'vertical' | 'logo'; +}) { + const ratio = + aspect === 'square' + ? 'aspect-square' + : aspect === 'portrait' + ? 'aspect-[4/5]' + : aspect === 'vertical' + ? 'aspect-[9/16]' + : aspect === 'logo' + ? 'aspect-square max-w-[140px] mx-auto' + : 'aspect-[1.91/1]'; + const label = + aspect === 'square' + ? '1:1 · cuadrado' + : aspect === 'portrait' + ? '4:5 · portrait' + : aspect === 'vertical' + ? '9:16 · vertical' + : aspect === 'logo' + ? 'Logo' + : '1.91:1 · landscape'; + return (

- Google Ads · display / PMax + Google Ads · Performance Max · {label}

- +
+ +

{data.headline || data.title} @@ -370,6 +408,30 @@ function GoogleDisplayPreview({ data }: { data: CreativePreviewData }) { ); } +function YouTubePreview({ data }: { data: CreativePreviewData }) { + return ( +

+

+ Google Ads · YouTube +

+
+ +
+ +
+
+
+

+ {data.headline || data.title} +

+

+ {data.description || data.primary_text} +

+
+
+ ); +} + /** Renders a placement mock like Ad Tracker phone / Google RSA cards. */ export default function CreativePlacementPreview({ data, @@ -384,13 +446,31 @@ export default function CreativePlacementPreview({ let body: React.ReactNode; - if (channel === 'google_ads' || format === 'rsa') { - body = - format === 'pmax' || placement === 'display' ? ( - - ) : ( - - ); + if (format === 'youtube') { + body = ; + } else if ( + format === 'pmax_landscape' || + format === 'pmax_square' || + format === 'pmax_portrait' || + format === 'pmax_vertical' || + format === 'pmax_logo' || + format === 'pmax' || + (channel === 'google_ads' && + (placement === 'display' || format.startsWith('pmax'))) + ) { + const aspect = + format === 'pmax_square' || format === 'pmax_logo' + ? format === 'pmax_logo' + ? 'logo' + : 'square' + : format === 'pmax_portrait' + ? 'portrait' + : format === 'pmax_vertical' + ? 'vertical' + : 'landscape'; + body = ; + } else if (channel === 'google_ads' || format === 'rsa') { + body = ; } else if ( channel === 'stories' || placement === 'stories' || @@ -443,8 +523,14 @@ export const FORMAT_OPTIONS: { id: CreativeFormat; label: string }[] = [ { id: 'carousel', label: 'Carrusel' }, { id: 'reel', label: 'Reel' }, { id: 'story', label: 'Story' }, - { id: 'rsa', label: 'RSA (búsqueda)' }, - { id: 'pmax', label: 'PMax / Display' }, + { id: 'rsa', label: 'Google RSA (búsqueda)' }, + { id: 'pmax', label: 'PMax (genérico)' }, + { id: 'pmax_landscape', label: 'PMax landscape 1.91:1' }, + { id: 'pmax_square', label: 'PMax cuadrado 1:1' }, + { id: 'pmax_portrait', label: 'PMax portrait 4:5' }, + { id: 'pmax_vertical', label: 'PMax vertical 9:16' }, + { id: 'pmax_logo', label: 'PMax logo' }, + { id: 'youtube', label: 'YouTube / vídeo in-stream' }, { id: 'other', label: 'Otro' }, ]; @@ -454,5 +540,9 @@ export const PLACEMENT_OPTIONS = [ { id: 'reels', label: 'Reels' }, { id: 'search', label: 'Búsqueda' }, { id: 'display', label: 'Display' }, + { id: 'youtube', label: 'YouTube' }, + { id: 'discover', label: 'Discover' }, + { id: 'gmail', label: 'Gmail' }, + { id: 'maps', label: 'Maps' }, { id: 'other', label: 'Otro' }, ]; diff --git a/src/components/CreativesPipeline.tsx b/src/components/CreativesPipeline.tsx index 4a1bf76..40fee38 100644 --- a/src/components/CreativesPipeline.tsx +++ b/src/components/CreativesPipeline.tsx @@ -7,6 +7,7 @@ import { Trash2, X, Eye, + Upload, } from 'lucide-react'; import CreativePlacementPreview, { CHANNEL_OPTIONS, @@ -74,6 +75,8 @@ export default function CreativesPipeline() { const [selected, setSelected] = useState(null); const [showForm, setShowForm] = useState(false); const [filter, setFilter] = useState<'all' | string>('all'); + const [uploading, setUploading] = useState(false); + const [uploadMsg, setUploadMsg] = useState(null); async function load() { setLoading(true); @@ -105,6 +108,47 @@ export default function CreativesPipeline() { return creatives.filter((c) => c.status === filter); }, [creatives, filter]); + async function uploadFile( + file: File, + creativeId?: string + ): Promise { + setUploading(true); + setUploadMsg(null); + setErr(null); + try { + const fd = new FormData(); + fd.append('file', file); + if (creativeId) fd.append('creativeId', creativeId); + if (draft.title) fd.append('title', draft.title); + const res = await fetch('/api/creatives/upload', { + method: 'POST', + body: fd, + }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error ?? 'Error al subir'); + return null; + } + const url = data.media_url || data.creative?.media_url || null; + if (url) { + setDraft((d) => ({ + ...d, + media_url: url, + title: d.title || data.creative?.title || d.title, + format: + file.type.startsWith('video/') && !d.format.startsWith('pmax') + ? 'video' + : d.format, + })); + setUploadMsg('Archivo subido · preview actualizada'); + } + await load(); + return data.creative?.id || creativeId || null; + } finally { + setUploading(false); + } + } + async function create(e: FormEvent) { e.preventDefault(); setErr(null); @@ -358,7 +402,7 @@ export default function CreativesPipeline() { setDraft((d) => ({ ...d, media_url: e.target.value })) } className={inputCls} - placeholder="https://… o /media/…" + placeholder="https://… o /api/creatives/media?id=…" /> @@ -372,6 +416,32 @@ export default function CreativesPipeline() { />
+
+

+ Subir creativo (sin URL) +

+ { + const f = e.target.files?.[0]; + if (f) void uploadFile(f); + e.target.value = ''; + }} + className="block w-full text-xs text-gray-300 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" + /> +

+ JPG, PNG, WebP, GIF o MP4/WebM. Se guarda en el storage del + tenant y alimenta la preview. +

+ {uploading ? ( +

Subiendo…

+ ) : null} + {uploadMsg ? ( +

{uploadMsg}

+ ) : null} +
+
+

+ Reemplazar archivo +

+ { + 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-300 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" + /> +
diff --git a/src/components/RrssArchiveYear.tsx b/src/components/RrssArchiveYear.tsx new file mode 100644 index 0000000..34aba39 --- /dev/null +++ b/src/components/RrssArchiveYear.tsx @@ -0,0 +1,218 @@ +import React from 'react'; +import Link from 'next/link'; +import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView'; + +const MONTH_NAMES = [ + 'Enero', + 'Febrero', + 'Marzo', + 'Abril', + 'Mayo', + 'Junio', + 'Julio', + 'Agosto', + 'Septiembre', + 'Octubre', + 'Noviembre', + 'Diciembre', +]; + +function colorFor(piece: RrssPiece): string { + if (piece.color) return piece.color; + if (piece.platform === 'linkedin') return '#7C66B8'; + if ((piece.format || '').toLowerCase().includes('stor')) return '#374151'; + const hay = `${piece.pillar || ''} ${piece.meta_line || ''} ${piece.title}`.toLowerCase(); + if (hay.includes('familia') || hay.includes('bienestar')) return '#E08E2E'; + if (hay.includes('sostenib')) return '#0F9D8F'; + if (hay.includes('b2b') || hay.includes('hosteler')) return '#7C66B8'; + if (hay.includes('prueba') || hay.includes('reseña')) return '#8A93A6'; + return '#2071cf'; +} + +function MiniMonth({ + year, + monthIndex, + plan, +}: { + year: number; + monthIndex: number; // 0-11 + plan?: RrssMonthPlan; +}) { + const monthKey = `${year}-${String(monthIndex + 1).padStart(2, '0')}`; + const first = new Date(Date.UTC(year, monthIndex, 1)); + const daysInMonth = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate(); + const startPad = (first.getUTCDay() + 6) % 7; + const live = Boolean(plan); + const byDate = new Map(); + for (const p of plan?.pieces || []) { + const k = p.date.slice(0, 10); + (byDate.get(k) || byDate.set(k, []).get(k)!).push(p); + } + + const headerCls = live + ? 'flex items-center justify-between gap-2 bg-gradient-to-r from-[#1a2a63] to-[#2071CF] text-white px-3.5 py-2.5' + : 'flex items-center justify-between gap-2 bg-[#9aa6c0] text-white px-3.5 py-2.5'; + + const body = ( + <> +
+ + {MONTH_NAMES[monthIndex]} + + + {live ? 'Publicado' : 'Pronto'} + +
+
+ {['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d, i) => ( + = 5 ? 'text-sky-400' : 'text-gray-500' + }`} + > + {d} + + ))} +
+
+ {Array.from({ length: startPad }).map((_, i) => ( + + ))} + {Array.from({ length: daysInMonth }).map((_, i) => { + const day = i + 1; + const key = `${monthKey}-${String(day).padStart(2, '0')}`; + const hits = byDate.get(key) || []; + const weekend = + new Date(Date.UTC(year, monthIndex, day)).getUTCDay() % 6 === 0; + const bg = hits[0] ? colorFor(hits[0]) : undefined; + return ( + h.title).join(' · ') || undefined} + className={`aspect-square flex items-center justify-center text-[11px] font-bold rounded-full ${ + weekend && !bg ? 'text-sky-400' : 'text-gray-300' + }`} + style={ + bg + ? { + background: bg, + color: '#fff', + boxShadow: 'inset 0 0 0 1px rgba(255,255,255,.35)', + } + : undefined + } + > + {day} + + ); + })} +
+
+ {live + ? `${plan!.pieces.length} piezas · abrir mes` + : 'Pendiente de publicar'} +
+ + ); + + const className = `rounded-2xl border overflow-hidden flex flex-col min-h-full transition-all ${ + live + ? 'border-gray-700 bg-[#111827] hover:border-sky-500/50 hover:-translate-y-0.5 shadow-lg shadow-sky-900/10' + : 'border-gray-800/80 bg-[#0D121F] opacity-80' + }`; + + if (live) { + return ( + + {body} + + ); + } + + return ( +
+ {body} +
+ ); +} + +/** Year archive cover like /agua-miia/rrss/ig-fb/ */ +export default function RrssArchiveYear({ + year = 2026, + plans, + tenantLabel, + agencyPublish, +}: { + year?: number; + plans: RrssMonthPlan[]; + tenantLabel: string; + agencyPublish?: React.ReactNode; +}) { + const byMonth = new Map(plans.map((p) => [p.month, p])); + const legend = plans[0]?.legend || [ + { label: 'Educativo / serie', color: '#2071cf' }, + { label: 'Familia / bienestar', color: '#E08E2E' }, + { label: 'Sostenibilidad', color: '#0F9D8F' }, + { label: 'B2B / LinkedIn', color: '#7C66B8' }, + { label: 'Stories', color: '#374151' }, + ]; + + return ( +
+
+
+ + ← Hub RRSS + +

+ Calendario IG / FB {year} +

+

+ Archivo mensual de contenidos. Los días redondeados y coloreados + marcan el tipo de publicación (misma paleta que en cada mes). +

+
+
+
+ + Año {year} · {tenantLabel} × 4Ventures +
+ {agencyPublish} +
+
+ +
+ {Array.from({ length: 12 }).map((_, i) => { + const key = `${year}-${String(i + 1).padStart(2, '0')}`; + return ( + + ); + })} +
+ +
+ {legend.map((l) => ( + + + {l.label} + + ))} +
+
+ ); +} diff --git a/src/components/RrssMonthView.tsx b/src/components/RrssMonthView.tsx index 29f5aa6..f0d63a3 100644 --- a/src/components/RrssMonthView.tsx +++ b/src/components/RrssMonthView.tsx @@ -145,9 +145,13 @@ export default function RrssMonthView({ return ['all', ...[...set].sort()]; }, [pieces]); - const filtered = pieces.filter( - (p) => platform === 'all' || p.platform === platform - ); + const filtered = pieces.filter((p) => { + if (platform === 'all') return true; + if (platform === 'instagram') { + return p.platform === 'instagram' || p.platform === 'facebook'; + } + return p.platform === platform; + }); const ids = pieces.map((p) => p.id); @@ -250,9 +254,9 @@ export default function RrssMonthView({ return (
- {/* Hub crumbs */} -
-
+ {/* Hub crumbs + network filters */} +
+
RRSS @@ -268,6 +272,42 @@ export default function RrssMonthView({
+
+ + + +
+ {/* Hero */}

@@ -291,7 +331,7 @@ export default function RrssMonthView({

- {/* Network tabs */} + {/* Network tabs (secondary) */}
{platforms.map((p) => ( + ))} +
+ + + {/* KPI cards (Métricas-like) */} +
+ {cards.map((c) => ( +
+
+

+ {c.label} +

+ {c.icon} +
+

{c.value}

- )} + ))}
{/* Channel evolution */} @@ -370,7 +532,7 @@ export default function SeguimientoBoard() {
- {channelSeries.channels.map((ch, i) => { + {rangedChannel.channels.map((ch, i) => { const on = activeChannels.has(ch); const color = CHANNEL_COLORS[i % CHANNEL_COLORS.length]; return ( @@ -395,20 +557,58 @@ export default function SeguimientoBoard() {
+ {/* Notes box */} +
+
+ + Comentarios y notas +
+

+ Añade observaciones del periodo (reunión, hipótesis, bloqueos, + siguientes pasos…). Quedan en el timeline de seguimiento. +

+ setNoteTitle(e.target.value)} + placeholder="Título (opcional)" + className="w-full rounded-lg bg-black/30 border border-gray-800 px-3 py-2 text-sm text-white placeholder:text-gray-600" + /> +