feat: Social con gráficas diarias por periodo y token Meta 4V2030
Series IG/FB (alcance, impresiones, clics, engagement, fans) según el rango del header; Meta y Google quedan como tokens separados. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
import { resolveMetaAccessTokens } from '@/lib/ads-discovery';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 90;
|
||||||
|
|
||||||
const META = 'https://graph.facebook.com/v21.0';
|
const META = 'https://graph.facebook.com/v21.0';
|
||||||
|
|
||||||
@@ -23,7 +23,6 @@ type SocialPost = {
|
|||||||
platform: 'facebook' | 'instagram';
|
platform: 'facebook' | 'instagram';
|
||||||
message?: string;
|
message?: string;
|
||||||
mediaUrl?: string | null;
|
mediaUrl?: string | null;
|
||||||
/** Poster / still for VIDEO / REELS when mediaUrl is a video. */
|
|
||||||
thumbnailUrl?: string | null;
|
thumbnailUrl?: string | null;
|
||||||
permalink?: string | null;
|
permalink?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
@@ -35,6 +34,8 @@ type SocialPost = {
|
|||||||
mediaType?: string | null;
|
mediaType?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DayPoint = { day: string; value: number };
|
||||||
|
|
||||||
function isVideoType(t?: string | null): boolean {
|
function isVideoType(t?: string | null): boolean {
|
||||||
const s = (t || '').toUpperCase();
|
const s = (t || '').toUpperCase();
|
||||||
return (
|
return (
|
||||||
@@ -61,11 +62,9 @@ function formatGraphError(raw: string): string {
|
|||||||
if (!e) return raw.slice(0, 220);
|
if (!e) return raw.slice(0, 220);
|
||||||
if (e.code === 190 || e.error_subcode === 2069032) {
|
if (e.code === 190 || e.error_subcode === 2069032) {
|
||||||
return (
|
return (
|
||||||
'Token Meta inválido o sin permisos de Página. En Business Manager → ' +
|
'Token Meta inválido para esta Página. Usa el system user de la app 4V2030 ' +
|
||||||
'Usuarios del sistema → genera un token con pages_show_list, ' +
|
'con pages_* + instagram_* + ads_*, asígnalo a la Página y pégalo en Agencia ' +
|
||||||
'pages_read_engagement, pages_read_user_content, ' +
|
'(o META_ACCESS_TOKEN en Dokploy).'
|
||||||
'instagram_basic, instagram_manage_insights (además de ads_*) y ' +
|
|
||||||
'pégalo en Agencia. Asigna el system user a cada Página.'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -79,23 +78,18 @@ function formatGraphError(raw: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Posts/insights need a Page Access Token. System-user tokens with ads_* alone
|
|
||||||
* fail with OAuthException 190 — exchange when Graph allows it.
|
|
||||||
*/
|
|
||||||
async function resolvePageAccessToken(
|
async function resolvePageAccessToken(
|
||||||
userToken: string,
|
userToken: string,
|
||||||
pageId: string
|
pageId: string
|
||||||
): Promise<{ token: string; via: 'page' | 'user' }> {
|
): Promise<string> {
|
||||||
const r = await fetch(
|
const r = await fetch(
|
||||||
`${META}/${encodeURIComponent(pageId)}?fields=access_token,name&access_token=${encodeURIComponent(userToken)}`,
|
`${META}/${encodeURIComponent(pageId)}?fields=access_token,name&access_token=${encodeURIComponent(userToken)}`,
|
||||||
{ cache: 'no-store' }
|
{ cache: 'no-store' }
|
||||||
);
|
);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const j = (await r.json()) as { access_token?: string };
|
const j = (await r.json()) as { access_token?: string };
|
||||||
if (j.access_token) return { token: j.access_token, via: 'page' };
|
if (j.access_token) return j.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await fetch(
|
const accounts = await fetch(
|
||||||
`${META}/me/accounts?fields=id,access_token&limit=100&access_token=${encodeURIComponent(userToken)}`,
|
`${META}/me/accounts?fields=id,access_token&limit=100&access_token=${encodeURIComponent(userToken)}`,
|
||||||
{ cache: 'no-store' }
|
{ cache: 'no-store' }
|
||||||
@@ -105,234 +99,407 @@ async function resolvePageAccessToken(
|
|||||||
data?: { id?: string; access_token?: string }[];
|
data?: { id?: string; access_token?: string }[];
|
||||||
};
|
};
|
||||||
const hit = (j.data || []).find((p) => p.id === pageId && p.access_token);
|
const hit = (j.data || []).find((p) => p.id === pageId && p.access_token);
|
||||||
if (hit?.access_token) return { token: hit.access_token, via: 'page' };
|
if (hit?.access_token) return hit.access_token;
|
||||||
}
|
}
|
||||||
|
return userToken;
|
||||||
return { token: userToken, via: 'user' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function insightValue(
|
function insightSeries(
|
||||||
data: { name?: string; values?: { value?: number }[] }[] | undefined,
|
data: { name?: string; values?: { value?: number; end_time?: string }[] }[] | undefined,
|
||||||
name: string
|
name: string
|
||||||
): number | null {
|
): DayPoint[] {
|
||||||
const row = data?.find((d) => d.name === name);
|
const row = data?.find((d) => d.name === name);
|
||||||
const v = row?.values?.[0]?.value;
|
if (!row?.values?.length) return [];
|
||||||
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
return row.values
|
||||||
|
.map((v) => ({
|
||||||
|
day: (v.end_time || '').slice(0, 10),
|
||||||
|
value: typeof v.value === 'number' && Number.isFinite(v.value) ? v.value : 0,
|
||||||
|
}))
|
||||||
|
.filter((p) => /^\d{4}-\d{2}-\d{2}$/.test(p.day))
|
||||||
|
.sort((a, b) => a.day.localeCompare(b.day));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPageBundle(agencyToken: string, pageId: string) {
|
function lastValue(series: DayPoint[]): number | null {
|
||||||
const { token } = await resolvePageAccessToken(agencyToken, pageId);
|
if (!series.length) return null;
|
||||||
const fields =
|
return series[series.length - 1].value;
|
||||||
'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: formatGraphError(await r.text()) };
|
|
||||||
const profile = await r.json();
|
|
||||||
|
|
||||||
const insightsRes = await fetch(
|
function sumSeries(series: DayPoint[]): number {
|
||||||
`${META}/${encodeURIComponent(pageId)}/insights?metric=page_impressions,page_impressions_unique,page_post_engagements,page_fans,page_views_total&period=day&access_token=${encodeURIComponent(token)}`,
|
return series.reduce((a, p) => a + (p.value || 0), 0);
|
||||||
{ cache: 'no-store' }
|
}
|
||||||
);
|
|
||||||
let pageInsights: Record<string, number | null> = {};
|
function toUnix(isoDay: string, endOfDay = false): number {
|
||||||
if (insightsRes.ok) {
|
const t = Date.parse(`${isoDay}T${endOfDay ? '23:59:59' : '00:00:00'}Z`);
|
||||||
const j = await insightsRes.json();
|
return Math.floor(t / 1000);
|
||||||
const data = j.data as { name?: string; values?: { value?: number }[] }[];
|
}
|
||||||
pageInsights = {
|
|
||||||
impressions: insightValue(data, 'page_impressions'),
|
async function graphGet(
|
||||||
reach: insightValue(data, 'page_impressions_unique'),
|
path: string,
|
||||||
postEngagements: insightValue(data, 'page_post_engagements'),
|
token: string,
|
||||||
fans: insightValue(data, 'page_fans'),
|
query: Record<string, string> = {}
|
||||||
pageViews: insightValue(data, 'page_views_total'),
|
): Promise<{ ok: boolean; json: Record<string, unknown>; raw: string }> {
|
||||||
};
|
const q = new URLSearchParams({ ...query, access_token: token });
|
||||||
|
const r = await fetch(`${META}/${path}?${q}`, { cache: 'no-store' });
|
||||||
|
const raw = await r.text();
|
||||||
|
let json: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
json = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
/* keep empty */
|
||||||
|
}
|
||||||
|
return { ok: r.ok, json, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick first agency token that can read the page (or just /me). */
|
||||||
|
async function pickWorkingToken(
|
||||||
|
tokens: string[],
|
||||||
|
pageId?: string | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (pageId) {
|
||||||
|
const probe = await graphGet(encodeURIComponent(pageId), t, {
|
||||||
|
fields: 'id,name',
|
||||||
|
});
|
||||||
|
if (probe.ok) return t;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const me = await graphGet('me', t, { fields: 'id,name' });
|
||||||
|
if (me.ok) return t;
|
||||||
|
}
|
||||||
|
return tokens[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPageBundle(
|
||||||
|
agencyToken: string,
|
||||||
|
pageId: string,
|
||||||
|
since: string,
|
||||||
|
until: string
|
||||||
|
) {
|
||||||
|
const token = await resolvePageAccessToken(agencyToken, pageId);
|
||||||
|
const profileRes = await graphGet(encodeURIComponent(pageId), token, {
|
||||||
|
fields: 'name,fan_count,followers_count,link,picture{url},engagement,about,website',
|
||||||
|
});
|
||||||
|
if (!profileRes.ok) return { error: formatGraphError(profileRes.raw) };
|
||||||
|
const profile = profileRes.json as Record<string, unknown>;
|
||||||
|
|
||||||
|
const sinceU = String(toUnix(since));
|
||||||
|
const untilU = String(toUnix(until, true));
|
||||||
|
|
||||||
|
const metricSets = [
|
||||||
|
'page_impressions,page_impressions_unique,page_post_engagements,page_fans,page_views_total,page_follows,page_daily_follows,page_actions_post_reactions_total',
|
||||||
|
'page_impressions,page_impressions_unique,page_post_engagements,page_fans,page_views_total',
|
||||||
|
'page_impressions,page_impressions_unique,page_post_engagements,page_fans',
|
||||||
|
];
|
||||||
|
|
||||||
|
let insightData: {
|
||||||
|
name?: string;
|
||||||
|
values?: { value?: number; end_time?: string }[];
|
||||||
|
}[] = [];
|
||||||
|
let insightsError: string | undefined;
|
||||||
|
|
||||||
|
for (const metrics of metricSets) {
|
||||||
|
const ins = await graphGet(`${encodeURIComponent(pageId)}/insights`, token, {
|
||||||
|
metric: metrics,
|
||||||
|
period: 'day',
|
||||||
|
since: sinceU,
|
||||||
|
until: untilU,
|
||||||
|
});
|
||||||
|
if (ins.ok && Array.isArray(ins.json.data)) {
|
||||||
|
insightData = ins.json.data as typeof insightData;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
insightsError = formatGraphError(ins.raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
const postsRes = await fetch(
|
const series = {
|
||||||
`${META}/${encodeURIComponent(pageId)}/published_posts?fields=id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}&limit=16&access_token=${encodeURIComponent(token)}`,
|
impressions: insightSeries(insightData, 'page_impressions'),
|
||||||
{ cache: 'no-store' }
|
reach: insightSeries(insightData, 'page_impressions_unique'),
|
||||||
);
|
postEngagements: insightSeries(insightData, 'page_post_engagements'),
|
||||||
|
fans: insightSeries(insightData, 'page_fans'),
|
||||||
|
pageViews: insightSeries(insightData, 'page_views_total'),
|
||||||
|
follows: insightSeries(insightData, 'page_follows').length
|
||||||
|
? insightSeries(insightData, 'page_follows')
|
||||||
|
: insightSeries(insightData, 'page_daily_follows'),
|
||||||
|
reactions: insightSeries(insightData, 'page_actions_post_reactions_total'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageInsights = {
|
||||||
|
impressions: sumSeries(series.impressions) || lastValue(series.impressions),
|
||||||
|
reach: sumSeries(series.reach) || lastValue(series.reach),
|
||||||
|
postEngagements:
|
||||||
|
sumSeries(series.postEngagements) || lastValue(series.postEngagements),
|
||||||
|
fans:
|
||||||
|
lastValue(series.fans) ??
|
||||||
|
(typeof profile.followers_count === 'number'
|
||||||
|
? profile.followers_count
|
||||||
|
: typeof profile.fan_count === 'number'
|
||||||
|
? profile.fan_count
|
||||||
|
: null),
|
||||||
|
pageViews: sumSeries(series.pageViews) || lastValue(series.pageViews),
|
||||||
|
follows: sumSeries(series.follows) || lastValue(series.follows),
|
||||||
|
reactions: sumSeries(series.reactions) || lastValue(series.reactions),
|
||||||
|
};
|
||||||
|
|
||||||
const posts: SocialPost[] = [];
|
const posts: SocialPost[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const postsRes = await graphGet(
|
||||||
|
`${encodeURIComponent(pageId)}/published_posts`,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
fields:
|
||||||
|
'id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}',
|
||||||
|
limit: '20',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let postsError: string | undefined;
|
||||||
if (postsRes.ok) {
|
if (postsRes.ok) {
|
||||||
const j = await postsRes.json();
|
const data = (postsRes.json.data as Record<string, unknown>[]) || [];
|
||||||
for (const p of j.data || []) {
|
for (const p of data) {
|
||||||
const att = p.attachments?.data?.[0];
|
pushFbPost(posts, seen, p);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const posts2 = await graphGet(`${encodeURIComponent(pageId)}/posts`, token, {
|
||||||
|
fields:
|
||||||
|
'id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}',
|
||||||
|
limit: '20',
|
||||||
|
});
|
||||||
|
if (posts2.ok) {
|
||||||
|
const data = (posts2.json.data as Record<string, unknown>[]) || [];
|
||||||
|
for (const p of data) pushFbPost(posts, seen, p);
|
||||||
|
} else {
|
||||||
|
postsError = formatGraphError(postsRes.raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const videosRes = await graphGet(`${encodeURIComponent(pageId)}/videos`, token, {
|
||||||
|
fields: 'id,description,created_time,source,permalink_url,picture,length,title',
|
||||||
|
limit: '10',
|
||||||
|
});
|
||||||
|
if (videosRes.ok) {
|
||||||
|
for (const v of (videosRes.json.data as Record<string, unknown>[]) || []) {
|
||||||
|
if (!v?.id || seen.has(String(v.id))) continue;
|
||||||
|
posts.push({
|
||||||
|
id: String(v.id),
|
||||||
|
platform: 'facebook',
|
||||||
|
message: String(v.description || v.title || ''),
|
||||||
|
mediaUrl: (v.source as string) || (v.picture as string) || null,
|
||||||
|
thumbnailUrl: (v.picture as string) || null,
|
||||||
|
permalink: (v.permalink_url as string) || null,
|
||||||
|
createdAt: (v.created_time as string) || null,
|
||||||
|
likes: 0,
|
||||||
|
comments: 0,
|
||||||
|
shares: 0,
|
||||||
|
mediaType: 'VIDEO',
|
||||||
|
});
|
||||||
|
seen.add(String(v.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
pageInsights,
|
||||||
|
series,
|
||||||
|
posts,
|
||||||
|
postsError,
|
||||||
|
insightsError: insightData.length ? undefined : insightsError,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushFbPost(
|
||||||
|
posts: SocialPost[],
|
||||||
|
seen: Set<string>,
|
||||||
|
p: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const id = String(p.id || '');
|
||||||
|
if (!id || seen.has(id)) return;
|
||||||
|
const att = (p.attachments as { data?: Record<string, unknown>[] })?.data?.[0];
|
||||||
const attType = String(att?.media_type || att?.type || '').toLowerCase();
|
const attType = String(att?.media_type || att?.type || '').toLowerCase();
|
||||||
|
const media = att?.media as { source?: string; image?: { src?: string } } | undefined;
|
||||||
const videoSrc =
|
const videoSrc =
|
||||||
att?.media?.source ||
|
media?.source ||
|
||||||
att?.url ||
|
(att?.url as string) ||
|
||||||
att?.subattachments?.data?.[0]?.media?.source ||
|
(
|
||||||
|
att?.subattachments as {
|
||||||
|
data?: { media?: { source?: string } }[];
|
||||||
|
}
|
||||||
|
)?.data?.[0]?.media?.source ||
|
||||||
null;
|
null;
|
||||||
const isVideo =
|
const isVideo =
|
||||||
attType.includes('video') ||
|
attType.includes('video') ||
|
||||||
String(p.status_type || '').includes('video') ||
|
String(p.status_type || '').includes('video') ||
|
||||||
Boolean(videoSrc && /\.mp4|video/i.test(String(videoSrc)));
|
Boolean(videoSrc && /\.mp4|video/i.test(String(videoSrc)));
|
||||||
const thumb =
|
const thumb =
|
||||||
p.full_picture ||
|
(p.full_picture as string) ||
|
||||||
att?.media?.image?.src ||
|
media?.image?.src ||
|
||||||
att?.subattachments?.data?.[0]?.media?.image?.src ||
|
(
|
||||||
|
att?.subattachments as {
|
||||||
|
data?: { media?: { image?: { src?: string } } }[];
|
||||||
|
}
|
||||||
|
)?.data?.[0]?.media?.image?.src ||
|
||||||
null;
|
null;
|
||||||
|
const likes = p.likes as { summary?: { total_count?: number } } | undefined;
|
||||||
|
const comments = p.comments as { summary?: { total_count?: number } } | undefined;
|
||||||
|
const shares = p.shares as { count?: number } | undefined;
|
||||||
posts.push({
|
posts.push({
|
||||||
id: p.id,
|
id,
|
||||||
platform: 'facebook',
|
platform: 'facebook',
|
||||||
message: p.message || '',
|
message: String(p.message || ''),
|
||||||
mediaUrl: isVideo ? videoSrc || thumb : thumb,
|
mediaUrl: isVideo ? videoSrc || thumb : thumb,
|
||||||
thumbnailUrl: thumb,
|
thumbnailUrl: thumb,
|
||||||
permalink: p.permalink_url || null,
|
permalink: (p.permalink_url as string) || null,
|
||||||
createdAt: p.created_time || null,
|
createdAt: (p.created_time as string) || null,
|
||||||
likes: Number(p.likes?.summary?.total_count || 0),
|
likes: Number(likes?.summary?.total_count || 0),
|
||||||
comments: Number(p.comments?.summary?.total_count || 0),
|
comments: Number(comments?.summary?.total_count || 0),
|
||||||
shares: Number(p.shares?.count || 0),
|
shares: Number(shares?.count || 0),
|
||||||
mediaType: isVideo ? 'VIDEO' : thumb ? 'IMAGE' : 'status',
|
mediaType: isVideo ? 'VIDEO' : thumb ? 'IMAGE' : 'status',
|
||||||
});
|
});
|
||||||
seen.add(p.id);
|
seen.add(id);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback /posts then surface clear error
|
|
||||||
const posts2 = await fetch(
|
|
||||||
`${META}/${encodeURIComponent(pageId)}/posts?fields=id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}&limit=16&access_token=${encodeURIComponent(token)}`,
|
|
||||||
{ cache: 'no-store' }
|
|
||||||
);
|
|
||||||
if (posts2.ok) {
|
|
||||||
const j = await posts2.json();
|
|
||||||
for (const p of j.data || []) {
|
|
||||||
posts.push({
|
|
||||||
id: p.id,
|
|
||||||
platform: 'facebook',
|
|
||||||
message: p.message || '',
|
|
||||||
mediaUrl: p.full_picture || null,
|
|
||||||
thumbnailUrl: 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',
|
|
||||||
});
|
|
||||||
seen.add(p.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
profile.postsError = formatGraphError(await postsRes.text());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const videosRes = await fetch(
|
|
||||||
`${META}/${encodeURIComponent(pageId)}/videos?fields=id,description,created_time,source,permalink_url,picture,length,title&limit=10&access_token=${encodeURIComponent(token)}`,
|
|
||||||
{ cache: 'no-store' }
|
|
||||||
);
|
|
||||||
if (videosRes.ok) {
|
|
||||||
const j = await videosRes.json();
|
|
||||||
for (const v of j.data || []) {
|
|
||||||
if (!v?.id || seen.has(v.id)) continue;
|
|
||||||
posts.push({
|
|
||||||
id: v.id,
|
|
||||||
platform: 'facebook',
|
|
||||||
message: v.description || v.title || '',
|
|
||||||
mediaUrl: v.source || v.picture || null,
|
|
||||||
thumbnailUrl: v.picture || null,
|
|
||||||
permalink: v.permalink_url || null,
|
|
||||||
createdAt: v.created_time || null,
|
|
||||||
likes: 0,
|
|
||||||
comments: 0,
|
|
||||||
shares: 0,
|
|
||||||
mediaType: 'VIDEO',
|
|
||||||
});
|
|
||||||
seen.add(v.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...profile, pageInsights, posts };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchIgBundle(token: string, igId: string) {
|
async function fetchIgBundle(
|
||||||
const r = await fetch(
|
agencyToken: string,
|
||||||
`${META}/${encodeURIComponent(igId)}?fields=username,name,followers_count,follows_count,media_count,profile_picture_url,biography,website&access_token=${encodeURIComponent(token)}`,
|
igId: string,
|
||||||
{ cache: 'no-store' }
|
since: string,
|
||||||
);
|
until: string
|
||||||
if (!r.ok) return { error: formatGraphError(await r.text()) };
|
) {
|
||||||
const profile = await r.json();
|
const profileRes = await graphGet(encodeURIComponent(igId), agencyToken, {
|
||||||
|
fields:
|
||||||
|
'username,name,followers_count,follows_count,media_count,profile_picture_url,biography,website',
|
||||||
|
});
|
||||||
|
if (!profileRes.ok) return { error: formatGraphError(profileRes.raw) };
|
||||||
|
const profile = profileRes.json as Record<string, unknown>;
|
||||||
|
|
||||||
const ins = await fetch(
|
const sinceU = String(toUnix(since));
|
||||||
`${META}/${encodeURIComponent(igId)}/insights?metric=reach,impressions,profile_views,website_clicks,accounts_engaged&period=day&access_token=${encodeURIComponent(token)}`,
|
const untilU = String(toUnix(until, true));
|
||||||
{ cache: 'no-store' }
|
|
||||||
);
|
const metricSets = [
|
||||||
let igInsights: Record<string, number | null> = {};
|
'reach,views,profile_views,website_clicks,accounts_engaged,follower_count,total_interactions',
|
||||||
if (ins.ok) {
|
'reach,profile_views,website_clicks,accounts_engaged,follower_count',
|
||||||
const j = await ins.json();
|
'reach,follower_count,profile_views',
|
||||||
const data = j.data as { name?: string; values?: { value?: number }[] }[];
|
'reach,follower_count',
|
||||||
igInsights = {
|
];
|
||||||
reach: insightValue(data, 'reach'),
|
|
||||||
impressions: insightValue(data, 'impressions'),
|
let insightData: {
|
||||||
profileViews: insightValue(data, 'profile_views'),
|
name?: string;
|
||||||
websiteClicks: insightValue(data, 'website_clicks'),
|
values?: { value?: number; end_time?: string }[];
|
||||||
accountsEngaged: insightValue(data, 'accounts_engaged'),
|
}[] = [];
|
||||||
};
|
let insightsError: string | undefined;
|
||||||
} else {
|
|
||||||
// Fallback subset (some metrics need different periods on newer API)
|
for (const metrics of metricSets) {
|
||||||
const ins2 = await fetch(
|
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
||||||
`${META}/${encodeURIComponent(igId)}/insights?metric=reach,follower_count&period=day&access_token=${encodeURIComponent(token)}`,
|
metric: metrics,
|
||||||
{ cache: 'no-store' }
|
period: 'day',
|
||||||
);
|
since: sinceU,
|
||||||
if (ins2.ok) {
|
until: untilU,
|
||||||
const j = await ins2.json();
|
});
|
||||||
const data = j.data as { name?: string; values?: { value?: number }[] }[];
|
if (ins.ok && Array.isArray(ins.json.data)) {
|
||||||
igInsights = {
|
insightData = ins.json.data as typeof insightData;
|
||||||
reach: insightValue(data, 'reach'),
|
break;
|
||||||
followerDelta: insightValue(data, 'follower_count'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
// Some metrics reject `since`; retry without range (last ~30d default)
|
||||||
|
const ins2 = await graphGet(
|
||||||
|
`${encodeURIComponent(igId)}/insights`,
|
||||||
|
agencyToken,
|
||||||
|
{ metric: metrics, period: 'day' }
|
||||||
|
);
|
||||||
|
if (ins2.ok && Array.isArray(ins2.json.data)) {
|
||||||
|
insightData = (ins2.json.data as typeof insightData).filter((row) => {
|
||||||
|
// keep points inside range when end_time present
|
||||||
|
const vals = (row.values || []).filter((v) => {
|
||||||
|
const d = (v.end_time || '').slice(0, 10);
|
||||||
|
return d >= since && d <= until;
|
||||||
|
});
|
||||||
|
row.values = vals;
|
||||||
|
return vals.length > 0;
|
||||||
|
});
|
||||||
|
if (insightData.length) break;
|
||||||
|
}
|
||||||
|
insightsError = formatGraphError(ins.raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaRes = await fetch(
|
const series = {
|
||||||
`${META}/${encodeURIComponent(igId)}/media?fields=id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count&limit=16&access_token=${encodeURIComponent(token)}`,
|
reach: insightSeries(insightData, 'reach'),
|
||||||
{ cache: 'no-store' }
|
impressions:
|
||||||
);
|
insightSeries(insightData, 'views').length
|
||||||
|
? insightSeries(insightData, 'views')
|
||||||
|
: insightSeries(insightData, 'impressions'),
|
||||||
|
profileViews: insightSeries(insightData, 'profile_views'),
|
||||||
|
websiteClicks: insightSeries(insightData, 'website_clicks'),
|
||||||
|
accountsEngaged: insightSeries(insightData, 'accounts_engaged'),
|
||||||
|
followerDelta: insightSeries(insightData, 'follower_count'),
|
||||||
|
interactions: insightSeries(insightData, 'total_interactions'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const igInsights = {
|
||||||
|
reach: sumSeries(series.reach) || lastValue(series.reach),
|
||||||
|
impressions: sumSeries(series.impressions) || lastValue(series.impressions),
|
||||||
|
profileViews: sumSeries(series.profileViews) || lastValue(series.profileViews),
|
||||||
|
websiteClicks:
|
||||||
|
sumSeries(series.websiteClicks) || lastValue(series.websiteClicks),
|
||||||
|
accountsEngaged:
|
||||||
|
sumSeries(series.accountsEngaged) || lastValue(series.accountsEngaged),
|
||||||
|
followerDelta:
|
||||||
|
sumSeries(series.followerDelta) || lastValue(series.followerDelta),
|
||||||
|
interactions:
|
||||||
|
sumSeries(series.interactions) || lastValue(series.interactions),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaRes = await graphGet(`${encodeURIComponent(igId)}/media`, agencyToken, {
|
||||||
|
fields:
|
||||||
|
'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count',
|
||||||
|
limit: '20',
|
||||||
|
});
|
||||||
const posts: SocialPost[] = [];
|
const posts: SocialPost[] = [];
|
||||||
|
let postsError: string | undefined;
|
||||||
if (mediaRes.ok) {
|
if (mediaRes.ok) {
|
||||||
const j = await mediaRes.json();
|
for (const m of (mediaRes.json.data as Record<string, unknown>[]) || []) {
|
||||||
for (const m of j.data || []) {
|
const mediaType = (m.media_type as string) || null;
|
||||||
const mediaType = m.media_type || null;
|
|
||||||
const video = isVideoType(mediaType);
|
const video = isVideoType(mediaType);
|
||||||
// VIDEO/REELS: keep media_url for <video>; thumbnail for poster
|
|
||||||
const mediaUrl = video
|
const mediaUrl = video
|
||||||
? m.media_url || m.thumbnail_url || null
|
? (m.media_url as string) || (m.thumbnail_url as string) || null
|
||||||
: m.media_url || m.thumbnail_url || null;
|
: (m.media_url as string) || (m.thumbnail_url as string) || null;
|
||||||
const thumbnailUrl = m.thumbnail_url || (video ? null : m.media_url) || null;
|
const thumbnailUrl =
|
||||||
|
(m.thumbnail_url as string) || (video ? null : (m.media_url as string)) || null;
|
||||||
let reach: number | null = null;
|
let reach: number | null = null;
|
||||||
let impressions: number | null = null;
|
let impressions: number | null = null;
|
||||||
let shares = 0;
|
let shares = 0;
|
||||||
try {
|
try {
|
||||||
const metrics = video
|
const metrics = video
|
||||||
? 'reach,saved,shares,plays'
|
? 'reach,saved,shares,views'
|
||||||
: 'reach,impressions,saved,shares';
|
: 'reach,views,saved,shares';
|
||||||
const mi = await fetch(
|
const mi = await graphGet(
|
||||||
`${META}/${encodeURIComponent(m.id)}/insights?metric=${metrics}&access_token=${encodeURIComponent(token)}`,
|
`${encodeURIComponent(String(m.id))}/insights`,
|
||||||
{ cache: 'no-store' }
|
agencyToken,
|
||||||
|
{ metric: metrics }
|
||||||
);
|
);
|
||||||
if (mi.ok) {
|
if (mi.ok && Array.isArray(mi.json.data)) {
|
||||||
const ij = await mi.json();
|
const data = mi.json.data as {
|
||||||
const data = ij.data as {
|
|
||||||
name?: string;
|
name?: string;
|
||||||
values?: { value?: number }[];
|
values?: { value?: number }[];
|
||||||
}[];
|
}[];
|
||||||
reach = insightValue(data, 'reach');
|
const val = (name: string) => {
|
||||||
impressions =
|
const row = data.find((d) => d.name === name);
|
||||||
insightValue(data, 'impressions') ??
|
const v = row?.values?.[0]?.value;
|
||||||
insightValue(data, 'plays');
|
return typeof v === 'number' ? v : null;
|
||||||
shares = insightValue(data, 'shares') ?? 0;
|
};
|
||||||
|
reach = val('reach');
|
||||||
|
impressions = val('views') ?? val('impressions');
|
||||||
|
shares = val('shares') ?? 0;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through without per-media insights */
|
/* skip */
|
||||||
}
|
}
|
||||||
posts.push({
|
posts.push({
|
||||||
id: m.id,
|
id: String(m.id),
|
||||||
platform: 'instagram',
|
platform: 'instagram',
|
||||||
message: m.caption || '',
|
message: String(m.caption || ''),
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
permalink: m.permalink || null,
|
permalink: (m.permalink as string) || null,
|
||||||
createdAt: m.timestamp || null,
|
createdAt: (m.timestamp as string) || null,
|
||||||
likes: Number(m.like_count || 0),
|
likes: Number(m.like_count || 0),
|
||||||
comments: Number(m.comments_count || 0),
|
comments: Number(m.comments_count || 0),
|
||||||
shares,
|
shares,
|
||||||
@@ -342,14 +509,34 @@ async function fetchIgBundle(token: string, igId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
profile.postsError = formatGraphError(await mediaRes.text());
|
postsError = formatGraphError(mediaRes.raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...profile, igInsights, posts };
|
return {
|
||||||
|
...profile,
|
||||||
|
igInsights,
|
||||||
|
series,
|
||||||
|
posts,
|
||||||
|
postsError,
|
||||||
|
insightsError: insightData.length ? undefined : insightsError,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET — social integrations + analytics + recent posts preview. */
|
function defaultRange(): { since: string; until: string } {
|
||||||
export async function GET() {
|
const until = new Date();
|
||||||
|
until.setUTCDate(until.getUTCDate() - 1);
|
||||||
|
const since = new Date(until);
|
||||||
|
since.setUTCDate(since.getUTCDate() - 27);
|
||||||
|
return {
|
||||||
|
since: since.toISOString().slice(0, 10),
|
||||||
|
until: until.toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET — social integrations + analytics series + posts. ?since=&until= */
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
@@ -368,6 +555,12 @@ export async function GET() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const def = defaultRange();
|
||||||
|
const sinceParam = req.nextUrl.searchParams.get('since') || '';
|
||||||
|
const untilParam = req.nextUrl.searchParams.get('until') || '';
|
||||||
|
const since = /^\d{4}-\d{2}-\d{2}$/.test(sinceParam) ? sinceParam : def.since;
|
||||||
|
const until = /^\d{4}-\d{2}-\d{2}$/.test(untilParam) ? untilParam : def.until;
|
||||||
|
|
||||||
const agency = isAgencyRole(userRole(user));
|
const agency = isAgencyRole(userRole(user));
|
||||||
const providers = 'instagram,facebook,tiktok,linkedin';
|
const providers = 'instagram,facebook,tiktok,linkedin';
|
||||||
let integrations: Integ[] = [];
|
let integrations: Integ[] = [];
|
||||||
@@ -390,35 +583,65 @@ export async function GET() {
|
|||||||
|
|
||||||
const metrics: Record<string, unknown> = {};
|
const metrics: Record<string, unknown> = {};
|
||||||
const posts: SocialPost[] = [];
|
const posts: SocialPost[] = [];
|
||||||
const token = await resolveMetaAccessToken();
|
const tokens = await resolveMetaAccessTokens();
|
||||||
if (token) {
|
|
||||||
for (const i of integrations) {
|
if (!tokens.length) {
|
||||||
if (i.status !== 'connected' || !i.external_account_id) continue;
|
metrics.warning =
|
||||||
const id = i.external_account_id;
|
'Sin token Meta: configura META_ACCESS_TOKEN (4V2030) o pégalo en Agencia.';
|
||||||
if (id.startsWith('http')) continue;
|
} else {
|
||||||
|
const fbInteg = integrations.find(
|
||||||
|
(i) =>
|
||||||
|
i.provider === 'facebook' &&
|
||||||
|
i.status === 'connected' &&
|
||||||
|
i.external_account_id &&
|
||||||
|
!i.external_account_id.startsWith('http')
|
||||||
|
);
|
||||||
|
const igInteg = integrations.find(
|
||||||
|
(i) =>
|
||||||
|
i.provider === 'instagram' &&
|
||||||
|
i.status === 'connected' &&
|
||||||
|
i.external_account_id &&
|
||||||
|
!i.external_account_id.startsWith('http')
|
||||||
|
);
|
||||||
|
|
||||||
|
const token =
|
||||||
|
(await pickWorkingToken(tokens, fbInteg?.external_account_id)) ||
|
||||||
|
(await pickWorkingToken(tokens, igInteg?.external_account_id)) ||
|
||||||
|
tokens[0];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (i.provider === 'facebook') {
|
if (fbInteg?.external_account_id) {
|
||||||
const fb = await fetchPageBundle(token, id);
|
const fb = await fetchPageBundle(
|
||||||
|
token,
|
||||||
|
fbInteg.external_account_id,
|
||||||
|
since,
|
||||||
|
until
|
||||||
|
);
|
||||||
metrics.facebook = fb;
|
metrics.facebook = fb;
|
||||||
if (Array.isArray(fb.posts)) posts.push(...fb.posts);
|
if (Array.isArray(fb.posts)) posts.push(...fb.posts);
|
||||||
}
|
}
|
||||||
if (i.provider === 'instagram') {
|
if (igInteg?.external_account_id) {
|
||||||
const ig = await fetchIgBundle(token, id);
|
const ig = await fetchIgBundle(
|
||||||
|
token,
|
||||||
|
igInteg.external_account_id,
|
||||||
|
since,
|
||||||
|
until
|
||||||
|
);
|
||||||
metrics.instagram = ig;
|
metrics.instagram = ig;
|
||||||
if (Array.isArray(ig.posts)) posts.push(...ig.posts);
|
if (Array.isArray(ig.posts)) posts.push(...ig.posts);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
metrics[i.provider] = {
|
metrics.warning = e instanceof Error ? e.message : 'metrics error';
|
||||||
error: e instanceof Error ? e.message : 'metrics error',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
metrics.warning =
|
|
||||||
'Sin token Meta de agencia: perfiles enlazados pero sin métricas live.';
|
|
||||||
}
|
|
||||||
|
|
||||||
posts.sort((a, b) => {
|
// Filter posts to selected range when dates exist
|
||||||
|
const ranged = posts.filter((p) => {
|
||||||
|
if (!p.createdAt) return true;
|
||||||
|
const d = p.createdAt.slice(0, 10);
|
||||||
|
return d >= since && d <= until;
|
||||||
|
});
|
||||||
|
ranged.sort((a, b) => {
|
||||||
const ta = Date.parse(a.createdAt || '') || 0;
|
const ta = Date.parse(a.createdAt || '') || 0;
|
||||||
const tb = Date.parse(b.createdAt || '') || 0;
|
const tb = Date.parse(b.createdAt || '') || 0;
|
||||||
return tb - ta;
|
return tb - ta;
|
||||||
@@ -427,7 +650,9 @@ export async function GET() {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
integrations,
|
integrations,
|
||||||
metrics,
|
metrics,
|
||||||
posts: posts.slice(0, 24),
|
posts: ranged.slice(0, 24),
|
||||||
|
since,
|
||||||
|
until,
|
||||||
label: ctx.label,
|
label: ctx.label,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,11 +183,12 @@ export default function AdsDiscoverPanel() {
|
|||||||
Meta Ads + Google Ads (agencia)
|
Meta Ads + Google Ads (agencia)
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
|
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
|
||||||
Lista cuentas y las enlaza por nombre. El system user token debe
|
Meta (FB/IG/Ads) usa el system user token de la app{' '}
|
||||||
incluir ads_* <em>y</em> páginas/IG: pages_show_list,
|
<strong className="text-gray-700">4V2030</strong> (
|
||||||
pages_read_engagement, pages_read_user_content, instagram_basic,
|
<code className="text-[10px]">META_ACCESS_TOKEN</code> en Dokploy
|
||||||
instagram_manage_insights. Asigna el system user a cada Página en
|
o pegado en Agencia). Google Ads/Drive/GA4 usan el OAuth de{' '}
|
||||||
Business Manager.
|
<code className="text-[10px]">marketing@</code> — son tokens
|
||||||
|
distintos.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
Facebook,
|
Facebook,
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Repeat2,
|
Repeat2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader } from '@/components/ui';
|
import { PageHeader } from '@/components/ui';
|
||||||
|
import { useDateRange } from '@/components/DateRangeContext';
|
||||||
|
|
||||||
type Integ = {
|
type Integ = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -26,6 +27,10 @@ type Integ = {
|
|||||||
meta?: { public_url?: string; account_name?: string; warning?: string } | null;
|
meta?: { public_url?: string; account_name?: string; warning?: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DayPoint = { day: string; value: number };
|
||||||
|
|
||||||
|
type SeriesBag = Partial<Record<string, DayPoint[]>>;
|
||||||
|
|
||||||
type MetricsFacebook = {
|
type MetricsFacebook = {
|
||||||
name?: string;
|
name?: string;
|
||||||
fan_count?: number;
|
fan_count?: number;
|
||||||
@@ -33,13 +38,17 @@ type MetricsFacebook = {
|
|||||||
link?: string;
|
link?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
postsError?: string;
|
postsError?: string;
|
||||||
|
insightsError?: string;
|
||||||
pageInsights?: {
|
pageInsights?: {
|
||||||
impressions?: number | null;
|
impressions?: number | null;
|
||||||
reach?: number | null;
|
reach?: number | null;
|
||||||
postEngagements?: number | null;
|
postEngagements?: number | null;
|
||||||
fans?: number | null;
|
fans?: number | null;
|
||||||
pageViews?: number | null;
|
pageViews?: number | null;
|
||||||
|
follows?: number | null;
|
||||||
|
reactions?: number | null;
|
||||||
};
|
};
|
||||||
|
series?: SeriesBag;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MetricsIg = {
|
type MetricsIg = {
|
||||||
@@ -49,13 +58,17 @@ type MetricsIg = {
|
|||||||
follows_count?: number;
|
follows_count?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
postsError?: string;
|
postsError?: string;
|
||||||
|
insightsError?: string;
|
||||||
igInsights?: {
|
igInsights?: {
|
||||||
reach?: number | null;
|
reach?: number | null;
|
||||||
impressions?: number | null;
|
impressions?: number | null;
|
||||||
profileViews?: number | null;
|
profileViews?: number | null;
|
||||||
websiteClicks?: number | null;
|
websiteClicks?: number | null;
|
||||||
accountsEngaged?: number | null;
|
accountsEngaged?: number | null;
|
||||||
|
followerDelta?: number | null;
|
||||||
|
interactions?: number | null;
|
||||||
};
|
};
|
||||||
|
series?: SeriesBag;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SocialPost = {
|
type SocialPost = {
|
||||||
@@ -110,6 +123,78 @@ function fmtDate(iso?: string | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortDay(day: string) {
|
||||||
|
const d = day.slice(5); // MM-DD
|
||||||
|
return d.replace('-', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniBars({
|
||||||
|
series,
|
||||||
|
color = 'bg-brand-400/80',
|
||||||
|
empty = 'Sin serie en el periodo',
|
||||||
|
}: {
|
||||||
|
series?: DayPoint[];
|
||||||
|
color?: string;
|
||||||
|
empty?: string;
|
||||||
|
}) {
|
||||||
|
const pts = series || [];
|
||||||
|
if (!pts.length) {
|
||||||
|
return <p className="text-xs text-gray-500 py-6 text-center">{empty}</p>;
|
||||||
|
}
|
||||||
|
const max = Math.max(1, ...pts.map((p) => p.value));
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-0.5 h-24 w-full">
|
||||||
|
{pts.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.day}
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
title={`${p.day}: ${n(p.value)}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-full rounded-t ${color} hover:opacity-90`}
|
||||||
|
style={{ height: `${Math.max(4, (p.value / max) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartCard({
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
series,
|
||||||
|
total,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
hint?: string;
|
||||||
|
series?: DayPoint[];
|
||||||
|
total?: number | null;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
{hint ? <p className="text-[10px] text-gray-400 mt-0.5">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 tabular-nums">{n(total)}</p>
|
||||||
|
</div>
|
||||||
|
<MiniBars series={series} color={color} />
|
||||||
|
{series && series.length > 1 ? (
|
||||||
|
<div className="flex justify-between text-[9px] text-gray-400 tabular-nums">
|
||||||
|
<span>{shortDay(series[0].day)}</span>
|
||||||
|
<span>{shortDay(series[series.length - 1].day)}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SocialDashboard({
|
export default function SocialDashboard({
|
||||||
tenantLabel,
|
tenantLabel,
|
||||||
isAgency,
|
isAgency,
|
||||||
@@ -117,6 +202,7 @@ export default function SocialDashboard({
|
|||||||
tenantLabel: string;
|
tenantLabel: string;
|
||||||
isAgency: boolean;
|
isAgency: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { since, until, label: rangeLabel } = useDateRange();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [integs, setIntegs] = useState<Integ[]>([]);
|
const [integs, setIntegs] = useState<Integ[]>([]);
|
||||||
const [metrics, setMetrics] = useState<{
|
const [metrics, setMetrics] = useState<{
|
||||||
@@ -134,7 +220,9 @@ export default function SocialDashboard({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/social');
|
setErr(null);
|
||||||
|
const qs = new URLSearchParams({ since, until });
|
||||||
|
const res = await fetch(`/api/social?${qs}`);
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!res.ok) setErr(data.error || 'No se pudo cargar Social');
|
if (!res.ok) setErr(data.error || 'No se pudo cargar Social');
|
||||||
@@ -148,9 +236,9 @@ export default function SocialDashboard({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [since, until]);
|
||||||
|
|
||||||
const by = new Map(integs.map((i) => [i.provider, i]));
|
const by = useMemo(() => new Map(integs.map((i) => [i.provider, i])), [integs]);
|
||||||
const ig = metrics.instagram;
|
const ig = metrics.instagram;
|
||||||
const fb = metrics.facebook;
|
const fb = metrics.facebook;
|
||||||
const visiblePosts =
|
const visiblePosts =
|
||||||
@@ -160,22 +248,22 @@ export default function SocialDashboard({
|
|||||||
|
|
||||||
const kpiCards = [
|
const kpiCards = [
|
||||||
{
|
{
|
||||||
label: 'Seguidores IG',
|
label: 'Seguidores IG (global)',
|
||||||
value: n(ig?.followers_count),
|
value: n(ig?.followers_count),
|
||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Publicaciones IG',
|
label: 'Publicaciones IG (global)',
|
||||||
value: n(ig?.media_count),
|
value: n(ig?.media_count),
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Alcance IG (día)',
|
label: 'Alcance IG (periodo)',
|
||||||
value: n(ig?.igInsights?.reach),
|
value: n(ig?.igInsights?.reach),
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Impresiones IG',
|
label: 'Impresiones/vistas IG',
|
||||||
value: n(ig?.igInsights?.impressions),
|
value: n(ig?.igInsights?.impressions),
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
},
|
},
|
||||||
@@ -185,17 +273,27 @@ export default function SocialDashboard({
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Cuentas engaged IG',
|
label: 'Interacciones IG',
|
||||||
value: n(ig?.igInsights?.accountsEngaged),
|
value: n(ig?.igInsights?.interactions ?? ig?.igInsights?.accountsEngaged),
|
||||||
icon: Heart,
|
icon: Heart,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Fans / seguidores FB',
|
label: 'Clics web IG',
|
||||||
value: n(fb?.followers_count ?? fb?.fan_count),
|
value: n(ig?.igInsights?.websiteClicks),
|
||||||
|
icon: ExternalLink,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Δ seguidores IG',
|
||||||
|
value: n(ig?.igInsights?.followerDelta),
|
||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Alcance FB (día)',
|
label: 'Fans FB (global)',
|
||||||
|
value: n(fb?.followers_count ?? fb?.fan_count ?? fb?.pageInsights?.fans),
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Alcance FB (periodo)',
|
||||||
value: n(fb?.pageInsights?.reach),
|
value: n(fb?.pageInsights?.reach),
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
},
|
},
|
||||||
@@ -209,15 +307,98 @@ export default function SocialDashboard({
|
|||||||
value: n(fb?.pageInsights?.postEngagements),
|
value: n(fb?.pageInsights?.postEngagements),
|
||||||
icon: Heart,
|
icon: Heart,
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const chartBlocks: {
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
series?: DayPoint[];
|
||||||
|
total?: number | null;
|
||||||
|
color: string;
|
||||||
|
}[] = [
|
||||||
{
|
{
|
||||||
label: 'Vistas página FB',
|
title: 'IG · Alcance / día',
|
||||||
value: n(fb?.pageInsights?.pageViews),
|
hint: 'Serie diaria en el periodo',
|
||||||
icon: Eye,
|
series: ig?.series?.reach,
|
||||||
|
total: ig?.igInsights?.reach,
|
||||||
|
color: 'bg-pink-400/80',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Clics web IG',
|
title: 'IG · Impresiones/vistas',
|
||||||
value: n(ig?.igInsights?.websiteClicks),
|
hint: 'Views / impressions',
|
||||||
icon: ExternalLink,
|
series: ig?.series?.impressions,
|
||||||
|
total: ig?.igInsights?.impressions,
|
||||||
|
color: 'bg-fuchsia-400/80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IG · Visitas al perfil',
|
||||||
|
hint: 'profile_views',
|
||||||
|
series: ig?.series?.profileViews,
|
||||||
|
total: ig?.igInsights?.profileViews,
|
||||||
|
color: 'bg-rose-400/80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IG · Clics web',
|
||||||
|
hint: 'website_clicks',
|
||||||
|
series: ig?.series?.websiteClicks,
|
||||||
|
total: ig?.igInsights?.websiteClicks,
|
||||||
|
color: 'bg-orange-400/80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IG · Cuentas engaged',
|
||||||
|
hint: 'accounts_engaged',
|
||||||
|
series: ig?.series?.accountsEngaged,
|
||||||
|
total: ig?.igInsights?.accountsEngaged,
|
||||||
|
color: 'bg-amber-400/80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IG · Δ seguidores / día',
|
||||||
|
hint: 'follower_count (delta)',
|
||||||
|
series: ig?.series?.followerDelta,
|
||||||
|
total: ig?.igInsights?.followerDelta,
|
||||||
|
color: 'bg-yellow-500/70',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'FB · Alcance / día',
|
||||||
|
hint: 'page_impressions_unique',
|
||||||
|
series: fb?.series?.reach,
|
||||||
|
total: fb?.pageInsights?.reach,
|
||||||
|
color: 'bg-sky-400/80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'FB · Impresiones',
|
||||||
|
hint: 'page_impressions',
|
||||||
|
series: fb?.series?.impressions,
|
||||||
|
total: fb?.pageInsights?.impressions,
|
||||||
|
color: 'bg-blue-500/70',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'FB · Engagement posts',
|
||||||
|
hint: 'page_post_engagements',
|
||||||
|
series: fb?.series?.postEngagements,
|
||||||
|
total: fb?.pageInsights?.postEngagements,
|
||||||
|
color: 'bg-indigo-400/80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'FB · Fans (stock)',
|
||||||
|
hint: 'page_fans diario',
|
||||||
|
series: fb?.series?.fans,
|
||||||
|
total: fb?.pageInsights?.fans,
|
||||||
|
color: 'bg-violet-400/80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'FB · Vistas página',
|
||||||
|
hint: 'page_views_total',
|
||||||
|
series: fb?.series?.pageViews,
|
||||||
|
total: fb?.pageInsights?.pageViews,
|
||||||
|
color: 'bg-cyan-400/80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'FB · Nuevos follows',
|
||||||
|
hint: 'page_follows / daily_follows',
|
||||||
|
series: fb?.series?.follows,
|
||||||
|
total: fb?.pageInsights?.follows,
|
||||||
|
color: 'bg-teal-400/80',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -226,7 +407,7 @@ export default function SocialDashboard({
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow={`Social · ${tenantLabel}`}
|
eyebrow={`Social · ${tenantLabel}`}
|
||||||
title="Redes sociales"
|
title="Redes sociales"
|
||||||
description="Analytics orgánicos IG/FB + preview del contenido publicado. Paid ads están en Ads."
|
description={`Analytics orgánicos IG/FB (token Meta 4V2030) · periodo ${rangeLabel}. Paid ads en Ads.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{err && (
|
{err && (
|
||||||
@@ -265,14 +446,43 @@ export default function SocialDashboard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(ig?.error || fb?.error || ig?.postsError || fb?.postsError) && (
|
{(ig?.error ||
|
||||||
|
fb?.error ||
|
||||||
|
ig?.postsError ||
|
||||||
|
fb?.postsError ||
|
||||||
|
ig?.insightsError ||
|
||||||
|
fb?.insightsError) && (
|
||||||
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||||
{[ig?.error && `IG: ${ig.error}`, fb?.error && `FB: ${fb.error}`, ig?.postsError && `IG posts: ${ig.postsError}`, fb?.postsError && `FB posts: ${fb.postsError}`]
|
{[
|
||||||
|
ig?.error && `IG: ${ig.error}`,
|
||||||
|
fb?.error && `FB: ${fb.error}`,
|
||||||
|
ig?.insightsError && `IG insights: ${ig.insightsError}`,
|
||||||
|
fb?.insightsError && `FB insights: ${fb.insightsError}`,
|
||||||
|
ig?.postsError && `IG posts: ${ig.postsError}`,
|
||||||
|
fb?.postsError && `FB posts: ${fb.postsError}`,
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')}
|
.join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
|
Evolución diaria · {rangeLabel}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Totales del periodo arriba a la derecha de cada gráfica. Globales
|
||||||
|
(seguidores/publicaciones) en las KPI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||||
|
{chartBlocks.map((c) => (
|
||||||
|
<ChartCard key={c.title} {...c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
@@ -304,8 +514,8 @@ export default function SocialDashboard({
|
|||||||
|
|
||||||
{visiblePosts.length === 0 ? (
|
{visiblePosts.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 border border-dashed border-gray-200 rounded-xl px-4 py-10 text-center">
|
<p className="text-sm text-gray-500 border border-dashed border-gray-200 rounded-xl px-4 py-10 text-center">
|
||||||
Sin posts recientes (o faltan permisos pages_read_engagement /
|
Sin posts en este periodo (o el token Meta no puede leer la
|
||||||
instagram_manage_insights).
|
Página/IG).
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
|||||||
@@ -31,10 +31,21 @@ export function googleAdsConfigured(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefer token saved in agency_oauth (pasted after App Review), else env. */
|
/** Prefer env META_ACCESS_TOKEN (Dokploy / 4V2030), then agency_oauth paste. */
|
||||||
export async function resolveMetaAccessToken(): Promise<string | null> {
|
export async function resolveMetaAccessToken(): Promise<string | null> {
|
||||||
|
const tokens = await resolveMetaAccessTokens();
|
||||||
|
return tokens[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All candidate Meta tokens (env first — production 4V2030 system user). */
|
||||||
|
export async function resolveMetaAccessTokens(): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
const env = process.env.META_ACCESS_TOKEN?.trim();
|
||||||
|
if (env) out.push(env);
|
||||||
|
|
||||||
const headers = serviceHeaders();
|
const headers = serviceHeaders();
|
||||||
if (headers) {
|
if (headers) {
|
||||||
|
try {
|
||||||
const r = await fetch(
|
const r = await fetch(
|
||||||
`${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${META_OAUTH_PROVIDER}&select=access_token`,
|
`${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${META_OAUTH_PROVIDER}&select=access_token`,
|
||||||
{ headers, cache: 'no-store' }
|
{ headers, cache: 'no-store' }
|
||||||
@@ -42,10 +53,13 @@ export async function resolveMetaAccessToken(): Promise<string | null> {
|
|||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const rows = (await r.json()) as { access_token?: string }[];
|
const rows = (await r.json()) as { access_token?: string }[];
|
||||||
const t = rows[0]?.access_token?.trim();
|
const t = rows[0]?.access_token?.trim();
|
||||||
if (t) return t;
|
if (t && !out.includes(t)) out.push(t);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return process.env.META_ACCESS_TOKEN?.trim() || null;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function metaConfigured(): Promise<boolean> {
|
export async function metaConfigured(): Promise<boolean> {
|
||||||
|
|||||||
Reference in New Issue
Block a user