feat: menú Ads + Social; Drive por defecto en activos
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 <cursoragent@cursor.com>
This commit is contained in:
51
src/app/api/social/route.ts
Normal file
51
src/app/api/social/route.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ export default async function AdTrackerPage() {
|
|||||||
if (ctx.portfolioWide) {
|
if (ctx.portfolioWide) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-gray-500 border border-gray-200 rounded-xl p-6 bg-white">
|
<p className="text-sm text-gray-500 border border-gray-200 rounded-xl p-6 bg-white">
|
||||||
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).
|
tenant).
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
32
src/app/dashboard/social/page.tsx
Normal file
32
src/app/dashboard/social/page.tsx
Normal file
@@ -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 (
|
||||||
|
<p className="text-sm text-gray-500 border border-gray-200 rounded-xl p-6 bg-white">
|
||||||
|
Selecciona un cliente en Contexto para abrir Social (perfiles RRSS por
|
||||||
|
tenant).
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocialDashboard tenantLabel={ctx.label} isAgency={agency} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -414,7 +414,7 @@ export default function AdTrackerClient({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow={`Ad Tracker · ${label}`}
|
eyebrow={`Ads · ${label}`}
|
||||||
title={tagline || 'Meta + Google Ads'}
|
title={tagline || 'Meta + Google Ads'}
|
||||||
description={`Gasto, clics, conversiones y preview de creatividades. Ventana: ${range.since} → ${range.until}.`}
|
description={`Gasto, clics, conversiones y preview de creatividades. Ventana: ${range.since} → ${range.until}.`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ function iconHint(mime?: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) {
|
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<Asset[]>([]);
|
const [assets, setAssets] = useState<Asset[]>([]);
|
||||||
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
|
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
|
||||||
const [driveFolder, setDriveFolder] = useState<string | null>(null);
|
const [driveFolder, setDriveFolder] = useState<string | null>(null);
|
||||||
@@ -222,7 +222,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Contenido"
|
eyebrow="Contenido"
|
||||||
title="Activos"
|
title="Activos"
|
||||||
description={`Archivo oficial de ${tenantLabel}. Subidas al portal se copian a Drive si está enlazado.`}
|
description={`Contenido de Drive de ${tenantLabel}. Pestaña Drive por defecto; Portal son archivos subidos al Centro de Mando.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
Radio,
|
Radio,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
|
Share2,
|
||||||
Contact,
|
Contact,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
@@ -70,11 +71,18 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard/ad-tracker',
|
href: '/dashboard/ad-tracker',
|
||||||
label: 'Ad Tracker',
|
label: 'Ads',
|
||||||
icon: Megaphone,
|
icon: Megaphone,
|
||||||
iconClass: 'text-violet-500',
|
iconClass: 'text-violet-500',
|
||||||
moduleId: 'ad-tracker',
|
moduleId: 'ad-tracker',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/dashboard/social',
|
||||||
|
label: 'Social',
|
||||||
|
icon: Share2,
|
||||||
|
iconClass: 'text-fuchsia-500',
|
||||||
|
moduleId: 'social',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard/organico',
|
href: '/dashboard/organico',
|
||||||
label: 'Orgánico',
|
label: 'Orgánico',
|
||||||
|
|||||||
169
src/components/SocialDashboard.tsx
Normal file
169
src/components/SocialDashboard.tsx
Normal file
@@ -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<Integ[]>([]);
|
||||||
|
const [err, setErr] = useState<string | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow={`Social · ${tenantLabel}`}
|
||||||
|
title="Redes sociales"
|
||||||
|
description="Perfiles orgánicos (FB, IG, LinkedIn, TikTok). Distinto de Ads pagados. Publicación y métricas live: siguiente fase."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<p className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-gray-500 flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Cargando…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{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 (
|
||||||
|
<li
|
||||||
|
key={ch.id}
|
||||||
|
className="rounded-xl border border-gray-200 bg-white p-4 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="w-4 h-4 text-gray-700" />
|
||||||
|
<p className="text-sm font-semibold text-gray-900">
|
||||||
|
{ch.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded ${
|
||||||
|
connected
|
||||||
|
? 'bg-emerald-50 text-emerald-700'
|
||||||
|
: 'bg-gray-100 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{connected ? 'Conectado' : 'Sin enlazar'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 truncate">
|
||||||
|
{row?.meta?.account_name ||
|
||||||
|
row?.label ||
|
||||||
|
row?.external_account_id ||
|
||||||
|
'—'}
|
||||||
|
</p>
|
||||||
|
{row?.meta?.warning && (
|
||||||
|
<p className="text-[11px] text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1">
|
||||||
|
{row.meta.warning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{url && (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] text-brand-600 hover:underline"
|
||||||
|
>
|
||||||
|
Abrir perfil <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">Próximo en Social</p>
|
||||||
|
<ul className="text-xs text-gray-600 space-y-1 list-disc pl-4">
|
||||||
|
<li>Métricas orgánicas (alcance, engagement) vía Graph API</li>
|
||||||
|
<li>Últimas publicaciones y rendimiento</li>
|
||||||
|
<li>Puente con Calendario editorial</li>
|
||||||
|
</ul>
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/calendario"
|
||||||
|
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Calendario editorial
|
||||||
|
</Link>
|
||||||
|
{isAgency && (
|
||||||
|
<Link
|
||||||
|
href="/dashboard/agencia"
|
||||||
|
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Emparejar en Agencia / ficha
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -259,6 +259,7 @@ export const ORGANICO_SECTIONS: OrganicoSection[] = [
|
|||||||
'Marcar prompts prioritarios por volumen o negocio',
|
'Marcar prompts prioritarios por volumen o negocio',
|
||||||
'Anotar marcas y URLs citadas en cada respuesta',
|
'Anotar marcas y URLs citadas en cada respuesta',
|
||||||
],
|
],
|
||||||
|
soon: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'geo-brand',
|
id: 'geo-brand',
|
||||||
@@ -376,10 +377,10 @@ export const ORGANICO_SECTIONS: OrganicoSection[] = [
|
|||||||
group: 'Rendimiento del sitio',
|
group: 'Rendimiento del sitio',
|
||||||
label: 'Visión general',
|
label: 'Visión general',
|
||||||
blurb:
|
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: [
|
actions: [
|
||||||
'Conectar Search Console y GA4',
|
'Conectar Search Console y GA4',
|
||||||
'Cargar o sincronizar serie diaria',
|
'Sincronizar (histórico acumulado en organic_daily)',
|
||||||
'Definir 3 prioridades del mes',
|
'Definir 3 prioridades del mes',
|
||||||
],
|
],
|
||||||
live: 'seo-metrics',
|
live: 'seo-metrics',
|
||||||
@@ -424,6 +425,7 @@ export const ORGANICO_SECTIONS: OrganicoSection[] = [
|
|||||||
'Comparar autoridad y temáticas',
|
'Comparar autoridad y temáticas',
|
||||||
'Extraer oportunidades de contenido',
|
'Extraer oportunidades de contenido',
|
||||||
],
|
],
|
||||||
|
soon: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'seo-organic',
|
id: 'seo-organic',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type DashboardModuleId =
|
|||||||
| 'estrategia'
|
| 'estrategia'
|
||||||
| 'calendario'
|
| 'calendario'
|
||||||
| 'ad-tracker'
|
| 'ad-tracker'
|
||||||
|
| 'social'
|
||||||
| 'organico'
|
| 'organico'
|
||||||
| 'activos'
|
| 'activos'
|
||||||
| 'creatives'
|
| 'creatives'
|
||||||
@@ -60,9 +61,16 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
|
|||||||
{
|
{
|
||||||
id: 'ad-tracker',
|
id: 'ad-tracker',
|
||||||
href: '/dashboard/ad-tracker',
|
href: '/dashboard/ad-tracker',
|
||||||
label: 'Ad Tracker',
|
label: 'Ads',
|
||||||
providers: ['meta_ads', 'google_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',
|
id: 'organico',
|
||||||
@@ -76,7 +84,7 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
|
|||||||
href: '/dashboard/activos',
|
href: '/dashboard/activos',
|
||||||
label: 'Biblioteca de Activos',
|
label: 'Biblioteca de Activos',
|
||||||
providers: ['google_drive'],
|
providers: ['google_drive'],
|
||||||
hint: 'Drive y archivos',
|
hint: 'Contenido de Google Drive del cliente',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'creatives',
|
id: 'creatives',
|
||||||
|
|||||||
Reference in New Issue
Block a user