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>
358 lines
14 KiB
TypeScript
358 lines
14 KiB
TypeScript
'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>
|
||
);
|
||
}
|