diff --git a/src/components/AdCreativesGallery.tsx b/src/components/AdCreativesGallery.tsx
new file mode 100644
index 0000000..7ca5367
--- /dev/null
+++ b/src/components/AdCreativesGallery.tsx
@@ -0,0 +1,594 @@
+'use client';
+
+import React, { useEffect } from 'react';
+import type { AdCreativeCard, CreativeAsset } from '@/lib/ad-creatives-meta';
+import { formatEur, formatNum } from '@/lib/metrics';
+import { ImageIcon, Play, Maximize2, X, ExternalLink } from 'lucide-react';
+
+export function proxiedMediaUrl(raw: string): string {
+ if (!raw) return '';
+ if (raw.startsWith('/api/')) return raw;
+ return `/api/ad-tracker/media?url=${encodeURIComponent(raw)}`;
+}
+
+function adHeadlines(ad: AdCreativeCard): string[] {
+ if (ad.headlines?.length) return ad.headlines;
+ return (ad.headline || '')
+ .split('\n')
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+function adDescriptions(ad: AdCreativeCard): string[] {
+ if (ad.descriptions?.length) return ad.descriptions;
+ return (ad.primaryText || '')
+ .split('\n')
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+function adAssets(ad: AdCreativeCard): CreativeAsset[] {
+ if (ad.assets?.length) return ad.assets;
+ const out: CreativeAsset[] = [];
+ const u = ad.imageUrl || ad.thumbnailUrl;
+ if (u) out.push({ kind: 'image', url: u, label: 'preview' });
+ if (ad.youtubeId) {
+ out.push({
+ kind: 'youtube',
+ url: `https://i.ytimg.com/vi/${ad.youtubeId}/hqdefault.jpg`,
+ youtubeId: ad.youtubeId,
+ label: 'YOUTUBE',
+ });
+ }
+ return out;
+}
+
+function previewUrlFor(ad: AdCreativeCard): string {
+ const assets = adAssets(ad);
+ const portrait = assets.find(
+ (a) =>
+ a.kind === 'image' &&
+ /portrait|vertical|tall|TALL|PORTRAIT|story|4.?5|9.?16/i.test(
+ a.label || ''
+ )
+ );
+ const raw =
+ portrait?.url ||
+ ad.imageUrl ||
+ ad.thumbnailUrl ||
+ assets.find((a) => a.kind === 'image')?.url ||
+ (ad.youtubeId
+ ? `https://i.ytimg.com/vi/${ad.youtubeId}/hqdefault.jpg`
+ : '') ||
+ assets.find((a) => a.youtubeId)?.url ||
+ '';
+ return raw ? proxiedMediaUrl(raw) : '';
+}
+
+function MiniKpi({ label, value }: { label: string; value: string }) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
+
+export function CreativesGallery({
+ platform,
+ ads,
+ loading,
+ warning,
+ onZoom,
+}: {
+ platform: 'meta' | 'google';
+ ads: AdCreativeCard[];
+ loading: boolean;
+ warning?: string | null;
+ onZoom: (ad: AdCreativeCard) => void;
+}) {
+ const filtered = ads.filter((a) => a.platform === platform);
+ const platformWarning =
+ warning
+ ?.split(' · ')
+ .find((w) =>
+ platform === 'meta'
+ ? /meta|token meta|sin ads meta/i.test(w)
+ : /google|pmax|rsa|sin ads google|sin assets/i.test(w)
+ ) || null;
+ const shownWarning =
+ filtered.length === 0 && warning
+ ? platformWarning || (ads.length === 0 ? warning : null)
+ : platformWarning && filtered.length === 0
+ ? platformWarning
+ : null;
+
+ return (
+
+
+
+ Creatividades · preview por anuncio
+
+
+ {platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · creatividades
+
+
+ Preview vertical + textos. Pulsa Zoom para ver todas las piezas.
+
+
+ {shownWarning ? (
+
+ {shownWarning}
+
+ ) : null}
+ {loading ? (
+
+ Cargando creatividades…
+
+ ) : filtered.length === 0 ? (
+
+ Sin creatividades para este periodo.
+
+ ) : (
+
+ {filtered.map((ad) => (
+ onZoom(ad)}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+function CreativeCard({
+ ad,
+ onZoom,
+}: {
+ ad: AdCreativeCard;
+ onZoom: () => void;
+}) {
+ const preview = previewUrlFor(ad);
+ const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId);
+ const headlines = adHeadlines(ad);
+ const bodies = adDescriptions(ad);
+ const assets = adAssets(ad);
+ const extraCount = Math.max(0, assets.length - 1);
+
+ return (
+
+
+
+
+
+
+ {ad.adName || 'Sin nombre'}
+
+
+
+ {ad.status || ad.platform}
+
+
+
+
+
{ad.campaignName || '—'}
+ {ad.adsetName ? (
+
Conjunto: {ad.adsetName}
+ ) : null}
+
+
+ {ad.platform === 'meta' ? 'Meta' : 'Google'}
+
+ {ad.mediaType && ad.mediaType !== 'unknown' ? (
+
+ {ad.mediaType}
+
+ ) : null}
+ {assets.length > 0 ? (
+
+ {assets.length} piezas
+
+ ) : null}
+
+
+ {(headlines.length > 0 || bodies.length > 0) && (
+
+ {headlines.length > 0 ? (
+
+
+ Titulares
+
+
+ {headlines.slice(0, 4).map((h) => (
+ -
+ {h}
+
+ ))}
+ {headlines.length > 4 ? (
+ - +{headlines.length - 4} más
+ ) : null}
+
+
+ ) : null}
+ {bodies.length > 0 ? (
+
+
+ Textos / descripciones
+
+
+ {bodies.slice(0, 3).map((b) => (
+ -
+ {b}
+
+ ))}
+ {bodies.length > 3 ? (
+ - +{bodies.length - 3} más
+ ) : null}
+
+
+ ) : null}
+ {ad.cta ? (
+
+ CTA · {ad.cta}
+
+ ) : null}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+export function CreativeZoomModal({
+ ad,
+ onClose,
+}: {
+ ad: AdCreativeCard;
+ onClose: () => void;
+}) {
+ const headlines = adHeadlines(ad);
+ const bodies = adDescriptions(ad);
+ const longH = ad.longHeadlines || [];
+ const assets = adAssets(ad);
+ const images = assets.filter((a) => a.kind === 'image' || a.kind === 'logo');
+ const videos = assets.filter((a) => a.kind === 'youtube' || a.kind === 'video');
+ const main = previewUrlFor(ad);
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', onKey);
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ return () => {
+ window.removeEventListener('keydown', onKey);
+ document.body.style.overflow = prev;
+ };
+ }, [onClose]);
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
+
+
+
+ {main ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ Sin preview
+
+ )}
+
+
+
+
+
+
+
+
+
+ Impresiones:{' '}
+
+ {formatNum(ad.impressions)}
+
+
+
+ Formato:{' '}
+ {ad.mediaType || '—'}
+
+
+ Estado:{' '}
+ {ad.status || '—'}
+
+ {ad.cta ? (
+
+ CTA: {ad.cta}
+
+ ) : null}
+ {ad.businessName ? (
+
+ Negocio:{' '}
+ {ad.businessName}
+
+ ) : null}
+ {ad.linkUrl ? (
+
+
+ {ad.linkUrl}
+
+ ) : null}
+
+
+
+
+ {images.length > 0 ? (
+
+
+ Imágenes y logos ({images.length})
+
+
+ {images.map((asset, i) => (
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
})
+
+ {asset.label ? (
+
+ {asset.label}
+ {asset.kind === 'logo' ? ' · logo' : ''}
+
+ ) : null}
+
+ ))}
+
+
+ ) : null}
+
+ {videos.length > 0 ? (
+
+
+ Vídeos ({videos.length})
+
+
+ {videos.map((v, i) => (
+
+ {v.youtubeId ? (
+
+
+
+ ) : v.url ? (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
})
+
+ Vídeo
+
+
+ ) : null}
+
+ {v.title || v.youtubeId || v.label || 'Vídeo'}
+
+
+ ))}
+
+
+ ) : null}
+
+ {headlines.length > 0 ? (
+
+
+ Titulares ({headlines.length})
+
+
+ {headlines.map((h) => (
+ -
+ {h}
+
+ ))}
+
+
+ ) : null}
+
+ {longH.length > 0 ? (
+
+
+ Titulares largos ({longH.length})
+
+
+ {longH.map((h) => (
+ -
+ {h}
+
+ ))}
+
+
+ ) : null}
+
+ {bodies.length > 0 ? (
+
+
+ Textos / descripciones ({bodies.length})
+
+
+ {bodies.map((b) => (
+ -
+ {b}
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx
index 07e483b..ad2561a 100644
--- a/src/components/AdTrackerClient.tsx
+++ b/src/components/AdTrackerClient.tsx
@@ -9,7 +9,10 @@ import {
} from '@/lib/ad-tracker-range';
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics';
import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
-import { ImageIcon, Play } from 'lucide-react';
+import {
+ CreativesGallery,
+ CreativeZoomModal,
+} from '@/components/AdCreativesGallery';
type PlatformKpis = {
spend: number;
@@ -304,205 +307,6 @@ function CampaignTable({
);
}
-function proxiedMediaUrl(raw: string): string {
- if (!raw) return '';
- if (raw.startsWith('/api/')) return raw;
- return `/api/ad-tracker/media?url=${encodeURIComponent(raw)}`;
-}
-
-function CreativesGallery({
- platform,
- ads,
- loading,
- warning,
-}: {
- platform: 'meta' | 'google';
- ads: AdCreativeCard[];
- loading: boolean;
- warning?: string | null;
-}) {
- const filtered = ads.filter((a) => a.platform === platform);
- const platformWarning =
- warning
- ?.split(' · ')
- .find((w) =>
- platform === 'meta'
- ? /meta|token meta|sin ads meta/i.test(w)
- : /google|pmax|rsa|sin ads google|sin assets/i.test(w)
- ) || (platform === 'meta' || platform === 'google' ? null : null);
-
- // Prefer full warning on matching platform; otherwise only platform-relevant part
- const shownWarning =
- filtered.length === 0 && warning
- ? platformWarning ||
- (ads.length === 0 ? warning : null)
- : platformWarning && filtered.length === 0
- ? platformWarning
- : null;
-
- return (
-
-
-
- Creatividades · preview por anuncio
-
-
- {platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · creatividades
-
-
- {platform === 'meta'
- ? 'Imagen / vídeo desde Meta Graph (proxy same-origin).'
- : 'Assets PMax e RSA en vivo desde Google Ads API.'}
-
-
- {shownWarning ? (
-
- {shownWarning}
-
- ) : null}
- {loading ? (
-
- Cargando creatividades…
-
- ) : filtered.length === 0 ? (
-
- Sin creatividades para este periodo.
-
- ) : (
-
- {filtered.map((ad) => (
-
- ))}
-
- )}
-
- );
-}
-
-function CreativeCard({ ad }: { ad: AdCreativeCard }) {
- const raw = ad.imageUrl || ad.thumbnailUrl;
- const ytPoster = ad.youtubeId
- ? proxiedMediaUrl(`https://i.ytimg.com/vi/${ad.youtubeId}/hqdefault.jpg`)
- : '';
- const preview = raw ? proxiedMediaUrl(raw) : ytPoster;
- const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId);
- const headlines = (ad.headline || '')
- .split('\n')
- .map((s) => s.trim())
- .filter(Boolean);
- const bodies = (ad.primaryText || '')
- .split('\n')
- .map((s) => s.trim())
- .filter(Boolean);
-
- return (
-
-
- {preview ? (
- // eslint-disable-next-line @next/next/no-img-element
-

- ) : ad.platform === 'google' && headlines.length ? (
-
-
- Google Ads · {ad.mediaType || 'rsa'}
-
- {headlines.slice(0, 3).map((h) => (
-
- {h}
-
- ))}
- {bodies[0] ? (
-
{bodies[0]}
- ) : null}
-
- ) : (
-
-
- Sin preview
-
- )}
- {isVideo && preview ? (
-
- Vídeo
-
- ) : null}
-
-
-
-
-
- {ad.adName || 'Sin nombre'}
-
-
- {ad.status || ad.platform}
-
-
-
{ad.campaignName || '—'}
- {ad.adsetName ? (
-
Conjunto: {ad.adsetName}
- ) : null}
-
-
- {ad.platform === 'meta' ? 'Meta' : 'Google'}
-
- {ad.mediaType && ad.mediaType !== 'unknown' ? (
-
- {ad.mediaType}
-
- ) : null}
-
-
- {(headlines.length > 0 || bodies.length > 0) && (
-
- {headlines[0] ? (
-
- {headlines[0]}
-
- ) : null}
- {bodies[0] ? (
-
{bodies[0]}
- ) : null}
-
- )}
-
-
-
-
-
-
-
-
- );
-}
-
-function MiniKpi({ label, value }: { label: string; value: string }) {
- return (
-
-
{label}
-
- {value}
-
-
- );
-}
-
export default function AdTrackerClient({
label,
tagline,
@@ -518,6 +322,7 @@ export default function AdTrackerClient({
const [creatives, setCreatives] = useState([]);
const [creativesLoading, setCreativesLoading] = useState(false);
const [creativesWarning, setCreativesWarning] = useState(null);
+ const [zoomAd, setZoomAd] = useState(null);
const range = useMemo(() => resolveAdRange(preset), [preset]);
@@ -776,12 +581,14 @@ export default function AdTrackerClient({
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
+ onZoom={setZoomAd}
/>
@@ -881,6 +688,7 @@ export default function AdTrackerClient({
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
+ onZoom={setZoomAd}
/>
@@ -918,6 +726,7 @@ export default function AdTrackerClient({
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
+ onZoom={setZoomAd}
/>
@@ -938,6 +747,9 @@ export default function AdTrackerClient({
/>
)}
+ {zoomAd ? (
+ setZoomAd(null)} />
+ ) : null}
);
}
diff --git a/src/lib/ad-creatives-google.ts b/src/lib/ad-creatives-google.ts
index 5228383..6c30c0d 100644
--- a/src/lib/ad-creatives-google.ts
+++ b/src/lib/ad-creatives-google.ts
@@ -1,8 +1,8 @@
-/** Google Ads creatives for Ad Tracker (PMax asset groups + RSA/RDA). */
+/** Google Ads creatives for Ad Tracker (PMax asset groups + RSA/RDA, full assets). */
import { googleAdsConfigured } from '@/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
-import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
+import type { AdCreativeCard, CreativeAsset } from '@/lib/ad-creatives-meta';
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta';
const GADS_API = 'https://googleads.googleapis.com/v22';
@@ -20,14 +20,15 @@ type GaqlRow = {
responsiveSearchAd?: {
headlines?: { text?: string }[];
descriptions?: { text?: string }[];
+ path1?: string;
+ path2?: string;
};
responsiveDisplayAd?: {
headlines?: { text?: string }[];
longHeadline?: { text?: string };
descriptions?: { text?: string }[];
businessName?: string;
- marketingImages?: { asset?: string }[];
- squareMarketingImages?: { asset?: string }[];
+ callToActionText?: string;
};
imageAd?: { imageUrl?: string; name?: string };
};
@@ -48,6 +49,7 @@ type GaqlRow = {
youtubeVideoId?: string;
youtubeVideoTitle?: string;
};
+ callToActionAsset?: { callToAction?: string };
};
metrics?: {
costMicros?: string;
@@ -70,6 +72,10 @@ function enumName(v?: string): string {
return (v || '').replace(/^[A-Z0-9]+_/, '') || str(v);
}
+function uniq(arr: string[]): string[] {
+ return [...new Set(arr.filter(Boolean))];
+}
+
async function googleAccessToken(): Promise {
const refresh =
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
@@ -159,21 +165,34 @@ function ytThumb(youtubeId: string): string {
return `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`;
}
+function pickVerticalGoogle(assets: CreativeAsset[]): string {
+ const images = assets.filter((a) => a.kind === 'image' && a.url);
+ const portrait = images.find((a) =>
+ /PORTRAIT|TALL|VERTICAL/i.test(a.label || '')
+ );
+ if (portrait) return portrait.url;
+ const square = images.find((a) => /SQUARE/i.test(a.label || ''));
+ if (square) return square.url;
+ return images[0]?.url || '';
+}
+
type AgBucket = {
adId: string;
adName: string;
campaignId: string;
campaignName: string;
status: string;
+ finalUrls: string[];
headlines: string[];
+ longHeadlines: string[];
descriptions: string[];
- images: string[];
- youtubeIds: string[];
+ ctas: string[];
+ assets: CreativeAsset[];
};
/**
* Live Google creatives: Performance Max asset groups + RSA/RDA ads.
- * Metrics: prefer asset_group / ad metrics in range; else fall back to campaign ad_daily.
+ * Returns full asset packs (images, videos, logos, texts) for zoom UI.
*/
export async function fetchGoogleCreativeCards(opts: {
tenantId: string;
@@ -226,7 +245,8 @@ SELECT
asset.text_asset.text,
asset.image_asset.full_size.url,
asset.youtube_video_asset.youtube_video_id,
- asset.youtube_video_asset.youtube_video_title
+ asset.youtube_video_asset.youtube_video_title,
+ asset.call_to_action_asset.call_to_action
FROM asset_group_asset
WHERE asset_group.status != 'REMOVED'
AND asset_group_asset.status != 'REMOVED'
@@ -262,10 +282,13 @@ SELECT
ad_group_ad.ad.final_urls,
ad_group_ad.ad.responsive_search_ad.headlines,
ad_group_ad.ad.responsive_search_ad.descriptions,
+ ad_group_ad.ad.responsive_search_ad.path1,
+ ad_group_ad.ad.responsive_search_ad.path2,
ad_group_ad.ad.responsive_display_ad.headlines,
ad_group_ad.ad.responsive_display_ad.long_headline,
ad_group_ad.ad.responsive_display_ad.descriptions,
ad_group_ad.ad.responsive_display_ad.business_name,
+ ad_group_ad.ad.responsive_display_ad.call_to_action_text,
ad_group_ad.ad.image_ad.image_url,
ad_group_ad.ad.image_ad.name
FROM ad_group_ad
@@ -330,7 +353,6 @@ WHERE ${dateWhere}
adMetricMap.set(id, cur);
}
- // Campaign-level spend from ad_daily as fallback for PMax
const dailyAggs = aggregateDailyByAd(
opts.dailyRows,
'google',
@@ -339,7 +361,7 @@ WHERE ${dateWhere}
);
const campaignSpend = new Map();
for (const a of dailyAggs) {
- campaignSpend.set(a.adId, a); // portal stores campaign id as ad_id
+ campaignSpend.set(a.adId, a);
if (a.campaignName) campaignSpend.set(a.campaignName.toLowerCase(), a);
}
@@ -355,10 +377,12 @@ WHERE ${dateWhere}
campaignId: str(row.campaign?.id),
campaignName: str(row.campaign?.name),
status: enumName(row.assetGroup?.status) || 'ENABLED',
+ finalUrls: (row.assetGroup?.finalUrls || []).map(str).filter(Boolean),
headlines: [],
+ longHeadlines: [],
descriptions: [],
- images: [],
- youtubeIds: [],
+ ctas: [],
+ assets: [],
};
agMap.set(agid, entry);
}
@@ -366,16 +390,31 @@ WHERE ${dateWhere}
const text = str(row.asset?.textAsset?.text);
const img = str(row.asset?.imageAsset?.fullSize?.url);
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
+ const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle);
+ const cta = enumName(row.asset?.callToActionAsset?.callToAction);
+
if (ft === 'HEADLINE' && text) entry.headlines.push(text);
- else if (ft === 'LONG_HEADLINE' && text) entry.headlines.push(text);
+ else if (ft === 'LONG_HEADLINE' && text) entry.longHeadlines.push(text);
else if (ft === 'DESCRIPTION' && text) entry.descriptions.push(text);
- else if (
- (ft.includes('MARKETING_IMAGE') || ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') &&
- img
- ) {
- if (!entry.images.includes(img)) entry.images.push(img);
+ else if (ft === 'CALL_TO_ACTION_SELECTION' && cta) entry.ctas.push(cta);
+ else if (ft.includes('MARKETING_IMAGE') && img) {
+ if (!entry.assets.some((a) => a.url === img)) {
+ entry.assets.push({ kind: 'image', url: img, label: ft });
+ }
+ } else if ((ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') && img) {
+ if (!entry.assets.some((a) => a.url === img)) {
+ entry.assets.push({ kind: 'logo', url: img, label: ft });
+ }
} else if (ft === 'YOUTUBE_VIDEO' && yt) {
- if (!entry.youtubeIds.includes(yt)) entry.youtubeIds.push(yt);
+ if (!entry.assets.some((a) => a.youtubeId === yt)) {
+ entry.assets.push({
+ kind: 'youtube',
+ url: ytThumb(yt),
+ label: 'YOUTUBE_VIDEO',
+ youtubeId: yt,
+ title: ytTitle || yt,
+ });
+ }
}
}
@@ -390,11 +429,17 @@ WHERE ${dateWhere}
const impressions = m?.impressions ?? camp?.impressions ?? 0;
const clicks = m?.clicks ?? camp?.clicks ?? 0;
const conversions = m?.conversions ?? camp?.conversions ?? 0;
- // Skip zero-activity asset groups when we have spend elsewhere, keep if any spend
if (spend <= 0 && impressions <= 0 && clicks <= 0 && !m) continue;
- const yt = entry.youtubeIds[0] || '';
- const imageUrl = entry.images[0] || (yt ? ytThumb(yt) : '');
+ const headlines = uniq(entry.headlines);
+ const longHeadlines = uniq(entry.longHeadlines);
+ const descriptions = uniq(entry.descriptions);
+ const preview =
+ pickVerticalGoogle(entry.assets) ||
+ entry.assets.find((a) => a.kind === 'youtube')?.url ||
+ '';
+ const firstYt = entry.assets.find((a) => a.youtubeId)?.youtubeId;
+
cards.push({
platform: 'google',
adId: entry.adId,
@@ -406,13 +451,19 @@ WHERE ${dateWhere}
clicks,
conversions: Math.round(conversions * 100) / 100,
cpl: conversions > 0 ? spend / conversions : null,
- mediaType: yt && !entry.images[0] ? 'video' : entry.images[0] ? 'pmax' : 'pmax',
- thumbnailUrl: imageUrl,
- imageUrl,
- headline: [...new Set(entry.headlines)].slice(0, 5).join('\n'),
- primaryText: [...new Set(entry.descriptions)].slice(0, 3).join('\n'),
+ mediaType: 'pmax',
+ thumbnailUrl: preview,
+ imageUrl: preview,
+ headline: [...headlines, ...longHeadlines].join('\n'),
+ primaryText: descriptions.join('\n'),
+ headlines,
+ longHeadlines,
+ descriptions,
+ assets: entry.assets,
+ linkUrl: entry.finalUrls[0],
+ cta: uniq(entry.ctas)[0],
status: entry.status,
- youtubeId: yt || undefined,
+ youtubeId: firstYt,
});
}
@@ -421,7 +472,6 @@ WHERE ${dateWhere}
const adId = str(ad?.id);
if (!adId) continue;
const m = adMetricMap.get(adId);
- // Prefer ads with activity in range; keep a few without if needed
const spend = m?.spend ?? 0;
const impressions = m?.impressions ?? 0;
const clicks = m?.clicks ?? 0;
@@ -430,24 +480,21 @@ WHERE ${dateWhere}
const rsa = ad?.responsiveSearchAd;
const rda = ad?.responsiveDisplayAd;
- const headlines = [
+ const headlines = uniq([
...(rsa?.headlines || []).map((h) => str(h.text)),
...(rda?.headlines || []).map((h) => str(h.text)),
- str(rda?.longHeadline?.text),
- ].filter(Boolean);
- const descriptions = [
+ ]);
+ const longHeadlines = uniq([str(rda?.longHeadline?.text)]);
+ const descriptions = uniq([
...(rsa?.descriptions || []).map((d) => str(d.text)),
...(rda?.descriptions || []).map((d) => str(d.text)),
- ].filter(Boolean);
+ ]);
const imageUrl = str(ad?.imageAd?.imageUrl);
+ const assets: CreativeAsset[] = [];
+ if (imageUrl) assets.push({ kind: 'image', url: imageUrl, label: 'IMAGE_AD' });
const adType = enumName(ad?.type).toLowerCase() || 'rsa';
- const mediaType = rsa
- ? 'rsa'
- : rda
- ? 'rda'
- : imageUrl
- ? 'image'
- : adType;
+ const mediaType = rsa ? 'rsa' : rda ? 'rda' : imageUrl ? 'image' : adType;
+ const paths = [str(rsa?.path1), str(rsa?.path2)].filter(Boolean);
cards.push({
platform: 'google',
@@ -463,17 +510,30 @@ WHERE ${dateWhere}
mediaType,
thumbnailUrl: imageUrl,
imageUrl,
- headline: [...new Set(headlines)].slice(0, 5).join('\n'),
- primaryText: [...new Set(descriptions)].slice(0, 3).join('\n'),
+ headline: [...headlines, ...longHeadlines].join('\n'),
+ primaryText: descriptions.join('\n'),
+ headlines,
+ longHeadlines,
+ descriptions,
+ assets,
+ linkUrl: (ad?.finalUrls || []).map(str).filter(Boolean)[0],
+ cta: str(rda?.callToActionText) || undefined,
+ businessName: str(rda?.businessName) || undefined,
status: enumName(row.adGroupAd?.status),
});
+ if (paths.length) {
+ // encode paths into descriptions note for zoom
+ cards[cards.length - 1].descriptions = [
+ ...descriptions,
+ `Path: /${paths.join('/')}`,
+ ];
+ }
}
cards.sort((a, b) => b.spend - a.spend);
const sliced = cards.slice(0, limit);
if (!sliced.length) {
- // Fallback: campaign cards from ad_daily (text only)
const fallback = dailyAggs.slice(0, limit).map((agg) => ({
platform: 'google' as const,
adId: agg.adId,
@@ -489,6 +549,9 @@ WHERE ${dateWhere}
imageUrl: '',
headline: agg.campaignName || agg.adName,
primaryText: 'Sin assets creativos en la API',
+ headlines: [agg.campaignName || agg.adName].filter(Boolean),
+ descriptions: ['Sin assets creativos en la API'],
+ assets: [] as CreativeAsset[],
status: undefined,
}));
return {
diff --git a/src/lib/ad-creatives-meta.ts b/src/lib/ad-creatives-meta.ts
index fa0564a..0c981e1 100644
--- a/src/lib/ad-creatives-meta.ts
+++ b/src/lib/ad-creatives-meta.ts
@@ -1,4 +1,4 @@
-/** Meta ad creatives for Ad Tracker (Brava-style previews). */
+/** Meta ad creatives for Ad Tracker (vertical previews + full copy/assets). */
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
@@ -10,6 +10,8 @@ const CREATIVE_FIELDS = [
'name',
'title',
'body',
+ 'call_to_action_type',
+ 'link_url',
'thumbnail_url',
'image_url',
'image_hash',
@@ -19,6 +21,14 @@ const CREATIVE_FIELDS = [
'effective_object_story_id',
].join(',');
+export type CreativeAsset = {
+ kind: 'image' | 'video' | 'logo' | 'youtube';
+ url: string;
+ label?: string;
+ youtubeId?: string;
+ title?: string;
+};
+
export type AdCreativeCard = {
platform: 'meta' | 'google';
adId: string;
@@ -31,10 +41,19 @@ export type AdCreativeCard = {
conversions: number;
cpl: number | null;
mediaType: string;
+ /** Best preview URL (prefer vertical / portrait). */
thumbnailUrl: string;
imageUrl: string;
+ /** Joined for backwards compat; prefer headlines/descriptions arrays. */
headline: string;
primaryText: string;
+ headlines: string[];
+ descriptions: string[];
+ longHeadlines?: string[];
+ assets: CreativeAsset[];
+ linkUrl?: string;
+ cta?: string;
+ businessName?: string;
status?: string;
youtubeId?: string;
};
@@ -58,6 +77,10 @@ function str(v: unknown): string {
return v == null ? '' : String(v).trim();
}
+function uniq(arr: string[]): string[] {
+ return [...new Set(arr.filter(Boolean))];
+}
+
function detectMediaType(creative: Record): string {
if (creative.video_id) return 'video';
const feed = creative.asset_feed_spec as Record | undefined;
@@ -71,32 +94,125 @@ function detectMediaType(creative: Record): string {
return 'unknown';
}
-function extractTexts(creative: Record): {
- 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));
+type Extracted = {
+ headlines: string[];
+ descriptions: string[];
+ assets: CreativeAsset[];
+ linkUrl: string;
+ cta: string;
+ hashes: string[];
+};
+
+function extractCreativePack(creative: Record): Extracted {
+ const headlines: string[] = [];
+ const descriptions: string[] = [];
+ const assets: CreativeAsset[] = [];
+ const hashes: string[] = [];
+ const pushHash = (h: unknown) => {
+ const s = str(h);
+ if (/^[a-f0-9]{16,64}$/i.test(s)) hashes.push(s);
+ };
+ const pushImg = (url: unknown, label?: string) => {
+ const u = str(url);
+ if (!/^https?:\/\//i.test(u)) return;
+ if (assets.some((a) => a.url === u)) return;
+ assets.push({ kind: 'image', url: u, label });
+ };
+
+ if (creative.title) headlines.push(str(creative.title));
+ if (creative.body) descriptions.push(str(creative.body));
+ pushImg(creative.image_url, 'image');
+ pushImg(creative.thumbnail_url, 'thumbnail');
+ pushHash(creative.image_hash);
+
const oss = creative.object_story_spec as Record | undefined;
const link = oss?.link_data as Record | undefined;
const video = oss?.video_data as Record | 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 photo = oss?.photo_data as Record | undefined;
+ if (link?.name) headlines.push(str(link.name));
+ if (link?.message) descriptions.push(str(link.message));
+ if (link?.description) descriptions.push(str(link.description));
+ if (link?.caption) descriptions.push(str(link.caption));
+ pushImg(link?.picture, 'story');
+ pushImg(link?.image_url, 'story');
+ pushHash(link?.image_hash);
+ if (video?.title) headlines.push(str(video.title));
+ if (video?.message) descriptions.push(str(video.message));
+ if (video?.link_description) descriptions.push(str(video.link_description));
+ pushImg(video?.image_url, 'video_poster');
+ pushHash(video?.image_hash);
+ pushImg(photo?.url, 'photo');
+ pushHash(photo?.image_hash);
+
+ if (Array.isArray(link?.child_attachments)) {
+ (link!.child_attachments as Record[]).forEach((c, i) => {
+ pushImg(c.picture || c.image_url, `carousel-${i + 1}`);
+ pushHash(c.image_hash);
+ if (c.name) headlines.push(str(c.name));
+ if (c.description) descriptions.push(str(c.description));
+ if (c.message) descriptions.push(str(c.message));
+ });
+ }
+
const feed = creative.asset_feed_spec as Record | 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));
+ if (feed) {
+ for (const t of (feed.titles as { text?: string }[]) || []) {
+ if (t?.text) headlines.push(str(t.text));
+ }
+ for (const b of (feed.bodies as { text?: string }[]) || []) {
+ if (b?.text) descriptions.push(str(b.text));
+ }
+ for (const d of (feed.descriptions as { text?: string }[]) || []) {
+ if (d?.text) descriptions.push(str(d.text));
+ }
+ for (const img of (feed.images as Record[]) || []) {
+ pushImg(img.url || img.permalink_url, 'feed');
+ pushHash(img.hash);
+ }
+ for (const v of (feed.videos as Record[]) || []) {
+ pushImg(v.thumbnail_url || v.picture, 'video_feed');
+ if (v.video_id) {
+ assets.push({
+ kind: 'video',
+ url: str(v.thumbnail_url || v.picture),
+ label: 'video',
+ title: str(v.video_id),
+ });
+ }
+ }
+ }
+
+ const linkUrl =
+ str(creative.link_url) ||
+ str(link?.link) ||
+ str(video?.link) ||
+ '';
+ const ctaRaw = (link?.call_to_action as { type?: string } | undefined)?.type;
+ const cta = str(creative.call_to_action_type) || str(ctaRaw);
+
return {
- headline: [...new Set(titles.filter(Boolean))].slice(0, 5).join('\n'),
- primaryText: [...new Set(bodies.filter(Boolean))].slice(0, 3).join('\n'),
+ headlines: uniq(headlines),
+ descriptions: uniq(descriptions),
+ assets,
+ linkUrl,
+ cta,
+ hashes: uniq(hashes),
};
}
+/** Prefer portrait/vertical-looking assets; else first image. */
+function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string {
+ const images = assets.filter((a) => a.kind === 'image' && a.url);
+ const portrait = images.find((a) =>
+ /portrait|vertical|9.?16|4.?5|tall|story/i.test(a.label || '')
+ );
+ if (portrait) return portrait.url;
+ // Meta often serves taller thumbnails when requested 1080x1920 — thumbnail label last
+ const nonSquare = images.find((a) => a.label === 'thumbnail' || a.label === 'video_poster');
+ if (nonSquare) return nonSquare.url;
+ return images[0]?.url || fallback;
+}
+
async function graphGet(
path: string,
params: Record,
@@ -124,6 +240,37 @@ async function resolveMetaAccountId(tenantId: string): Promise {
return id || null;
}
+async function resolveImageHashes(
+ accountId: string,
+ hashes: string[],
+ token: string
+): Promise