Organic social channels per client in the agency hub, separate from paid Meta Ads. Co-authored-by: Cursor <cursoragent@cursor.com>
318 lines
12 KiB
TypeScript
318 lines
12 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: 'Folder ID (auto-match o pegar)',
|
||
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/…',
|
||
};
|
||
|
||
export default function ClientIntegrationsPanel({
|
||
tenantId,
|
||
tenantName,
|
||
}: {
|
||
tenantId: string;
|
||
tenantName: string;
|
||
}) {
|
||
const [integrations, setIntegrations] = useState<Integ[]>([]);
|
||
const [providers, setProviders] = useState<Provider[]>([]);
|
||
const [warning, setWarning] = 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);
|
||
await fetch('/api/admin/integrations', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tenantId, provider, ...body }),
|
||
});
|
||
await load();
|
||
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' },
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<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-400 hover:text-white"
|
||
>
|
||
<ArrowLeft className="w-4 h-4" />
|
||
</Link>
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-white tracking-tight">
|
||
{tenantName}
|
||
</h2>
|
||
<p className="text-sm text-gray-400 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-500/40 text-emerald-400'
|
||
: 'border-gray-700 text-gray-400'
|
||
}`}
|
||
>
|
||
{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>
|
||
|
||
{warning && (
|
||
<p className="text-xs text-amber-400 border border-amber-500/20 rounded-lg px-3 py-2">
|
||
{warning}
|
||
</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-500">
|
||
{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-800 bg-[#111827]'
|
||
: 'border-gray-800/50 bg-[#0d121c] opacity-70'
|
||
}`}
|
||
>
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||
<div>
|
||
<p className="text-sm font-medium text-white">
|
||
{p.label}
|
||
</p>
|
||
{folderHint && (
|
||
<p className="text-[11px] text-brand-400 mt-0.5">
|
||
Carpeta: {folderHint}
|
||
</p>
|
||
)}
|
||
{row?.last_sync_at && (
|
||
<p className="text-[10px] text-gray-500">
|
||
Último sync{' '}
|
||
{new Date(row.last_sync_at).toLocaleString('es-ES')}
|
||
</p>
|
||
)}
|
||
{row?.error_message && (
|
||
<p className="text-[11px] text-red-400 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] px-2.5 py-1 rounded-md border ${
|
||
enabled
|
||
? 'border-emerald-500/40 text-emerald-400'
|
||
: 'border-gray-700 text-gray-500'
|
||
}`}
|
||
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] px-2.5 py-1 rounded-md border ${
|
||
status === 'connected'
|
||
? 'border-brand-500/40 text-brand-400'
|
||
: 'border-gray-700 text-gray-400'
|
||
}`}
|
||
>
|
||
{status === 'connected' ? 'Desconectar' : 'Conectar'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 items-center">
|
||
<input
|
||
className="flex-1 text-xs rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-white placeholder:text-gray-600"
|
||
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: status === 'disconnected' ? 'pending' : status,
|
||
externalAccountId: accountDraft[p.id] || '',
|
||
})
|
||
}
|
||
className="text-xs px-3 py-2 rounded-lg border border-gray-700 text-gray-300 hover:text-white hover:border-gray-500 inline-flex items-center gap-1"
|
||
>
|
||
{saving === p.id ? (
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
) : (
|
||
'Guardar ID'
|
||
)}
|
||
</button>
|
||
</div>
|
||
{p.id === 'google_drive' && (
|
||
<p className="text-[10px] text-gray-500">
|
||
Preferible: Agencia → Conectar Drive → Re-emparejar
|
||
(asigna carpeta por nombre). Aquí puedes pegar un Folder
|
||
ID manual.
|
||
</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>
|
||
);
|
||
}
|