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';
|
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
const META = 'https://graph.facebook.com/v21.0';
|
const META = 'https://graph.facebook.com/v21.0';
|
||||||
|
|
||||||
@@ -17,34 +18,188 @@ type Integ = {
|
|||||||
meta?: Record<string, unknown> | null;
|
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 =
|
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(
|
const r = await fetch(
|
||||||
`${META}/${encodeURIComponent(pageId)}?fields=${fields}&access_token=${encodeURIComponent(token)}`,
|
`${META}/${encodeURIComponent(pageId)}?fields=${fields}&access_token=${encodeURIComponent(token)}`,
|
||||||
{ cache: 'no-store' }
|
{ 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) };
|
||||||
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) };
|
|
||||||
const profile = await r.json();
|
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' }
|
{ cache: 'no-store' }
|
||||||
);
|
);
|
||||||
let insights: unknown = null;
|
let pageInsights: Record<string, number | null> = {};
|
||||||
if (ins.ok) insights = await ins.json();
|
if (insightsRes.ok) {
|
||||||
return { ...profile, insights };
|
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() {
|
export async function GET() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
@@ -59,6 +214,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
integrations: [],
|
integrations: [],
|
||||||
metrics: {},
|
metrics: {},
|
||||||
|
posts: [],
|
||||||
warning: 'Selecciona un cliente en Contexto',
|
warning: 'Selecciona un cliente en Contexto',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -84,6 +240,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const metrics: Record<string, unknown> = {};
|
const metrics: Record<string, unknown> = {};
|
||||||
|
const posts: SocialPost[] = [];
|
||||||
const token = await resolveMetaAccessToken();
|
const token = await resolveMetaAccessToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
for (const i of integrations) {
|
for (const i of integrations) {
|
||||||
@@ -92,10 +249,14 @@ export async function GET() {
|
|||||||
if (id.startsWith('http')) continue;
|
if (id.startsWith('http')) continue;
|
||||||
try {
|
try {
|
||||||
if (i.provider === 'facebook') {
|
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') {
|
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) {
|
} catch (e) {
|
||||||
metrics[i.provider] = {
|
metrics[i.provider] = {
|
||||||
@@ -108,9 +269,16 @@ export async function GET() {
|
|||||||
'Sin token Meta de agencia: perfiles enlazados pero sin métricas live.';
|
'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({
|
return NextResponse.json({
|
||||||
integrations,
|
integrations,
|
||||||
metrics,
|
metrics,
|
||||||
|
posts: posts.slice(0, 24),
|
||||||
label: ctx.label,
|
label: ctx.label,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,12 +112,21 @@ function buildSeries(rows: AdDailyRow[]): DailyPoint[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function aggregate(rows: AdDailyRow[]): PlatformKpis {
|
function aggregate(rows: AdDailyRow[]): PlatformKpis {
|
||||||
const spend = rows.reduce((a, r) => a + Number(r.spend || 0), 0);
|
const active = rows.filter(
|
||||||
const impressions = rows.reduce((a, r) => a + Number(r.impressions || 0), 0);
|
(r) =>
|
||||||
const clicks = rows.reduce((a, r) => a + Number(r.clicks || 0), 0);
|
Number(r.spend || 0) > 0 ||
|
||||||
const conversions = rows.reduce((a, r) => a + convOf(r), 0);
|
Number(r.impressions || 0) > 0 ||
|
||||||
|
Number(r.clicks || 0) > 0 ||
|
||||||
|
convOf(r) > 0
|
||||||
|
);
|
||||||
|
const spend = active.reduce((a, r) => a + Number(r.spend || 0), 0);
|
||||||
|
const impressions = active.reduce((a, r) => a + Number(r.impressions || 0), 0);
|
||||||
|
const clicks = active.reduce((a, r) => a + Number(r.clicks || 0), 0);
|
||||||
|
const conversions = active.reduce((a, r) => a + convOf(r), 0);
|
||||||
const ads = new Set(
|
const ads = new Set(
|
||||||
rows.map((r) => campaignName(r)).filter((n) => n && n !== 'Sin nombre')
|
active
|
||||||
|
.map((r) => String(r.ad_id || campaignName(r) || '').trim())
|
||||||
|
.filter((n) => n && n !== 'Sin nombre')
|
||||||
).size;
|
).size;
|
||||||
return rates({ spend, impressions, clicks, conversions, ads });
|
return rates({ spend, impressions, clicks, conversions, ads });
|
||||||
}
|
}
|
||||||
@@ -125,6 +134,13 @@ function aggregate(rows: AdDailyRow[]): PlatformKpis {
|
|||||||
function byCampaign(rows: AdDailyRow[]): CampaignRow[] {
|
function byCampaign(rows: AdDailyRow[]): CampaignRow[] {
|
||||||
const map = new Map<string, CampaignRow>();
|
const map = new Map<string, CampaignRow>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
const spend = Number(r.spend || 0);
|
||||||
|
const impressions = Number(r.impressions || 0);
|
||||||
|
const clicks = Number(r.clicks || 0);
|
||||||
|
const conversions = convOf(r);
|
||||||
|
if (spend <= 0 && impressions <= 0 && clicks <= 0 && conversions <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const name = campaignName(r);
|
const name = campaignName(r);
|
||||||
const cur =
|
const cur =
|
||||||
map.get(name) ||
|
map.get(name) ||
|
||||||
@@ -136,10 +152,10 @@ function byCampaign(rows: AdDailyRow[]): CampaignRow[] {
|
|||||||
conversions: 0,
|
conversions: 0,
|
||||||
cpl: null,
|
cpl: null,
|
||||||
} satisfies CampaignRow);
|
} satisfies CampaignRow);
|
||||||
cur.spend += Number(r.spend || 0);
|
cur.spend += spend;
|
||||||
cur.impressions += Number(r.impressions || 0);
|
cur.impressions += impressions;
|
||||||
cur.clicks += Number(r.clicks || 0);
|
cur.clicks += clicks;
|
||||||
cur.conversions += convOf(r);
|
cur.conversions += conversions;
|
||||||
map.set(name, cur);
|
map.set(name, cur);
|
||||||
}
|
}
|
||||||
return [...map.values()]
|
return [...map.values()]
|
||||||
@@ -622,16 +638,36 @@ export default function AdTrackerClient({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
<KpiTile label="Gasto" value={formatEur(meta.spend)} />
|
<KpiTile label="Gasto" value={formatEur(meta.spend)} />
|
||||||
|
<KpiTile
|
||||||
|
label="Impresiones"
|
||||||
|
value={formatNum(meta.impressions)}
|
||||||
|
/>
|
||||||
<KpiTile label="Clics" value={formatNum(meta.clicks)} />
|
<KpiTile label="Clics" value={formatNum(meta.clicks)} />
|
||||||
<KpiTile label="Leads" value={formatNum(meta.conversions)} />
|
<KpiTile
|
||||||
|
label="Conversiones"
|
||||||
|
value={formatNum(meta.conversions)}
|
||||||
|
/>
|
||||||
|
<KpiTile
|
||||||
|
label="CTR"
|
||||||
|
value={meta.ctr != null ? `${meta.ctr.toFixed(2)}%` : '—'}
|
||||||
|
/>
|
||||||
|
<KpiTile
|
||||||
|
label="CPC"
|
||||||
|
value={meta.cpc != null ? formatEur(meta.cpc) : '—'}
|
||||||
|
/>
|
||||||
<KpiTile
|
<KpiTile
|
||||||
label="CPL"
|
label="CPL"
|
||||||
value={meta.cpl != null ? formatEur(meta.cpl) : '—'}
|
value={meta.cpl != null ? formatEur(meta.cpl) : '—'}
|
||||||
/>
|
/>
|
||||||
|
<KpiTile label="Ads activos" value={formatNum(meta.ads)} />
|
||||||
</div>
|
</div>
|
||||||
<CreativesGallery
|
<CreativesGallery
|
||||||
platform="meta"
|
platform="meta"
|
||||||
ads={creatives}
|
ads={creatives.filter(
|
||||||
|
(c) =>
|
||||||
|
c.platform === 'meta' &&
|
||||||
|
(c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0)
|
||||||
|
)}
|
||||||
loading={creativesLoading}
|
loading={creativesLoading}
|
||||||
warning={creativesWarning}
|
warning={creativesWarning}
|
||||||
onZoom={setZoomAd}
|
onZoom={setZoomAd}
|
||||||
@@ -649,9 +685,9 @@ export default function AdTrackerClient({
|
|||||||
<SparkBars data={daily} mode="meta" metric="spend" />
|
<SparkBars data={daily} mode="meta" metric="spend" />
|
||||||
</section>
|
</section>
|
||||||
<CampaignTable
|
<CampaignTable
|
||||||
title="Meta Ads"
|
title="Campañas Meta con rendimiento"
|
||||||
rows={metaCampaigns}
|
rows={metaCampaigns}
|
||||||
empty="Sin campañas Meta activas en este periodo"
|
empty="Sin campañas Meta con actividad en este periodo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -660,16 +696,36 @@ export default function AdTrackerClient({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
<KpiTile label="Gasto" value={formatEur(google.spend)} />
|
<KpiTile label="Gasto" value={formatEur(google.spend)} />
|
||||||
|
<KpiTile
|
||||||
|
label="Impresiones"
|
||||||
|
value={formatNum(google.impressions)}
|
||||||
|
/>
|
||||||
<KpiTile label="Clics" value={formatNum(google.clicks)} />
|
<KpiTile label="Clics" value={formatNum(google.clicks)} />
|
||||||
<KpiTile label="Conv." value={formatNum(google.conversions)} />
|
<KpiTile
|
||||||
|
label="Conversiones"
|
||||||
|
value={formatNum(google.conversions)}
|
||||||
|
/>
|
||||||
|
<KpiTile
|
||||||
|
label="CTR"
|
||||||
|
value={google.ctr != null ? `${google.ctr.toFixed(2)}%` : '—'}
|
||||||
|
/>
|
||||||
|
<KpiTile
|
||||||
|
label="CPC"
|
||||||
|
value={google.cpc != null ? formatEur(google.cpc) : '—'}
|
||||||
|
/>
|
||||||
<KpiTile
|
<KpiTile
|
||||||
label="CPL"
|
label="CPL"
|
||||||
value={google.cpl != null ? formatEur(google.cpl) : '—'}
|
value={google.cpl != null ? formatEur(google.cpl) : '—'}
|
||||||
/>
|
/>
|
||||||
|
<KpiTile label="Ads activos" value={formatNum(google.ads)} />
|
||||||
</div>
|
</div>
|
||||||
<CreativesGallery
|
<CreativesGallery
|
||||||
platform="google"
|
platform="google"
|
||||||
ads={creatives}
|
ads={creatives.filter(
|
||||||
|
(c) =>
|
||||||
|
c.platform === 'google' &&
|
||||||
|
(c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0)
|
||||||
|
)}
|
||||||
loading={creativesLoading}
|
loading={creativesLoading}
|
||||||
warning={creativesWarning}
|
warning={creativesWarning}
|
||||||
onZoom={setZoomAd}
|
onZoom={setZoomAd}
|
||||||
@@ -687,9 +743,9 @@ export default function AdTrackerClient({
|
|||||||
<SparkBars data={daily} mode="google" metric="spend" />
|
<SparkBars data={daily} mode="google" metric="spend" />
|
||||||
</section>
|
</section>
|
||||||
<CampaignTable
|
<CampaignTable
|
||||||
title="Google Ads"
|
title="Campañas Google con rendimiento"
|
||||||
rows={googleCampaigns}
|
rows={googleCampaigns}
|
||||||
empty="Sin campañas Google activas en este periodo"
|
empty="Sin campañas Google con actividad en este periodo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -547,8 +547,8 @@ export default function OrganicoDashboard({
|
|||||||
<div className="space-y-4 min-w-0">
|
<div className="space-y-4 min-w-0">
|
||||||
<SectionHero section={section} />
|
<SectionHero section={section} />
|
||||||
|
|
||||||
{/* Live panels by section.live */}
|
{/* Paneles por sección (no el mismo dashboard en todos) */}
|
||||||
{section.live === 'seo-metrics' && (
|
{section.id === 'seo-overview' && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<Kpi label="Clics GSC" value={formatNum(totals.gscClicks)} />
|
<Kpi label="Clics GSC" value={formatNum(totals.gscClicks)} />
|
||||||
@@ -631,22 +631,29 @@ export default function OrganicoDashboard({
|
|||||||
<EmptyHint text="GSC/GA4 conectados pero sin filas en organic_daily. Lanza sync en Agencia o revisa property/URL-prefix." />
|
<EmptyHint text="GSC/GA4 conectados pero sin filas en organic_daily. Lanza sync en Agencia o revisa property/URL-prefix." />
|
||||||
)}
|
)}
|
||||||
<SeoTables
|
<SeoTables
|
||||||
queries={gscQueries}
|
queries={gscQueries.slice(0, 8)}
|
||||||
pages={gscPages}
|
pages={gscPages.slice(0, 8)}
|
||||||
landings={ga4Landings}
|
landings={ga4Landings.slice(0, 8)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(section.id === 'seo-keywords' ||
|
{(section.id === 'seo-keywords' ||
|
||||||
section.id === 'seo-rankings' ||
|
section.id === 'seo-rankings' ||
|
||||||
section.id === 'seo-keyword-magic') && (
|
section.id === 'seo-keyword-magic' ||
|
||||||
<SeoTables queries={gscQueries} pages={[]} landings={[]} focus="queries" />
|
section.id === 'seo-keyword-gap') && (
|
||||||
|
<SeoTables
|
||||||
|
queries={gscQueries}
|
||||||
|
pages={[]}
|
||||||
|
landings={[]}
|
||||||
|
focus="queries"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(section.id === 'seo-pages' ||
|
{(section.id === 'seo-pages' ||
|
||||||
section.id === 'seo-organic' ||
|
section.id === 'seo-organic' ||
|
||||||
section.id === 'seo-traffic-insights') && (
|
section.id === 'seo-traffic-insights' ||
|
||||||
|
section.id === 'seo-compare') && (
|
||||||
<SeoTables
|
<SeoTables
|
||||||
queries={[]}
|
queries={[]}
|
||||||
pages={gscPages}
|
pages={gscPages}
|
||||||
@@ -655,7 +662,34 @@ export default function OrganicoDashboard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section.live === 'geo-metrics' && (
|
{section.id === 'seo-organic' && seoDaily.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
||||||
|
Evolución clics orgánicos
|
||||||
|
</p>
|
||||||
|
<div className="flex items-end gap-0.5 h-20">
|
||||||
|
{seoDaily.map((d) => (
|
||||||
|
<div
|
||||||
|
key={`o-${d.day}`}
|
||||||
|
className="flex-1"
|
||||||
|
title={`${d.day}: ${d.clicks}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t bg-emerald-500/80"
|
||||||
|
style={{
|
||||||
|
height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section.live === 'geo-metrics' &&
|
||||||
|
(section.id === 'geo-overview' ||
|
||||||
|
section.id === 'geo-brand' ||
|
||||||
|
section.id === 'geo-tracking') && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<Kpi
|
<Kpi
|
||||||
@@ -785,7 +819,8 @@ export default function OrganicoDashboard({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Priorities + section notes */}
|
{/* Prioridades + notas solo en visión general SEO/GEO */}
|
||||||
|
{(section.id === 'seo-overview' || section.id === 'geo-overview') && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<section className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm space-y-2">
|
<section className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm space-y-2">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||||
@@ -892,6 +927,7 @@ export default function OrganicoDashboard({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{section.soon && (
|
{section.soon && (
|
||||||
<p className="text-xs text-amber-900 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
<p className="text-xs text-amber-900 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||||
@@ -899,6 +935,28 @@ export default function OrganicoDashboard({
|
|||||||
en un clic.
|
en un clic.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!section.soon &&
|
||||||
|
section.id !== 'seo-overview' &&
|
||||||
|
section.id !== 'geo-overview' &&
|
||||||
|
!section.live && (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">
|
||||||
|
Acciones de esta sección
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{section.actions.map((a) => (
|
||||||
|
<li
|
||||||
|
key={a}
|
||||||
|
className="flex items-start gap-2 text-xs text-gray-700"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5 text-brand-500 shrink-0 mt-0.5" />
|
||||||
|
{a}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Eye,
|
Eye,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
Heart,
|
||||||
|
MessageCircle,
|
||||||
|
Repeat2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader } from '@/components/ui';
|
import { PageHeader } from '@/components/ui';
|
||||||
|
|
||||||
@@ -29,6 +32,14 @@ type MetricsFacebook = {
|
|||||||
followers_count?: number;
|
followers_count?: number;
|
||||||
link?: string;
|
link?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
postsError?: string;
|
||||||
|
pageInsights?: {
|
||||||
|
impressions?: number | null;
|
||||||
|
reach?: number | null;
|
||||||
|
postEngagements?: number | null;
|
||||||
|
fans?: number | null;
|
||||||
|
pageViews?: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type MetricsIg = {
|
type MetricsIg = {
|
||||||
@@ -37,7 +48,29 @@ type MetricsIg = {
|
|||||||
media_count?: number;
|
media_count?: number;
|
||||||
follows_count?: number;
|
follows_count?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
insights?: { data?: { name?: string; values?: { value?: number }[] }[] };
|
postsError?: string;
|
||||||
|
igInsights?: {
|
||||||
|
reach?: number | null;
|
||||||
|
impressions?: number | null;
|
||||||
|
profileViews?: number | null;
|
||||||
|
websiteClicks?: number | null;
|
||||||
|
accountsEngaged?: number | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type SocialPost = {
|
||||||
|
id: string;
|
||||||
|
platform: 'facebook' | 'instagram';
|
||||||
|
message?: string;
|
||||||
|
mediaUrl?: string | null;
|
||||||
|
permalink?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
likes: number;
|
||||||
|
comments: number;
|
||||||
|
shares: number;
|
||||||
|
reach?: number | null;
|
||||||
|
impressions?: number | null;
|
||||||
|
mediaType?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHANNELS: {
|
const CHANNELS: {
|
||||||
@@ -51,11 +84,24 @@ const CHANNELS: {
|
|||||||
{ id: 'tiktok', label: 'TikTok', icon: Share2 },
|
{ id: 'tiktok', label: 'TikTok', icon: Share2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
function n(v?: number) {
|
function n(v?: number | null) {
|
||||||
if (v == null || !Number.isFinite(v)) return '—';
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
return new Intl.NumberFormat('es-ES').format(v);
|
return new Intl.NumberFormat('es-ES').format(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso?: string | null) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString('es-ES', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso.slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function SocialDashboard({
|
export default function SocialDashboard({
|
||||||
tenantLabel,
|
tenantLabel,
|
||||||
isAgency,
|
isAgency,
|
||||||
@@ -70,6 +116,10 @@ export default function SocialDashboard({
|
|||||||
instagram?: MetricsIg;
|
instagram?: MetricsIg;
|
||||||
warning?: string;
|
warning?: string;
|
||||||
}>({});
|
}>({});
|
||||||
|
const [posts, setPosts] = useState<SocialPost[]>([]);
|
||||||
|
const [feedFilter, setFeedFilter] = useState<'all' | 'instagram' | 'facebook'>(
|
||||||
|
'all'
|
||||||
|
);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -83,6 +133,7 @@ export default function SocialDashboard({
|
|||||||
else {
|
else {
|
||||||
setIntegs(data.integrations || []);
|
setIntegs(data.integrations || []);
|
||||||
setMetrics(data.metrics || {});
|
setMetrics(data.metrics || {});
|
||||||
|
setPosts(data.posts || []);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})();
|
})();
|
||||||
@@ -94,15 +145,80 @@ export default function SocialDashboard({
|
|||||||
const by = new Map(integs.map((i) => [i.provider, i]));
|
const by = new Map(integs.map((i) => [i.provider, i]));
|
||||||
const ig = metrics.instagram;
|
const ig = metrics.instagram;
|
||||||
const fb = metrics.facebook;
|
const fb = metrics.facebook;
|
||||||
const igReach =
|
const visiblePosts =
|
||||||
ig?.insights?.data?.find((d) => d.name === 'reach')?.values?.[0]?.value;
|
feedFilter === 'all'
|
||||||
|
? posts
|
||||||
|
: posts.filter((p) => p.platform === feedFilter);
|
||||||
|
|
||||||
|
const kpiCards = [
|
||||||
|
{
|
||||||
|
label: 'Seguidores IG',
|
||||||
|
value: n(ig?.followers_count),
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Publicaciones IG',
|
||||||
|
value: n(ig?.media_count),
|
||||||
|
icon: ImageIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Alcance IG (día)',
|
||||||
|
value: n(ig?.igInsights?.reach),
|
||||||
|
icon: Eye,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Impresiones IG',
|
||||||
|
value: n(ig?.igInsights?.impressions),
|
||||||
|
icon: Eye,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Visitas perfil IG',
|
||||||
|
value: n(ig?.igInsights?.profileViews),
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cuentas engaged IG',
|
||||||
|
value: n(ig?.igInsights?.accountsEngaged),
|
||||||
|
icon: Heart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fans / seguidores FB',
|
||||||
|
value: n(fb?.followers_count ?? fb?.fan_count),
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Alcance FB (día)',
|
||||||
|
value: n(fb?.pageInsights?.reach),
|
||||||
|
icon: Eye,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Impresiones FB',
|
||||||
|
value: n(fb?.pageInsights?.impressions),
|
||||||
|
icon: Eye,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Engagement posts FB',
|
||||||
|
value: n(fb?.pageInsights?.postEngagements),
|
||||||
|
icon: Heart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Vistas página FB',
|
||||||
|
value: n(fb?.pageInsights?.pageViews),
|
||||||
|
icon: Eye,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Clics web IG',
|
||||||
|
value: n(ig?.igInsights?.websiteClicks),
|
||||||
|
icon: ExternalLink,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow={`Social · ${tenantLabel}`}
|
eyebrow={`Social · ${tenantLabel}`}
|
||||||
title="Redes sociales"
|
title="Redes sociales"
|
||||||
description="Perfiles orgánicos y métricas de audiencia. Paid ads están en Ads / Métricas."
|
description="Analytics orgánicos IG/FB + preview del contenido publicado. Paid ads están en Ads."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{err && (
|
{err && (
|
||||||
@@ -118,33 +234,12 @@ export default function SocialDashboard({
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-gray-500 flex items-center gap-2">
|
<p className="text-sm text-gray-500 flex items-center gap-2">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" /> Cargando…
|
<Loader2 className="w-4 h-4 animate-spin" /> Cargando analytics…
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
{[
|
{kpiCards.map((k) => (
|
||||||
{
|
|
||||||
label: 'Seguidores IG',
|
|
||||||
value: n(ig?.followers_count),
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Publicaciones IG',
|
|
||||||
value: n(ig?.media_count),
|
|
||||||
icon: ImageIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Fans / seguidores FB',
|
|
||||||
value: n(fb?.followers_count ?? fb?.fan_count),
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Alcance IG (día)',
|
|
||||||
value: n(igReach),
|
|
||||||
icon: Eye,
|
|
||||||
},
|
|
||||||
].map((k) => (
|
|
||||||
<div
|
<div
|
||||||
key={k.label}
|
key={k.label}
|
||||||
className="rounded-xl border border-gray-200 bg-white p-4"
|
className="rounded-xl border border-gray-200 bg-white p-4"
|
||||||
@@ -162,37 +257,113 @@ export default function SocialDashboard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(ig?.error || fb?.error) && (
|
{(ig?.error || fb?.error || ig?.postsError || fb?.postsError) && (
|
||||||
<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} `}
|
{[ig?.error && `IG: ${ig.error}`, fb?.error && `FB: ${fb.error}`, ig?.postsError && `IG posts: ${ig.postsError}`, fb?.postsError && `FB posts: ${fb.postsError}`]
|
||||||
{fb?.error && `FB: ${fb.error}`}
|
.filter(Boolean)
|
||||||
{' · '}
|
.join(' · ')}
|
||||||
Suele faltar Page token o permisos de insights en el system user.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
|
<section className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
Insights (social expert)
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
</h3>
|
Contenido publicado
|
||||||
<ul className="text-xs text-gray-700 space-y-1.5 list-disc pl-4">
|
</h3>
|
||||||
<li>
|
<div className="flex gap-1">
|
||||||
Prioriza contenido que convierta a lead/venta (CTA + landings
|
{(
|
||||||
medidas en CRM), no solo vanity metrics.
|
[
|
||||||
</li>
|
['all', 'Todo'],
|
||||||
<li>
|
['instagram', 'IG'],
|
||||||
Si seguidores IG ≫ fans FB, concentra producción en Reels /
|
['facebook', 'FB'],
|
||||||
carruseles y usa FB como amplificación ligera.
|
] as const
|
||||||
</li>
|
).map(([id, lbl]) => (
|
||||||
<li>
|
<button
|
||||||
Revisa Calendario editorial: cada pieza debería tener objetivo
|
key={id}
|
||||||
(awareness / consideración / conversión).
|
type="button"
|
||||||
</li>
|
onClick={() => setFeedFilter(id)}
|
||||||
<li>
|
className={`px-2.5 py-1 rounded-lg text-[11px] font-semibold ${
|
||||||
Menciones brand: próxima fase con listening; hoy el foco es
|
feedFilter === id
|
||||||
audiencia propia + engagement on-profile.
|
? 'bg-gray-900 text-white'
|
||||||
</li>
|
: 'bg-gray-100 text-gray-600'
|
||||||
</ul>
|
}`}
|
||||||
|
>
|
||||||
|
{lbl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visiblePosts.length === 0 ? (
|
||||||
|
<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 /
|
||||||
|
instagram_manage_insights).
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{visiblePosts.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p.id}
|
||||||
|
className="rounded-2xl border border-gray-200 bg-white overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-square bg-gray-100">
|
||||||
|
{p.mediaUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={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">
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
{fmtDate(p.createdAt)}
|
||||||
|
</p>
|
||||||
|
{p.message ? (
|
||||||
|
<p className="text-xs text-gray-800 line-clamp-3 whitespace-pre-wrap">
|
||||||
|
{p.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap gap-3 text-[11px] text-gray-700 pt-1 mt-auto">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Heart className="w-3 h-3" /> {n(p.likes)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<MessageCircle className="w-3 h-3" /> {n(p.comments)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Repeat2 className="w-3 h-3" /> {n(p.shares)}
|
||||||
|
</span>
|
||||||
|
{p.reach != null && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Eye className="w-3 h-3" /> {n(p.reach)} alc.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{p.permalink && (
|
||||||
|
<a
|
||||||
|
href={p.permalink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] text-brand-600 hover:underline"
|
||||||
|
>
|
||||||
|
Ver en red <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ul className="grid gap-3 sm:grid-cols-2">
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
@@ -234,11 +405,6 @@ export default function SocialDashboard({
|
|||||||
row?.external_account_id ||
|
row?.external_account_id ||
|
||||||
'—'}
|
'—'}
|
||||||
</p>
|
</p>
|
||||||
{row?.meta?.warning && (
|
|
||||||
<p className="text-[11px] text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1">
|
|
||||||
{row.meta.warning}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{url && (
|
{url && (
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
|
|||||||
@@ -546,7 +546,12 @@ WHERE ${dateWhere}
|
|||||||
}
|
}
|
||||||
|
|
||||||
cards.sort((a, b) => b.spend - a.spend);
|
cards.sort((a, b) => b.spend - a.spend);
|
||||||
const sliced = cards.slice(0, limit);
|
const sliced = cards
|
||||||
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0
|
||||||
|
)
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
if (!sliced.length) {
|
if (!sliced.length) {
|
||||||
const fallback = dailyAggs.slice(0, limit).map((agg) => ({
|
const fallback = dailyAggs.slice(0, limit).map((agg) => ({
|
||||||
|
|||||||
@@ -313,7 +313,12 @@ export function aggregateDailyByAd(
|
|||||||
if (str(r.campaign_name)) cur.campaignName = str(r.campaign_name);
|
if (str(r.campaign_name)) cur.campaignName = str(r.campaign_name);
|
||||||
map.set(adId, cur);
|
map.set(adId, cur);
|
||||||
}
|
}
|
||||||
return [...map.values()].sort((a, b) => b.spend - a.spend);
|
return [...map.values()]
|
||||||
|
.filter(
|
||||||
|
(a) =>
|
||||||
|
a.spend > 0 || a.impressions > 0 || a.clicks > 0 || a.conversions > 0
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.spend - a.spend);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMetaCreativeBundle(
|
async function fetchMetaCreativeBundle(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type AdDailyRow = {
|
|||||||
platform?: string;
|
platform?: string;
|
||||||
campaign_name?: string;
|
campaign_name?: string;
|
||||||
campaign?: string;
|
campaign?: string;
|
||||||
|
ad_id?: string | null;
|
||||||
ad_name?: string;
|
ad_name?: string;
|
||||||
conversions?: number;
|
conversions?: number;
|
||||||
tenant_id?: string;
|
tenant_id?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user