fix: Social — métricas IG total_value, posts en formato original y tamaño
Rellena views/perfil/clics/interacciones con metric_type=total_value y series día a día; carruseles/vídeos/reels con aspect ratio y px originales. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,6 +26,8 @@ function allowed(url: string): boolean {
|
|||||||
return (
|
return (
|
||||||
host.endsWith('.fbcdn.net') ||
|
host.endsWith('.fbcdn.net') ||
|
||||||
host.endsWith('.facebook.com') ||
|
host.endsWith('.facebook.com') ||
|
||||||
|
host.endsWith('.cdninstagram.com') ||
|
||||||
|
host.endsWith('.instagram.com') ||
|
||||||
host.endsWith('.googleusercontent.com') ||
|
host.endsWith('.googleusercontent.com') ||
|
||||||
host.endsWith('.googlesyndication.com') ||
|
host.endsWith('.googlesyndication.com') ||
|
||||||
host === 'i.ytimg.com' ||
|
host === 'i.ytimg.com' ||
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ type SocialPost = {
|
|||||||
reach?: number | null;
|
reach?: number | null;
|
||||||
impressions?: number | null;
|
impressions?: number | null;
|
||||||
mediaType?: string | null;
|
mediaType?: string | null;
|
||||||
|
/** FEED | REELS | STORY | … */
|
||||||
|
mediaProductType?: string | null;
|
||||||
|
/** Slides for CAROUSEL_ALBUM / album */
|
||||||
|
children?: {
|
||||||
|
id?: string;
|
||||||
|
mediaType?: string | null;
|
||||||
|
mediaUrl?: string | null;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DayPoint = { day: string; value: number };
|
export type DayPoint = { day: string; value: number };
|
||||||
@@ -186,6 +195,27 @@ function insightSeries(
|
|||||||
.sort((a, b) => a.day.localeCompare(b.day));
|
.sort((a, b) => a.day.localeCompare(b.day));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Meta IG: many metrics only return total_value (not daily values[]). */
|
||||||
|
function totalValueOf(
|
||||||
|
data: { name?: string; total_value?: { value?: number } }[] | undefined,
|
||||||
|
name: string
|
||||||
|
): number | null {
|
||||||
|
const row = data?.find((d) => d.name === name);
|
||||||
|
const v = row?.total_value?.value;
|
||||||
|
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eachDay(since: string, until: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const cur = new Date(`${since}T12:00:00Z`);
|
||||||
|
const end = new Date(`${until}T12:00:00Z`);
|
||||||
|
while (cur <= end) {
|
||||||
|
out.push(cur.toISOString().slice(0, 10));
|
||||||
|
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function lastValue(series: DayPoint[]): number | null {
|
function lastValue(series: DayPoint[]): number | null {
|
||||||
if (!series.length) return null;
|
if (!series.length) return null;
|
||||||
return series[series.length - 1].value;
|
return series[series.length - 1].value;
|
||||||
@@ -353,8 +383,8 @@ async function fetchPageBundle(
|
|||||||
token,
|
token,
|
||||||
{
|
{
|
||||||
fields:
|
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}',
|
'id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments{media_type,type,url,media}}',
|
||||||
limit: '20',
|
limit: '25',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
let postsError: string | undefined;
|
let postsError: string | undefined;
|
||||||
@@ -366,8 +396,8 @@ async function fetchPageBundle(
|
|||||||
} else {
|
} else {
|
||||||
const posts2 = await graphGet(`${encodeURIComponent(pageId)}/posts`, token, {
|
const posts2 = await graphGet(`${encodeURIComponent(pageId)}/posts`, token, {
|
||||||
fields:
|
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}',
|
'id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments{media_type,type,url,media}}',
|
||||||
limit: '20',
|
limit: '25',
|
||||||
});
|
});
|
||||||
if (posts2.ok) {
|
if (posts2.ok) {
|
||||||
const data = (posts2.json.data as Record<string, unknown>[]) || [];
|
const data = (posts2.json.data as Record<string, unknown>[]) || [];
|
||||||
@@ -423,28 +453,45 @@ function pushFbPost(
|
|||||||
const att = (p.attachments as { data?: Record<string, unknown>[] })?.data?.[0];
|
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 media = att?.media as { source?: string; image?: { src?: string } } | undefined;
|
||||||
|
const subs =
|
||||||
|
(
|
||||||
|
att?.subattachments as {
|
||||||
|
data?: {
|
||||||
|
media_type?: string;
|
||||||
|
type?: string;
|
||||||
|
media?: { source?: string; image?: { src?: string } };
|
||||||
|
url?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
)?.data || [];
|
||||||
const videoSrc =
|
const videoSrc =
|
||||||
media?.source ||
|
media?.source ||
|
||||||
(att?.url as string) ||
|
(att?.url as string) ||
|
||||||
(
|
subs[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 isAlbum = attType.includes('album') || subs.length > 1;
|
||||||
const thumb =
|
const thumb =
|
||||||
(p.full_picture as string) ||
|
(p.full_picture as string) ||
|
||||||
media?.image?.src ||
|
media?.image?.src ||
|
||||||
(
|
subs[0]?.media?.image?.src ||
|
||||||
att?.subattachments as {
|
|
||||||
data?: { media?: { image?: { src?: string } } }[];
|
|
||||||
}
|
|
||||||
)?.data?.[0]?.media?.image?.src ||
|
|
||||||
null;
|
null;
|
||||||
|
const children = isAlbum
|
||||||
|
? subs.map((s, i) => {
|
||||||
|
const v = s.media?.source;
|
||||||
|
const img = s.media?.image?.src || s.url || null;
|
||||||
|
const t = String(s.media_type || s.type || '').toLowerCase();
|
||||||
|
return {
|
||||||
|
id: `${id}-slide-${i}`,
|
||||||
|
mediaType: t.includes('video') ? 'VIDEO' : 'IMAGE',
|
||||||
|
mediaUrl: v || img,
|
||||||
|
thumbnailUrl: img,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
const likes = p.likes as { summary?: { total_count?: number } } | undefined;
|
const likes = p.likes as { summary?: { total_count?: number } } | undefined;
|
||||||
const comments = p.comments as { summary?: { total_count?: number } } | undefined;
|
const comments = p.comments as { summary?: { total_count?: number } } | undefined;
|
||||||
const shares = p.shares as { count?: number } | undefined;
|
const shares = p.shares as { count?: number } | undefined;
|
||||||
@@ -452,14 +499,15 @@ function pushFbPost(
|
|||||||
id,
|
id,
|
||||||
platform: 'facebook',
|
platform: 'facebook',
|
||||||
message: String(p.message || ''),
|
message: String(p.message || ''),
|
||||||
mediaUrl: isVideo ? videoSrc || thumb : thumb,
|
mediaUrl: isVideo ? videoSrc || thumb : thumb || children?.[0]?.mediaUrl || null,
|
||||||
thumbnailUrl: thumb,
|
thumbnailUrl: thumb,
|
||||||
permalink: (p.permalink_url as string) || null,
|
permalink: (p.permalink_url as string) || null,
|
||||||
createdAt: (p.created_time as string) || null,
|
createdAt: (p.created_time as string) || null,
|
||||||
likes: Number(likes?.summary?.total_count || 0),
|
likes: Number(likes?.summary?.total_count || 0),
|
||||||
comments: Number(comments?.summary?.total_count || 0),
|
comments: Number(comments?.summary?.total_count || 0),
|
||||||
shares: Number(shares?.count || 0),
|
shares: Number(shares?.count || 0),
|
||||||
mediaType: isVideo ? 'VIDEO' : thumb ? 'IMAGE' : 'status',
|
mediaType: isVideo ? 'VIDEO' : isAlbum ? 'CAROUSEL_ALBUM' : thumb ? 'IMAGE' : 'status',
|
||||||
|
children,
|
||||||
});
|
});
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
}
|
}
|
||||||
@@ -480,20 +528,12 @@ async function fetchIgBundle(
|
|||||||
const sinceU = String(toUnix(since));
|
const sinceU = String(toUnix(since));
|
||||||
const untilU = String(toUnix(until, true));
|
const untilU = String(toUnix(until, true));
|
||||||
|
|
||||||
const metricSets = [
|
// Time-series metrics (daily values[])
|
||||||
'reach,views,profile_views,website_clicks,accounts_engaged,follower_count,total_interactions',
|
let seriesData: {
|
||||||
'reach,profile_views,website_clicks,accounts_engaged,follower_count',
|
|
||||||
'reach,follower_count,profile_views',
|
|
||||||
'reach,follower_count',
|
|
||||||
];
|
|
||||||
|
|
||||||
let insightData: {
|
|
||||||
name?: string;
|
name?: string;
|
||||||
values?: { value?: number; end_time?: string }[];
|
values?: { value?: number; end_time?: string }[];
|
||||||
}[] = [];
|
}[] = [];
|
||||||
let insightsError: string | undefined;
|
for (const metrics of ['reach,follower_count', 'reach', 'follower_count']) {
|
||||||
|
|
||||||
for (const metrics of metricSets) {
|
|
||||||
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
||||||
metric: metrics,
|
metric: metrics,
|
||||||
period: 'day',
|
period: 'day',
|
||||||
@@ -501,125 +541,169 @@ async function fetchIgBundle(
|
|||||||
until: untilU,
|
until: untilU,
|
||||||
});
|
});
|
||||||
if (ins.ok && Array.isArray(ins.json.data)) {
|
if (ins.ok && Array.isArray(ins.json.data)) {
|
||||||
insightData = ins.json.data as typeof insightData;
|
seriesData = ins.json.data as typeof seriesData;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Partial recovery: one bad metric in a comma-set fails the whole call
|
// total_value metrics (period totals + optional per-day series)
|
||||||
if (!insightData.length || insightData.length < 3) {
|
const totalMetrics = [
|
||||||
const singles = [
|
'views',
|
||||||
'reach',
|
'profile_views',
|
||||||
'views',
|
'website_clicks',
|
||||||
'impressions',
|
'accounts_engaged',
|
||||||
'profile_views',
|
'total_interactions',
|
||||||
'website_clicks',
|
];
|
||||||
'accounts_engaged',
|
let totalData: {
|
||||||
'follower_count',
|
name?: string;
|
||||||
'total_interactions',
|
total_value?: { value?: number };
|
||||||
];
|
}[] = [];
|
||||||
const byName = new Map<string, (typeof insightData)[0]>();
|
{
|
||||||
for (const row of insightData) {
|
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
||||||
if (row.name) byName.set(row.name, row);
|
metric: totalMetrics.join(','),
|
||||||
}
|
period: 'day',
|
||||||
for (const metric of singles) {
|
metric_type: 'total_value',
|
||||||
if (byName.has(metric)) continue;
|
since: sinceU,
|
||||||
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
until: untilU,
|
||||||
metric,
|
});
|
||||||
period: 'day',
|
if (ins.ok && Array.isArray(ins.json.data)) {
|
||||||
since: sinceU,
|
totalData = ins.json.data as typeof totalData;
|
||||||
until: untilU,
|
} else {
|
||||||
});
|
for (const metric of totalMetrics) {
|
||||||
if (ins.ok && Array.isArray(ins.json.data) && ins.json.data.length) {
|
const one = await graphGet(
|
||||||
for (const row of ins.json.data as typeof insightData) {
|
`${encodeURIComponent(igId)}/insights`,
|
||||||
if (row.name) byName.set(row.name, row);
|
agencyToken,
|
||||||
}
|
{
|
||||||
continue;
|
metric,
|
||||||
}
|
period: 'day',
|
||||||
const ins2 = await graphGet(
|
metric_type: 'total_value',
|
||||||
`${encodeURIComponent(igId)}/insights`,
|
since: sinceU,
|
||||||
agencyToken,
|
until: untilU,
|
||||||
{ metric, period: 'day' }
|
}
|
||||||
);
|
);
|
||||||
if (ins2.ok && Array.isArray(ins2.json.data)) {
|
if (one.ok && Array.isArray(one.json.data)) {
|
||||||
for (const row of ins2.json.data as typeof insightData) {
|
totalData.push(...(one.json.data as typeof totalData));
|
||||||
const vals = (row.values || []).filter((v) => {
|
|
||||||
const d = (v.end_time || '').slice(0, 10);
|
|
||||||
return d >= since && d <= until;
|
|
||||||
});
|
|
||||||
if (!vals.length || !row.name) continue;
|
|
||||||
byName.set(row.name, { ...row, values: vals });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (byName.size) {
|
}
|
||||||
insightData = [...byName.values()];
|
|
||||||
insightsError = undefined;
|
// Daily series for total_value metrics (Meta only returns a single total per range)
|
||||||
}
|
const days = eachDay(since, until).slice(-31);
|
||||||
|
const dailyMaps: Record<string, DayPoint[]> = {
|
||||||
|
views: [],
|
||||||
|
profile_views: [],
|
||||||
|
website_clicks: [],
|
||||||
|
accounts_engaged: [],
|
||||||
|
total_interactions: [],
|
||||||
|
};
|
||||||
|
for (let i = 0; i < days.length; i += 7) {
|
||||||
|
const chunk = days.slice(i, i + 7);
|
||||||
|
await Promise.all(
|
||||||
|
chunk.map(async (day) => {
|
||||||
|
const s = String(toUnix(day, false));
|
||||||
|
const u = String(toUnix(day, true));
|
||||||
|
const ins = await graphGet(
|
||||||
|
`${encodeURIComponent(igId)}/insights`,
|
||||||
|
agencyToken,
|
||||||
|
{
|
||||||
|
metric: totalMetrics.join(','),
|
||||||
|
period: 'day',
|
||||||
|
metric_type: 'total_value',
|
||||||
|
since: s,
|
||||||
|
until: u,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!ins.ok || !Array.isArray(ins.json.data)) return;
|
||||||
|
for (const row of ins.json.data as {
|
||||||
|
name?: string;
|
||||||
|
total_value?: { value?: number };
|
||||||
|
}[]) {
|
||||||
|
const name = row.name || '';
|
||||||
|
if (!(name in dailyMaps)) continue;
|
||||||
|
const v = row.total_value?.value;
|
||||||
|
if (typeof v !== 'number') continue;
|
||||||
|
dailyMaps[name].push({ day, value: v });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const k of Object.keys(dailyMaps)) {
|
||||||
|
dailyMaps[k].sort((a, b) => a.day.localeCompare(b.day));
|
||||||
}
|
}
|
||||||
|
|
||||||
const series = {
|
const series = {
|
||||||
reach: insightSeries(insightData, 'reach'),
|
reach: insightSeries(seriesData, 'reach'),
|
||||||
impressions:
|
impressions: dailyMaps.views,
|
||||||
insightSeries(insightData, 'views').length
|
profileViews: dailyMaps.profile_views,
|
||||||
? insightSeries(insightData, 'views')
|
websiteClicks: dailyMaps.website_clicks,
|
||||||
: insightSeries(insightData, 'impressions'),
|
accountsEngaged: dailyMaps.accounts_engaged,
|
||||||
profileViews: insightSeries(insightData, 'profile_views'),
|
followerDelta: insightSeries(seriesData, 'follower_count'),
|
||||||
websiteClicks: insightSeries(insightData, 'website_clicks'),
|
interactions: dailyMaps.total_interactions,
|
||||||
accountsEngaged: insightSeries(insightData, 'accounts_engaged'),
|
|
||||||
followerDelta: insightSeries(insightData, 'follower_count'),
|
|
||||||
interactions: insightSeries(insightData, 'total_interactions'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const igInsights = {
|
const igInsights = {
|
||||||
reach: sumSeries(series.reach) || lastValue(series.reach),
|
reach: sumSeries(series.reach) || lastValue(series.reach),
|
||||||
impressions: sumSeries(series.impressions) || lastValue(series.impressions),
|
impressions:
|
||||||
profileViews: sumSeries(series.profileViews) || lastValue(series.profileViews),
|
totalValueOf(totalData, 'views') ??
|
||||||
|
(sumSeries(series.impressions) || lastValue(series.impressions)),
|
||||||
|
profileViews:
|
||||||
|
totalValueOf(totalData, 'profile_views') ??
|
||||||
|
(sumSeries(series.profileViews) || lastValue(series.profileViews)),
|
||||||
websiteClicks:
|
websiteClicks:
|
||||||
sumSeries(series.websiteClicks) || lastValue(series.websiteClicks),
|
totalValueOf(totalData, 'website_clicks') ??
|
||||||
|
(sumSeries(series.websiteClicks) || lastValue(series.websiteClicks)),
|
||||||
accountsEngaged:
|
accountsEngaged:
|
||||||
sumSeries(series.accountsEngaged) || lastValue(series.accountsEngaged),
|
totalValueOf(totalData, 'accounts_engaged') ??
|
||||||
|
(sumSeries(series.accountsEngaged) || lastValue(series.accountsEngaged)),
|
||||||
followerDelta:
|
followerDelta:
|
||||||
sumSeries(series.followerDelta) || lastValue(series.followerDelta),
|
sumSeries(series.followerDelta) || lastValue(series.followerDelta),
|
||||||
interactions:
|
interactions:
|
||||||
sumSeries(series.interactions) || lastValue(series.interactions),
|
totalValueOf(totalData, 'total_interactions') ??
|
||||||
|
(sumSeries(series.interactions) || lastValue(series.interactions)),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mediaRes = await graphGet(`${encodeURIComponent(igId)}/media`, agencyToken, {
|
const mediaRes = await graphGet(`${encodeURIComponent(igId)}/media`, agencyToken, {
|
||||||
fields:
|
fields:
|
||||||
'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count',
|
'id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count,children{id,media_type,media_url,thumbnail_url}',
|
||||||
limit: '20',
|
limit: '30',
|
||||||
});
|
});
|
||||||
const posts: SocialPost[] = [];
|
const posts: SocialPost[] = [];
|
||||||
let postsError: string | undefined;
|
let postsError: string | undefined;
|
||||||
if (mediaRes.ok) {
|
if (mediaRes.ok) {
|
||||||
for (const m of (mediaRes.json.data as Record<string, unknown>[]) || []) {
|
for (const m of (mediaRes.json.data as Record<string, unknown>[]) || []) {
|
||||||
const mediaType = (m.media_type as string) || null;
|
const mediaType = (m.media_type as string) || null;
|
||||||
const video = isVideoType(mediaType);
|
const mediaProductType = (m.media_product_type as string) || null;
|
||||||
|
const video = isVideoType(mediaType) || mediaProductType === 'REELS';
|
||||||
|
const childrenRaw =
|
||||||
|
(
|
||||||
|
m.children as {
|
||||||
|
data?: {
|
||||||
|
id?: string;
|
||||||
|
media_type?: string;
|
||||||
|
media_url?: string;
|
||||||
|
thumbnail_url?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
)?.data || [];
|
||||||
|
const children = childrenRaw.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
mediaType: c.media_type || null,
|
||||||
|
mediaUrl: c.media_url || null,
|
||||||
|
thumbnailUrl: c.thumbnail_url || null,
|
||||||
|
}));
|
||||||
const mediaUrl = video
|
const mediaUrl = video
|
||||||
? (m.media_url as string) || (m.thumbnail_url as string) || null
|
? (m.media_url as string) || (m.thumbnail_url as string) || null
|
||||||
: (m.media_url as string) || (m.thumbnail_url as string) || null;
|
: (m.media_url as string) ||
|
||||||
|
children[0]?.mediaUrl ||
|
||||||
|
(m.thumbnail_url as string) ||
|
||||||
|
null;
|
||||||
const thumbnailUrl =
|
const thumbnailUrl =
|
||||||
(m.thumbnail_url as string) || (video ? null : (m.media_url as string)) || null;
|
(m.thumbnail_url as string) ||
|
||||||
|
(video ? null : (m.media_url as string)) ||
|
||||||
|
children[0]?.thumbnailUrl ||
|
||||||
|
children[0]?.mediaUrl ||
|
||||||
|
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;
|
||||||
@@ -662,7 +746,12 @@ async function fetchIgBundle(
|
|||||||
shares,
|
shares,
|
||||||
reach,
|
reach,
|
||||||
impressions,
|
impressions,
|
||||||
mediaType,
|
mediaType:
|
||||||
|
mediaProductType === 'REELS'
|
||||||
|
? 'REELS'
|
||||||
|
: mediaType || (children.length > 1 ? 'CAROUSEL_ALBUM' : null),
|
||||||
|
mediaProductType,
|
||||||
|
children: children.length ? children : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -675,7 +764,7 @@ async function fetchIgBundle(
|
|||||||
series,
|
series,
|
||||||
posts,
|
posts,
|
||||||
postsError,
|
postsError,
|
||||||
insightsError: insightData.length ? undefined : insightsError,
|
insightsError: undefined,
|
||||||
since,
|
since,
|
||||||
until,
|
until,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,13 +85,147 @@ type SocialPost = {
|
|||||||
reach?: number | null;
|
reach?: number | null;
|
||||||
impressions?: number | null;
|
impressions?: number | null;
|
||||||
mediaType?: string | null;
|
mediaType?: string | null;
|
||||||
|
mediaProductType?: string | null;
|
||||||
|
children?: {
|
||||||
|
id?: string;
|
||||||
|
mediaType?: string | null;
|
||||||
|
mediaUrl?: string | null;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function isVideoPost(p: SocialPost): boolean {
|
function formatLabel(p: SocialPost): string {
|
||||||
const t = (p.mediaType || '').toUpperCase();
|
const t = (p.mediaType || '').toUpperCase();
|
||||||
|
const prod = (p.mediaProductType || '').toUpperCase();
|
||||||
|
if (prod === 'REELS' || t === 'REELS' || t === 'REEL') return 'Reel';
|
||||||
|
if (t.includes('CAROUSEL') || t.includes('ALBUM')) return 'Carrusel';
|
||||||
|
if (t.includes('VIDEO')) return 'Vídeo';
|
||||||
|
if (t.includes('IMAGE') || t === 'PHOTO') return 'Imagen';
|
||||||
|
if (t === 'STORY' || prod === 'STORY') return 'Story';
|
||||||
|
return t || 'Post';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoSlide(mediaType?: string | null, url?: string | null): boolean {
|
||||||
|
const t = (mediaType || '').toUpperCase();
|
||||||
if (t.includes('VIDEO') || t === 'REELS' || t === 'REEL') return true;
|
if (t.includes('VIDEO') || t === 'REELS' || t === 'REEL') return true;
|
||||||
const url = p.mediaUrl || '';
|
return /\.mp4($|\?)/i.test(url || '') || /video/i.test(url || '');
|
||||||
return /\.mp4($|\?)/i.test(url) || /video/i.test(url);
|
}
|
||||||
|
|
||||||
|
function proxiedSocialUrl(raw?: string | null): string {
|
||||||
|
if (!raw) return '';
|
||||||
|
if (raw.startsWith('/api/')) return raw;
|
||||||
|
return `/api/ad-tracker/media?url=${encodeURIComponent(raw)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostMedia({ post }: { post: SocialPost }) {
|
||||||
|
const slides =
|
||||||
|
post.children && post.children.length > 0
|
||||||
|
? post.children
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: post.id,
|
||||||
|
mediaType: post.mediaType,
|
||||||
|
mediaUrl: post.mediaUrl,
|
||||||
|
thumbnailUrl: post.thumbnailUrl,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
||||||
|
const slide = slides[Math.min(idx, slides.length - 1)] || slides[0];
|
||||||
|
const video = isVideoSlide(slide?.mediaType, slide?.mediaUrl);
|
||||||
|
const src = proxiedSocialUrl(slide?.mediaUrl || slide?.thumbnailUrl);
|
||||||
|
const poster = proxiedSocialUrl(slide?.thumbnailUrl);
|
||||||
|
const aspect =
|
||||||
|
dims && dims.w > 0 && dims.h > 0 ? `${dims.w} / ${dims.h}` : video ? '9 / 16' : '1 / 1';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div
|
||||||
|
className="relative w-full bg-gray-950 overflow-hidden rounded-t-2xl"
|
||||||
|
style={{ aspectRatio: aspect, maxHeight: 520 }}
|
||||||
|
>
|
||||||
|
{src && video ? (
|
||||||
|
<video
|
||||||
|
key={src}
|
||||||
|
src={src}
|
||||||
|
poster={poster || undefined}
|
||||||
|
className="absolute inset-0 w-full h-full object-contain bg-black"
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
if (el.videoWidth && el.videoHeight) {
|
||||||
|
setDims({ w: el.videoWidth, h: el.videoHeight });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : src ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
key={src}
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 w-full h-full object-contain bg-black"
|
||||||
|
onLoad={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
if (el.naturalWidth && el.naturalHeight) {
|
||||||
|
setDims({ w: el.naturalWidth, h: el.naturalHeight });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-400 px-4 text-center">
|
||||||
|
{post.message?.slice(0, 120) || 'Sin media'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="absolute top-2 left-2 text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded bg-black/70 text-white">
|
||||||
|
{post.platform === 'instagram' ? 'IG' : 'FB'} · {formatLabel(post)}
|
||||||
|
{slides.length > 1 ? ` · ${idx + 1}/${slides.length}` : ''}
|
||||||
|
</span>
|
||||||
|
{dims ? (
|
||||||
|
<span className="absolute top-2 right-2 text-[10px] font-semibold tabular-nums px-2 py-0.5 rounded bg-black/70 text-white">
|
||||||
|
{dims.w}×{dims.h}
|
||||||
|
{dims.w && dims.h
|
||||||
|
? ` · ${(dims.w / dims.h).toFixed(2)}:1`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{slides.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Anterior"
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-black/60 text-white text-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDims(null);
|
||||||
|
setIdx((i) => (i - 1 + slides.length) % slides.length);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Siguiente"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-black/60 text-white text-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDims(null);
|
||||||
|
setIdx((i) => (i + 1) % slides.length);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{dims ? (
|
||||||
|
<p className="px-3 text-[10px] text-gray-500 tabular-nums">
|
||||||
|
Tamaño original: {dims.w} × {dims.h}px
|
||||||
|
{video ? ' (vídeo)' : slides.length > 1 ? ` · slide ${idx + 1}` : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHANNELS: {
|
const CHANNELS: {
|
||||||
@@ -263,7 +397,7 @@ export default function SocialDashboard({
|
|||||||
icon: Eye,
|
icon: Eye,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Impresiones/vistas IG',
|
label: 'Visualizaciones IG',
|
||||||
value: n(ig?.igInsights?.impressions),
|
value: n(ig?.igInsights?.impressions),
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
},
|
},
|
||||||
@@ -324,8 +458,8 @@ export default function SocialDashboard({
|
|||||||
color: 'bg-pink-400/80',
|
color: 'bg-pink-400/80',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'IG · Impresiones/vistas',
|
title: 'IG · Visualizaciones',
|
||||||
hint: 'Views / impressions',
|
hint: 'views (metric_type=total_value)',
|
||||||
series: ig?.series?.impressions,
|
series: ig?.series?.impressions,
|
||||||
total: ig?.igInsights?.impressions,
|
total: ig?.igInsights?.impressions,
|
||||||
color: 'bg-fuchsia-400/80',
|
color: 'bg-fuchsia-400/80',
|
||||||
@@ -524,36 +658,13 @@ export default function SocialDashboard({
|
|||||||
key={p.id}
|
key={p.id}
|
||||||
className="rounded-2xl border border-gray-200 bg-white overflow-hidden flex flex-col"
|
className="rounded-2xl border border-gray-200 bg-white overflow-hidden flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-square bg-gray-100">
|
<PostMedia post={p} />
|
||||||
{p.mediaUrl && isVideoPost(p) ? (
|
|
||||||
<video
|
|
||||||
src={p.mediaUrl}
|
|
||||||
poster={p.thumbnailUrl || undefined}
|
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
|
||||||
controls
|
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
/>
|
|
||||||
) : p.mediaUrl || p.thumbnailUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={p.thumbnailUrl || p.mediaUrl || ''}
|
|
||||||
alt=""
|
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-400 px-4 text-center">
|
|
||||||
{p.message?.slice(0, 120) || 'Sin media'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="absolute top-2 left-2 text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded bg-black/70 text-white">
|
|
||||||
{p.platform === 'instagram' ? 'IG' : 'FB'}
|
|
||||||
{p.mediaType ? ` · ${p.mediaType}` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 space-y-2 flex-1 flex flex-col">
|
<div className="p-3 space-y-2 flex-1 flex flex-col">
|
||||||
<p className="text-[11px] text-gray-500">
|
<p className="text-[11px] text-gray-500">
|
||||||
{fmtDate(p.createdAt)}
|
{fmtDate(p.createdAt)}
|
||||||
|
{p.mediaProductType
|
||||||
|
? ` · ${p.mediaProductType}`
|
||||||
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
{p.message ? (
|
{p.message ? (
|
||||||
<p className="text-xs text-gray-800 line-clamp-3 whitespace-pre-wrap">
|
<p className="text-xs text-gray-800 line-clamp-3 whitespace-pre-wrap">
|
||||||
@@ -575,6 +686,11 @@ export default function SocialDashboard({
|
|||||||
<Eye className="w-3 h-3" /> {n(p.reach)} alc.
|
<Eye className="w-3 h-3" /> {n(p.reach)} alc.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{p.impressions != null && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Eye className="w-3 h-3" /> {n(p.impressions)} views
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{p.permalink && (
|
{p.permalink && (
|
||||||
<a
|
<a
|
||||||
|
|||||||
Reference in New Issue
Block a user