feat: Ads KPIs completos, Social feed+analytics, Orgánico por sección
Meta/Google con las mismas métricas que Resumen y solo campañas/ads con actividad en el periodo; Social con insights FB/IG y preview de posts (likes/comentarios/shares); Orgánico con paneles distintos por ítem del menú y prioridades/notas solo en visión general. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<string, unknown> | 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<string, number | null> = {};
|
||||
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<string, number | null> = {};
|
||||
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<string, unknown> = {};
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user