fix: split Google Ads creatives by Search / PMax / Display
Fetch display/RDA assets, classify Brand vs Generic Search, and group the Ad Tracker gallery so previews are not a single undifferentiated image. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -183,7 +183,7 @@ export async function GET(req: NextRequest) {
|
|||||||
dailyRows: daily,
|
dailyRows: daily,
|
||||||
since,
|
since,
|
||||||
until,
|
until,
|
||||||
limit: 24,
|
limit: 48,
|
||||||
});
|
});
|
||||||
googleAds = google.ads.length
|
googleAds = google.ads.length
|
||||||
? google.ads
|
? google.ads
|
||||||
|
|||||||
@@ -148,6 +148,72 @@ export function CreativesGallery({
|
|||||||
? warning
|
? warning
|
||||||
: null);
|
: null);
|
||||||
|
|
||||||
|
const googleGroups: {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
items: AdCreativeCard[];
|
||||||
|
}[] =
|
||||||
|
platform === 'google'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'search_brand',
|
||||||
|
title: 'Search · Brand',
|
||||||
|
hint: 'RSA de marca (titulares + URL)',
|
||||||
|
items: filtered.filter((a) => a.channel === 'search_brand'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'search_generic',
|
||||||
|
title: 'Search · Generic',
|
||||||
|
hint: 'RSA genéricos / captación',
|
||||||
|
items: filtered.filter(
|
||||||
|
(a) =>
|
||||||
|
a.channel === 'search_generic' ||
|
||||||
|
(!a.channel && a.mediaType === 'rsa')
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pmax',
|
||||||
|
title: 'Performance Max',
|
||||||
|
hint: 'Asset groups · imágenes, logos y vídeo',
|
||||||
|
items: filtered.filter(
|
||||||
|
(a) => a.channel === 'pmax' || a.mediaType === 'pmax'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'display',
|
||||||
|
title: 'Display · Remarketing',
|
||||||
|
hint: 'RDA / image ads · creatividades visuales',
|
||||||
|
items: filtered.filter(
|
||||||
|
(a) =>
|
||||||
|
a.channel === 'display' ||
|
||||||
|
a.mediaType === 'rda' ||
|
||||||
|
a.mediaType === 'image'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'other',
|
||||||
|
title: 'Otros Google',
|
||||||
|
hint: 'Campañas sin clasificar',
|
||||||
|
items: filtered.filter((a) => {
|
||||||
|
const ch = a.channel;
|
||||||
|
if (
|
||||||
|
ch === 'search_brand' ||
|
||||||
|
ch === 'search_generic' ||
|
||||||
|
ch === 'pmax' ||
|
||||||
|
ch === 'display'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!ch && (a.mediaType === 'rsa' || a.mediaType === 'pmax' || a.mediaType === 'rda' || a.mediaType === 'image')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
].filter((g) => g.items.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -158,7 +224,9 @@ export function CreativesGallery({
|
|||||||
{platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · creatividades
|
{platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · creatividades
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Preview 9:16. Pulsa Zoom para ver el resto de piezas en su formato.
|
{platform === 'google'
|
||||||
|
? 'Agrupado por Search Brand / Generic, Performance Max y Display. Search muestra preview tipo SERP; PMax y Display muestran assets.'
|
||||||
|
: 'Preview 9:16. Pulsa Zoom para ver el resto de piezas en su formato.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{platformWarning ? (
|
{platformWarning ? (
|
||||||
@@ -174,6 +242,26 @@ export function CreativesGallery({
|
|||||||
<p className="text-sm text-gray-500 py-8 text-center border border-dashed border-gray-200 rounded-xl">
|
<p className="text-sm text-gray-500 py-8 text-center border border-dashed border-gray-200 rounded-xl">
|
||||||
Sin creatividades para este periodo.
|
Sin creatividades para este periodo.
|
||||||
</p>
|
</p>
|
||||||
|
) : platform === 'google' && googleGroups.length > 0 ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{googleGroups.map((g) => (
|
||||||
|
<div key={g.key} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900">{g.title}</h4>
|
||||||
|
<p className="text-[11px] text-gray-500">{g.hint} · {g.items.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{g.items.map((ad) => (
|
||||||
|
<CreativeCard
|
||||||
|
key={`${ad.platform}-${ad.adId}`}
|
||||||
|
ad={ad}
|
||||||
|
onZoom={() => onZoom(ad)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filtered.map((ad) => (
|
{filtered.map((ad) => (
|
||||||
@@ -219,10 +307,41 @@ function CreativeCard({
|
|||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
) : ad.platform === 'google' &&
|
||||||
|
(ad.mediaType === 'rsa' ||
|
||||||
|
ad.channel === 'search_brand' ||
|
||||||
|
ad.channel === 'search_generic') &&
|
||||||
|
headlines.length ? (
|
||||||
|
<div className="w-full h-full p-3.5 space-y-1.5 text-left bg-white flex flex-col justify-center border-t border-gray-100">
|
||||||
|
<p className="text-[9px] uppercase tracking-wider text-emerald-700 font-medium">
|
||||||
|
Anuncio ·{' '}
|
||||||
|
{ad.channel === 'search_brand'
|
||||||
|
? 'Search Brand'
|
||||||
|
: ad.channel === 'search_generic'
|
||||||
|
? 'Search Generic'
|
||||||
|
: 'Search'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-emerald-800 truncate">
|
||||||
|
{(ad.linkUrl || 'https://ejemplo.com').replace(/^https?:\/\//, '')}
|
||||||
|
</p>
|
||||||
|
{headlines.slice(0, 2).map((h) => (
|
||||||
|
<p
|
||||||
|
key={h}
|
||||||
|
className="text-[13px] font-medium text-[#1a0dab] leading-snug line-clamp-2"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{bodies[0] ? (
|
||||||
|
<p className="text-[11px] text-gray-600 leading-snug line-clamp-3">
|
||||||
|
{bodies[0]}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : ad.platform === 'google' && headlines.length ? (
|
) : ad.platform === 'google' && headlines.length ? (
|
||||||
<div className="w-full h-full p-4 space-y-2 text-left bg-gradient-to-b from-[#1a2332] to-[#0d121f] flex flex-col justify-center">
|
<div className="w-full h-full p-4 space-y-2 text-left bg-gradient-to-b from-[#1a2332] to-[#0d121f] flex flex-col justify-center">
|
||||||
<p className="text-[10px] uppercase tracking-wider text-sky-400">
|
<p className="text-[10px] uppercase tracking-wider text-sky-400">
|
||||||
Google · {ad.mediaType || 'rsa'}
|
Google · {ad.mediaType || 'ad'}
|
||||||
</p>
|
</p>
|
||||||
{headlines.slice(0, 3).map((h) => (
|
{headlines.slice(0, 3).map((h) => (
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
import { googleAdsConfigured } from '@/lib/ads-discovery';
|
import { googleAdsConfigured } from '@/lib/ads-discovery';
|
||||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
import type { AdCreativeCard, CreativeAsset } from '@/lib/ad-creatives-meta';
|
import type {
|
||||||
|
AdCreativeCard,
|
||||||
|
CreativeAsset,
|
||||||
|
GoogleCreativeChannel,
|
||||||
|
} from '@/lib/ad-creatives-meta';
|
||||||
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta';
|
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta';
|
||||||
import {
|
import {
|
||||||
friendlyGoogleCreativeWarning,
|
friendlyGoogleCreativeWarning,
|
||||||
@@ -12,7 +16,12 @@ import {
|
|||||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||||
|
|
||||||
type GaqlRow = {
|
type GaqlRow = {
|
||||||
campaign?: { id?: string; name?: string; status?: string };
|
campaign?: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
status?: string;
|
||||||
|
advertisingChannelType?: string;
|
||||||
|
};
|
||||||
adGroup?: { id?: string; name?: string };
|
adGroup?: { id?: string; name?: string };
|
||||||
adGroupAd?: {
|
adGroupAd?: {
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -37,6 +46,7 @@ type GaqlRow = {
|
|||||||
imageAd?: { imageUrl?: string; name?: string };
|
imageAd?: { imageUrl?: string; name?: string };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
adGroupAdAssetView?: { fieldType?: string };
|
||||||
assetGroup?: {
|
assetGroup?: {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -63,6 +73,44 @@ type GaqlRow = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Classify Google creative into gallery buckets (Search brand/generic, PMax, Display). */
|
||||||
|
export function classifyGoogleChannel(opts: {
|
||||||
|
campaignName?: string;
|
||||||
|
channelType?: string;
|
||||||
|
mediaType?: string;
|
||||||
|
adsetName?: string;
|
||||||
|
}): GoogleCreativeChannel {
|
||||||
|
const camp = `${opts.campaignName || ''} ${opts.adsetName || ''}`.toUpperCase();
|
||||||
|
const ct = (opts.channelType || '').toUpperCase();
|
||||||
|
const mt = (opts.mediaType || '').toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
mt === 'pmax' ||
|
||||||
|
ct.includes('PERFORMANCE_MAX') ||
|
||||||
|
/P[\s_-]?MAX|PERFORMANCE[\s_-]?MAX/.test(camp)
|
||||||
|
) {
|
||||||
|
return 'pmax';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
mt === 'rda' ||
|
||||||
|
mt === 'image' ||
|
||||||
|
ct.includes('DISPLAY') ||
|
||||||
|
ct.includes('DEMAND_GEN') ||
|
||||||
|
/DISPLAY|REMARKETING|REMARK|RMKT|RETARGET|DISCOVERY|DEMAND/.test(camp)
|
||||||
|
) {
|
||||||
|
return 'display';
|
||||||
|
}
|
||||||
|
if (/BRAND|MARCA|\bBRND\b/.test(camp)) return 'search_brand';
|
||||||
|
if (
|
||||||
|
mt === 'rsa' ||
|
||||||
|
ct.includes('SEARCH') ||
|
||||||
|
/SEARCH|B[ÚU]SQUEDA|GENERIC|GEN[EÉ]RICO/.test(camp)
|
||||||
|
) {
|
||||||
|
return 'search_generic';
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
function n(v: unknown): number {
|
function n(v: unknown): number {
|
||||||
const x = Number(v);
|
const x = Number(v);
|
||||||
return Number.isFinite(x) ? x : 0;
|
return Number.isFinite(x) ? x : 0;
|
||||||
@@ -176,6 +224,7 @@ type AgBucket = {
|
|||||||
adName: string;
|
adName: string;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
campaignName: string;
|
campaignName: string;
|
||||||
|
channelType: string;
|
||||||
status: string;
|
status: string;
|
||||||
finalUrls: string[];
|
finalUrls: string[];
|
||||||
headlines: string[];
|
headlines: string[];
|
||||||
@@ -244,7 +293,7 @@ export async function fetchGoogleCreativeCards(opts: {
|
|||||||
until: string;
|
until: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
|
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
|
||||||
const limit = opts.limit ?? 24;
|
const limit = opts.limit ?? 48;
|
||||||
const warehouseFallback = (warning: string) => {
|
const warehouseFallback = (warning: string) => {
|
||||||
const ads = cardsFromDaily(
|
const ads = cardsFromDaily(
|
||||||
opts.dailyRows,
|
opts.dailyRows,
|
||||||
@@ -284,7 +333,7 @@ export async function fetchGoogleCreativeCards(opts: {
|
|||||||
const dateWhere = `segments.date BETWEEN '${opts.since}' AND '${opts.until}'`;
|
const dateWhere = `segments.date BETWEEN '${opts.since}' AND '${opts.until}'`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [agAssets, agMetrics, adRows, adMetrics] = await Promise.all([
|
const [agAssets, agMetrics, adRows, adMetrics, adAssets] = await Promise.all([
|
||||||
searchGoogle(
|
searchGoogle(
|
||||||
customerId,
|
customerId,
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -292,6 +341,7 @@ export async function fetchGoogleCreativeCards(opts: {
|
|||||||
SELECT
|
SELECT
|
||||||
campaign.id,
|
campaign.id,
|
||||||
campaign.name,
|
campaign.name,
|
||||||
|
campaign.advertising_channel_type,
|
||||||
asset_group.id,
|
asset_group.id,
|
||||||
asset_group.name,
|
asset_group.name,
|
||||||
asset_group.status,
|
asset_group.status,
|
||||||
@@ -330,6 +380,7 @@ WHERE ${dateWhere}
|
|||||||
SELECT
|
SELECT
|
||||||
campaign.id,
|
campaign.id,
|
||||||
campaign.name,
|
campaign.name,
|
||||||
|
campaign.advertising_channel_type,
|
||||||
ad_group.id,
|
ad_group.id,
|
||||||
ad_group.name,
|
ad_group.name,
|
||||||
ad_group_ad.status,
|
ad_group_ad.status,
|
||||||
@@ -351,7 +402,7 @@ SELECT
|
|||||||
FROM ad_group_ad
|
FROM ad_group_ad
|
||||||
WHERE ad_group_ad.status != 'REMOVED'
|
WHERE ad_group_ad.status != 'REMOVED'
|
||||||
AND campaign.status != 'REMOVED'
|
AND campaign.status != 'REMOVED'
|
||||||
LIMIT 80
|
LIMIT 120
|
||||||
`
|
`
|
||||||
),
|
),
|
||||||
searchGoogle(
|
searchGoogle(
|
||||||
@@ -368,8 +419,60 @@ FROM ad_group_ad
|
|||||||
WHERE ${dateWhere}
|
WHERE ${dateWhere}
|
||||||
`
|
`
|
||||||
),
|
),
|
||||||
|
searchGoogle(
|
||||||
|
customerId,
|
||||||
|
accessToken,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
ad_group_ad.ad.id,
|
||||||
|
ad_group_ad_asset_view.field_type,
|
||||||
|
asset.image_asset.full_size.url,
|
||||||
|
asset.youtube_video_asset.youtube_video_id,
|
||||||
|
asset.youtube_video_asset.youtube_video_title
|
||||||
|
FROM ad_group_ad_asset_view
|
||||||
|
WHERE ad_group_ad.status != 'REMOVED'
|
||||||
|
AND campaign.status != 'REMOVED'
|
||||||
|
AND ad_group_ad_asset_view.field_type IN (
|
||||||
|
'MARKETING_IMAGE',
|
||||||
|
'SQUARE_MARKETING_IMAGE',
|
||||||
|
'PORTRAIT_MARKETING_IMAGE',
|
||||||
|
'LOGO',
|
||||||
|
'LANDSCAPE_LOGO',
|
||||||
|
'YOUTUBE_VIDEO'
|
||||||
|
)
|
||||||
|
LIMIT 400
|
||||||
|
`
|
||||||
|
).catch(() => [] as GaqlRow[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const assetsByAdId = new Map<string, CreativeAsset[]>();
|
||||||
|
for (const row of adAssets) {
|
||||||
|
const adId = str(row.adGroupAd?.ad?.id);
|
||||||
|
if (!adId) continue;
|
||||||
|
const list = assetsByAdId.get(adId) || [];
|
||||||
|
const ft = enumName(row.adGroupAdAssetView?.fieldType).toUpperCase();
|
||||||
|
const img = str(row.asset?.imageAsset?.fullSize?.url);
|
||||||
|
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
|
||||||
|
const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle);
|
||||||
|
if (img && !list.some((a) => a.url === img)) {
|
||||||
|
list.push({
|
||||||
|
kind: ft.includes('LOGO') ? 'logo' : 'image',
|
||||||
|
url: img,
|
||||||
|
label: ft || 'MARKETING_IMAGE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (yt && !list.some((a) => a.youtubeId === yt)) {
|
||||||
|
list.push({
|
||||||
|
kind: 'youtube',
|
||||||
|
url: ytThumb(yt),
|
||||||
|
label: 'YOUTUBE_VIDEO',
|
||||||
|
youtubeId: yt,
|
||||||
|
title: ytTitle || yt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assetsByAdId.set(adId, list);
|
||||||
|
}
|
||||||
|
|
||||||
const agMetricMap = new Map<
|
const agMetricMap = new Map<
|
||||||
string,
|
string,
|
||||||
{ spend: number; impressions: number; clicks: number; conversions: number }
|
{ spend: number; impressions: number; clicks: number; conversions: number }
|
||||||
@@ -433,6 +536,7 @@ WHERE ${dateWhere}
|
|||||||
adName: str(row.assetGroup?.name) || `Asset group ${agid}`,
|
adName: str(row.assetGroup?.name) || `Asset group ${agid}`,
|
||||||
campaignId: str(row.campaign?.id),
|
campaignId: str(row.campaign?.id),
|
||||||
campaignName: str(row.campaign?.name),
|
campaignName: str(row.campaign?.name),
|
||||||
|
channelType: enumName(row.campaign?.advertisingChannelType),
|
||||||
status: enumName(row.assetGroup?.status) || 'ENABLED',
|
status: enumName(row.assetGroup?.status) || 'ENABLED',
|
||||||
finalUrls: (row.assetGroup?.finalUrls || []).map(str).filter(Boolean),
|
finalUrls: (row.assetGroup?.finalUrls || []).map(str).filter(Boolean),
|
||||||
headlines: [],
|
headlines: [],
|
||||||
@@ -509,6 +613,11 @@ WHERE ${dateWhere}
|
|||||||
conversions: Math.round(conversions * 100) / 100,
|
conversions: Math.round(conversions * 100) / 100,
|
||||||
cpl: conversions > 0 ? spend / conversions : null,
|
cpl: conversions > 0 ? spend / conversions : null,
|
||||||
mediaType: 'pmax',
|
mediaType: 'pmax',
|
||||||
|
channel: classifyGoogleChannel({
|
||||||
|
campaignName: entry.campaignName,
|
||||||
|
channelType: entry.channelType,
|
||||||
|
mediaType: 'pmax',
|
||||||
|
}),
|
||||||
thumbnailUrl: preview,
|
thumbnailUrl: preview,
|
||||||
imageUrl: preview,
|
imageUrl: preview,
|
||||||
headline: [...headlines, ...longHeadlines].join('\n'),
|
headline: [...headlines, ...longHeadlines].join('\n'),
|
||||||
@@ -548,26 +657,50 @@ WHERE ${dateWhere}
|
|||||||
...(rda?.descriptions || []).map((d) => str(d.text)),
|
...(rda?.descriptions || []).map((d) => str(d.text)),
|
||||||
]);
|
]);
|
||||||
const imageUrl = str(ad?.imageAd?.imageUrl);
|
const imageUrl = str(ad?.imageAd?.imageUrl);
|
||||||
const assets: CreativeAsset[] = [];
|
const linkedAssets = assetsByAdId.get(adId) || [];
|
||||||
if (imageUrl) assets.push({ kind: 'image', url: imageUrl, label: 'IMAGE_AD' });
|
const assets: CreativeAsset[] = [...linkedAssets];
|
||||||
|
if (imageUrl && !assets.some((a) => a.url === imageUrl)) {
|
||||||
|
assets.push({ kind: 'image', url: imageUrl, label: 'IMAGE_AD' });
|
||||||
|
}
|
||||||
const adType = enumName(ad?.type).toLowerCase() || 'rsa';
|
const adType = enumName(ad?.type).toLowerCase() || 'rsa';
|
||||||
const mediaType = rsa ? 'rsa' : rda ? 'rda' : imageUrl ? 'image' : adType;
|
const mediaType = rsa
|
||||||
|
? 'rsa'
|
||||||
|
: rda
|
||||||
|
? 'rda'
|
||||||
|
: imageUrl || linkedAssets.length
|
||||||
|
? 'image'
|
||||||
|
: adType;
|
||||||
const paths = [str(rsa?.path1), str(rsa?.path2)].filter(Boolean);
|
const paths = [str(rsa?.path1), str(rsa?.path2)].filter(Boolean);
|
||||||
|
const channelType = enumName(row.campaign?.advertisingChannelType);
|
||||||
|
const campaignName = str(row.campaign?.name);
|
||||||
|
const adsetName = str(row.adGroup?.name);
|
||||||
|
const preview =
|
||||||
|
pickVerticalGoogle(assets) ||
|
||||||
|
assets.find((a) => a.kind === 'image')?.url ||
|
||||||
|
assets.find((a) => a.kind === 'youtube')?.url ||
|
||||||
|
'';
|
||||||
|
const firstYt = assets.find((a) => a.youtubeId)?.youtubeId;
|
||||||
|
|
||||||
cards.push({
|
cards.push({
|
||||||
platform: 'google',
|
platform: 'google',
|
||||||
adId,
|
adId,
|
||||||
adName: str(ad?.name) || headlines[0] || `Ad ${adId}`,
|
adName: str(ad?.name) || headlines[0] || `Ad ${adId}`,
|
||||||
campaignName: str(row.campaign?.name),
|
campaignName,
|
||||||
adsetName: str(row.adGroup?.name),
|
adsetName,
|
||||||
spend: Math.round(spend * 100) / 100,
|
spend: Math.round(spend * 100) / 100,
|
||||||
impressions,
|
impressions,
|
||||||
clicks,
|
clicks,
|
||||||
conversions: Math.round(conversions * 100) / 100,
|
conversions: Math.round(conversions * 100) / 100,
|
||||||
cpl: conversions > 0 ? spend / conversions : null,
|
cpl: conversions > 0 ? spend / conversions : null,
|
||||||
mediaType,
|
mediaType,
|
||||||
thumbnailUrl: imageUrl,
|
channel: classifyGoogleChannel({
|
||||||
imageUrl,
|
campaignName,
|
||||||
|
channelType,
|
||||||
|
mediaType,
|
||||||
|
adsetName,
|
||||||
|
}),
|
||||||
|
thumbnailUrl: preview,
|
||||||
|
imageUrl: preview,
|
||||||
headline: [...headlines, ...longHeadlines].join('\n'),
|
headline: [...headlines, ...longHeadlines].join('\n'),
|
||||||
primaryText: descriptions.join('\n'),
|
primaryText: descriptions.join('\n'),
|
||||||
headlines,
|
headlines,
|
||||||
@@ -579,6 +712,7 @@ WHERE ${dateWhere}
|
|||||||
cta: str(rda?.callToActionText) || undefined,
|
cta: str(rda?.callToActionText) || undefined,
|
||||||
businessName: str(rda?.businessName) || undefined,
|
businessName: str(rda?.businessName) || undefined,
|
||||||
status: enumName(row.adGroupAd?.status),
|
status: enumName(row.adGroupAd?.status),
|
||||||
|
youtubeId: firstYt,
|
||||||
});
|
});
|
||||||
if (paths.length) {
|
if (paths.length) {
|
||||||
cards[cards.length - 1].bodies = [
|
cards[cards.length - 1].bodies = [
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ export type CreativeAsset = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Google Ads channel bucket for gallery grouping. */
|
||||||
|
export type GoogleCreativeChannel =
|
||||||
|
| 'search_brand'
|
||||||
|
| 'search_generic'
|
||||||
|
| 'pmax'
|
||||||
|
| 'display'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
export type AdCreativeCard = {
|
export type AdCreativeCard = {
|
||||||
platform: 'meta' | 'google';
|
platform: 'meta' | 'google';
|
||||||
adId: string;
|
adId: string;
|
||||||
@@ -45,6 +53,8 @@ export type AdCreativeCard = {
|
|||||||
conversions: number;
|
conversions: number;
|
||||||
cpl: number | null;
|
cpl: number | null;
|
||||||
mediaType: string;
|
mediaType: string;
|
||||||
|
/** Google: search_brand | search_generic | pmax | display | other */
|
||||||
|
channel?: GoogleCreativeChannel;
|
||||||
/** Best preview URL (prefer vertical / portrait). */
|
/** Best preview URL (prefer vertical / portrait). */
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user