feat: calendario bajo Social, SEO/GEO con datos live, Drive = 03-Clientes
Calendario editorial anidado en Social; Orgánico con queries/páginas GSC y landings GA4 + dashboard GEO; Biblioteca enlazada a la carpeta real del cliente bajo 03-Clientes (no carpetas homónimas); previews Ads solo en Meta/Google. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,8 +3,11 @@ import { createClient } from '@/lib/supabase/server';
|
|||||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
import {
|
import {
|
||||||
|
findClientesRootFolder,
|
||||||
getDriveDownloadUrl,
|
getDriveDownloadUrl,
|
||||||
|
getDriveFileMeta,
|
||||||
getTenantDriveFolderId,
|
getTenantDriveFolderId,
|
||||||
|
isUnderDriveFolder,
|
||||||
listFilesInFolder,
|
listFilesInFolder,
|
||||||
} from '@/lib/google-drive';
|
} from '@/lib/google-drive';
|
||||||
|
|
||||||
@@ -12,7 +15,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
const FOLDER_MIME = 'application/vnd.google-apps.folder';
|
const FOLDER_MIME = 'application/vnd.google-apps.folder';
|
||||||
|
|
||||||
/** GET — list Drive files for tenant folder (browse with ?folderId=). */
|
/** GET — exact replica of the tenant's Drive folder tree (under Clientes). */
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
@@ -35,6 +38,20 @@ export async function GET(req: NextRequest) {
|
|||||||
const downloadId = req.nextUrl.searchParams.get('download')?.trim();
|
const downloadId = req.nextUrl.searchParams.get('download')?.trim();
|
||||||
if (downloadId) {
|
if (downloadId) {
|
||||||
try {
|
try {
|
||||||
|
const link = await getTenantDriveFolderId(ctx.tenantId);
|
||||||
|
if (!link) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Sin carpeta Drive del cliente' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ok = await isUnderDriveFolder(downloadId, link.folderId);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Archivo fuera de la carpeta del cliente' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
const url = await getDriveDownloadUrl(downloadId);
|
const url = await getDriveDownloadUrl(downloadId);
|
||||||
return NextResponse.json({ url });
|
return NextResponse.json({ url });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -51,14 +68,45 @@ export async function GET(req: NextRequest) {
|
|||||||
files: [],
|
files: [],
|
||||||
linked: false,
|
linked: false,
|
||||||
warning:
|
warning:
|
||||||
'Sin carpeta Drive enlazada. Conecta marketing@ en Agencia y ejecuta auto-match bajo «Clientes».',
|
'Sin carpeta Drive del cliente. En Agencia → auto-match bajo la carpeta «Clientes».',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Soft check: linked folder should live under «Clientes»
|
||||||
|
let scopeWarning: string | null = null;
|
||||||
|
try {
|
||||||
|
const clientes = await findClientesRootFolder();
|
||||||
|
if (clientes) {
|
||||||
|
const ok = await isUnderDriveFolder(link.folderId, clientes.id);
|
||||||
|
if (!ok) {
|
||||||
|
scopeWarning = `La carpeta enlazada («${link.folderName || link.folderId}») no está dentro de «Clientes». Rematch en Agencia.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
const requested = req.nextUrl.searchParams.get('folderId')?.trim();
|
const requested = req.nextUrl.searchParams.get('folderId')?.trim();
|
||||||
const folderId = requested || link.folderId;
|
let folderId = link.folderId;
|
||||||
|
|
||||||
|
if (requested && requested !== link.folderId) {
|
||||||
|
const allowed = await isUnderDriveFolder(requested, link.folderId);
|
||||||
|
if (!allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'Carpeta fuera del árbol del cliente. Solo se permite navegar dentro de su carpeta en Clientes.',
|
||||||
|
rootFolderId: link.folderId,
|
||||||
|
folderName: link.folderName,
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
folderId = requested;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const currentMeta = await getDriveFileMeta(folderId);
|
||||||
const files = await listFilesInFolder(folderId);
|
const files = await listFilesInFolder(folderId);
|
||||||
const mapped = files
|
const mapped = files
|
||||||
.map((f) => ({
|
.map((f) => ({
|
||||||
@@ -79,22 +127,22 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
linked: true,
|
linked: true,
|
||||||
rootFolderId: link.folderId,
|
rootFolderId: link.folderId,
|
||||||
|
rootFolderName: link.folderName,
|
||||||
folderId,
|
folderId,
|
||||||
folderName: link.folderName,
|
folderName: currentMeta?.name || link.folderName,
|
||||||
driveOpenUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`,
|
driveOpenUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`,
|
||||||
embedPossible: false,
|
|
||||||
embedHint:
|
|
||||||
'Réplica vía API (misma carpeta Clientes del cliente). Embed iframe de Drive exige carpeta pública; usamos listado nativo + Abrir en Drive.',
|
|
||||||
files: mapped,
|
files: mapped,
|
||||||
|
warning: scopeWarning,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
linked: true,
|
linked: true,
|
||||||
rootFolderId: link.folderId,
|
rootFolderId: link.folderId,
|
||||||
|
rootFolderName: link.folderName,
|
||||||
folderId,
|
folderId,
|
||||||
folderName: link.folderName,
|
folderName: link.folderName,
|
||||||
files: [],
|
files: [],
|
||||||
warning: e instanceof Error ? e.message : 'Drive error',
|
warning: scopeWarning || (e instanceof Error ? e.message : 'Drive error'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,105 @@ import {
|
|||||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
import type { GeoSnapshot, OrganicDailyRow } from '@/lib/organic';
|
import type { GeoSnapshot, OrganicDailyRow } from '@/lib/organic';
|
||||||
import {
|
import {
|
||||||
|
fetchGa4TopLandings,
|
||||||
|
fetchGscTopRows,
|
||||||
organicNeedsRefresh,
|
organicNeedsRefresh,
|
||||||
syncAllConnectedOrganic,
|
syncAllConnectedOrganic,
|
||||||
} from '@/lib/google-organic';
|
} from '@/lib/google-organic';
|
||||||
import { getAgencyOAuthStatus } from '@/lib/google-drive';
|
import { getAgencyOAuthStatus } from '@/lib/google-drive';
|
||||||
|
import { isoYesterday } from '@/lib/ad-tracker-range';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
async function loadLiveSeoDims(
|
||||||
|
integrations: { provider?: string; status?: string; external_account_id?: string | null; meta?: { property_id?: string; site_url?: string } }[]
|
||||||
|
): Promise<{
|
||||||
|
gscQueries: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
|
||||||
|
gscPages: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
|
||||||
|
ga4Landings: { page: string; sessions: number; users: number }[];
|
||||||
|
liveWarning?: string;
|
||||||
|
}> {
|
||||||
|
const empty = { gscQueries: [], gscPages: [], ga4Landings: [] };
|
||||||
|
try {
|
||||||
|
const oauth = await getAgencyOAuthStatus();
|
||||||
|
if (!oauth.connected) return empty;
|
||||||
|
|
||||||
|
const gsc = integrations.find(
|
||||||
|
(i) => i.provider === 'search_console' && i.status === 'connected'
|
||||||
|
);
|
||||||
|
const ga4 = integrations.find(
|
||||||
|
(i) => i.provider === 'ga4' && i.status === 'connected'
|
||||||
|
);
|
||||||
|
const until = isoYesterday();
|
||||||
|
const sinceDate = new Date(`${until}T12:00:00Z`);
|
||||||
|
sinceDate.setUTCDate(sinceDate.getUTCDate() - 27);
|
||||||
|
const since = sinceDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const siteUrl =
|
||||||
|
gsc?.meta?.site_url ||
|
||||||
|
(gsc?.external_account_id?.includes('http') ||
|
||||||
|
gsc?.external_account_id?.startsWith('sc-domain:')
|
||||||
|
? gsc.external_account_id
|
||||||
|
: null);
|
||||||
|
const propertyId =
|
||||||
|
ga4?.meta?.property_id ||
|
||||||
|
(ga4?.external_account_id?.startsWith('properties/')
|
||||||
|
? ga4.external_account_id
|
||||||
|
: ga4?.external_account_id?.match(/^\d+$/)
|
||||||
|
? `properties/${ga4.external_account_id}`
|
||||||
|
: null);
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let gscQueries: Awaited<ReturnType<typeof fetchGscTopRows>> = [];
|
||||||
|
let gscPages: Awaited<ReturnType<typeof fetchGscTopRows>> = [];
|
||||||
|
let ga4Landings: Awaited<ReturnType<typeof fetchGa4TopLandings>> = [];
|
||||||
|
|
||||||
|
if (siteUrl) {
|
||||||
|
try {
|
||||||
|
gscQueries = await fetchGscTopRows({
|
||||||
|
siteUrl,
|
||||||
|
startDate: since,
|
||||||
|
endDate: until,
|
||||||
|
dimension: 'query',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
warnings.push(e instanceof Error ? e.message : 'gsc queries');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
gscPages = await fetchGscTopRows({
|
||||||
|
siteUrl,
|
||||||
|
startDate: since,
|
||||||
|
endDate: until,
|
||||||
|
dimension: 'page',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
warnings.push(e instanceof Error ? e.message : 'gsc pages');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (propertyId) {
|
||||||
|
try {
|
||||||
|
ga4Landings = await fetchGa4TopLandings({
|
||||||
|
propertyId,
|
||||||
|
startDate: since,
|
||||||
|
endDate: until,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
warnings.push(e instanceof Error ? e.message : 'ga4 landings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gscQueries,
|
||||||
|
gscPages,
|
||||||
|
ga4Landings,
|
||||||
|
liveWarning: warnings[0],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function gate() {
|
async function gate() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
@@ -89,7 +180,7 @@ export async function GET(req: NextRequest) {
|
|||||||
{ headers, cache: 'no-store' }
|
{ headers, cache: 'no-store' }
|
||||||
),
|
),
|
||||||
fetch(
|
fetch(
|
||||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tid)}&provider=in.(ga4,search_console,tag_manager)&select=provider,status,enabled,external_account_id,last_sync_at,error_message`,
|
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tid)}&provider=in.(ga4,search_console,tag_manager)&select=provider,status,enabled,external_account_id,last_sync_at,error_message,meta`,
|
||||||
{ headers, cache: 'no-store' }
|
{ headers, cache: 'no-store' }
|
||||||
),
|
),
|
||||||
fetch(
|
fetch(
|
||||||
@@ -126,6 +217,15 @@ export async function GET(req: NextRequest) {
|
|||||||
workspace = rows[0]?.settings?.organico || {};
|
workspace = rows[0]?.settings?.organico || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const live = await loadLiveSeoDims(
|
||||||
|
integrations as {
|
||||||
|
provider?: string;
|
||||||
|
status?: string;
|
||||||
|
external_account_id?: string | null;
|
||||||
|
meta?: { property_id?: string; site_url?: string };
|
||||||
|
}[]
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
portfolioWide: false,
|
portfolioWide: false,
|
||||||
label: ctx.label,
|
label: ctx.label,
|
||||||
@@ -135,7 +235,14 @@ export async function GET(req: NextRequest) {
|
|||||||
integrations,
|
integrations,
|
||||||
workspace,
|
workspace,
|
||||||
autoSynced,
|
autoSynced,
|
||||||
warning: warningParts[0] || autoSynced?.warning || null,
|
gscQueries: live.gscQueries,
|
||||||
|
gscPages: live.gscPages,
|
||||||
|
ga4Landings: live.ga4Landings,
|
||||||
|
warning:
|
||||||
|
warningParts[0] ||
|
||||||
|
autoSynced?.warning ||
|
||||||
|
live.liveWarning ||
|
||||||
|
null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,12 +263,21 @@ export async function GET(req: NextRequest) {
|
|||||||
supabase
|
supabase
|
||||||
.from('integrations')
|
.from('integrations')
|
||||||
.select(
|
.select(
|
||||||
'provider,status,enabled,external_account_id,last_sync_at,error_message'
|
'provider,status,enabled,external_account_id,last_sync_at,error_message,meta'
|
||||||
)
|
)
|
||||||
.in('provider', ['ga4', 'search_console', 'tag_manager']),
|
.in('provider', ['ga4', 'search_console', 'tag_manager']),
|
||||||
supabase.from('tenants').select('settings').eq('id', tid).maybeSingle(),
|
supabase.from('tenants').select('settings').eq('id', tid).maybeSingle(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const live = await loadLiveSeoDims(
|
||||||
|
(integ.data || []) as {
|
||||||
|
provider?: string;
|
||||||
|
status?: string;
|
||||||
|
external_account_id?: string | null;
|
||||||
|
meta?: { property_id?: string; site_url?: string };
|
||||||
|
}[]
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
portfolioWide: false,
|
portfolioWide: false,
|
||||||
label: ctx.label,
|
label: ctx.label,
|
||||||
@@ -171,7 +287,15 @@ export async function GET(req: NextRequest) {
|
|||||||
integrations: integ.data || [],
|
integrations: integ.data || [],
|
||||||
workspace: (ten.data?.settings as { organico?: unknown } | null)?.organico || {},
|
workspace: (ten.data?.settings as { organico?: unknown } | null)?.organico || {},
|
||||||
autoSynced,
|
autoSynced,
|
||||||
warning: o.error?.message || ge.error?.message || autoSynced?.warning || null,
|
gscQueries: live.gscQueries,
|
||||||
|
gscPages: live.gscPages,
|
||||||
|
ga4Landings: live.ga4Landings,
|
||||||
|
warning:
|
||||||
|
o.error?.message ||
|
||||||
|
ge.error?.message ||
|
||||||
|
autoSynced?.warning ||
|
||||||
|
live.liveWarning ||
|
||||||
|
null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -328,13 +328,18 @@ export default function AdTrackerClient({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (tab === 'resumen') {
|
||||||
|
setCreatives([]);
|
||||||
|
setCreativesWarning(null);
|
||||||
|
setCreativesLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function load() {
|
async function load() {
|
||||||
setCreativesLoading(true);
|
setCreativesLoading(true);
|
||||||
setCreativesWarning(null);
|
setCreativesWarning(null);
|
||||||
try {
|
try {
|
||||||
const platform =
|
const platform = tab === 'meta' ? 'meta' : 'google';
|
||||||
tab === 'meta' ? 'meta' : tab === 'google' ? 'google' : 'all';
|
|
||||||
const qs = new URLSearchParams({
|
const qs = new URLSearchParams({
|
||||||
since: range.since,
|
since: range.since,
|
||||||
until: range.until,
|
until: range.until,
|
||||||
@@ -532,21 +537,6 @@ export default function AdTrackerClient({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<CreativesGallery
|
|
||||||
platform="meta"
|
|
||||||
ads={creatives}
|
|
||||||
loading={creativesLoading}
|
|
||||||
warning={creativesWarning}
|
|
||||||
onZoom={setZoomAd}
|
|
||||||
/>
|
|
||||||
<CreativesGallery
|
|
||||||
platform="google"
|
|
||||||
ads={creatives}
|
|
||||||
loading={creativesLoading}
|
|
||||||
warning={creativesWarning}
|
|
||||||
onZoom={setZoomAd}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="rounded-2xl border border-gray-200 bg-white p-4 md:p-5">
|
<section className="rounded-2xl border border-gray-200 bg-white p-4 md:p-5">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
|
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -256,34 +256,34 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Contenido"
|
eyebrow="Contenido"
|
||||||
title="Activos"
|
title="Activos"
|
||||||
description={`Réplica de la carpeta Drive del cliente (${tenantLabel}) bajo Clientes. Navega carpetas como en Drive; «Abrir en Drive» para la UI nativa.`}
|
description={`Réplica exacta de Drive · carpeta del cliente «${tenantLabel}» (bajo Clientes). Solo ese árbol; sin mezclar otros clientes.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTab('portal')}
|
|
||||||
className={`text-xs px-3 py-1.5 rounded-lg border ${
|
|
||||||
tab === 'portal'
|
|
||||||
? 'border-brand-500/40 text-brand-400'
|
|
||||||
: 'border-gray-200 text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Portal
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab('drive')}
|
onClick={() => setTab('drive')}
|
||||||
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${
|
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${
|
||||||
tab === 'drive'
|
tab === 'drive'
|
||||||
? 'border-brand-500/40 text-brand-400'
|
? 'border-brand-500/40 text-brand-600 bg-brand-50'
|
||||||
: 'border-gray-200 text-gray-400'
|
: 'border-gray-200 text-gray-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<HardDrive className="w-3.5 h-3.5" />
|
<HardDrive className="w-3.5 h-3.5" />
|
||||||
Google Drive
|
Google Drive
|
||||||
{driveFolder ? ` · ${driveFolder}` : ''}
|
{driveFolder ? ` · ${driveFolder}` : ''}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('portal')}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded-lg border ${
|
||||||
|
tab === 'portal'
|
||||||
|
? 'border-brand-500/40 text-brand-600 bg-brand-50'
|
||||||
|
: 'border-gray-200 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Subidas portal
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{warning && (
|
{warning && (
|
||||||
|
|||||||
@@ -64,13 +64,6 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
iconClass: 'text-emerald-500',
|
iconClass: 'text-emerald-500',
|
||||||
moduleId: 'estrategia',
|
moduleId: 'estrategia',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: '/dashboard/calendario',
|
|
||||||
label: 'Calendario Editorial',
|
|
||||||
icon: CalendarDays,
|
|
||||||
iconClass: 'text-pink-500',
|
|
||||||
moduleId: 'calendario',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: '/dashboard/ad-tracker',
|
href: '/dashboard/ad-tracker',
|
||||||
label: 'Ads',
|
label: 'Ads',
|
||||||
@@ -174,6 +167,7 @@ export default function DashboardShell({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const close = () => setOpen(false);
|
const close = () => setOpen(false);
|
||||||
const dateRange = useDateRange();
|
const dateRange = useDateRange();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const visibleNav = NAV_ITEMS.filter((item) => {
|
const visibleNav = NAV_ITEMS.filter((item) => {
|
||||||
if (isAgency) return true;
|
if (isAgency) return true;
|
||||||
@@ -230,9 +224,32 @@ export default function DashboardShell({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto space-y-1.5 pr-1">
|
<nav className="flex-1 overflow-y-auto space-y-1.5 pr-1">
|
||||||
{visibleNav.map((item) => (
|
{visibleNav.map((item) => {
|
||||||
<NavLink key={item.href} item={item} onNavigate={close} />
|
const showCalendarioUnderSocial =
|
||||||
))}
|
item.moduleId === 'social' &&
|
||||||
|
(isAgency ||
|
||||||
|
!enabledModules ||
|
||||||
|
enabledModules.calendario !== false);
|
||||||
|
return (
|
||||||
|
<div key={item.href} className="space-y-0.5">
|
||||||
|
<NavLink item={item} onNavigate={close} />
|
||||||
|
{showCalendarioUnderSocial ? (
|
||||||
|
<Link
|
||||||
|
href="/dashboard/calendario"
|
||||||
|
onClick={close}
|
||||||
|
className={`ml-4 flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
isActive(pathname, '/dashboard/calendario')
|
||||||
|
? 'bg-brand-50 text-gray-900 border border-brand-200'
|
||||||
|
: 'text-gray-500 border border-transparent hover:text-gray-900 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-3.5 h-3.5 shrink-0 text-pink-500" />
|
||||||
|
Calendario editorial
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{isAgency ? (
|
{isAgency ? (
|
||||||
<div className="pt-4 mt-4 border-t border-gray-200">
|
<div className="pt-4 mt-4 border-t border-gray-200">
|
||||||
|
|||||||
@@ -86,14 +86,23 @@ export default function OrganicoDashboard({
|
|||||||
isAgency,
|
isAgency,
|
||||||
tenantId,
|
tenantId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [pillar, setPillar] = useState<OrganicoPillar>('geo');
|
const [pillar, setPillar] = useState<OrganicoPillar>('seo');
|
||||||
const [sectionId, setSectionId] = useState<OrganicoSectionId>('geo-overview');
|
const [sectionId, setSectionId] = useState<OrganicoSectionId>('seo-overview');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [warning, setWarning] = useState<string | null>(null);
|
const [warning, setWarning] = useState<string | null>(null);
|
||||||
const [organic, setOrganic] = useState<OrganicDailyRow[]>([]);
|
const [organic, setOrganic] = useState<OrganicDailyRow[]>([]);
|
||||||
const [geo, setGeo] = useState<GeoSnapshot[]>([]);
|
const [geo, setGeo] = useState<GeoSnapshot[]>([]);
|
||||||
const [integrations, setIntegrations] = useState<IntegrationLite[]>([]);
|
const [integrations, setIntegrations] = useState<IntegrationLite[]>([]);
|
||||||
const [workspace, setWorkspace] = useState<OrganicoWorkspace>({});
|
const [workspace, setWorkspace] = useState<OrganicoWorkspace>({});
|
||||||
|
const [gscQueries, setGscQueries] = useState<
|
||||||
|
{ key: string; clicks: number; impressions: number; ctr: number; position: number }[]
|
||||||
|
>([]);
|
||||||
|
const [gscPages, setGscPages] = useState<
|
||||||
|
{ key: string; clicks: number; impressions: number; ctr: number; position: number }[]
|
||||||
|
>([]);
|
||||||
|
const [ga4Landings, setGa4Landings] = useState<
|
||||||
|
{ page: string; sessions: number; users: number }[]
|
||||||
|
>([]);
|
||||||
const [sectionNote, setSectionNote] = useState('');
|
const [sectionNote, setSectionNote] = useState('');
|
||||||
const [prioritiesText, setPrioritiesText] = useState('');
|
const [prioritiesText, setPrioritiesText] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -135,6 +144,9 @@ export default function OrganicoDashboard({
|
|||||||
setGeo(data.geo || []);
|
setGeo(data.geo || []);
|
||||||
setIntegrations(data.integrations || []);
|
setIntegrations(data.integrations || []);
|
||||||
setWorkspace(data.workspace || {});
|
setWorkspace(data.workspace || {});
|
||||||
|
setGscQueries(data.gscQueries || []);
|
||||||
|
setGscPages(data.gscPages || []);
|
||||||
|
setGa4Landings(data.ga4Landings || []);
|
||||||
setWarning(data.warning || null);
|
setWarning(data.warning || null);
|
||||||
if (data.autoSynced?.daysWritten) {
|
if (data.autoSynced?.daysWritten) {
|
||||||
setMsg(
|
setMsg(
|
||||||
@@ -618,26 +630,49 @@ export default function OrganicoDashboard({
|
|||||||
{totals.days === 0 && (
|
{totals.days === 0 && (
|
||||||
<EmptyHint text="GSC/GA4 conectados pero sin filas en organic_daily. Lanza sync en Agencia o revisa property/URL-prefix." />
|
<EmptyHint text="GSC/GA4 conectados pero sin filas en organic_daily. Lanza sync en Agencia o revisa property/URL-prefix." />
|
||||||
)}
|
)}
|
||||||
|
<SeoTables
|
||||||
|
queries={gscQueries}
|
||||||
|
pages={gscPages}
|
||||||
|
landings={ga4Landings}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section.live === 'geo-metrics' && latestGeo && (
|
{(section.id === 'seo-keywords' ||
|
||||||
|
section.id === 'seo-rankings' ||
|
||||||
|
section.id === 'seo-keyword-magic') && (
|
||||||
|
<SeoTables queries={gscQueries} pages={[]} landings={[]} focus="queries" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(section.id === 'seo-pages' ||
|
||||||
|
section.id === 'seo-organic' ||
|
||||||
|
section.id === 'seo-traffic-insights') && (
|
||||||
|
<SeoTables
|
||||||
|
queries={[]}
|
||||||
|
pages={gscPages}
|
||||||
|
landings={ga4Landings}
|
||||||
|
focus="pages"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section.live === 'geo-metrics' && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<Kpi
|
<Kpi
|
||||||
accent
|
accent
|
||||||
label="Share of Model"
|
label="Share of Model"
|
||||||
value={
|
value={
|
||||||
latestGeo.share_of_model != null
|
latestGeo?.share_of_model != null
|
||||||
? formatPct(Number(latestGeo.share_of_model))
|
? formatPct(Number(latestGeo.share_of_model))
|
||||||
: '—'
|
: '—'
|
||||||
}
|
}
|
||||||
|
hint={latestGeo ? undefined : 'Sin medición aún'}
|
||||||
/>
|
/>
|
||||||
<Kpi
|
<Kpi
|
||||||
accent
|
accent
|
||||||
label="Citas web oficial"
|
label="Citas web oficial"
|
||||||
value={
|
value={
|
||||||
latestGeo.official_citation_pct != null
|
latestGeo?.official_citation_pct != null
|
||||||
? formatPct(Number(latestGeo.official_citation_pct))
|
? formatPct(Number(latestGeo.official_citation_pct))
|
||||||
: '—'
|
: '—'
|
||||||
}
|
}
|
||||||
@@ -645,26 +680,25 @@ export default function OrganicoDashboard({
|
|||||||
<Kpi
|
<Kpi
|
||||||
label="Sentimiento"
|
label="Sentimiento"
|
||||||
value={
|
value={
|
||||||
latestGeo.sentiment_score != null
|
latestGeo?.sentiment_score != null
|
||||||
? formatNum(Number(latestGeo.sentiment_score), 0)
|
? formatNum(Number(latestGeo.sentiment_score), 0)
|
||||||
: '—'
|
: '—'
|
||||||
}
|
}
|
||||||
hint="0–100"
|
hint="0–100"
|
||||||
/>
|
/>
|
||||||
<Kpi
|
<Kpi
|
||||||
label="Muestra"
|
label="Prompts analizados"
|
||||||
value={
|
value={
|
||||||
latestGeo.prompts_analyzed != null
|
latestGeo?.prompts_analyzed != null
|
||||||
? formatNum(Number(latestGeo.prompts_analyzed))
|
? formatNum(Number(latestGeo.prompts_analyzed))
|
||||||
: '—'
|
: '—'
|
||||||
}
|
}
|
||||||
hint="Prompts"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
{GEO_ENGINES.map((eng) => {
|
{GEO_ENGINES.map((eng) => {
|
||||||
const v = Number(
|
const v = Number(
|
||||||
(latestGeo.engines || {})[
|
(latestGeo?.engines || {})[
|
||||||
eng.id as keyof typeof latestGeo.engines
|
eng.id as keyof typeof latestGeo.engines
|
||||||
] ?? NaN
|
] ?? NaN
|
||||||
);
|
);
|
||||||
@@ -691,19 +725,16 @@ export default function OrganicoDashboard({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
{!latestGeo && (
|
||||||
)}
|
|
||||||
|
|
||||||
{section.live === 'geo-metrics' && !latestGeo && (
|
|
||||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 space-y-2">
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 space-y-2">
|
||||||
<p className="text-sm font-semibold text-amber-900">
|
<p className="text-sm font-semibold text-amber-900">
|
||||||
GEO sin medición aún
|
Dashboard GEO listo — falta la primera medición
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-amber-800">
|
<p className="text-xs text-amber-800">
|
||||||
A diferencia de GSC/GA4, Share of Model no llega por API
|
A diferencia de SEO (GSC/GA4 por API), Share of Model se
|
||||||
automática: registra un snapshot (prompts × motores) para
|
captura con un estudio de prompts × motores. Usa las
|
||||||
activar el dashboard. Mientras tanto, trabaja las prioridades
|
prioridades expertas y registra el snapshot para rellenar
|
||||||
expertas de la derecha.
|
estas tarjetas.
|
||||||
</p>
|
</p>
|
||||||
{isAgency && (
|
{isAgency && (
|
||||||
<button
|
<button
|
||||||
@@ -717,6 +748,8 @@ export default function OrganicoDashboard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{section.live === 'geo-competitors' && (
|
{section.live === 'geo-competitors' && (
|
||||||
<CompetitorPanel latest={latestGeo} />
|
<CompetitorPanel latest={latestGeo} />
|
||||||
@@ -872,6 +905,151 @@ export default function OrganicoDashboard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function SeoTables({
|
||||||
|
queries,
|
||||||
|
pages,
|
||||||
|
landings,
|
||||||
|
focus = 'all',
|
||||||
|
}: {
|
||||||
|
queries: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
|
||||||
|
pages: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
|
||||||
|
landings: { page: string; sessions: number; users: number }[];
|
||||||
|
focus?: 'all' | 'queries' | 'pages';
|
||||||
|
}) {
|
||||||
|
const showQ = focus === 'all' || focus === 'queries';
|
||||||
|
const showP = focus === 'all' || focus === 'pages';
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{showQ && (
|
||||||
|
<section className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900">
|
||||||
|
Top queries GSC (28d)
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
{queries.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-xs text-gray-500">
|
||||||
|
Sin queries. Verifica Search Console conectado y permisos.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2">Query</th>
|
||||||
|
<th className="px-3 py-2">Clics</th>
|
||||||
|
<th className="px-3 py-2">Imp.</th>
|
||||||
|
<th className="px-3 py-2">CTR</th>
|
||||||
|
<th className="px-3 py-2">Pos.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{queries.slice(0, 20).map((r) => (
|
||||||
|
<tr key={r.key} className="border-t border-gray-100">
|
||||||
|
<td className="px-4 py-2 text-gray-800 max-w-[280px] truncate">
|
||||||
|
{r.key}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums">{formatNum(r.clicks)}</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums">
|
||||||
|
{formatNum(r.impressions)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums">{formatPct(r.ctr)}</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums">
|
||||||
|
{formatNum(r.position, 1)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{showP && (
|
||||||
|
<section className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900">
|
||||||
|
Top páginas GSC (28d)
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
{pages.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-xs text-gray-500">Sin páginas GSC.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2">URL</th>
|
||||||
|
<th className="px-3 py-2">Clics</th>
|
||||||
|
<th className="px-3 py-2">Imp.</th>
|
||||||
|
<th className="px-3 py-2">Pos.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pages.slice(0, 20).map((r) => (
|
||||||
|
<tr key={r.key} className="border-t border-gray-100">
|
||||||
|
<td className="px-4 py-2 text-gray-800 max-w-[320px] truncate">
|
||||||
|
{r.key}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums">{formatNum(r.clicks)}</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums">
|
||||||
|
{formatNum(r.impressions)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums">
|
||||||
|
{formatNum(r.position, 1)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{showP && (
|
||||||
|
<section className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900">
|
||||||
|
Landings orgánicas GA4 (28d)
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
{landings.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-xs text-gray-500">
|
||||||
|
Sin landings orgánicas GA4.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2">Landing</th>
|
||||||
|
<th className="px-3 py-2">Sesiones</th>
|
||||||
|
<th className="px-3 py-2">Usuarios</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{landings.slice(0, 20).map((r) => (
|
||||||
|
<tr key={r.page} className="border-t border-gray-100">
|
||||||
|
<td className="px-4 py-2 text-gray-800 max-w-[320px] truncate">
|
||||||
|
{r.page}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums">
|
||||||
|
{formatNum(r.sessions)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums">{formatNum(r.users)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SectionHero({ section }: { section: OrganicoSection }) {
|
function SectionHero({ section }: { section: OrganicoSection }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export default function SocialDashboard({
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/calendario"
|
href="/dashboard/calendario"
|
||||||
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50"
|
className="text-xs font-semibold px-3 py-1.5 rounded-lg bg-brand-500 text-white hover:bg-brand-600"
|
||||||
>
|
>
|
||||||
Calendario editorial
|
Calendario editorial
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -253,28 +253,55 @@ export function scoreFolderToTenant(
|
|||||||
tenantName: string,
|
tenantName: string,
|
||||||
tenantSlug: string
|
tenantSlug: string
|
||||||
): number {
|
): number {
|
||||||
const f = normalizeName(folderName);
|
// «27-Agua Miia» / «11-Blue Parrot School» → strip numeric prefix
|
||||||
|
const stripped = folderName.replace(/^\d+[\.\-\s_]+/, '').trim();
|
||||||
|
const candidates = [folderName, stripped];
|
||||||
|
const aliasMap: Record<string, string[]> = {
|
||||||
|
bps: ['blue parrot', 'blue parrot school', 'blueparrotschool'],
|
||||||
|
'wine advisors': ['wine advisor', 'wineadvisor'],
|
||||||
|
'de tu a tu formacion': ['detuatu', 'de tu a tu'],
|
||||||
|
'clinica dental sonrisas': ['sonrisas', 'clinica sonrisas'],
|
||||||
|
};
|
||||||
|
let best = 0;
|
||||||
|
for (const raw of candidates) {
|
||||||
|
const f = normalizeName(raw);
|
||||||
const n = normalizeName(tenantName);
|
const n = normalizeName(tenantName);
|
||||||
const s = normalizeName(tenantSlug.replace(/-/g, ' '));
|
const s = normalizeName(tenantSlug.replace(/-/g, ' '));
|
||||||
if (!f || (!n && !s)) return 0;
|
if (!f || (!n && !s)) continue;
|
||||||
if (f === n || f === s) return 100;
|
if (f === n || f === s) best = Math.max(best, 100);
|
||||||
if (n && (f.includes(n) || n.includes(f))) return 85;
|
else if (n && (f.includes(n) || n.includes(f))) best = Math.max(best, 85);
|
||||||
if (s && (f.includes(s) || s.includes(f))) return 80;
|
else if (s && (f.includes(s) || s.includes(f))) best = Math.max(best, 80);
|
||||||
// token overlap
|
else {
|
||||||
|
const aliases = aliasMap[n] || aliasMap[s] || [];
|
||||||
|
for (const a of aliases) {
|
||||||
|
if (f.includes(a) || a.includes(f)) best = Math.max(best, 90);
|
||||||
|
}
|
||||||
const ft = new Set(f.split(' ').filter((t) => t.length > 2));
|
const ft = new Set(f.split(' ').filter((t) => t.length > 2));
|
||||||
const nt = new Set(
|
const nt = new Set(
|
||||||
[...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
|
[...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
|
||||||
);
|
);
|
||||||
if (!ft.size || !nt.size) return 0;
|
if (ft.size && nt.size) {
|
||||||
let hit = 0;
|
let hit = 0;
|
||||||
for (const t of nt) if (ft.has(t)) hit += 1;
|
for (const t of nt) if (ft.has(t)) hit += 1;
|
||||||
const ratio = hit / nt.size;
|
const ratio = hit / nt.size;
|
||||||
if (ratio >= 0.8) return 70;
|
if (ratio >= 0.8) best = Math.max(best, 70);
|
||||||
if (ratio >= 0.5) return 55;
|
else if (ratio >= 0.5) best = Math.max(best, 55);
|
||||||
return Math.round(ratio * 50);
|
else best = Math.max(best, Math.round(ratio * 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listDriveFolders(): Promise<DriveFolder[]> {
|
export async function listDriveFolders(): Promise<DriveFolder[]> {
|
||||||
|
/** Prefer children of agency folder «Clientes»; fallback to all folders. */
|
||||||
|
const under = await listClientFoldersUnderClientes();
|
||||||
|
if (under.folders.length) return under.folders;
|
||||||
|
return listAllDriveFolders();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All folders visible to marketing@ (legacy / fallback). */
|
||||||
|
export async function listAllDriveFolders(): Promise<DriveFolder[]> {
|
||||||
const token = await getValidAccessToken();
|
const token = await getValidAccessToken();
|
||||||
const out: DriveFolder[] = [];
|
const out: DriveFolder[] = [];
|
||||||
let pageToken: string | undefined;
|
let pageToken: string | undefined;
|
||||||
@@ -306,6 +333,123 @@ export async function listDriveFolders(): Promise<DriveFolder[]> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Find the agency «Clientes» root (prefer «03-Clientes»). */
|
||||||
|
export async function findClientesRootFolder(): Promise<DriveFolder | null> {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
const preferred = [
|
||||||
|
'03-Clientes',
|
||||||
|
'Clientes',
|
||||||
|
'01.Clientes - Documentos maestros',
|
||||||
|
];
|
||||||
|
for (const name of preferred) {
|
||||||
|
const safe = name.replace(/'/g, "\\'");
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: `name='${safe}' and mimeType='application/vnd.google-apps.folder' and trashed=false`,
|
||||||
|
fields: 'files(id,name)',
|
||||||
|
pageSize: '5',
|
||||||
|
corpora: 'allDrives',
|
||||||
|
includeItemsFromAllDrives: 'true',
|
||||||
|
supportsAllDrives: 'true',
|
||||||
|
});
|
||||||
|
const r = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files?${params}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!r.ok) continue;
|
||||||
|
const j = (await r.json()) as { files?: DriveFolder[] };
|
||||||
|
const hit = (j.files || []).find((f) => f.name === name);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: any folder whose name contains «Clientes» with many children later
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: "name contains 'Clientes' and mimeType='application/vnd.google-apps.folder' and trashed=false",
|
||||||
|
fields: 'files(id,name)',
|
||||||
|
pageSize: '20',
|
||||||
|
corpora: 'allDrives',
|
||||||
|
includeItemsFromAllDrives: 'true',
|
||||||
|
supportsAllDrives: 'true',
|
||||||
|
});
|
||||||
|
const r = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files?${params}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const j = (await r.json()) as { files?: DriveFolder[] };
|
||||||
|
const files = (j.files || []).filter(
|
||||||
|
(f) => /clientes/i.test(f.name) && !/opensalud|documentos maestros/i.test(f.name)
|
||||||
|
);
|
||||||
|
// Prefer 03-Clientes style
|
||||||
|
return (
|
||||||
|
files.find((f) => /^0?\d+[-.]?\s*clientes$/i.test(f.name)) ||
|
||||||
|
files.find((f) => /clientes/i.test(f.name)) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Direct child folders of «Clientes» — the only valid tenant roots. */
|
||||||
|
export async function listClientFoldersUnderClientes(): Promise<{
|
||||||
|
clientesRoot: DriveFolder | null;
|
||||||
|
folders: DriveFolder[];
|
||||||
|
}> {
|
||||||
|
const root = await findClientesRootFolder();
|
||||||
|
if (!root) return { clientesRoot: null, folders: [] };
|
||||||
|
const children = await listFilesInFolder(root.id);
|
||||||
|
const folders = children
|
||||||
|
.filter((f) => f.mimeType === 'application/vnd.google-apps.folder')
|
||||||
|
.map((f) => ({ id: f.id, name: f.name }));
|
||||||
|
return { clientesRoot: root, folders };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk parents to check fileId is rootId or a descendant. */
|
||||||
|
export async function isUnderDriveFolder(
|
||||||
|
fileId: string,
|
||||||
|
rootId: string,
|
||||||
|
maxHops = 24
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!fileId || !rootId) return false;
|
||||||
|
if (fileId === rootId) return true;
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
let cur = fileId;
|
||||||
|
for (let i = 0; i < maxHops; i++) {
|
||||||
|
const r = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(cur)}?fields=id,parents&supportsAllDrives=true`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!r.ok) return false;
|
||||||
|
const j = (await r.json()) as { parents?: string[] };
|
||||||
|
const parents = j.parents || [];
|
||||||
|
if (!parents.length) return false;
|
||||||
|
if (parents.includes(rootId)) return true;
|
||||||
|
cur = parents[0];
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDriveFileMeta(
|
||||||
|
fileId: string
|
||||||
|
): Promise<{ id: string; name: string; parents?: string[] } | null> {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
const r = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?fields=id,name,parents&supportsAllDrives=true`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!r.ok) return null;
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function matchFoldersToTenants(
|
export async function matchFoldersToTenants(
|
||||||
tenants: { id: string; name: string; slug: string }[],
|
tenants: { id: string; name: string; slug: string }[],
|
||||||
folders: DriveFolder[]
|
folders: DriveFolder[]
|
||||||
|
|||||||
@@ -283,6 +283,97 @@ function isoDay(d: Date): string {
|
|||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchGscTopRows(opts: {
|
||||||
|
siteUrl: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
dimension: 'query' | 'page';
|
||||||
|
rowLimit?: number;
|
||||||
|
}): Promise<
|
||||||
|
{ key: string; clicks: number; impressions: number; ctr: number; position: number }[]
|
||||||
|
> {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
const site = encodeURIComponent(opts.siteUrl);
|
||||||
|
const r = await fetch(
|
||||||
|
`https://www.googleapis.com/webmasters/v3/sites/${site}/searchAnalytics/query`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
startDate: opts.startDate,
|
||||||
|
endDate: opts.endDate,
|
||||||
|
dimensions: [opts.dimension],
|
||||||
|
rowLimit: opts.rowLimit ?? 25,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!r.ok) throw new Error(`GSC ${opts.dimension}: ${(await r.text()).slice(0, 160)}`);
|
||||||
|
const j = (await r.json()) as {
|
||||||
|
rows?: {
|
||||||
|
keys?: string[];
|
||||||
|
clicks?: number;
|
||||||
|
impressions?: number;
|
||||||
|
ctr?: number;
|
||||||
|
position?: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
return (j.rows || []).map((row) => ({
|
||||||
|
key: row.keys?.[0] || '',
|
||||||
|
clicks: n(row.clicks),
|
||||||
|
impressions: n(row.impressions),
|
||||||
|
ctr: row.ctr != null ? Math.round(n(row.ctr) * 10000) / 100 : 0,
|
||||||
|
position: row.position != null ? Math.round(n(row.position) * 10) / 10 : 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGa4TopLandings(opts: {
|
||||||
|
propertyId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
rowLimit?: number;
|
||||||
|
}): Promise<{ page: string; sessions: number; users: number }[]> {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
const prop = opts.propertyId.replace(/^properties\//, '');
|
||||||
|
const r = await fetch(
|
||||||
|
`https://analyticsdata.googleapis.com/v1beta/properties/${prop}:runReport`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
dateRanges: [{ startDate: opts.startDate, endDate: opts.endDate }],
|
||||||
|
dimensions: [{ name: 'landingPagePlusQueryString' }],
|
||||||
|
metrics: [{ name: 'sessions' }, { name: 'totalUsers' }],
|
||||||
|
dimensionFilter: {
|
||||||
|
filter: {
|
||||||
|
fieldName: 'sessionDefaultChannelGroup',
|
||||||
|
stringFilter: { value: 'Organic Search' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
||||||
|
limit: opts.rowLimit ?? 20,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!r.ok) throw new Error(`GA4 landings: ${(await r.text()).slice(0, 160)}`);
|
||||||
|
const j = (await r.json()) as {
|
||||||
|
rows?: {
|
||||||
|
dimensionValues?: { value?: string }[];
|
||||||
|
metricValues?: { value?: string }[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
return (j.rows || []).map((row) => ({
|
||||||
|
page: row.dimensionValues?.[0]?.value || '/',
|
||||||
|
sessions: n(row.metricValues?.[0]?.value),
|
||||||
|
users: n(row.metricValues?.[1]?.value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Sync last N days of GSC + GA4 into organic_daily for one tenant. */
|
/** Sync last N days of GSC + GA4 into organic_daily for one tenant. */
|
||||||
export async function syncOrganicTenant(opts: {
|
export async function syncOrganicTenant(opts: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user