feat: previews de creatividades en Ad Tracker (estilo Brava)
Meta Graph en vivo + ficha Google; galería por anuncio en resumen/Meta/Google. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
96
src/app/api/ad-tracker/creatives/route.ts
Normal file
96
src/app/api/ad-tracker/creatives/route.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||||
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
|
import { AdDailyRow } from '@/lib/metrics';
|
||||||
|
import {
|
||||||
|
AdCreativeCard,
|
||||||
|
buildGoogleCardsFromDaily,
|
||||||
|
fetchMetaCreativeCards,
|
||||||
|
} from '@/lib/ad-creatives-meta';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ad-tracker/creatives?since=YYYY-MM-DD&until=YYYY-MM-DD&platform=meta|google|all
|
||||||
|
* Returns ad cards with creative preview URLs (Meta live; Google text fallback).
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'select_tenant', hint: 'Elige un cliente en Contexto' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const since = req.nextUrl.searchParams.get('since') || '';
|
||||||
|
const until = req.nextUrl.searchParams.get('until') || '';
|
||||||
|
const platform = (req.nextUrl.searchParams.get('platform') || 'all').toLowerCase();
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(since) || !/^\d{4}-\d{2}-\d{2}$/.test(until)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'bad_range', hint: 'since/until YYYY-MM-DD' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
let daily: AdDailyRow[] = [];
|
||||||
|
if (agency && ctx.viaView) {
|
||||||
|
const tid = encodeURIComponent(ctx.tenantId);
|
||||||
|
const d = await restGet<AdDailyRow[]>(
|
||||||
|
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads&order=date.desc&limit=5000`
|
||||||
|
);
|
||||||
|
daily = d.data || [];
|
||||||
|
} else {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('ad_daily')
|
||||||
|
.select(
|
||||||
|
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads'
|
||||||
|
)
|
||||||
|
.gte('date', since)
|
||||||
|
.lte('date', until)
|
||||||
|
.order('date', { ascending: false })
|
||||||
|
.limit(5000);
|
||||||
|
daily = (data as AdDailyRow[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let metaAds: AdCreativeCard[] = [];
|
||||||
|
let googleAds: AdCreativeCard[] = [];
|
||||||
|
|
||||||
|
if (platform === 'meta' || platform === 'all') {
|
||||||
|
const meta = await fetchMetaCreativeCards({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
dailyRows: daily,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
limit: 40,
|
||||||
|
});
|
||||||
|
metaAds = meta.ads;
|
||||||
|
if (meta.warning) warnings.push(meta.warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'google' || platform === 'all') {
|
||||||
|
googleAds = buildGoogleCardsFromDaily(daily, since, until, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
ads: [...metaAds, ...googleAds],
|
||||||
|
metaCount: metaAds.length,
|
||||||
|
googleCount: googleAds.length,
|
||||||
|
warnings,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AD_TRACKER_PRESETS,
|
AD_TRACKER_PRESETS,
|
||||||
RangePreset,
|
RangePreset,
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
resolveAdRange,
|
resolveAdRange,
|
||||||
} from '@/lib/ad-tracker-range';
|
} from '@/lib/ad-tracker-range';
|
||||||
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics';
|
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics';
|
||||||
|
import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
|
||||||
|
import { ImageIcon, Play } from 'lucide-react';
|
||||||
|
|
||||||
type PlatformKpis = {
|
type PlatformKpis = {
|
||||||
spend: number;
|
spend: number;
|
||||||
@@ -302,6 +304,165 @@ function CampaignTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CreativesGallery({
|
||||||
|
platform,
|
||||||
|
ads,
|
||||||
|
loading,
|
||||||
|
warning,
|
||||||
|
}: {
|
||||||
|
platform: 'meta' | 'google';
|
||||||
|
ads: AdCreativeCard[];
|
||||||
|
loading: boolean;
|
||||||
|
warning?: string | null;
|
||||||
|
}) {
|
||||||
|
const filtered = ads.filter((a) => a.platform === platform);
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
||||||
|
Creatividades · estilo Brava Ad Tracker
|
||||||
|
</p>
|
||||||
|
<h3 className="text-lg font-bold text-white">
|
||||||
|
{platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · preview por anuncio
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Preview serving / asset + KPIs del rango. Meta tira creatividades en
|
||||||
|
vivo; Google muestra ficha (assets RSA en siguiente iteración).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{warning ? (
|
||||||
|
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-gray-500 py-8 text-center">
|
||||||
|
Cargando creatividades…
|
||||||
|
</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 py-8 text-center border border-dashed border-gray-800 rounded-xl">
|
||||||
|
Sin creatividades para este periodo.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filtered.map((ad) => (
|
||||||
|
<CreativeCard key={`${ad.platform}-${ad.adId}`} ad={ad} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreativeCard({ ad }: { ad: AdCreativeCard }) {
|
||||||
|
const preview = ad.imageUrl || ad.thumbnailUrl;
|
||||||
|
const isVideo = ad.mediaType === 'video';
|
||||||
|
return (
|
||||||
|
<article className="grid gap-4 rounded-2xl border border-gray-800 bg-[#111827] p-4 md:grid-cols-[minmax(220px,320px)_1fr]">
|
||||||
|
<div className="relative overflow-hidden rounded-xl bg-black/40 border border-gray-800 min-h-[220px] flex items-center justify-center">
|
||||||
|
{preview ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt={ad.adName}
|
||||||
|
className="w-full h-full object-cover max-h-[420px]"
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
) : ad.platform === 'google' ? (
|
||||||
|
<div className="w-full p-4 space-y-2 text-left">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-sky-400">
|
||||||
|
Google Ads · {ad.mediaType || 'rsa'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-blue-300 leading-snug">
|
||||||
|
{(ad.headline || ad.adName).split('\n')[0]}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 line-clamp-3">
|
||||||
|
{(ad.primaryText || ad.campaignName || '').split('\n')[0]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-500 text-xs px-4">
|
||||||
|
<ImageIcon className="w-8 h-8 mx-auto mb-2 opacity-40" />
|
||||||
|
Sin preview
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isVideo && preview ? (
|
||||||
|
<span className="absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-black/70 px-2 py-1 text-[10px] text-white">
|
||||||
|
<Play className="w-3 h-3" /> Vídeo
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<h4 className="text-base font-semibold text-white leading-snug">
|
||||||
|
{ad.adName || 'Sin nombre'}
|
||||||
|
</h4>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-gray-500 border border-gray-700 px-2 py-0.5 rounded">
|
||||||
|
{ad.status || ad.platform}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{ad.campaignName || '—'}</p>
|
||||||
|
{ad.adsetName ? (
|
||||||
|
<p className="text-xs text-gray-400">Conjunto: {ad.adsetName}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide ${
|
||||||
|
ad.platform === 'meta'
|
||||||
|
? 'border border-violet-500/40 text-violet-300'
|
||||||
|
: 'border border-sky-500/40 text-sky-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ad.platform === 'meta' ? 'Meta' : 'Google'}
|
||||||
|
</span>
|
||||||
|
{ad.mediaType && ad.mediaType !== 'unknown' ? (
|
||||||
|
<span className="rounded-full border border-gray-700 px-2 py-0.5 text-[10px] uppercase text-gray-400">
|
||||||
|
{ad.mediaType}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(ad.headline || ad.primaryText) && (
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
{ad.headline ? (
|
||||||
|
<p className="text-gray-200 font-medium line-clamp-2">
|
||||||
|
{ad.headline.split('\n')[0]}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{ad.primaryText ? (
|
||||||
|
<p className="text-gray-500 line-clamp-3">
|
||||||
|
{ad.primaryText.split('\n')[0]}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
<MiniKpi label="Gasto" value={formatEur(ad.spend)} />
|
||||||
|
<MiniKpi label="Clics" value={formatNum(ad.clicks)} />
|
||||||
|
<MiniKpi label="Conv." value={formatNum(ad.conversions)} />
|
||||||
|
<MiniKpi
|
||||||
|
label="CPL"
|
||||||
|
value={ad.cpl != null ? formatEur(ad.cpl) : '—'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniKpi({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-black/25 px-2.5 py-2">
|
||||||
|
<p className="text-[9px] uppercase tracking-wider text-gray-500">{label}</p>
|
||||||
|
<p className="text-sm font-semibold text-white mt-0.5 tabular-nums">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdTrackerClient({
|
export default function AdTrackerClient({
|
||||||
label,
|
label,
|
||||||
tagline,
|
tagline,
|
||||||
@@ -314,9 +475,53 @@ export default function AdTrackerClient({
|
|||||||
const [preset, setPreset] = useState<RangePreset>('last_7d');
|
const [preset, setPreset] = useState<RangePreset>('last_7d');
|
||||||
const [tab, setTab] = useState<Tab>('resumen');
|
const [tab, setTab] = useState<Tab>('resumen');
|
||||||
const [chartMode, setChartMode] = useState<ChartMode>('combined');
|
const [chartMode, setChartMode] = useState<ChartMode>('combined');
|
||||||
|
const [creatives, setCreatives] = useState<AdCreativeCard[]>([]);
|
||||||
|
const [creativesLoading, setCreativesLoading] = useState(false);
|
||||||
|
const [creativesWarning, setCreativesWarning] = useState<string | null>(null);
|
||||||
|
|
||||||
const range = useMemo(() => resolveAdRange(preset), [preset]);
|
const range = useMemo(() => resolveAdRange(preset), [preset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
|
setCreativesLoading(true);
|
||||||
|
setCreativesWarning(null);
|
||||||
|
try {
|
||||||
|
const platform =
|
||||||
|
tab === 'meta' ? 'meta' : tab === 'google' ? 'google' : 'all';
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
since: range.since,
|
||||||
|
until: range.until,
|
||||||
|
platform,
|
||||||
|
});
|
||||||
|
const r = await fetch(`/api/ad-tracker/creatives?${qs}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!r.ok) {
|
||||||
|
setCreatives([]);
|
||||||
|
setCreativesWarning(data.hint || data.error || 'No se pudieron cargar creatividades');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreatives((data.ads as AdCreativeCard[]) || []);
|
||||||
|
const w = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : [];
|
||||||
|
setCreativesWarning(w.length ? w.join(' · ') : null);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCreatives([]);
|
||||||
|
setCreativesWarning('Error al cargar creatividades');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setCreativesLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [range.since, range.until, tab]);
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => rows.filter((r) => inRange(r.date, range.since, range.until)),
|
() => rows.filter((r) => inRange(r.date, range.since, range.until)),
|
||||||
[rows, range.since, range.until]
|
[rows, range.since, range.until]
|
||||||
@@ -366,8 +571,18 @@ export default function AdTrackerClient({
|
|||||||
{tagline || 'Meta + Google Ads'}
|
{tagline || 'Meta + Google Ads'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-400 mt-2 max-w-2xl">
|
<p className="text-sm text-gray-400 mt-2 max-w-2xl">
|
||||||
Resumen agregado de Meta y Google Ads: gasto, clics, conversiones y
|
Resumen Meta + Google: gasto, clics, conversiones y{' '}
|
||||||
campañas activas en el periodo.
|
<span className="text-gray-300">preview de creatividades</span> por
|
||||||
|
anuncio (como en{' '}
|
||||||
|
<a
|
||||||
|
href="https://4ventures.es/adtracker/brava-dermoestetica"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sky-400 hover:underline"
|
||||||
|
>
|
||||||
|
Ad Tracker Brava
|
||||||
|
</a>
|
||||||
|
). Ventana: {range.since} → {range.until}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -594,6 +809,19 @@ export default function AdTrackerClient({
|
|||||||
empty="Sin campañas Google activas en este periodo"
|
empty="Sin campañas Google activas en este periodo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CreativesGallery
|
||||||
|
platform="meta"
|
||||||
|
ads={creatives}
|
||||||
|
loading={creativesLoading}
|
||||||
|
warning={creativesWarning}
|
||||||
|
/>
|
||||||
|
<CreativesGallery
|
||||||
|
platform="google"
|
||||||
|
ads={creatives}
|
||||||
|
loading={false}
|
||||||
|
warning={null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -625,6 +853,12 @@ export default function AdTrackerClient({
|
|||||||
rows={metaCampaigns}
|
rows={metaCampaigns}
|
||||||
empty="Sin campañas Meta activas en este periodo"
|
empty="Sin campañas Meta activas en este periodo"
|
||||||
/>
|
/>
|
||||||
|
<CreativesGallery
|
||||||
|
platform="meta"
|
||||||
|
ads={creatives}
|
||||||
|
loading={creativesLoading}
|
||||||
|
warning={creativesWarning}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -656,6 +890,12 @@ export default function AdTrackerClient({
|
|||||||
rows={googleCampaigns}
|
rows={googleCampaigns}
|
||||||
empty="Sin campañas Google activas en este periodo"
|
empty="Sin campañas Google activas en este periodo"
|
||||||
/>
|
/>
|
||||||
|
<CreativesGallery
|
||||||
|
platform="google"
|
||||||
|
ads={creatives}
|
||||||
|
loading={creativesLoading}
|
||||||
|
warning={creativesWarning}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
324
src/lib/ad-creatives-meta.ts
Normal file
324
src/lib/ad-creatives-meta.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
/** Meta ad creatives for Ad Tracker (Brava-style previews). */
|
||||||
|
|
||||||
|
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
||||||
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
|
|
||||||
|
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
||||||
|
|
||||||
|
const CREATIVE_FIELDS = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'title',
|
||||||
|
'body',
|
||||||
|
'thumbnail_url',
|
||||||
|
'image_url',
|
||||||
|
'image_hash',
|
||||||
|
'video_id',
|
||||||
|
'object_story_spec',
|
||||||
|
'asset_feed_spec',
|
||||||
|
'effective_object_story_id',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
export type AdCreativeCard = {
|
||||||
|
platform: 'meta' | 'google';
|
||||||
|
adId: string;
|
||||||
|
adName: string;
|
||||||
|
campaignName: string;
|
||||||
|
adsetName?: string;
|
||||||
|
spend: number;
|
||||||
|
impressions: number;
|
||||||
|
clicks: number;
|
||||||
|
conversions: number;
|
||||||
|
cpl: number | null;
|
||||||
|
mediaType: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
imageUrl: string;
|
||||||
|
headline: string;
|
||||||
|
primaryText: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DailyAgg = {
|
||||||
|
adId: string;
|
||||||
|
adName: string;
|
||||||
|
campaignName: string;
|
||||||
|
spend: number;
|
||||||
|
impressions: number;
|
||||||
|
clicks: number;
|
||||||
|
conversions: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function n(v: unknown): number {
|
||||||
|
const x = Number(v);
|
||||||
|
return Number.isFinite(x) ? x : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(v: unknown): string {
|
||||||
|
return v == null ? '' : String(v).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectMediaType(creative: Record<string, unknown>): string {
|
||||||
|
if (creative.video_id) return 'video';
|
||||||
|
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
||||||
|
if (feed?.videos || feed?.video_ids) return 'video';
|
||||||
|
const oss = creative.object_story_spec as Record<string, unknown> | undefined;
|
||||||
|
const link = oss?.link_data as Record<string, unknown> | undefined;
|
||||||
|
if (Array.isArray(link?.child_attachments) && (link!.child_attachments as unknown[]).length > 1) {
|
||||||
|
return 'carousel';
|
||||||
|
}
|
||||||
|
if (creative.image_url || creative.image_hash || creative.thumbnail_url) return 'image';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTexts(creative: Record<string, unknown>): {
|
||||||
|
headline: string;
|
||||||
|
primaryText: string;
|
||||||
|
} {
|
||||||
|
const titles: string[] = [];
|
||||||
|
const bodies: string[] = [];
|
||||||
|
if (creative.title) titles.push(str(creative.title));
|
||||||
|
if (creative.body) bodies.push(str(creative.body));
|
||||||
|
const oss = creative.object_story_spec as Record<string, unknown> | undefined;
|
||||||
|
const link = oss?.link_data as Record<string, unknown> | undefined;
|
||||||
|
const video = oss?.video_data as Record<string, unknown> | undefined;
|
||||||
|
if (link?.name) titles.push(str(link.name));
|
||||||
|
if (link?.message) bodies.push(str(link.message));
|
||||||
|
if (video?.title) titles.push(str(video.title));
|
||||||
|
if (video?.message) bodies.push(str(video.message));
|
||||||
|
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
||||||
|
const feedTitles = feed?.titles as { text?: string }[] | undefined;
|
||||||
|
const feedBodies = feed?.bodies as { text?: string }[] | undefined;
|
||||||
|
for (const t of feedTitles || []) if (t?.text) titles.push(str(t.text));
|
||||||
|
for (const b of feedBodies || []) if (b?.text) bodies.push(str(b.text));
|
||||||
|
return {
|
||||||
|
headline: [...new Set(titles.filter(Boolean))].slice(0, 5).join('\n'),
|
||||||
|
primaryText: [...new Set(bodies.filter(Boolean))].slice(0, 3).join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function graphGet<T>(
|
||||||
|
path: string,
|
||||||
|
params: Record<string, string>,
|
||||||
|
token: string
|
||||||
|
): Promise<T> {
|
||||||
|
const q = new URLSearchParams({ ...params, access_token: token });
|
||||||
|
const r = await fetch(`${META_GRAPH}/${path}?${q}`, { cache: 'no-store' });
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`Meta ${path}: ${(await r.text()).slice(0, 220)}`);
|
||||||
|
}
|
||||||
|
return (await r.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveMetaAccountId(tenantId: string): Promise<string | null> {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) return null;
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.meta_ads&select=external_account_id,status&limit=1`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const rows = (await r.json()) as { external_account_id?: string | null }[];
|
||||||
|
const id = rows[0]?.external_account_id?.trim();
|
||||||
|
return id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregate ad_daily rows by ad_id for a platform. */
|
||||||
|
export function aggregateDailyByAd(
|
||||||
|
rows: {
|
||||||
|
platform?: string;
|
||||||
|
ad_id?: string | null;
|
||||||
|
ad_name?: string | null;
|
||||||
|
campaign_name?: string | null;
|
||||||
|
spend?: number;
|
||||||
|
impressions?: number;
|
||||||
|
clicks?: number;
|
||||||
|
platform_leads?: number;
|
||||||
|
conversions?: number;
|
||||||
|
date?: string;
|
||||||
|
}[],
|
||||||
|
platform: 'meta' | 'google',
|
||||||
|
since: string,
|
||||||
|
until: string
|
||||||
|
): DailyAgg[] {
|
||||||
|
const map = new Map<string, DailyAgg>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if ((r.platform || '').toLowerCase() !== platform) continue;
|
||||||
|
const d = (r.date || '').slice(0, 10);
|
||||||
|
if (d < since || d > until) continue;
|
||||||
|
const adId = str(r.ad_id);
|
||||||
|
if (!adId) continue;
|
||||||
|
const cur = map.get(adId) || {
|
||||||
|
adId,
|
||||||
|
adName: str(r.ad_name) || adId,
|
||||||
|
campaignName: str(r.campaign_name),
|
||||||
|
spend: 0,
|
||||||
|
impressions: 0,
|
||||||
|
clicks: 0,
|
||||||
|
conversions: 0,
|
||||||
|
};
|
||||||
|
cur.spend += n(r.spend);
|
||||||
|
cur.impressions += n(r.impressions);
|
||||||
|
cur.clicks += n(r.clicks);
|
||||||
|
cur.conversions += n(r.platform_leads ?? r.conversions);
|
||||||
|
if (str(r.ad_name)) cur.adName = str(r.ad_name);
|
||||||
|
if (str(r.campaign_name)) cur.campaignName = str(r.campaign_name);
|
||||||
|
map.set(adId, cur);
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => b.spend - a.spend);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMetaCreativeBundle(
|
||||||
|
adId: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{
|
||||||
|
adName: string;
|
||||||
|
campaignName: string;
|
||||||
|
adsetName: string;
|
||||||
|
status: string;
|
||||||
|
creative: Record<string, unknown>;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const raw = await graphGet<{
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
effective_status?: string;
|
||||||
|
campaign?: { name?: string };
|
||||||
|
adset?: { name?: string };
|
||||||
|
creative?: Record<string, unknown>;
|
||||||
|
}>(adId, {
|
||||||
|
fields: `id,name,effective_status,campaign{name},adset{name},creative{${CREATIVE_FIELDS}}`,
|
||||||
|
thumbnail_width: '1080',
|
||||||
|
thumbnail_height: '1080',
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
let creative = (raw.creative || {}) as Record<string, unknown>;
|
||||||
|
const cid = str(creative.id);
|
||||||
|
if (cid) {
|
||||||
|
try {
|
||||||
|
const detail = await graphGet<Record<string, unknown>>(
|
||||||
|
cid,
|
||||||
|
{
|
||||||
|
fields: CREATIVE_FIELDS,
|
||||||
|
thumbnail_width: '1080',
|
||||||
|
thumbnail_height: '1080',
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
creative = { ...creative, ...detail };
|
||||||
|
} catch {
|
||||||
|
/* keep lightweight */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
adName: str(raw.name),
|
||||||
|
campaignName: str(raw.campaign?.name),
|
||||||
|
adsetName: str(raw.adset?.name),
|
||||||
|
status: str(raw.effective_status),
|
||||||
|
creative,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Meta creative cards for a tenant: metrics from ad_daily + live Meta creative assets.
|
||||||
|
*/
|
||||||
|
export async function fetchMetaCreativeCards(opts: {
|
||||||
|
tenantId: string;
|
||||||
|
dailyRows: Parameters<typeof aggregateDailyByAd>[0];
|
||||||
|
since: string;
|
||||||
|
until: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
|
||||||
|
const token = await resolveMetaAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
return { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await resolveMetaAccountId(opts.tenantId);
|
||||||
|
if (!account) {
|
||||||
|
return { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggs = aggregateDailyByAd(
|
||||||
|
opts.dailyRows,
|
||||||
|
'meta',
|
||||||
|
opts.since,
|
||||||
|
opts.until
|
||||||
|
).slice(0, opts.limit ?? 40);
|
||||||
|
|
||||||
|
if (!aggs.length) {
|
||||||
|
return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: AdCreativeCard[] = [];
|
||||||
|
// Sequential batches of 5 to respect rate limits
|
||||||
|
for (let i = 0; i < aggs.length; i += 5) {
|
||||||
|
const chunk = aggs.slice(i, i + 5);
|
||||||
|
const bundles = await Promise.all(
|
||||||
|
chunk.map((a) => fetchMetaCreativeBundle(a.adId, token))
|
||||||
|
);
|
||||||
|
chunk.forEach((agg, idx) => {
|
||||||
|
const b = bundles[idx];
|
||||||
|
const creative = b?.creative || {};
|
||||||
|
const texts = extractTexts(creative);
|
||||||
|
const imageUrl =
|
||||||
|
str(creative.image_url) ||
|
||||||
|
str(creative.thumbnail_url) ||
|
||||||
|
'';
|
||||||
|
const thumbnailUrl =
|
||||||
|
str(creative.thumbnail_url) || imageUrl;
|
||||||
|
out.push({
|
||||||
|
platform: 'meta',
|
||||||
|
adId: agg.adId,
|
||||||
|
adName: b?.adName || agg.adName,
|
||||||
|
campaignName: b?.campaignName || agg.campaignName,
|
||||||
|
adsetName: b?.adsetName,
|
||||||
|
spend: Math.round(agg.spend * 100) / 100,
|
||||||
|
impressions: agg.impressions,
|
||||||
|
clicks: agg.clicks,
|
||||||
|
conversions: agg.conversions,
|
||||||
|
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
||||||
|
mediaType: detectMediaType(creative),
|
||||||
|
thumbnailUrl,
|
||||||
|
imageUrl: imageUrl || thumbnailUrl,
|
||||||
|
headline: texts.headline,
|
||||||
|
primaryText: texts.primaryText,
|
||||||
|
status: b?.status,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ads: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Google cards from ad_daily only (no asset pull yet — text fallback). */
|
||||||
|
export function buildGoogleCardsFromDaily(
|
||||||
|
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
|
||||||
|
since: string,
|
||||||
|
until: string,
|
||||||
|
limit = 40
|
||||||
|
): AdCreativeCard[] {
|
||||||
|
return aggregateDailyByAd(dailyRows, 'google', since, until)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((agg) => ({
|
||||||
|
platform: 'google' as const,
|
||||||
|
adId: agg.adId,
|
||||||
|
adName: agg.adName,
|
||||||
|
campaignName: agg.campaignName,
|
||||||
|
spend: Math.round(agg.spend * 100) / 100,
|
||||||
|
impressions: agg.impressions,
|
||||||
|
clicks: agg.clicks,
|
||||||
|
conversions: agg.conversions,
|
||||||
|
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
||||||
|
mediaType: 'rsa',
|
||||||
|
thumbnailUrl: '',
|
||||||
|
imageUrl: '',
|
||||||
|
headline: agg.adName,
|
||||||
|
primaryText: agg.campaignName,
|
||||||
|
status: undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user