Files
4ventures-client-portal/src/components/ClientIntegrationsPanel.tsx
4Ventures eb00256bea feat: add Instagram, Facebook, TikTok and LinkedIn slots
Organic social channels per client in the agency hub, separate from
paid Meta Ads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 18:58:29 +02:00

318 lines
12 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: '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>
);
}