diff --git a/src/app/api/social/route.ts b/src/app/api/social/route.ts index ea21bfe..e51a7e4 100644 --- a/src/app/api/social/route.ts +++ b/src/app/api/social/route.ts @@ -5,6 +5,7 @@ import { isAgencyRole, userRole } from '@/lib/agency'; import { resolveMetaAccessToken } from '@/lib/ads-discovery'; export const dynamic = 'force-dynamic'; +export const maxDuration = 60; const META = 'https://graph.facebook.com/v21.0'; @@ -17,34 +18,188 @@ type Integ = { meta?: Record | null; }; -async function fetchPageMetrics(token: string, pageId: string) { +type SocialPost = { + id: string; + platform: 'facebook' | 'instagram'; + message?: string; + mediaUrl?: string | null; + permalink?: string | null; + createdAt?: string | null; + likes: number; + comments: number; + shares: number; + reach?: number | null; + impressions?: number | null; + mediaType?: string | null; +}; + +function insightValue( + data: { name?: string; values?: { value?: number }[] }[] | undefined, + name: string +): number | null { + const row = data?.find((d) => d.name === name); + const v = row?.values?.[0]?.value; + return typeof v === 'number' && Number.isFinite(v) ? v : null; +} + +async function fetchPageBundle(token: string, pageId: string) { const fields = - 'name,fan_count,followers_count,link,picture{url},engagement'; + 'name,fan_count,followers_count,link,picture{url},engagement,about,website'; const r = await fetch( `${META}/${encodeURIComponent(pageId)}?fields=${fields}&access_token=${encodeURIComponent(token)}`, { cache: 'no-store' } ); - if (!r.ok) return { error: (await r.text()).slice(0, 160) }; - return r.json(); -} - -async function fetchIgMetrics(token: string, igId: string) { - const r = await fetch( - `${META}/${encodeURIComponent(igId)}?fields=username,name,followers_count,follows_count,media_count,profile_picture_url&access_token=${encodeURIComponent(token)}`, - { cache: 'no-store' } - ); - if (!r.ok) return { error: (await r.text()).slice(0, 160) }; + if (!r.ok) return { error: (await r.text()).slice(0, 200) }; const profile = await r.json(); - const ins = await fetch( - `${META}/${encodeURIComponent(igId)}/insights?metric=reach,impressions,profile_views&period=day&access_token=${encodeURIComponent(token)}`, + + const insightsRes = await fetch( + `${META}/${encodeURIComponent(pageId)}/insights?metric=page_impressions,page_impressions_unique,page_post_engagements,page_fans,page_views_total&period=day&access_token=${encodeURIComponent(token)}`, { cache: 'no-store' } ); - let insights: unknown = null; - if (ins.ok) insights = await ins.json(); - return { ...profile, insights }; + let pageInsights: Record = {}; + if (insightsRes.ok) { + const j = await insightsRes.json(); + const data = j.data as { name?: string; values?: { value?: number }[] }[]; + pageInsights = { + impressions: insightValue(data, 'page_impressions'), + reach: insightValue(data, 'page_impressions_unique'), + postEngagements: insightValue(data, 'page_post_engagements'), + fans: insightValue(data, 'page_fans'), + pageViews: insightValue(data, 'page_views_total'), + }; + } + + const postsRes = await fetch( + `${META}/${encodeURIComponent(pageId)}/posts?fields=id,message,created_time,full_picture,permalink_url,shares,likes.summary(true),comments.summary(true)&limit=12&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + const posts: SocialPost[] = []; + if (postsRes.ok) { + const j = await postsRes.json(); + for (const p of j.data || []) { + posts.push({ + id: p.id, + platform: 'facebook', + message: p.message || '', + mediaUrl: p.full_picture || null, + permalink: p.permalink_url || null, + createdAt: p.created_time || null, + likes: Number(p.likes?.summary?.total_count || 0), + comments: Number(p.comments?.summary?.total_count || 0), + shares: Number(p.shares?.count || 0), + mediaType: p.full_picture ? 'image' : 'status', + }); + } + } else { + profile.postsError = (await postsRes.text()).slice(0, 160); + } + + return { ...profile, pageInsights, posts }; } -/** GET — social integrations + live Meta metrics when token allows. */ +async function fetchIgBundle(token: string, igId: string) { + const r = await fetch( + `${META}/${encodeURIComponent(igId)}?fields=username,name,followers_count,follows_count,media_count,profile_picture_url,biography,website&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + if (!r.ok) return { error: (await r.text()).slice(0, 200) }; + const profile = await r.json(); + + const ins = await fetch( + `${META}/${encodeURIComponent(igId)}/insights?metric=reach,impressions,profile_views,website_clicks,accounts_engaged&period=day&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + let igInsights: Record = {}; + if (ins.ok) { + const j = await ins.json(); + const data = j.data as { name?: string; values?: { value?: number }[] }[]; + igInsights = { + reach: insightValue(data, 'reach'), + impressions: insightValue(data, 'impressions'), + profileViews: insightValue(data, 'profile_views'), + websiteClicks: insightValue(data, 'website_clicks'), + accountsEngaged: insightValue(data, 'accounts_engaged'), + }; + } else { + // Fallback subset (some metrics need different periods on newer API) + const ins2 = await fetch( + `${META}/${encodeURIComponent(igId)}/insights?metric=reach,follower_count&period=day&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + if (ins2.ok) { + const j = await ins2.json(); + const data = j.data as { name?: string; values?: { value?: number }[] }[]; + igInsights = { + reach: insightValue(data, 'reach'), + followerDelta: insightValue(data, 'follower_count'), + }; + } + } + + const mediaRes = await fetch( + `${META}/${encodeURIComponent(igId)}/media?fields=id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count&limit=12&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + const posts: SocialPost[] = []; + if (mediaRes.ok) { + const j = await mediaRes.json(); + for (const m of j.data || []) { + let reach: number | null = null; + let impressions: number | null = null; + try { + const mi = await fetch( + `${META}/${encodeURIComponent(m.id)}/insights?metric=reach,impressions,saved,shares&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + if (mi.ok) { + const ij = await mi.json(); + const data = ij.data as { + name?: string; + values?: { value?: number }[]; + }[]; + reach = insightValue(data, 'reach'); + impressions = insightValue(data, 'impressions'); + const shares = insightValue(data, 'shares'); + posts.push({ + id: m.id, + platform: 'instagram', + message: m.caption || '', + mediaUrl: m.media_url || m.thumbnail_url || null, + permalink: m.permalink || null, + createdAt: m.timestamp || null, + likes: Number(m.like_count || 0), + comments: Number(m.comments_count || 0), + shares: shares ?? 0, + reach, + impressions, + mediaType: m.media_type || null, + }); + continue; + } + } catch { + /* fall through without per-media insights */ + } + posts.push({ + id: m.id, + platform: 'instagram', + message: m.caption || '', + mediaUrl: m.media_url || m.thumbnail_url || null, + permalink: m.permalink || null, + createdAt: m.timestamp || null, + likes: Number(m.like_count || 0), + comments: Number(m.comments_count || 0), + shares: 0, + mediaType: m.media_type || null, + }); + } + } else { + profile.postsError = (await mediaRes.text()).slice(0, 160); + } + + return { ...profile, igInsights, posts }; +} + +/** GET — social integrations + analytics + recent posts preview. */ export async function GET() { const supabase = await createClient(); const { @@ -59,6 +214,7 @@ export async function GET() { return NextResponse.json({ integrations: [], metrics: {}, + posts: [], warning: 'Selecciona un cliente en Contexto', }); } @@ -84,6 +240,7 @@ export async function GET() { } const metrics: Record = {}; + const posts: SocialPost[] = []; const token = await resolveMetaAccessToken(); if (token) { for (const i of integrations) { @@ -92,10 +249,14 @@ export async function GET() { if (id.startsWith('http')) continue; try { if (i.provider === 'facebook') { - metrics.facebook = await fetchPageMetrics(token, id); + const fb = await fetchPageBundle(token, id); + metrics.facebook = fb; + if (Array.isArray(fb.posts)) posts.push(...fb.posts); } if (i.provider === 'instagram') { - metrics.instagram = await fetchIgMetrics(token, id); + const ig = await fetchIgBundle(token, id); + metrics.instagram = ig; + if (Array.isArray(ig.posts)) posts.push(...ig.posts); } } catch (e) { metrics[i.provider] = { @@ -108,9 +269,16 @@ export async function GET() { 'Sin token Meta de agencia: perfiles enlazados pero sin métricas live.'; } + posts.sort((a, b) => { + const ta = Date.parse(a.createdAt || '') || 0; + const tb = Date.parse(b.createdAt || '') || 0; + return tb - ta; + }); + return NextResponse.json({ integrations, metrics, + posts: posts.slice(0, 24), label: ctx.label, }); } diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx index 9ddb6aa..67883b4 100644 --- a/src/components/AdTrackerClient.tsx +++ b/src/components/AdTrackerClient.tsx @@ -112,12 +112,21 @@ function buildSeries(rows: AdDailyRow[]): DailyPoint[] { } function aggregate(rows: AdDailyRow[]): PlatformKpis { - const spend = rows.reduce((a, r) => a + Number(r.spend || 0), 0); - const impressions = rows.reduce((a, r) => a + Number(r.impressions || 0), 0); - const clicks = rows.reduce((a, r) => a + Number(r.clicks || 0), 0); - const conversions = rows.reduce((a, r) => a + convOf(r), 0); + const active = rows.filter( + (r) => + Number(r.spend || 0) > 0 || + Number(r.impressions || 0) > 0 || + Number(r.clicks || 0) > 0 || + convOf(r) > 0 + ); + const spend = active.reduce((a, r) => a + Number(r.spend || 0), 0); + const impressions = active.reduce((a, r) => a + Number(r.impressions || 0), 0); + const clicks = active.reduce((a, r) => a + Number(r.clicks || 0), 0); + const conversions = active.reduce((a, r) => a + convOf(r), 0); const ads = new Set( - rows.map((r) => campaignName(r)).filter((n) => n && n !== 'Sin nombre') + active + .map((r) => String(r.ad_id || campaignName(r) || '').trim()) + .filter((n) => n && n !== 'Sin nombre') ).size; return rates({ spend, impressions, clicks, conversions, ads }); } @@ -125,6 +134,13 @@ function aggregate(rows: AdDailyRow[]): PlatformKpis { function byCampaign(rows: AdDailyRow[]): CampaignRow[] { const map = new Map(); for (const r of rows) { + const spend = Number(r.spend || 0); + const impressions = Number(r.impressions || 0); + const clicks = Number(r.clicks || 0); + const conversions = convOf(r); + if (spend <= 0 && impressions <= 0 && clicks <= 0 && conversions <= 0) { + continue; + } const name = campaignName(r); const cur = map.get(name) || @@ -136,10 +152,10 @@ function byCampaign(rows: AdDailyRow[]): CampaignRow[] { conversions: 0, cpl: null, } satisfies CampaignRow); - cur.spend += Number(r.spend || 0); - cur.impressions += Number(r.impressions || 0); - cur.clicks += Number(r.clicks || 0); - cur.conversions += convOf(r); + cur.spend += spend; + cur.impressions += impressions; + cur.clicks += clicks; + cur.conversions += conversions; map.set(name, cur); } return [...map.values()] @@ -622,16 +638,36 @@ export default function AdTrackerClient({
+ - + + + +
+ c.platform === 'meta' && + (c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0) + )} loading={creativesLoading} warning={creativesWarning} onZoom={setZoomAd} @@ -649,9 +685,9 @@ export default function AdTrackerClient({
)} @@ -660,16 +696,36 @@ export default function AdTrackerClient({
+ - + + + +
+ c.platform === 'google' && + (c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0) + )} loading={creativesLoading} warning={creativesWarning} onZoom={setZoomAd} @@ -687,9 +743,9 @@ export default function AdTrackerClient({
)} diff --git a/src/components/OrganicoDashboard.tsx b/src/components/OrganicoDashboard.tsx index 18c1bf1..0f54f20 100644 --- a/src/components/OrganicoDashboard.tsx +++ b/src/components/OrganicoDashboard.tsx @@ -547,8 +547,8 @@ export default function OrganicoDashboard({
- {/* Live panels by section.live */} - {section.live === 'seo-metrics' && ( + {/* Paneles por sección (no el mismo dashboard en todos) */} + {section.id === 'seo-overview' && ( <>
@@ -631,22 +631,29 @@ export default function OrganicoDashboard({ )} )} {(section.id === 'seo-keywords' || section.id === 'seo-rankings' || - section.id === 'seo-keyword-magic') && ( - + section.id === 'seo-keyword-magic' || + section.id === 'seo-keyword-gap') && ( + )} {(section.id === 'seo-pages' || section.id === 'seo-organic' || - section.id === 'seo-traffic-insights') && ( + section.id === 'seo-traffic-insights' || + section.id === 'seo-compare') && ( )} - {section.live === 'geo-metrics' && ( + {section.id === 'seo-organic' && seoDaily.length > 0 && ( +
+

+ Evolución clics orgánicos +

+
+ {seoDaily.map((d) => ( +
+
+
+ ))} +
+
+ )} + + {section.live === 'geo-metrics' && + (section.id === 'geo-overview' || + section.id === 'geo-brand' || + section.id === 'geo-tracking') && ( <>
)} - {/* Priorities + section notes */} + {/* Prioridades + notas solo en visión general SEO/GEO */} + {(section.id === 'seo-overview' || section.id === 'geo-overview') && (

@@ -892,6 +927,7 @@ export default function OrganicoDashboard({ )}

+ )} {section.soon && (

@@ -899,6 +935,28 @@ export default function OrganicoDashboard({ en un clic.

)} + + {!section.soon && + section.id !== 'seo-overview' && + section.id !== 'geo-overview' && + !section.live && ( +
+

+ Acciones de esta sección +

+
    + {section.actions.map((a) => ( +
  • + + {a} +
  • + ))} +
+
+ )}
diff --git a/src/components/SocialDashboard.tsx b/src/components/SocialDashboard.tsx index ffd3b3f..a2fa888 100644 --- a/src/components/SocialDashboard.tsx +++ b/src/components/SocialDashboard.tsx @@ -12,6 +12,9 @@ import { Users, Eye, Image as ImageIcon, + Heart, + MessageCircle, + Repeat2, } from 'lucide-react'; import { PageHeader } from '@/components/ui'; @@ -29,6 +32,14 @@ type MetricsFacebook = { followers_count?: number; link?: string; error?: string; + postsError?: string; + pageInsights?: { + impressions?: number | null; + reach?: number | null; + postEngagements?: number | null; + fans?: number | null; + pageViews?: number | null; + }; }; type MetricsIg = { @@ -37,7 +48,29 @@ type MetricsIg = { media_count?: number; follows_count?: number; error?: string; - insights?: { data?: { name?: string; values?: { value?: number }[] }[] }; + postsError?: string; + igInsights?: { + reach?: number | null; + impressions?: number | null; + profileViews?: number | null; + websiteClicks?: number | null; + accountsEngaged?: number | null; + }; +}; + +type SocialPost = { + id: string; + platform: 'facebook' | 'instagram'; + message?: string; + mediaUrl?: string | null; + permalink?: string | null; + createdAt?: string | null; + likes: number; + comments: number; + shares: number; + reach?: number | null; + impressions?: number | null; + mediaType?: string | null; }; const CHANNELS: { @@ -51,11 +84,24 @@ const CHANNELS: { { id: 'tiktok', label: 'TikTok', icon: Share2 }, ]; -function n(v?: number) { +function n(v?: number | null) { if (v == null || !Number.isFinite(v)) return '—'; return new Intl.NumberFormat('es-ES').format(v); } +function fmtDate(iso?: string | null) { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleDateString('es-ES', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + } catch { + return iso.slice(0, 10); + } +} + export default function SocialDashboard({ tenantLabel, isAgency, @@ -70,6 +116,10 @@ export default function SocialDashboard({ instagram?: MetricsIg; warning?: string; }>({}); + const [posts, setPosts] = useState([]); + const [feedFilter, setFeedFilter] = useState<'all' | 'instagram' | 'facebook'>( + 'all' + ); const [err, setErr] = useState(null); useEffect(() => { @@ -83,6 +133,7 @@ export default function SocialDashboard({ else { setIntegs(data.integrations || []); setMetrics(data.metrics || {}); + setPosts(data.posts || []); } setLoading(false); })(); @@ -94,15 +145,80 @@ export default function SocialDashboard({ const by = new Map(integs.map((i) => [i.provider, i])); const ig = metrics.instagram; const fb = metrics.facebook; - const igReach = - ig?.insights?.data?.find((d) => d.name === 'reach')?.values?.[0]?.value; + const visiblePosts = + feedFilter === 'all' + ? posts + : posts.filter((p) => p.platform === feedFilter); + + const kpiCards = [ + { + label: 'Seguidores IG', + value: n(ig?.followers_count), + icon: Users, + }, + { + label: 'Publicaciones IG', + value: n(ig?.media_count), + icon: ImageIcon, + }, + { + label: 'Alcance IG (día)', + value: n(ig?.igInsights?.reach), + icon: Eye, + }, + { + label: 'Impresiones IG', + value: n(ig?.igInsights?.impressions), + icon: Eye, + }, + { + label: 'Visitas perfil IG', + value: n(ig?.igInsights?.profileViews), + icon: Users, + }, + { + label: 'Cuentas engaged IG', + value: n(ig?.igInsights?.accountsEngaged), + icon: Heart, + }, + { + label: 'Fans / seguidores FB', + value: n(fb?.followers_count ?? fb?.fan_count), + icon: Users, + }, + { + label: 'Alcance FB (día)', + value: n(fb?.pageInsights?.reach), + icon: Eye, + }, + { + label: 'Impresiones FB', + value: n(fb?.pageInsights?.impressions), + icon: Eye, + }, + { + label: 'Engagement posts FB', + value: n(fb?.pageInsights?.postEngagements), + icon: Heart, + }, + { + label: 'Vistas página FB', + value: n(fb?.pageInsights?.pageViews), + icon: Eye, + }, + { + label: 'Clics web IG', + value: n(ig?.igInsights?.websiteClicks), + icon: ExternalLink, + }, + ]; return (
{err && ( @@ -118,33 +234,12 @@ export default function SocialDashboard({ {loading ? (

- Cargando… + Cargando analytics…

) : ( <>
- {[ - { - label: 'Seguidores IG', - value: n(ig?.followers_count), - icon: Users, - }, - { - label: 'Publicaciones IG', - value: n(ig?.media_count), - icon: ImageIcon, - }, - { - label: 'Fans / seguidores FB', - value: n(fb?.followers_count ?? fb?.fan_count), - icon: Users, - }, - { - label: 'Alcance IG (día)', - value: n(igReach), - icon: Eye, - }, - ].map((k) => ( + {kpiCards.map((k) => (
- {(ig?.error || fb?.error) && ( + {(ig?.error || fb?.error || ig?.postsError || fb?.postsError) && (

- {ig?.error && `IG: ${ig.error} `} - {fb?.error && `FB: ${fb.error}`} - {' · '} - Suele faltar Page token o permisos de insights en el system user. + {[ig?.error && `IG: ${ig.error}`, fb?.error && `FB: ${fb.error}`, ig?.postsError && `IG posts: ${ig.postsError}`, fb?.postsError && `FB posts: ${fb.postsError}`] + .filter(Boolean) + .join(' · ')}

)} -
-

- Insights (social expert) -

-
    -
  • - Prioriza contenido que convierta a lead/venta (CTA + landings - medidas en CRM), no solo vanity metrics. -
  • -
  • - Si seguidores IG ≫ fans FB, concentra producción en Reels / - carruseles y usa FB como amplificación ligera. -
  • -
  • - Revisa Calendario editorial: cada pieza debería tener objetivo - (awareness / consideración / conversión). -
  • -
  • - Menciones brand: próxima fase con listening; hoy el foco es - audiencia propia + engagement on-profile. -
  • -
+
+
+

+ Contenido publicado +

+
+ {( + [ + ['all', 'Todo'], + ['instagram', 'IG'], + ['facebook', 'FB'], + ] as const + ).map(([id, lbl]) => ( + + ))} +
+
+ + {visiblePosts.length === 0 ? ( +

+ Sin posts recientes (o faltan permisos pages_read_engagement / + instagram_manage_insights). +

+ ) : ( +
    + {visiblePosts.map((p) => ( +
  • +
    + {p.mediaUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
    + {p.message?.slice(0, 120) || 'Sin media'} +
    + )} + + {p.platform === 'instagram' ? 'IG' : 'FB'} + {p.mediaType ? ` · ${p.mediaType}` : ''} + +
    +
    +

    + {fmtDate(p.createdAt)} +

    + {p.message ? ( +

    + {p.message} +

    + ) : null} +
    + + {n(p.likes)} + + + {n(p.comments)} + + + {n(p.shares)} + + {p.reach != null && ( + + {n(p.reach)} alc. + + )} +
    + {p.permalink && ( + + Ver en red + + )} +
    +
  • + ))} +
+ )}