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:
4Ventures
2026-09-28 18:46:24 +02:00
parent 6d8dfaa79c
commit 331c19e873
8 changed files with 435 additions and 314 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -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', );
};
} }
} }

View File

@@ -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;
} }

View File

@@ -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[]> {

View File

@@ -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
View 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;
}

View File

@@ -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'),
}; };
} }