fix: creatividades — Google siempre con fallback; Meta preview 9:16 y textos reales
OAuth Ads caducado: aviso amable + campañas warehouse. Meta deja de volcar todas las variantes del asset_feed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import type { AdCreativeCard, CreativeAsset } from '@/lib/ad-creatives-meta';
|
import type {
|
||||||
|
AdCreativeCard,
|
||||||
|
CreativeAspect,
|
||||||
|
CreativeAsset,
|
||||||
|
} from '@/lib/ad-creatives-meta';
|
||||||
import { formatEur, formatNum } from '@/lib/metrics';
|
import { formatEur, formatNum } from '@/lib/metrics';
|
||||||
import { ImageIcon, Play, Maximize2, X, ExternalLink } from 'lucide-react';
|
import { ImageIcon, Play, Maximize2, X, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
@@ -31,29 +35,57 @@ function adAssets(ad: AdCreativeCard): CreativeAsset[] {
|
|||||||
if (ad.assets?.length) return ad.assets;
|
if (ad.assets?.length) return ad.assets;
|
||||||
const out: CreativeAsset[] = [];
|
const out: CreativeAsset[] = [];
|
||||||
const u = ad.imageUrl || ad.thumbnailUrl;
|
const u = ad.imageUrl || ad.thumbnailUrl;
|
||||||
if (u) out.push({ kind: 'image', url: u, label: 'preview' });
|
if (u) out.push({ kind: 'image', url: u, label: 'preview', aspect: '9:16' });
|
||||||
if (ad.youtubeId) {
|
if (ad.youtubeId) {
|
||||||
out.push({
|
out.push({
|
||||||
kind: 'youtube',
|
kind: 'youtube',
|
||||||
url: `https://i.ytimg.com/vi/${ad.youtubeId}/hqdefault.jpg`,
|
url: `https://i.ytimg.com/vi/${ad.youtubeId}/hqdefault.jpg`,
|
||||||
youtubeId: ad.youtubeId,
|
youtubeId: ad.youtubeId,
|
||||||
label: 'YOUTUBE',
|
label: 'YOUTUBE',
|
||||||
|
aspect: '16:9',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAspect(asset: CreativeAsset): CreativeAspect {
|
||||||
|
if (asset.aspect && asset.aspect !== 'unknown') return asset.aspect;
|
||||||
|
const L = (asset.label || '').toLowerCase();
|
||||||
|
if (/9\s*:?\s*16|story|reels|vertical|tall/.test(L)) return '9:16';
|
||||||
|
if (/4\s*:?\s*5|portrait/.test(L)) return '4:5';
|
||||||
|
if (/16\s*:?\s*9|1\.91|landscape|horizontal|youtube|video/.test(L)) {
|
||||||
|
return '16:9';
|
||||||
|
}
|
||||||
|
if (/1\s*:?\s*1|square|logo/.test(L)) return '1:1';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function aspectClass(aspect: CreativeAspect): string {
|
||||||
|
switch (aspect) {
|
||||||
|
case '9:16':
|
||||||
|
return 'aspect-[9/16]';
|
||||||
|
case '4:5':
|
||||||
|
return 'aspect-[4/5]';
|
||||||
|
case '16:9':
|
||||||
|
return 'aspect-video';
|
||||||
|
case '1:1':
|
||||||
|
return 'aspect-square';
|
||||||
|
default:
|
||||||
|
return 'aspect-[4/5]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function previewUrlFor(ad: AdCreativeCard): string {
|
function previewUrlFor(ad: AdCreativeCard): string {
|
||||||
const assets = adAssets(ad);
|
const assets = adAssets(ad);
|
||||||
const portrait = assets.find(
|
const nine = assets.find(
|
||||||
(a) =>
|
(a) => a.kind === 'image' && resolveAspect(a) === '9:16'
|
||||||
a.kind === 'image' &&
|
);
|
||||||
/portrait|vertical|tall|TALL|PORTRAIT|story|4.?5|9.?16/i.test(
|
const fourFive = assets.find(
|
||||||
a.label || ''
|
(a) => a.kind === 'image' && resolveAspect(a) === '4:5'
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const raw =
|
const raw =
|
||||||
portrait?.url ||
|
nine?.url ||
|
||||||
|
fourFive?.url ||
|
||||||
ad.imageUrl ||
|
ad.imageUrl ||
|
||||||
ad.thumbnailUrl ||
|
ad.thumbnailUrl ||
|
||||||
assets.find((a) => a.kind === 'image')?.url ||
|
assets.find((a) => a.kind === 'image')?.url ||
|
||||||
@@ -96,14 +128,13 @@ export function CreativesGallery({
|
|||||||
.find((w) =>
|
.find((w) =>
|
||||||
platform === 'meta'
|
platform === 'meta'
|
||||||
? /meta|token meta|sin ads meta/i.test(w)
|
? /meta|token meta|sin ads meta/i.test(w)
|
||||||
: /google|pmax|rsa|sin ads google|sin assets/i.test(w)
|
: /google|pmax|rsa|sin ads google|sin assets|oauth|warehouse|reconecta/i.test(
|
||||||
) || null;
|
w
|
||||||
const shownWarning =
|
)
|
||||||
filtered.length === 0 && warning
|
) ||
|
||||||
? platformWarning || (ads.length === 0 ? warning : null)
|
(platform === 'google' && warning && /google|oauth|token/i.test(warning)
|
||||||
: platformWarning && filtered.length === 0
|
? warning
|
||||||
? platformWarning
|
: null);
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
@@ -115,12 +146,12 @@ 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 vertical + textos. Pulsa Zoom para ver todas las piezas.
|
Preview 9:16. Pulsa Zoom para ver el resto de piezas en su formato.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{shownWarning ? (
|
{platformWarning ? (
|
||||||
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
<p className="text-xs text-amber-800 border border-amber-200 bg-amber-50 rounded-lg px-3 py-2">
|
||||||
{shownWarning}
|
{platformWarning}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -155,17 +186,17 @@ function CreativeCard({
|
|||||||
}) {
|
}) {
|
||||||
const preview = previewUrlFor(ad);
|
const preview = previewUrlFor(ad);
|
||||||
const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId);
|
const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId);
|
||||||
const headlines = adHeadlines(ad);
|
const headline = adHeadlines(ad)[0] || '';
|
||||||
const bodies = adDescriptions(ad);
|
const body = adDescriptions(ad)[0] || '';
|
||||||
const assets = adAssets(ad);
|
const assets = adAssets(ad);
|
||||||
const extraCount = Math.max(0, assets.length - 1);
|
const extraCount = Math.max(0, assets.length - 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="grid gap-4 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(160px,220px)_1fr]">
|
<article className="grid gap-4 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(140px,180px)_1fr]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onZoom}
|
onClick={onZoom}
|
||||||
className="relative mx-auto w-full max-w-[220px] overflow-hidden rounded-xl bg-gray-100 border border-gray-200 aspect-[4/5] flex items-center justify-center group"
|
className="relative mx-auto w-full max-w-[180px] overflow-hidden rounded-xl bg-gray-100 border border-gray-200 aspect-[9/16] flex items-center justify-center group"
|
||||||
>
|
>
|
||||||
{preview ? (
|
{preview ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -175,19 +206,14 @@ function CreativeCard({
|
|||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : ad.platform === 'google' && headlines.length ? (
|
) : ad.platform === 'google' && headline ? (
|
||||||
<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 || 'rsa'}
|
||||||
</p>
|
</p>
|
||||||
{headlines.slice(0, 3).map((h) => (
|
<p className="text-sm font-semibold text-blue-300 leading-snug line-clamp-4">
|
||||||
<p
|
{headline}
|
||||||
key={h}
|
</p>
|
||||||
className="text-sm font-semibold text-blue-300 leading-snug"
|
|
||||||
>
|
|
||||||
{h}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-gray-500 text-xs px-4">
|
<div className="text-center text-gray-500 text-xs px-4">
|
||||||
@@ -202,7 +228,7 @@ function CreativeCard({
|
|||||||
) : null}
|
) : null}
|
||||||
{extraCount > 0 ? (
|
{extraCount > 0 ? (
|
||||||
<span className="absolute top-2 right-2 rounded-full bg-black/70 px-2 py-1 text-[10px] text-white">
|
<span className="absolute top-2 right-2 rounded-full bg-black/70 px-2 py-1 text-[10px] text-white">
|
||||||
+{extraCount} assets
|
+{extraCount}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="absolute inset-x-0 bottom-0 translate-y-full group-hover:translate-y-0 transition-transform bg-black/75 text-white text-[11px] py-2 flex items-center justify-center gap-1">
|
<span className="absolute inset-x-0 bottom-0 translate-y-full group-hover:translate-y-0 transition-transform bg-black/75 text-white text-[11px] py-2 flex items-center justify-center gap-1">
|
||||||
@@ -232,72 +258,19 @@ function CreativeCard({
|
|||||||
{ad.adsetName ? (
|
{ad.adsetName ? (
|
||||||
<p className="text-xs text-gray-400">Conjunto: {ad.adsetName}</p>
|
<p className="text-xs text-gray-400">Conjunto: {ad.adsetName}</p>
|
||||||
) : null}
|
) : 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-200 px-2 py-0.5 text-[10px] uppercase text-gray-400">
|
|
||||||
{ad.mediaType}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{assets.length > 0 ? (
|
|
||||||
<span className="rounded-full border border-gray-200 px-2 py-0.5 text-[10px] text-gray-400">
|
|
||||||
{assets.length} piezas
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{(headlines.length > 0 || bodies.length > 0) && (
|
{(headline || body) && (
|
||||||
<div className="space-y-2 text-xs border border-gray-200 rounded-xl p-3 bg-gray-50">
|
<div className="space-y-1.5 text-xs border border-gray-200 rounded-xl p-3 bg-gray-50">
|
||||||
{headlines.length > 0 ? (
|
{headline ? (
|
||||||
<div>
|
<p className="text-gray-900 font-medium leading-snug line-clamp-2">
|
||||||
<p className="text-[9px] uppercase tracking-wider text-gray-500 mb-1">
|
{headline}
|
||||||
Titulares
|
</p>
|
||||||
</p>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{headlines.slice(0, 4).map((h) => (
|
|
||||||
<li
|
|
||||||
key={h}
|
|
||||||
className="text-gray-800 font-medium leading-snug"
|
|
||||||
>
|
|
||||||
{h}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{headlines.length > 4 ? (
|
|
||||||
<li className="text-gray-500">+{headlines.length - 4} más</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
{bodies.length > 0 ? (
|
{body ? (
|
||||||
<div>
|
<p className="text-gray-500 leading-relaxed line-clamp-3">{body}</p>
|
||||||
<p className="text-[9px] uppercase tracking-wider text-gray-500 mb-1">
|
|
||||||
Textos / descripciones
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-1.5">
|
|
||||||
{bodies.slice(0, 3).map((b) => (
|
|
||||||
<li
|
|
||||||
key={b}
|
|
||||||
className="text-gray-400 leading-relaxed line-clamp-3"
|
|
||||||
>
|
|
||||||
{b}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{bodies.length > 3 ? (
|
|
||||||
<li className="text-gray-500">+{bodies.length - 3} más</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
{ad.cta ? (
|
{ad.cta ? (
|
||||||
<p className="text-[10px] text-brand-700 uppercase tracking-wide">
|
<p className="text-[10px] text-brand-700 uppercase tracking-wide pt-1">
|
||||||
CTA · {ad.cta}
|
CTA · {ad.cta}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -331,6 +304,9 @@ export function CreativeZoomModal({
|
|||||||
const images = assets.filter((a) => a.kind === 'image' || a.kind === 'logo');
|
const images = assets.filter((a) => a.kind === 'image' || a.kind === 'logo');
|
||||||
const videos = assets.filter((a) => a.kind === 'youtube' || a.kind === 'video');
|
const videos = assets.filter((a) => a.kind === 'youtube' || a.kind === 'video');
|
||||||
const main = previewUrlFor(ad);
|
const main = previewUrlFor(ad);
|
||||||
|
const previewAsset =
|
||||||
|
images.find((a) => resolveAspect(a) === '9:16') || images[0];
|
||||||
|
const otherImages = images.filter((a) => a.url !== previewAsset?.url);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -381,9 +357,9 @@ export function CreativeZoomModal({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6">
|
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6">
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(220px,320px)_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[minmax(200px,280px)_1fr]">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="relative mx-auto w-full max-w-[320px] overflow-hidden rounded-2xl border border-gray-200 bg-black aspect-[4/5]">
|
<div className="relative mx-auto w-full max-w-[280px] overflow-hidden rounded-2xl border border-gray-200 bg-black aspect-[9/16]">
|
||||||
{main ? (
|
{main ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
@@ -397,6 +373,9 @@ export function CreativeZoomModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-center text-[10px] uppercase tracking-wider text-gray-500">
|
||||||
|
Preview 9:16
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<MiniKpi label="Gasto" value={formatEur(ad.spend)} />
|
<MiniKpi label="Gasto" value={formatEur(ad.spend)} />
|
||||||
<MiniKpi label="Clics" value={formatNum(ad.clicks)} />
|
<MiniKpi label="Clics" value={formatNum(ad.clicks)} />
|
||||||
@@ -417,21 +396,11 @@ export function CreativeZoomModal({
|
|||||||
Formato:{' '}
|
Formato:{' '}
|
||||||
<span className="text-gray-600">{ad.mediaType || '—'}</span>
|
<span className="text-gray-600">{ad.mediaType || '—'}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
|
||||||
Estado:{' '}
|
|
||||||
<span className="text-gray-600">{ad.status || '—'}</span>
|
|
||||||
</p>
|
|
||||||
{ad.cta ? (
|
{ad.cta ? (
|
||||||
<p>
|
<p>
|
||||||
CTA: <span className="text-brand-700">{ad.cta}</span>
|
CTA: <span className="text-brand-700">{ad.cta}</span>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{ad.businessName ? (
|
|
||||||
<p>
|
|
||||||
Negocio:{' '}
|
|
||||||
<span className="text-gray-600">{ad.businessName}</span>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{ad.linkUrl ? (
|
{ad.linkUrl ? (
|
||||||
<a
|
<a
|
||||||
href={ad.linkUrl}
|
href={ad.linkUrl}
|
||||||
@@ -447,44 +416,37 @@ export function CreativeZoomModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{images.length > 0 ? (
|
{otherImages.length > 0 ? (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||||
Imágenes y logos ({images.length})
|
Otras piezas ({otherImages.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{images.map((asset, i) => (
|
{otherImages.map((asset, i) => {
|
||||||
<figure
|
const aspect = resolveAspect(asset);
|
||||||
key={`${asset.url}-${i}`}
|
return (
|
||||||
className="overflow-hidden rounded-xl border border-gray-200 bg-gray-100"
|
<figure
|
||||||
>
|
key={`${asset.url}-${i}`}
|
||||||
<div
|
className="overflow-hidden rounded-xl border border-gray-200 bg-gray-100"
|
||||||
className={
|
|
||||||
/PORTRAIT|TALL|vertical|story/i.test(
|
|
||||||
asset.label || ''
|
|
||||||
)
|
|
||||||
? 'aspect-[4/5]'
|
|
||||||
: /SQUARE/i.test(asset.label || '')
|
|
||||||
? 'aspect-square'
|
|
||||||
: 'aspect-video'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<div
|
||||||
<img
|
className={`${aspectClass(aspect)} max-h-[420px] mx-auto w-full bg-black/5`}
|
||||||
src={proxiedMediaUrl(asset.url)}
|
>
|
||||||
alt={asset.label || `asset-${i}`}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
className="w-full h-full object-cover"
|
<img
|
||||||
loading="lazy"
|
src={proxiedMediaUrl(asset.url)}
|
||||||
/>
|
alt={asset.label || `asset-${i}`}
|
||||||
</div>
|
className="w-full h-full object-contain"
|
||||||
{asset.label ? (
|
loading="lazy"
|
||||||
<figcaption className="px-2 py-1 text-[9px] uppercase tracking-wider text-gray-500 truncate">
|
/>
|
||||||
{asset.label}
|
</div>
|
||||||
|
<figcaption className="px-2 py-1.5 text-[10px] uppercase tracking-wider text-gray-500 truncate">
|
||||||
|
{aspect !== 'unknown' ? aspect : asset.label || 'imagen'}
|
||||||
{asset.kind === 'logo' ? ' · logo' : ''}
|
{asset.kind === 'logo' ? ' · logo' : ''}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
) : null}
|
</figure>
|
||||||
</figure>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -511,7 +473,7 @@ export function CreativeZoomModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : v.url ? (
|
) : v.url ? (
|
||||||
<div className="aspect-video relative">
|
<div className="aspect-[9/16] relative max-h-[420px] mx-auto">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={proxiedMediaUrl(v.url)}
|
src={proxiedMediaUrl(v.url)}
|
||||||
@@ -523,9 +485,6 @@ export function CreativeZoomModal({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="px-2 py-1.5 text-[10px] text-gray-400 truncate">
|
|
||||||
{v.title || v.youtubeId || v.label || 'Vídeo'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -535,10 +494,10 @@ export function CreativeZoomModal({
|
|||||||
{headlines.length > 0 ? (
|
{headlines.length > 0 ? (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||||
Titulares ({headlines.length})
|
Titular
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-1.5 text-sm text-gray-800">
|
<ul className="space-y-1.5 text-sm text-gray-800">
|
||||||
{headlines.map((h) => (
|
{headlines.slice(0, 2).map((h) => (
|
||||||
<li
|
<li
|
||||||
key={h}
|
key={h}
|
||||||
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
|
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
|
||||||
@@ -553,10 +512,10 @@ export function CreativeZoomModal({
|
|||||||
{longH.length > 0 ? (
|
{longH.length > 0 ? (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||||
Titulares largos ({longH.length})
|
Titulares largos
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-1.5 text-sm text-gray-800">
|
<ul className="space-y-1.5 text-sm text-gray-800">
|
||||||
{longH.map((h) => (
|
{longH.slice(0, 2).map((h) => (
|
||||||
<li
|
<li
|
||||||
key={h}
|
key={h}
|
||||||
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
|
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
|
||||||
@@ -571,10 +530,10 @@ export function CreativeZoomModal({
|
|||||||
{bodies.length > 0 ? (
|
{bodies.length > 0 ? (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||||
Textos / descripciones ({bodies.length})
|
Texto
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-2 text-sm text-gray-600">
|
<ul className="space-y-2 text-sm text-gray-600">
|
||||||
{bodies.map((b) => (
|
{bodies.slice(0, 2).map((b) => (
|
||||||
<li
|
<li
|
||||||
key={b}
|
key={b}
|
||||||
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 leading-relaxed whitespace-pre-wrap"
|
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 leading-relaxed whitespace-pre-wrap"
|
||||||
|
|||||||
@@ -103,9 +103,10 @@ export default function DriveConnectPanel() {
|
|||||||
<p className="text-xs text-gray-600 mt-0.5 max-w-xl">
|
<p className="text-xs text-gray-600 mt-0.5 max-w-xl">
|
||||||
Una sola conexión con{' '}
|
Una sola conexión con{' '}
|
||||||
<strong className="text-gray-800">marketing@4ventures.es</strong>.
|
<strong className="text-gray-800">marketing@4ventures.es</strong>.
|
||||||
En el consentimiento acepta Drive, Analytics y Search Console.
|
En el consentimiento acepta Drive, Analytics, Search Console y{' '}
|
||||||
Sirve para carpetas Drive, GA4, GSC y (con MCC) Google Ads.
|
<strong className="text-gray-800">Google Ads</strong>.
|
||||||
Tras el consentimiento se emparejan solos a las fichas.
|
Sirve para carpetas Drive, GA4, GSC y creatividades Google Ads.
|
||||||
|
Si ves error de token caducado en Ads, pulsa Reconectar Google.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ 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 } from '@/lib/ad-creatives-meta';
|
||||||
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta';
|
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta';
|
||||||
|
import {
|
||||||
|
friendlyGoogleCreativeWarning,
|
||||||
|
resolveGoogleAdsAccessToken,
|
||||||
|
} from '@/lib/google-ads-auth';
|
||||||
|
|
||||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||||
|
|
||||||
@@ -77,31 +81,7 @@ function uniq(arr: string[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function googleAccessToken(): Promise<string> {
|
async function googleAccessToken(): Promise<string> {
|
||||||
const refresh =
|
return resolveGoogleAdsAccessToken();
|
||||||
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
|
|
||||||
process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim();
|
|
||||||
const clientId =
|
|
||||||
process.env.GOOGLE_ADS_CLIENT_ID?.trim() ||
|
|
||||||
process.env.GOOGLE_OAUTH_CLIENT_ID?.trim();
|
|
||||||
const clientSecret =
|
|
||||||
process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() ||
|
|
||||||
process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim();
|
|
||||||
if (!refresh || !clientId || !clientSecret) {
|
|
||||||
throw new Error('Faltan credenciales Google Ads');
|
|
||||||
}
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
refresh_token: refresh,
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
});
|
|
||||||
const r = await fetch('https://oauth2.googleapis.com/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
if (!r.ok) throw new Error(`Google token: ${(await r.text()).slice(0, 180)}`);
|
|
||||||
return ((await r.json()) as { access_token: string }).access_token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function gadsHeaders(
|
function gadsHeaders(
|
||||||
@@ -205,9 +185,40 @@ type AgBucket = {
|
|||||||
assets: CreativeAsset[];
|
assets: CreativeAsset[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function cardsFromDaily(
|
||||||
|
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
|
||||||
|
since: string,
|
||||||
|
until: string,
|
||||||
|
limit: number,
|
||||||
|
description: string
|
||||||
|
): AdCreativeCard[] {
|
||||||
|
return aggregateDailyByAd(dailyRows, 'google', since, until)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((agg) => ({
|
||||||
|
platform: 'google' as const,
|
||||||
|
adId: agg.adId,
|
||||||
|
adName: agg.adName || agg.campaignName || agg.adId,
|
||||||
|
campaignName: agg.campaignName,
|
||||||
|
spend: Math.round(agg.spend * 100) / 100,
|
||||||
|
impressions: agg.impressions,
|
||||||
|
clicks: agg.clicks,
|
||||||
|
conversions: Math.round(agg.conversions * 100) / 100,
|
||||||
|
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
||||||
|
mediaType: 'campaign',
|
||||||
|
thumbnailUrl: '',
|
||||||
|
imageUrl: '',
|
||||||
|
headline: agg.campaignName || agg.adName,
|
||||||
|
primaryText: description,
|
||||||
|
headlines: [agg.campaignName || agg.adName].filter(Boolean),
|
||||||
|
descriptions: [description],
|
||||||
|
assets: [] as CreativeAsset[],
|
||||||
|
status: undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live Google creatives: Performance Max asset groups + RSA/RDA ads.
|
* Live Google creatives: Performance Max asset groups + RSA/RDA ads.
|
||||||
* Returns full asset packs (images, videos, logos, texts) for zoom UI.
|
* Always falls back to ad_daily campaign cards when the API/token fails.
|
||||||
*/
|
*/
|
||||||
export async function fetchGoogleCreativeCards(opts: {
|
export async function fetchGoogleCreativeCards(opts: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
@@ -216,30 +227,44 @@ export async function fetchGoogleCreativeCards(opts: {
|
|||||||
until: string;
|
until: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
|
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
|
||||||
if (!googleAdsConfigured()) {
|
const limit = opts.limit ?? 24;
|
||||||
|
const warehouseFallback = (warning: string) => {
|
||||||
|
const ads = cardsFromDaily(
|
||||||
|
opts.dailyRows,
|
||||||
|
opts.since,
|
||||||
|
opts.until,
|
||||||
|
limit,
|
||||||
|
'Preview desde warehouse (reconecta Google para assets)'
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
ads: [],
|
ads,
|
||||||
warning: 'Google Ads API no configurada (GOOGLE_ADS_*)',
|
warning: friendlyGoogleCreativeWarning(
|
||||||
|
ads.length ? warning : 'Sin ads Google en este periodo'
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!googleAdsConfigured()) {
|
||||||
|
return warehouseFallback(
|
||||||
|
'Google Ads API no configurada — mostrando campañas del periodo'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerId = await resolveGoogleCustomerId(opts.tenantId);
|
const customerId = await resolveGoogleCustomerId(opts.tenantId);
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
return { ads: [], warning: 'Cliente sin cuenta Google Ads enlazada' };
|
return warehouseFallback('Cliente sin cuenta Google Ads enlazada');
|
||||||
}
|
}
|
||||||
|
|
||||||
let accessToken: string;
|
let accessToken: string;
|
||||||
try {
|
try {
|
||||||
accessToken = await googleAccessToken();
|
accessToken = await googleAccessToken();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return warehouseFallback(
|
||||||
ads: [],
|
e instanceof Error ? e.message : 'Token Google Ads falló'
|
||||||
warning: e instanceof Error ? e.message : 'Token Google Ads falló',
|
);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateWhere = `segments.date BETWEEN '${opts.since}' AND '${opts.until}'`;
|
const dateWhere = `segments.date BETWEEN '${opts.since}' AND '${opts.until}'`;
|
||||||
const limit = opts.limit ?? 24;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [agAssets, agMetrics, adRows, adMetrics] = await Promise.all([
|
const [agAssets, agMetrics, adRows, adMetrics] = await Promise.all([
|
||||||
@@ -554,39 +579,15 @@ WHERE ${dateWhere}
|
|||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
if (!sliced.length) {
|
if (!sliced.length) {
|
||||||
const fallback = dailyAggs.slice(0, limit).map((agg) => ({
|
return warehouseFallback(
|
||||||
platform: 'google' as const,
|
'Google: sin assets PMax/RSA; mostrando campañas de ad_daily'
|
||||||
adId: agg.adId,
|
);
|
||||||
adName: agg.adName || agg.campaignName || agg.adId,
|
|
||||||
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: 'campaign',
|
|
||||||
thumbnailUrl: '',
|
|
||||||
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 {
|
|
||||||
ads: fallback,
|
|
||||||
warning: fallback.length
|
|
||||||
? 'Google: sin assets PMax/RSA; mostrando campañas de ad_daily'
|
|
||||||
: 'Sin ads Google en este periodo',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ads: sliced };
|
return { ads: sliced };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return warehouseFallback(
|
||||||
ads: [],
|
e instanceof Error ? e.message : 'Error Google creatives'
|
||||||
warning: e instanceof Error ? e.message : 'Error Google creatives',
|
);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ const CREATIVE_FIELDS = [
|
|||||||
'effective_object_story_id',
|
'effective_object_story_id',
|
||||||
].join(',');
|
].join(',');
|
||||||
|
|
||||||
|
export type CreativeAspect = '9:16' | '4:5' | '16:9' | '1:1' | 'unknown';
|
||||||
|
|
||||||
export type CreativeAsset = {
|
export type CreativeAsset = {
|
||||||
kind: 'image' | 'video' | 'logo' | 'youtube';
|
kind: 'image' | 'video' | 'logo' | 'youtube';
|
||||||
url: string;
|
url: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
aspect?: CreativeAspect;
|
||||||
youtubeId?: string;
|
youtubeId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
};
|
};
|
||||||
@@ -82,6 +85,28 @@ function uniq(arr: string[]): string[] {
|
|||||||
return [...new Set(arr.filter(Boolean))];
|
return [...new Set(arr.filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function inferCreativeAspect(
|
||||||
|
label?: string,
|
||||||
|
width?: number,
|
||||||
|
height?: number
|
||||||
|
): CreativeAspect {
|
||||||
|
const L = (label || '').toLowerCase();
|
||||||
|
if (/9\s*:?\s*16|story|reels|vertical|tall|portrait_9/.test(L)) return '9:16';
|
||||||
|
if (/4\s*:?\s*5|portrait|feed_portrait/.test(L)) return '4:5';
|
||||||
|
if (/16\s*:?\s*9|1\.91|landscape|horizontal|right_column|video/.test(L)) {
|
||||||
|
return '16:9';
|
||||||
|
}
|
||||||
|
if (/1\s*:?\s*1|square/.test(L)) return '1:1';
|
||||||
|
if (width && height && width > 0 && height > 0) {
|
||||||
|
const r = width / height;
|
||||||
|
if (r <= 0.62) return '9:16';
|
||||||
|
if (r <= 0.9) return '4:5';
|
||||||
|
if (r >= 1.5) return '16:9';
|
||||||
|
if (Math.abs(r - 1) < 0.12) return '1:1';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
function detectMediaType(creative: Record<string, unknown>): string {
|
function detectMediaType(creative: Record<string, unknown>): string {
|
||||||
if (creative.video_id) return 'video';
|
if (creative.video_id) return 'video';
|
||||||
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
||||||
@@ -113,74 +138,105 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
|||||||
const s = str(h);
|
const s = str(h);
|
||||||
if (/^[a-f0-9]{16,64}$/i.test(s)) hashes.push(s);
|
if (/^[a-f0-9]{16,64}$/i.test(s)) hashes.push(s);
|
||||||
};
|
};
|
||||||
const pushImg = (url: unknown, label?: string) => {
|
const pushImg = (
|
||||||
|
url: unknown,
|
||||||
|
label?: string,
|
||||||
|
width?: number,
|
||||||
|
height?: number
|
||||||
|
) => {
|
||||||
const u = str(url);
|
const u = str(url);
|
||||||
if (!/^https?:\/\//i.test(u)) return;
|
if (!/^https?:\/\//i.test(u)) return;
|
||||||
if (assets.some((a) => a.url === u)) return;
|
if (assets.some((a) => a.url === u)) return;
|
||||||
assets.push({ kind: 'image', url: u, label });
|
assets.push({
|
||||||
|
kind: 'image',
|
||||||
|
url: u,
|
||||||
|
label,
|
||||||
|
aspect: inferCreativeAspect(label, width, height),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (creative.title) headlines.push(str(creative.title));
|
// Primary serving copy = object_story_spec (what Ads Manager shows as the ad).
|
||||||
if (creative.body) descriptions.push(str(creative.body));
|
// Do NOT dump every asset_feed_spec A/B variant into the card.
|
||||||
pushImg(creative.image_url, 'image');
|
|
||||||
pushImg(creative.thumbnail_url, 'thumbnail');
|
|
||||||
pushHash(creative.image_hash);
|
|
||||||
|
|
||||||
const oss = creative.object_story_spec as Record<string, unknown> | undefined;
|
const oss = creative.object_story_spec as Record<string, unknown> | undefined;
|
||||||
const link = oss?.link_data 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;
|
const video = oss?.video_data as Record<string, unknown> | undefined;
|
||||||
const photo = oss?.photo_data as Record<string, unknown> | undefined;
|
const photo = oss?.photo_data as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (link?.name) headlines.push(str(link.name));
|
if (link?.name) headlines.push(str(link.name));
|
||||||
if (link?.message) descriptions.push(str(link.message));
|
if (link?.message) descriptions.push(str(link.message));
|
||||||
if (link?.description) descriptions.push(str(link.description));
|
else if (link?.description) descriptions.push(str(link.description));
|
||||||
if (link?.caption) descriptions.push(str(link.caption));
|
pushImg(link?.picture, '4:5 story', 1080, 1350);
|
||||||
pushImg(link?.picture, 'story');
|
pushImg(link?.image_url, '4:5 story', 1080, 1350);
|
||||||
pushImg(link?.image_url, 'story');
|
|
||||||
pushHash(link?.image_hash);
|
pushHash(link?.image_hash);
|
||||||
|
|
||||||
if (video?.title) headlines.push(str(video.title));
|
if (video?.title) headlines.push(str(video.title));
|
||||||
if (video?.message) descriptions.push(str(video.message));
|
if (video?.message) descriptions.push(str(video.message));
|
||||||
if (video?.link_description) descriptions.push(str(video.link_description));
|
else if (video?.link_description) descriptions.push(str(video.link_description));
|
||||||
pushImg(video?.image_url, 'video_poster');
|
pushImg(video?.image_url, 'video_poster 9:16', 1080, 1920);
|
||||||
pushHash(video?.image_hash);
|
pushHash(video?.image_hash);
|
||||||
pushImg(photo?.url, 'photo');
|
|
||||||
|
pushImg(photo?.url, 'photo 4:5', 1080, 1350);
|
||||||
pushHash(photo?.image_hash);
|
pushHash(photo?.image_hash);
|
||||||
|
|
||||||
|
if (!headlines.length && creative.title) headlines.push(str(creative.title));
|
||||||
|
if (!descriptions.length && creative.body) descriptions.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)) {
|
if (Array.isArray(link?.child_attachments)) {
|
||||||
(link!.child_attachments as Record<string, unknown>[]).forEach((c, i) => {
|
(link!.child_attachments as Record<string, unknown>[]).forEach((c, i) => {
|
||||||
pushImg(c.picture || c.image_url, `carousel-${i + 1}`);
|
pushImg(c.picture || c.image_url, `carousel-${i + 1} 4:5`, 1080, 1350);
|
||||||
pushHash(c.image_hash);
|
pushHash(c.image_hash);
|
||||||
if (c.name) headlines.push(str(c.name));
|
if (i === 0 && c.name && !headlines.length) headlines.push(str(c.name));
|
||||||
if (c.description) descriptions.push(str(c.description));
|
if (i === 0 && c.description && !descriptions.length) {
|
||||||
if (c.message) descriptions.push(str(c.message));
|
descriptions.push(str(c.description));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
||||||
if (feed) {
|
if (feed) {
|
||||||
for (const t of (feed.titles as { text?: string }[]) || []) {
|
// Images/videos from dynamic creative — label formats; texts only if still empty.
|
||||||
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<string, unknown>[]) || []) {
|
for (const img of (feed.images as Record<string, unknown>[]) || []) {
|
||||||
pushImg(img.url || img.permalink_url, 'feed');
|
const labels = Array.isArray(img.adlabels)
|
||||||
|
? (img.adlabels as { name?: string }[])
|
||||||
|
.map((l) => str(l.name))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
: '';
|
||||||
|
const tag = labels || 'feed';
|
||||||
|
pushImg(
|
||||||
|
img.url || img.permalink_url,
|
||||||
|
tag,
|
||||||
|
n(img.width) || undefined,
|
||||||
|
n(img.height) || undefined
|
||||||
|
);
|
||||||
pushHash(img.hash);
|
pushHash(img.hash);
|
||||||
}
|
}
|
||||||
for (const v of (feed.videos as Record<string, unknown>[]) || []) {
|
for (const v of (feed.videos as Record<string, unknown>[]) || []) {
|
||||||
pushImg(v.thumbnail_url || v.picture, 'video_feed');
|
pushImg(v.thumbnail_url || v.picture, 'video_feed 9:16', 1080, 1920);
|
||||||
if (v.video_id) {
|
if (v.video_id) {
|
||||||
assets.push({
|
assets.push({
|
||||||
kind: 'video',
|
kind: 'video',
|
||||||
url: str(v.thumbnail_url || v.picture),
|
url: str(v.thumbnail_url || v.picture),
|
||||||
label: 'video',
|
label: 'video 9:16',
|
||||||
title: str(v.video_id),
|
title: str(v.video_id),
|
||||||
|
aspect: '9:16',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 =
|
const linkUrl =
|
||||||
@@ -192,8 +248,8 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
|||||||
const cta = str(creative.call_to_action_type) || str(ctaRaw);
|
const cta = str(creative.call_to_action_type) || str(ctaRaw);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
headlines: uniq(headlines),
|
headlines: uniq(headlines).slice(0, 3),
|
||||||
descriptions: uniq(descriptions),
|
descriptions: uniq(descriptions).slice(0, 3),
|
||||||
assets,
|
assets,
|
||||||
linkUrl,
|
linkUrl,
|
||||||
cta,
|
cta,
|
||||||
@@ -201,15 +257,20 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefer portrait/vertical-looking assets; else first image. */
|
/** Prefer 9:16 story; then 4:5; else first image. */
|
||||||
function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string {
|
function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string {
|
||||||
const images = assets.filter((a) => a.kind === 'image' && a.url);
|
const images = assets.filter((a) => a.kind === 'image' && a.url);
|
||||||
const portrait = images.find((a) =>
|
const nine = images.find((a) => a.aspect === '9:16');
|
||||||
/portrait|vertical|9.?16|4.?5|tall|story/i.test(a.label || '')
|
if (nine) return nine.url;
|
||||||
|
const portrait = images.find(
|
||||||
|
(a) =>
|
||||||
|
a.aspect === '4:5' ||
|
||||||
|
/portrait|vertical|9.?16|4.?5|tall|story/i.test(a.label || '')
|
||||||
);
|
);
|
||||||
if (portrait) return portrait.url;
|
if (portrait) return portrait.url;
|
||||||
// Meta often serves taller thumbnails when requested 1080x1920 — thumbnail label last
|
const nonSquare = images.find(
|
||||||
const nonSquare = images.find((a) => a.label === 'thumbnail' || a.label === 'video_poster');
|
(a) => a.label?.includes('thumbnail') || a.label?.includes('video_poster')
|
||||||
|
);
|
||||||
if (nonSquare) return nonSquare.url;
|
if (nonSquare) return nonSquare.url;
|
||||||
return images[0]?.url || fallback;
|
return images[0]?.url || fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,36 +133,8 @@ async function metaToken(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function googleAdsAccessToken(): Promise<string> {
|
async function googleAdsAccessToken(): Promise<string> {
|
||||||
const refresh =
|
const { resolveGoogleAdsAccessToken } = await import('@/lib/google-ads-auth');
|
||||||
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
|
return resolveGoogleAdsAccessToken();
|
||||||
process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim();
|
|
||||||
const clientId =
|
|
||||||
process.env.GOOGLE_ADS_CLIENT_ID?.trim() ||
|
|
||||||
process.env.GOOGLE_OAUTH_CLIENT_ID?.trim();
|
|
||||||
const clientSecret =
|
|
||||||
process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() ||
|
|
||||||
process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim();
|
|
||||||
if (!refresh || !clientId || !clientSecret) {
|
|
||||||
throw new Error(
|
|
||||||
'Faltan GOOGLE_ADS_REFRESH_TOKEN + CLIENT_ID/SECRET (o GOOGLE_OAUTH_*)'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
refresh_token: refresh,
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
});
|
|
||||||
const r = await fetch('https://oauth2.googleapis.com/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
if (!r.ok) {
|
|
||||||
throw new Error(`Google Ads token: ${(await r.text()).slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
const j = (await r.json()) as { access_token: string };
|
|
||||||
return j.access_token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMetaAdAccounts(): Promise<NamedAccount[]> {
|
export async function listMetaAdAccounts(): Promise<NamedAccount[]> {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
||||||
import { leadFromMetaActions, nMeta } from '@/lib/meta-leads';
|
import { leadFromMetaActions, nMeta } from '@/lib/meta-leads';
|
||||||
|
import { resolveGoogleAdsAccessToken } from '@/lib/google-ads-auth';
|
||||||
|
|
||||||
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
||||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||||
@@ -66,32 +67,7 @@ function isoWeekMonday(d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function googleAccessToken(): Promise<string> {
|
async function googleAccessToken(): Promise<string> {
|
||||||
const refresh =
|
return resolveGoogleAdsAccessToken();
|
||||||
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
|
|
||||||
process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim();
|
|
||||||
const clientId =
|
|
||||||
process.env.GOOGLE_ADS_CLIENT_ID?.trim() ||
|
|
||||||
process.env.GOOGLE_OAUTH_CLIENT_ID?.trim();
|
|
||||||
const clientSecret =
|
|
||||||
process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() ||
|
|
||||||
process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim();
|
|
||||||
if (!refresh || !clientId || !clientSecret) {
|
|
||||||
throw new Error('Faltan credenciales Google Ads');
|
|
||||||
}
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
refresh_token: refresh,
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
});
|
|
||||||
const r = await fetch('https://oauth2.googleapis.com/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
if (!r.ok) throw new Error(`Google token: ${(await r.text()).slice(0, 180)}`);
|
|
||||||
const j = (await r.json()) as { access_token: string };
|
|
||||||
return j.access_token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function gadsHeaders(accessToken: string, loginCustomerId?: string) {
|
function gadsHeaders(accessToken: string, loginCustomerId?: string) {
|
||||||
|
|||||||
146
src/lib/google-ads-auth.ts
Normal file
146
src/lib/google-ads-auth.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/** Shared Google Ads OAuth access token resolution. */
|
||||||
|
|
||||||
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
|
|
||||||
|
const ADS_SCOPE = 'https://www.googleapis.com/auth/adwords';
|
||||||
|
|
||||||
|
type TokenAttempt = {
|
||||||
|
refresh: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function refreshAccess(attempt: TokenAttempt): Promise<string> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
refresh_token: attempt.refresh,
|
||||||
|
client_id: attempt.clientId,
|
||||||
|
client_secret: attempt.clientSecret,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
});
|
||||||
|
const r = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Google token (${attempt.label}): ${(await r.text()).slice(0, 180)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ((await r.json()) as { access_token: string }).access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adsClientPair(): { clientId: string; clientSecret: string } | null {
|
||||||
|
const clientId =
|
||||||
|
process.env.GOOGLE_ADS_CLIENT_ID?.trim() ||
|
||||||
|
process.env.GOOGLE_OAUTH_CLIENT_ID?.trim();
|
||||||
|
const clientSecret =
|
||||||
|
process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() ||
|
||||||
|
process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim();
|
||||||
|
if (!clientId || !clientSecret) return null;
|
||||||
|
return { clientId, clientSecret };
|
||||||
|
}
|
||||||
|
|
||||||
|
function oauthClientPair(): { clientId: string; clientSecret: string } | null {
|
||||||
|
const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID?.trim();
|
||||||
|
const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim();
|
||||||
|
if (!clientId || !clientSecret) return null;
|
||||||
|
return { clientId, clientSecret };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a Google Ads API access token.
|
||||||
|
* Order: env GOOGLE_ADS_REFRESH → agency_oauth google_ads → agency_oauth google_drive (si tiene scope Ads).
|
||||||
|
*/
|
||||||
|
export async function resolveGoogleAdsAccessToken(): Promise<string> {
|
||||||
|
const attempts: TokenAttempt[] = [];
|
||||||
|
const adsPair = adsClientPair();
|
||||||
|
const oauthPair = oauthClientPair();
|
||||||
|
|
||||||
|
const envRefresh =
|
||||||
|
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
|
||||||
|
process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim();
|
||||||
|
if (envRefresh && adsPair) {
|
||||||
|
attempts.push({
|
||||||
|
refresh: envRefresh,
|
||||||
|
clientId: adsPair.clientId,
|
||||||
|
clientSecret: adsPair.clientSecret,
|
||||||
|
label: 'env',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (headers) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/agency_oauth?provider=in.(google_ads,google_drive)&select=provider,refresh_token,scopes`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (r.ok) {
|
||||||
|
const rows = (await r.json()) as {
|
||||||
|
provider?: string;
|
||||||
|
refresh_token?: string | null;
|
||||||
|
scopes?: string | null;
|
||||||
|
}[];
|
||||||
|
for (const row of rows) {
|
||||||
|
const refresh = row.refresh_token?.trim();
|
||||||
|
if (!refresh) continue;
|
||||||
|
if (row.provider === 'google_ads' && adsPair) {
|
||||||
|
attempts.push({
|
||||||
|
refresh,
|
||||||
|
clientId: adsPair.clientId,
|
||||||
|
clientSecret: adsPair.clientSecret,
|
||||||
|
label: 'agency_oauth.google_ads',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (row.provider === 'google_drive') {
|
||||||
|
const scopes = row.scopes || '';
|
||||||
|
if (!scopes.includes('adwords') && !scopes.includes(ADS_SCOPE)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pair = oauthPair || adsPair;
|
||||||
|
if (!pair) continue;
|
||||||
|
attempts.push({
|
||||||
|
refresh,
|
||||||
|
clientId: pair.clientId,
|
||||||
|
clientSecret: pair.clientSecret,
|
||||||
|
label: 'agency_oauth.google_drive+ads',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attempts.length) {
|
||||||
|
throw new Error(
|
||||||
|
'Faltan credenciales Google Ads (GOOGLE_ADS_REFRESH_TOKEN o reconectar Agencia → Drive con Ads)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
try {
|
||||||
|
return await refreshAccess(attempt);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const joined = errors.join(' | ');
|
||||||
|
if (/invalid_grant|expired|revoked/i.test(joined)) {
|
||||||
|
throw new Error(
|
||||||
|
'Google OAuth caducado — reconecta en Agencia → Drive (incluye Ads)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(joined.slice(0, 220) || 'Token Google Ads falló');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function friendlyGoogleCreativeWarning(raw: string): string {
|
||||||
|
if (/invalid_grant|caducado|expired|revoked/i.test(raw)) {
|
||||||
|
return 'Google OAuth caducado — reconecta en Agencia → Drive (incluye Ads). Mostrando campañas del periodo.';
|
||||||
|
}
|
||||||
|
return raw.length > 180 ? `${raw.slice(0, 180)}…` : raw;
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@ import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
|||||||
|
|
||||||
export const DRIVE_PROVIDER = 'google_drive';
|
export const DRIVE_PROVIDER = 'google_drive';
|
||||||
|
|
||||||
/** Unified agency Google scopes: Drive + GA4 + Search Console. */
|
/** Unified agency Google scopes: Drive + GA4 + Search Console + Ads. */
|
||||||
export const DRIVE_SCOPES = [
|
export const DRIVE_SCOPES = [
|
||||||
'https://www.googleapis.com/auth/drive',
|
'https://www.googleapis.com/auth/drive',
|
||||||
'https://www.googleapis.com/auth/analytics.readonly',
|
'https://www.googleapis.com/auth/analytics.readonly',
|
||||||
'https://www.googleapis.com/auth/webmasters.readonly',
|
'https://www.googleapis.com/auth/webmasters.readonly',
|
||||||
|
'https://www.googleapis.com/auth/adwords',
|
||||||
'openid',
|
'openid',
|
||||||
'email',
|
'email',
|
||||||
'profile',
|
'profile',
|
||||||
@@ -172,6 +173,7 @@ export async function getAgencyOAuthStatus(): Promise<{
|
|||||||
hasAnalytics?: boolean;
|
hasAnalytics?: boolean;
|
||||||
hasSearchConsole?: boolean;
|
hasSearchConsole?: boolean;
|
||||||
hasDrive?: boolean;
|
hasDrive?: boolean;
|
||||||
|
hasAds?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const configured = driveConfigured();
|
const configured = driveConfigured();
|
||||||
const headers = serviceHeaders();
|
const headers = serviceHeaders();
|
||||||
@@ -202,6 +204,9 @@ export async function getAgencyOAuthStatus(): Promise<{
|
|||||||
hasSearchConsole:
|
hasSearchConsole:
|
||||||
scopes.includes('webmasters') || scopes.includes('searchconsole'),
|
scopes.includes('webmasters') || scopes.includes('searchconsole'),
|
||||||
hasDrive: scopes.includes('drive') || !scopes,
|
hasDrive: scopes.includes('drive') || !scopes,
|
||||||
|
hasAds:
|
||||||
|
scopes.includes('adwords') ||
|
||||||
|
scopes.includes('https://www.googleapis.com/auth/adwords'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user