From ba05626fed539e619cae29f99c9b1878a3e702e1 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Thu, 24 Sep 2026 13:44:56 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20men=C3=BA=20Ads=20+=20Social;=20Drive?= =?UTF-8?q?=20por=20defecto=20en=20activos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renombra Ad Tracker a Ads, añade módulo Social (perfiles RRSS) y abre Biblioteca en pestaña Drive. Aclara histórico GSC en Orgánico. Co-authored-by: Cursor --- src/app/api/social/route.ts | 51 ++++++++ src/app/dashboard/ad-tracker/page.tsx | 2 +- src/app/dashboard/social/page.tsx | 32 +++++ src/components/AdTrackerClient.tsx | 2 +- src/components/AssetsLibrary.tsx | 4 +- src/components/DashboardShell.tsx | 10 +- src/components/SocialDashboard.tsx | 169 ++++++++++++++++++++++++++ src/lib/organic.ts | 6 +- src/lib/tenant-modules.ts | 14 ++- 9 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 src/app/api/social/route.ts create mode 100644 src/app/dashboard/social/page.tsx create mode 100644 src/components/SocialDashboard.tsx diff --git a/src/app/api/social/route.ts b/src/app/api/social/route.ts new file mode 100644 index 0000000..9abff26 --- /dev/null +++ b/src/app/api/social/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; +import { isAgencyRole, userRole } from '@/lib/agency'; + +export const dynamic = 'force-dynamic'; + +/** GET — social channel integrations for active tenant. */ +export async function GET() { + 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({ + integrations: [], + warning: 'Selecciona un cliente en Contexto', + }); + } + + const agency = isAgencyRole(userRole(user)); + const providers = 'instagram,facebook,tiktok,linkedin'; + + if (agency && ctx.viaView) { + const tid = encodeURIComponent(ctx.tenantId); + const r = await restGet( + `/rest/v1/integrations?tenant_id=eq.${tid}&provider=in.(${providers})&select=provider,status,external_account_id,label,last_sync_at,meta,error_message` + ); + return NextResponse.json({ + integrations: r.data || [], + warning: r.error || null, + }); + } + + const { data, error } = await supabase + .from('integrations') + .select( + 'provider,status,external_account_id,label,last_sync_at,meta,error_message' + ) + .in('provider', ['instagram', 'facebook', 'tiktok', 'linkedin']); + + return NextResponse.json({ + integrations: data || [], + warning: error?.message || null, + }); +} diff --git a/src/app/dashboard/ad-tracker/page.tsx b/src/app/dashboard/ad-tracker/page.tsx index b63c899..77847f6 100644 --- a/src/app/dashboard/ad-tracker/page.tsx +++ b/src/app/dashboard/ad-tracker/page.tsx @@ -34,7 +34,7 @@ export default async function AdTrackerPage() { if (ctx.portfolioWide) { return (

- Selecciona un cliente en Contexto para abrir su Ad Tracker (privado por + Selecciona un cliente en Contexto para abrir Ads (Meta / Google Ads por tenant).

); diff --git a/src/app/dashboard/social/page.tsx b/src/app/dashboard/social/page.tsx new file mode 100644 index 0000000..ae4c9ce --- /dev/null +++ b/src/app/dashboard/social/page.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { createClient } from '@/lib/supabase/server'; +import { redirect } from 'next/navigation'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { isAgencyRole, userRole } from '@/lib/agency'; +import SocialDashboard from '@/components/SocialDashboard'; + +export const dynamic = 'force-dynamic'; + +export default async function SocialPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login'); + + const ctx = await resolveActiveTenant(user); + const agency = isAgencyRole(userRole(user)); + + if (ctx.portfolioWide) { + return ( +

+ Selecciona un cliente en Contexto para abrir Social (perfiles RRSS por + tenant). +

+ ); + } + + return ( + + ); +} diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx index 40c80fb..8e0e414 100644 --- a/src/components/AdTrackerClient.tsx +++ b/src/components/AdTrackerClient.tsx @@ -414,7 +414,7 @@ export default function AdTrackerClient({ return (
diff --git a/src/components/AssetsLibrary.tsx b/src/components/AssetsLibrary.tsx index db177a5..33d7f2b 100644 --- a/src/components/AssetsLibrary.tsx +++ b/src/components/AssetsLibrary.tsx @@ -85,7 +85,7 @@ function iconHint(mime?: string | null) { } export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) { - const [tab, setTab] = useState<'portal' | 'drive'>('portal'); + const [tab, setTab] = useState<'portal' | 'drive'>('drive'); const [assets, setAssets] = useState([]); const [driveFiles, setDriveFiles] = useState([]); const [driveFolder, setDriveFolder] = useState(null); @@ -222,7 +222,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx index 12fee66..7600f36 100644 --- a/src/components/DashboardShell.tsx +++ b/src/components/DashboardShell.tsx @@ -16,6 +16,7 @@ import { CalendarDays, Radio, Megaphone, + Share2, Contact, Menu, X, @@ -70,11 +71,18 @@ const NAV_ITEMS: NavItem[] = [ }, { href: '/dashboard/ad-tracker', - label: 'Ad Tracker', + label: 'Ads', icon: Megaphone, iconClass: 'text-violet-500', moduleId: 'ad-tracker', }, + { + href: '/dashboard/social', + label: 'Social', + icon: Share2, + iconClass: 'text-fuchsia-500', + moduleId: 'social', + }, { href: '/dashboard/organico', label: 'Orgánico', diff --git a/src/components/SocialDashboard.tsx b/src/components/SocialDashboard.tsx new file mode 100644 index 0000000..4db2ab3 --- /dev/null +++ b/src/components/SocialDashboard.tsx @@ -0,0 +1,169 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { + Facebook, + Instagram, + Linkedin, + Loader2, + Share2, + ExternalLink, +} from 'lucide-react'; +import PageHeader from '@/components/PageHeader'; + +type Integ = { + provider: string; + status: string; + external_account_id?: string | null; + label?: string | null; + last_sync_at?: string | null; + meta?: { public_url?: string; account_name?: string; warning?: string } | null; +}; + +const CHANNELS: { + id: string; + label: string; + icon: React.ComponentType<{ className?: string }>; +}[] = [ + { id: 'instagram', label: 'Instagram', icon: Instagram }, + { id: 'facebook', label: 'Facebook', icon: Facebook }, + { id: 'linkedin', label: 'LinkedIn', icon: Linkedin }, + { id: 'tiktok', label: 'TikTok', icon: Share2 }, +]; + +export default function SocialDashboard({ + tenantLabel, + isAgency, +}: { + tenantLabel: string; + isAgency: boolean; +}) { + const [loading, setLoading] = useState(true); + const [integs, setIntegs] = useState([]); + const [err, setErr] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + const res = await fetch('/api/social'); + const data = await res.json().catch(() => ({})); + if (cancelled) return; + if (!res.ok) setErr(data.error || 'No se pudo cargar Social'); + else setIntegs(data.integrations || []); + setLoading(false); + })(); + return () => { + cancelled = true; + }; + }, []); + + const by = new Map(integs.map((i) => [i.provider, i])); + + return ( +
+ + + {err && ( +

+ {err} +

+ )} + + {loading ? ( +

+ Cargando… +

+ ) : ( +
    + {CHANNELS.map((ch) => { + const row = by.get(ch.id); + const connected = + row?.status === 'connected' && Boolean(row.external_account_id); + const Icon = ch.icon; + const url = + row?.meta?.public_url || + (row?.external_account_id?.startsWith('http') + ? row.external_account_id + : null); + return ( +
  • +
    +
    + +

    + {ch.label} +

    +
    + + {connected ? 'Conectado' : 'Sin enlazar'} + +
    +

    + {row?.meta?.account_name || + row?.label || + row?.external_account_id || + '—'} +

    + {row?.meta?.warning && ( +

    + {row.meta.warning} +

    + )} + {url && ( + + Abrir perfil + + )} +
  • + ); + })} +
+ )} + +
+

Próximo en Social

+
    +
  • Métricas orgánicas (alcance, engagement) vía Graph API
  • +
  • Últimas publicaciones y rendimiento
  • +
  • Puente con Calendario editorial
  • +
+
+ + Calendario editorial + + {isAgency && ( + + Emparejar en Agencia / ficha + + )} +
+
+
+ ); +} diff --git a/src/lib/organic.ts b/src/lib/organic.ts index 48bf4ac..19c20cd 100644 --- a/src/lib/organic.ts +++ b/src/lib/organic.ts @@ -259,6 +259,7 @@ export const ORGANICO_SECTIONS: OrganicoSection[] = [ 'Marcar prompts prioritarios por volumen o negocio', 'Anotar marcas y URLs citadas en cada respuesta', ], + soon: true, }, { id: 'geo-brand', @@ -376,10 +377,10 @@ export const ORGANICO_SECTIONS: OrganicoSection[] = [ group: 'Rendimiento del sitio', label: 'Visión general', blurb: - 'Hazte visible donde busca tu audiencia: datos de mercado, acción y resultados medibles.', + 'Hazte visible donde busca tu audiencia. Serie diaria desde GA4/GSC — el portal guarda histórico propio (GSC solo ofrece ~16 meses en API).', actions: [ 'Conectar Search Console y GA4', - 'Cargar o sincronizar serie diaria', + 'Sincronizar (histórico acumulado en organic_daily)', 'Definir 3 prioridades del mes', ], live: 'seo-metrics', @@ -424,6 +425,7 @@ export const ORGANICO_SECTIONS: OrganicoSection[] = [ 'Comparar autoridad y temáticas', 'Extraer oportunidades de contenido', ], + soon: true, }, { id: 'seo-organic', diff --git a/src/lib/tenant-modules.ts b/src/lib/tenant-modules.ts index 09bbd66..ceb4d7a 100644 --- a/src/lib/tenant-modules.ts +++ b/src/lib/tenant-modules.ts @@ -7,6 +7,7 @@ export type DashboardModuleId = | 'estrategia' | 'calendario' | 'ad-tracker' + | 'social' | 'organico' | 'activos' | 'creatives' @@ -60,9 +61,16 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [ { id: 'ad-tracker', href: '/dashboard/ad-tracker', - label: 'Ad Tracker', + label: 'Ads', providers: ['meta_ads', 'google_ads'], - hint: 'Creatividades Meta / Google', + hint: 'Paid: Meta Ads, Google Ads y creatividades', + }, + { + id: 'social', + href: '/dashboard/social', + label: 'Social', + providers: ['instagram', 'facebook', 'tiktok', 'linkedin'], + hint: 'RRSS orgánicas: perfiles, alcance y contenido', }, { id: 'organico', @@ -76,7 +84,7 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [ href: '/dashboard/activos', label: 'Biblioteca de Activos', providers: ['google_drive'], - hint: 'Drive y archivos', + hint: 'Contenido de Google Drive del cliente', }, { id: 'creatives',