diff --git a/src/components/AdCreativesGallery.tsx b/src/components/AdCreativesGallery.tsx
index ae9b1f6..9dee4ad 100644
--- a/src/components/AdCreativesGallery.tsx
+++ b/src/components/AdCreativesGallery.tsx
@@ -16,19 +16,27 @@ export function proxiedMediaUrl(raw: string): string {
}
function adHeadlines(ad: AdCreativeCard): string[] {
- if (ad.headlines?.length) return ad.headlines;
+ if (ad.headlines?.length) return ad.headlines.slice(0, 5);
return (ad.headline || '')
.split('\n')
.map((s) => s.trim())
- .filter(Boolean);
+ .filter(Boolean)
+ .slice(0, 5);
}
-function adDescriptions(ad: AdCreativeCard): string[] {
- if (ad.descriptions?.length) return ad.descriptions;
+function adBodies(ad: AdCreativeCard): string[] {
+ if (ad.bodies?.length) return ad.bodies.slice(0, 5);
+ // Legacy: primaryText used to hold bodies
return (ad.primaryText || '')
.split('\n')
.map((s) => s.trim())
- .filter(Boolean);
+ .filter(Boolean)
+ .slice(0, 5);
+}
+
+function adLinkDescriptions(ad: AdCreativeCard): string[] {
+ if (ad.descriptions?.length) return ad.descriptions.slice(0, 5);
+ return [];
}
function adAssets(ad: AdCreativeCard): CreativeAsset[] {
@@ -186,8 +194,9 @@ function CreativeCard({
}) {
const preview = previewUrlFor(ad);
const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId);
- const headline = adHeadlines(ad)[0] || '';
- const body = adDescriptions(ad)[0] || '';
+ const headlines = adHeadlines(ad);
+ const bodies = adBodies(ad);
+ const linkDescs = adLinkDescriptions(ad);
const assets = adAssets(ad);
const extraCount = Math.max(0, assets.length - 1);
@@ -206,14 +215,19 @@ function CreativeCard({
className="w-full h-full object-cover"
loading="lazy"
/>
- ) : ad.platform === 'google' && headline ? (
+ ) : ad.platform === 'google' && headlines.length ? (
Google · {ad.mediaType || 'rsa'}
-
- {headline}
-
+ {headlines.slice(0, 3).map((h) => (
+
+ {h}
+
+ ))}
) : (
@@ -259,18 +273,55 @@ function CreativeCard({
Conjunto: {ad.adsetName}
) : null}
- {(headline || body) && (
-
- {headline ? (
-
- {headline}
-
+ {(headlines.length > 0 || bodies.length > 0 || linkDescs.length > 0) && (
+
+ {headlines.length > 0 ? (
+
+
+ Titulares ({headlines.length})
+
+
+ {headlines.map((h) => (
+ -
+ {h}
+
+ ))}
+
+
) : null}
- {body ? (
-
{body}
+ {bodies.length > 0 ? (
+
+
+ Textos ({bodies.length})
+
+
+ {bodies.map((b) => (
+ -
+ {b}
+
+ ))}
+
+
+ ) : null}
+ {linkDescs.length > 0 ? (
+
+
+ Descripción
+
+
+ {linkDescs.map((d) => (
+ -
+ {d}
+
+ ))}
+
+
) : null}
{ad.cta ? (
-
+
CTA · {ad.cta}
) : null}
@@ -298,7 +349,8 @@ export function CreativeZoomModal({
onClose: () => void;
}) {
const headlines = adHeadlines(ad);
- const bodies = adDescriptions(ad);
+ const bodies = adBodies(ad);
+ const linkDescs = adLinkDescriptions(ad);
const longH = ad.longHeadlines || [];
const assets = adAssets(ad);
const images = assets.filter((a) => a.kind === 'image' || a.kind === 'logo');
@@ -494,10 +546,10 @@ export function CreativeZoomModal({
{headlines.length > 0 ? (
- Titular
+ Titulares ({headlines.length})
- {headlines.slice(0, 2).map((h) => (
+ {headlines.map((h) => (
- 0 ? (
- Titulares largos
+ Titulares largos ({longH.length})
- {longH.slice(0, 2).map((h) => (
+ {longH.slice(0, 5).map((h) => (
- 0 ? (
- Texto
+ Textos ({bodies.length})
- {bodies.slice(0, 2).map((b) => (
+ {bodies.map((b) => (
-
) : null}
+
+ {linkDescs.length > 0 ? (
+
+
+ Descripción
+
+
+ {linkDescs.map((d) => (
+ -
+ {d}
+
+ ))}
+
+
+ ) : null}
diff --git a/src/components/DriveConnectPanel.tsx b/src/components/DriveConnectPanel.tsx
index c938722..189641e 100644
--- a/src/components/DriveConnectPanel.tsx
+++ b/src/components/DriveConnectPanel.tsx
@@ -107,6 +107,9 @@ export default function DriveConnectPanel() {
Google Ads.
Sirve para carpetas Drive, GA4, GSC y creatividades Google Ads.
Si ves error de token caducado en Ads, pulsa Reconectar Google.
+ En Google Cloud el consent screen debe estar en{' '}
+ In production (si está
+ en Testing el refresh token caduca a los 7 días).
diff --git a/src/lib/ad-creatives-google.ts b/src/lib/ad-creatives-google.ts
index b4d6842..d3bd76c 100644
--- a/src/lib/ad-creatives-google.ts
+++ b/src/lib/ad-creatives-google.ts
@@ -210,6 +210,7 @@ function cardsFromDaily(
headline: agg.campaignName || agg.adName,
primaryText: description,
headlines: [agg.campaignName || agg.adName].filter(Boolean),
+ bodies: [],
descriptions: [description],
assets: [] as CreativeAsset[],
status: undefined,
@@ -497,8 +498,9 @@ WHERE ${dateWhere}
headline: [...headlines, ...longHeadlines].join('\n'),
primaryText: descriptions.join('\n'),
headlines,
+ bodies: descriptions,
longHeadlines,
- descriptions,
+ descriptions: [],
assets: entry.assets,
linkUrl: entry.finalUrls[0],
cta: uniq(entry.ctas)[0],
@@ -553,8 +555,9 @@ WHERE ${dateWhere}
headline: [...headlines, ...longHeadlines].join('\n'),
primaryText: descriptions.join('\n'),
headlines,
+ bodies: descriptions,
longHeadlines,
- descriptions,
+ descriptions: [],
assets,
linkUrl: (ad?.finalUrls || []).map(str).filter(Boolean)[0],
cta: str(rda?.callToActionText) || undefined,
@@ -562,8 +565,7 @@ WHERE ${dateWhere}
status: enumName(row.adGroupAd?.status),
});
if (paths.length) {
- // encode paths into descriptions note for zoom
- cards[cards.length - 1].descriptions = [
+ cards[cards.length - 1].bodies = [
...descriptions,
`Path: /${paths.join('/')}`,
];
diff --git a/src/lib/ad-creatives-meta.ts b/src/lib/ad-creatives-meta.ts
index dbf1bd7..42c1de8 100644
--- a/src/lib/ad-creatives-meta.ts
+++ b/src/lib/ad-creatives-meta.ts
@@ -48,10 +48,14 @@ export type AdCreativeCard = {
/** Best preview URL (prefer vertical / portrait). */
thumbnailUrl: string;
imageUrl: string;
- /** Joined for backwards compat; prefer headlines/descriptions arrays. */
+ /** Joined for backwards compat; prefer headlines/bodies/descriptions arrays. */
headline: string;
primaryText: string;
+ /** Titulares (Meta titles / Google headlines), máx. 5. */
headlines: string[];
+ /** Textos principales (Meta bodies), máx. 5. */
+ bodies: string[];
+ /** Descripción del enlace (Meta descriptions), suele ser 1. */
descriptions: string[];
longHeadlines?: string[];
assets: CreativeAsset[];
@@ -122,6 +126,7 @@ function detectMediaType(creative: Record): string {
type Extracted = {
headlines: string[];
+ bodies: string[];
descriptions: string[];
assets: CreativeAsset[];
linkUrl: string;
@@ -131,6 +136,7 @@ type Extracted = {
function extractCreativePack(creative: Record): Extracted {
const headlines: string[] = [];
+ const bodies: string[] = [];
const descriptions: string[] = [];
const assets: CreativeAsset[] = [];
const hashes: string[] = [];
@@ -155,50 +161,56 @@ function extractCreativePack(creative: Record): Extracted {
});
};
- // Primary serving copy = object_story_spec (what Ads Manager shows as the ad).
- // Do NOT dump every asset_feed_spec A/B variant into the card.
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;
const photo = oss?.photo_data as Record | undefined;
+ // Story (anuncio publicado): 1 titular + texto + descripción de enlace.
if (link?.name) headlines.push(str(link.name));
- if (link?.message) descriptions.push(str(link.message));
- else if (link?.description) descriptions.push(str(link.description));
+ if (link?.message) bodies.push(str(link.message));
+ if (link?.description) descriptions.push(str(link.description));
pushImg(link?.picture, '4:5 story', 1080, 1350);
pushImg(link?.image_url, '4:5 story', 1080, 1350);
pushHash(link?.image_hash);
if (video?.title) headlines.push(str(video.title));
- if (video?.message) descriptions.push(str(video.message));
- else if (video?.link_description) descriptions.push(str(video.link_description));
+ if (video?.message) bodies.push(str(video.message));
+ if (video?.link_description) descriptions.push(str(video.link_description));
pushImg(video?.image_url, 'video_poster 9:16', 1080, 1920);
pushHash(video?.image_hash);
pushImg(photo?.url, 'photo 4:5', 1080, 1350);
pushHash(photo?.image_hash);
- if (!headlines.length && creative.title) headlines.push(str(creative.title));
- if (!descriptions.length && creative.body) descriptions.push(str(creative.body));
+ if (creative.title) headlines.push(str(creative.title));
+ if (creative.body) bodies.push(str(creative.body));
pushImg(creative.image_url, 'image 4:5', 1080, 1350);
pushImg(creative.thumbnail_url, 'thumbnail 9:16', 1080, 1920);
pushHash(creative.image_hash);
- // Carousel: keep images; only first card's name as optional secondary headline.
if (Array.isArray(link?.child_attachments)) {
(link!.child_attachments as Record[]).forEach((c, i) => {
pushImg(c.picture || c.image_url, `carousel-${i + 1} 4:5`, 1080, 1350);
pushHash(c.image_hash);
- if (i === 0 && c.name && !headlines.length) headlines.push(str(c.name));
- if (i === 0 && c.description && !descriptions.length) {
- descriptions.push(str(c.description));
- }
+ if (c.name) headlines.push(str(c.name));
+ if (c.message) bodies.push(str(c.message));
+ if (c.description) descriptions.push(str(c.description));
});
}
+ // Dynamic creative / Advantage+: hasta 5 titles, 5 bodies, descriptions.
const feed = creative.asset_feed_spec as Record | undefined;
if (feed) {
- // Images/videos from dynamic creative — label formats; texts only if still empty.
+ 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) bodies.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[]) || []) {
const labels = Array.isArray(img.adlabels)
? (img.adlabels as { name?: string }[])
@@ -206,10 +218,9 @@ function extractCreativePack(creative: Record): Extracted {
.filter(Boolean)
.join(' ')
: '';
- const tag = labels || 'feed';
pushImg(
img.url || img.permalink_url,
- tag,
+ labels || 'feed',
n(img.width) || undefined,
n(img.height) || undefined
);
@@ -227,16 +238,6 @@ function extractCreativePack(creative: Record): Extracted {
});
}
}
- if (!headlines.length) {
- const t = (feed.titles as { text?: string }[] | undefined)?.[0]?.text;
- if (t) headlines.push(str(t));
- }
- if (!descriptions.length) {
- const b =
- (feed.bodies as { text?: string }[] | undefined)?.[0]?.text ||
- (feed.descriptions as { text?: string }[] | undefined)?.[0]?.text;
- if (b) descriptions.push(str(b));
- }
}
const linkUrl =
@@ -248,8 +249,9 @@ function extractCreativePack(creative: Record): Extracted {
const cta = str(creative.call_to_action_type) || str(ctaRaw);
return {
- headlines: uniq(headlines).slice(0, 3),
- descriptions: uniq(descriptions).slice(0, 3),
+ headlines: uniq(headlines).slice(0, 5),
+ bodies: uniq(bodies).slice(0, 5),
+ descriptions: uniq(descriptions).slice(0, 5),
assets,
linkUrl,
cta,
@@ -549,6 +551,7 @@ function cardFromBundle(
str(creative.image_url) || str(creative.thumbnail_url) || '';
const preview = pickVerticalPreview(assets, fallback);
const headlines = pack.headlines;
+ const bodies = pack.bodies;
const descriptions = pack.descriptions;
return {
platform: 'meta',
@@ -565,8 +568,9 @@ function cardFromBundle(
thumbnailUrl: preview,
imageUrl: preview,
headline: headlines.join('\n'),
- primaryText: descriptions.join('\n'),
+ primaryText: bodies.join('\n') || descriptions.join('\n'),
headlines,
+ bodies,
descriptions,
assets,
linkUrl: pack.linkUrl || undefined,
@@ -751,6 +755,7 @@ export function buildGoogleCardsFromDaily(
headline: agg.adName,
primaryText: agg.campaignName,
headlines: agg.adName ? [agg.adName] : [],
+ bodies: [],
descriptions: agg.campaignName ? [agg.campaignName] : [],
assets: [],
status: undefined,