Files
4ventures-client-portal/src/components/ClientIntegrationsPanel.tsx
4Ventures 99895fe918 feat: conectar GA4/GSC/Drive desde la ficha (verificar + sync real)
Al guardar ID o URL en el cliente se valida con OAuth de agencia;
Drive acepta URL o folder id; Orgánico se rellena si hay acceso.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:27:15 +02:00

358 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { ArrowLeft, Loader2, Power } from 'lucide-react';
type Integ = {
id?: string;
provider: string;
status: string;
label?: string | null;
external_account_id?: string | null;
error_message?: string | null;
last_sync_at?: string | null;
enabled?: boolean;
meta?: { folder_name?: string; match_score?: number } | null;
};
type Provider = { id: string; label: string; group: string };
const PLACEHOLDERS: Record<string, string> = {
meta_ads: 'act_… (Ad Account ID)',
google_ads: 'Customer ID MCC / cuenta',
ga4: 'G-… o Property ID',
search_console: 'sc-domain:ejemplo.com',
tag_manager: 'GTM-…',
clientify: 'ID / workspace Clientify',
klaviyo: 'Account / list ID',
mailchimp: 'Audience ID',
google_drive: 'URL de carpeta Drive o folder id',
wordpress: 'https://sitio.com + Application Password',
prestashop: 'https://tienda.com + API key',
shopify: 'mitienda.myshopify.com + Admin API token',
instagram: '@handle o Instagram Business Account ID',
facebook: 'Page ID / facebook.com/…',
tiktok: '@handle o Advertiser / Business ID',
linkedin: 'Company Page ID / linkedin.com/company/…',
};
const LIVE_FROM_FICHA = new Set(['ga4', 'search_console', 'google_drive']);
export default function ClientIntegrationsPanel({
tenantId,
tenantName,
embedded = false,
onChanged,
}: {
tenantId: string;
tenantName: string;
/** Hide page chrome when rendered inside ClientFicha. */
embedded?: boolean;
onChanged?: () => void;
}) {
const [integrations, setIntegrations] = useState<Integ[]>([]);
const [providers, setProviders] = useState<Provider[]>([]);
const [warning, setWarning] = useState<string | null>(null);
const [flash, setFlash] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const [accountDraft, setAccountDraft] = useState<Record<string, string>>({});
const [followed, setFollowed] = useState(true);
const [followBusy, setFollowBusy] = useState(false);
async function load() {
const res = await fetch(
`/api/admin/integrations?tenantId=${encodeURIComponent(tenantId)}`
);
const data = await res.json();
setIntegrations(data.integrations || []);
setProviders(data.providers || []);
setWarning(data.warning || null);
const drafts: Record<string, string> = {};
for (const i of data.integrations || []) {
drafts[i.provider] = i.external_account_id || '';
}
setAccountDraft(drafts);
}
useEffect(() => {
void load();
fetch('/api/admin/portfolio')
.then((r) => r.json())
.then((d) => {
const row = (d.portfolio || []).find(
(t: { id: string }) => t.id === tenantId
);
if (row) setFollowed(row.agency_followed !== false);
})
.catch(() => {});
}, [tenantId]);
async function patch(provider: string, body: Record<string, unknown>) {
setSaving(provider);
setFlash(null);
const res = await fetch('/api/admin/integrations', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenantId, provider, ...body }),
});
const data = await res.json().catch(() => ({}));
if (data.activation?.message) setFlash(data.activation.message);
else if (data.error) setFlash(String(data.error));
else if (LIVE_FROM_FICHA.has(provider) && body.status === 'connected') {
setFlash(res.ok ? 'Conexión verificada' : 'No se pudo activar');
}
await load();
onChanged?.();
setSaving(null);
}
async function toggleFollow() {
setFollowBusy(true);
const next = !followed;
await fetch('/api/admin/tenants', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenantId, followed: next }),
});
setFollowed(next);
setFollowBusy(false);
}
const byProvider = new Map(integrations.map((i) => [i.provider, i]));
const groups = [
{ id: 'paid', title: 'Paid media' },
{ id: 'social', title: 'Redes sociales (orgánico)' },
{ id: 'analytics', title: 'Analítica y medición' },
{ id: 'crm', title: 'CRM' },
{ id: 'email', title: 'Email / lifecycle' },
{ id: 'cms', title: 'Web / e‑commerce' },
{ id: 'files', title: 'Archivos' },
{ id: 'ops', title: 'Operaciones' },
];
return (
<div className="space-y-6">
{!embedded ? (
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="flex items-center gap-3">
<Link
href="/dashboard/agencia"
className="text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4" />
</Link>
<div>
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">
{tenantName}
</h2>
<p className="text-sm text-gray-600 mt-0.5">
Hub de conexiones · asocia cuentas y decide qué entra a la
plataforma
</p>
</div>
</div>
<button
type="button"
disabled={followBusy}
onClick={() => void toggleFollow()}
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${
followed
? 'border-emerald-300 text-emerald-700 bg-emerald-50'
: 'border-gray-200 text-gray-600'
}`}
>
{followBusy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Power className="w-3.5 h-3.5" />
)}
{followed ? 'Cliente activo en portafolio' : 'Archivado'}
</button>
</div>
) : null}
{warning && (
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
{warning}
</p>
)}
{flash && (
<p className="text-xs text-gray-800 border border-gray-200 bg-gray-50 rounded-lg px-3 py-2">
{flash}
</p>
)}
<p className="text-[11px] text-gray-600">
GA4, Search Console y Drive: al guardar/conectar se verifica con el
Google de agencia (marketing@) y se sincronizan datos. URL de carpeta =
mismo que folder id. Primero conecta Google en Agencia si aún no.
</p>
{groups.map((g) => {
const list = providers.filter((p) => p.group === g.id);
if (!list.length) return null;
return (
<section key={g.id} className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-600">
{g.title}
</h3>
<ul className="space-y-2">
{list.map((p) => {
const row = byProvider.get(p.id);
const status = row?.status || 'disconnected';
const enabled = row?.enabled !== false;
const folderHint =
p.id === 'google_drive'
? row?.meta?.folder_name || row?.label
: null;
return (
<li
key={p.id}
className={`rounded-xl border p-4 space-y-3 ${
enabled
? 'border-gray-200 bg-white'
: 'border-gray-200 bg-gray-50 opacity-80'
}`}
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-900">
{p.label}
</p>
{folderHint && (
<p className="text-[11px] text-brand-700 mt-0.5">
Carpeta: {folderHint}
</p>
)}
{row?.last_sync_at ? (
<p className="text-[10px] text-gray-500">
Último sync real{' '}
{new Date(row.last_sync_at).toLocaleString('es-ES')}
</p>
) : status === 'connected' ? (
<p className="text-[10px] text-amber-700">
Referencia guardada (aún sin sync de datos)
</p>
) : null}
{row?.error_message && (
<p className="text-[11px] text-red-600 mt-1">
{row.error_message}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={saving === p.id}
onClick={() => void patch(p.id, { enabled: !enabled })}
className={`text-[11px] font-semibold px-2.5 py-1 rounded-md border ${
enabled
? 'border-emerald-300 text-emerald-700 bg-emerald-50'
: 'border-gray-200 text-gray-600'
}`}
title="Si está OFF, no cuenta en salud ni se usa"
>
{enabled ? 'Canal ON' : 'Canal OFF'}
</button>
<button
type="button"
disabled={saving === p.id || !enabled}
onClick={() =>
void patch(p.id, {
status:
status === 'connected'
? 'disconnected'
: 'connected',
externalAccountId: accountDraft[p.id] || '',
})
}
className={`text-[11px] font-semibold px-2.5 py-1 rounded-md border ${
status === 'connected'
? 'border-brand-300 text-brand-700 bg-brand-50'
: 'border-gray-200 text-gray-600'
}`}
>
{status === 'connected' ? 'Desconectar' : 'Conectar'}
</button>
</div>
</div>
<div className="flex gap-2 items-center">
<input
className="flex-1 text-xs rounded-lg bg-white border border-gray-200 px-3 py-2 text-gray-900 placeholder:text-gray-400"
placeholder={PLACEHOLDERS[p.id] || 'Account ID'}
value={accountDraft[p.id] || ''}
disabled={!enabled}
onChange={(e) =>
setAccountDraft((d) => ({
...d,
[p.id]: e.target.value,
}))
}
/>
<button
type="button"
disabled={saving === p.id || !enabled}
onClick={() =>
void patch(p.id, {
status: LIVE_FROM_FICHA.has(p.id)
? 'connected'
: status === 'disconnected'
? 'pending'
: status,
externalAccountId: accountDraft[p.id] || '',
})
}
className="text-xs font-semibold px-3 py-2 rounded-lg border border-gray-200 text-gray-700 hover:text-gray-900 hover:bg-gray-50 inline-flex items-center gap-1"
>
{saving === p.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : LIVE_FROM_FICHA.has(p.id) ? (
'Guardar y conectar'
) : (
'Guardar ID'
)}
</button>
</div>
{p.id === 'google_drive' && (
<p className="text-[10px] text-gray-500">
Pega la URL completa o solo el id (
…/folders/<strong>ID</strong>). Da igual: se normaliza y
se comprueba acceso con marketing@.
</p>
)}
{(p.id === 'ga4' || p.id === 'search_console') && (
<p className="text-[10px] text-gray-500">
Al conectar se verifica que marketing@ vea la propiedad /
site y se cargan datos en Orgánico.
</p>
)}
{(p.id === 'wordpress' ||
p.id === 'prestashop' ||
p.id === 'shopify') && (
<p className="text-[10px] text-gray-500">
Guarda la URL del site (y key/token si aplica) en el
campo. Sync automático de pedidos/contenidos: siguiente
fase.
</p>
)}
{(p.id === 'instagram' ||
p.id === 'facebook' ||
p.id === 'tiktok' ||
p.id === 'linkedin') && (
<p className="text-[10px] text-gray-500">
Orgánico (páginas/perfiles). Distinto de Meta Ads /
TikTok Ads. OAuth live y publicación: siguiente fase.
</p>
)}
</li>
);
})}
</ul>
</section>
);
})}
</div>
);
}