feat(activos): seccion para guardar y gestionar URLs con descripcion del cliente

This commit is contained in:
Marcos Fargas
2026-10-15 02:27:47 +02:00
parent 9a4ed8b66c
commit 4f8ea26938
2 changed files with 820 additions and 13 deletions

View File

@@ -0,0 +1,287 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createClient as createAdminClient } from '@supabase/supabase-js';
import { isAgencyRole, supabaseUrl, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { randomUUID } from 'crypto';
export const dynamic = 'force-dynamic';
export interface ClientUrlAsset {
id: string;
url: string;
title: string;
description: string;
category?: string;
created_at: string;
updated_at?: string;
created_by?: string;
}
function getAdmin() {
const url = supabaseUrl();
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return createAdminClient(url, key);
}
async function gate(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
// Permite autenticación por x-api-key en llamadas directas
const apiKey = req.headers.get('x-api-key');
const validApiKey = process.env.CRON_SECRET || '4v-cron-sec-2026-mkt';
const isApiAuth = Boolean(apiKey && apiKey === validApiKey);
if (!user && !isApiAuth) {
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
}
const queryTenant =
req.nextUrl.searchParams.get('tenant_id') ||
req.nextUrl.searchParams.get('tenantId');
const role = user ? userRole(user) : 'admin';
const isAgency = user ? isAgencyRole(role) : true;
let tenantId: string | null = null;
let label = 'Cliente';
if ((isAgency || isApiAuth) && queryTenant) {
tenantId = queryTenant;
const admin = getAdmin();
const { data: t } = await admin
.from('tenants')
.select('name')
.eq('id', queryTenant)
.maybeSingle();
if (t?.name) label = t.name;
} else if (user) {
const ctx = await resolveActiveTenant(user);
if (ctx.portfolioWide || !ctx.tenantId) {
return {
error: NextResponse.json(
{ error: 'Selecciona un cliente en Contexto antes de gestionar URLs.' },
{ status: 400 }
),
};
}
tenantId = ctx.tenantId;
label = ctx.label;
}
if (!tenantId) {
return {
error: NextResponse.json(
{ error: 'No se pudo identificar el tenant objetivo.' },
{ status: 400 }
),
};
}
return { user, tenantId, label, isAgency };
}
async function loadUrlsForTenant(tenantId: string): Promise<{ docId: string | null; urls: ClientUrlAsset[] }> {
const admin = getAdmin();
const { data: row } = await admin
.from('documents')
.select('id, content')
.eq('tenant_id', tenantId)
.eq('type', 'client_urls')
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
if (!row) return { docId: null, urls: [] };
try {
const parsed = typeof row.content === 'string' ? JSON.parse(row.content) : row.content;
const list = Array.isArray(parsed) ? parsed : parsed?.urls || [];
return { docId: row.id, urls: list };
} catch {
return { docId: row.id, urls: [] };
}
}
async function saveUrlsForTenant(tenantId: string, docId: string | null, urls: ClientUrlAsset[]) {
const admin = getAdmin();
const payload = {
tenant_id: tenantId,
type: 'client_urls',
title: 'Enlaces y URLs del Cliente',
content: JSON.stringify(urls),
updated_at: new Date().toISOString(),
};
if (docId) {
await admin
.from('documents')
.update({
content: payload.content,
updated_at: payload.updated_at,
})
.eq('id', docId);
} else {
await admin
.from('documents')
.insert(payload);
}
}
function normalizeUrl(raw: string): string {
let s = (raw || '').trim();
if (!s) return '';
if (!/^https?:\/\//i.test(s)) {
s = 'https://' + s;
}
return s;
}
export async function GET(req: NextRequest) {
const g = await gate(req);
if ('error' in g && g.error) return g.error;
const { tenantId, label } = g as { tenantId: string; label: string };
const { urls } = await loadUrlsForTenant(tenantId);
return NextResponse.json({
tenant_id: tenantId,
tenant_label: label,
urls,
});
}
export async function POST(req: NextRequest) {
const g = await gate(req);
if ('error' in g && g.error) return g.error;
const { tenantId, user } = g as { tenantId: string; user?: any };
try {
const body = await req.json();
const rawUrl = body.url?.trim();
const description = body.description?.trim();
const title = body.title?.trim() || '';
const category = body.category?.trim() || 'General';
if (!rawUrl) {
return NextResponse.json({ error: 'La URL es obligatoria' }, { status: 400 });
}
if (!description) {
return NextResponse.json({ error: 'El texto descriptivo es obligatorio' }, { status: 400 });
}
const cleanUrl = normalizeUrl(rawUrl);
// Intentar derivar un título limpio si no se proporcionó
let finalTitle = title;
if (!finalTitle) {
try {
const parsed = new URL(cleanUrl);
finalTitle = parsed.hostname.replace(/^www\./, '');
if (parsed.pathname && parsed.pathname !== '/') {
const segment = parsed.pathname.split('/').filter(Boolean).pop();
if (segment) finalTitle += ` / ${segment.replace(/[-_]/g, ' ')}`;
}
} catch {
finalTitle = 'Enlace web';
}
}
const newAsset: ClientUrlAsset = {
id: `url_${Date.now()}_${randomUUID().slice(0, 8)}`,
url: cleanUrl,
title: finalTitle,
description,
category,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
created_by: user?.email || 'sistema',
};
const { docId, urls } = await loadUrlsForTenant(tenantId);
const updatedUrls = [newAsset, ...urls];
await saveUrlsForTenant(tenantId, docId, updatedUrls);
return NextResponse.json({
success: true,
url: newAsset,
urls: updatedUrls,
});
} catch (err: any) {
return NextResponse.json(
{ error: err.message || 'Error al guardar la URL' },
{ status: 500 }
);
}
}
export async function PUT(req: NextRequest) {
const g = await gate(req);
if ('error' in g && g.error) return g.error;
const { tenantId } = g as { tenantId: string };
try {
const body = await req.json();
const id = body.id;
if (!id) {
return NextResponse.json({ error: 'ID de URL no proporcionado' }, { status: 400 });
}
const { docId, urls } = await loadUrlsForTenant(tenantId);
const index = urls.findIndex((u) => u.id === id);
if (index === -1) {
return NextResponse.json({ error: 'URL no encontrada' }, { status: 404 });
}
const current = urls[index];
const cleanUrl = body.url ? normalizeUrl(body.url) : current.url;
const updatedItem: ClientUrlAsset = {
...current,
url: cleanUrl,
title: body.title !== undefined ? body.title.trim() : current.title,
description: body.description !== undefined ? body.description.trim() : current.description,
category: body.category !== undefined ? body.category.trim() : current.category,
updated_at: new Date().toISOString(),
};
const updatedUrls = [...urls];
updatedUrls[index] = updatedItem;
await saveUrlsForTenant(tenantId, docId, updatedUrls);
return NextResponse.json({
success: true,
url: updatedItem,
urls: updatedUrls,
});
} catch (err: any) {
return NextResponse.json(
{ error: err.message || 'Error al actualizar la URL' },
{ status: 500 }
);
}
}
export async function DELETE(req: NextRequest) {
const g = await gate(req);
if ('error' in g && g.error) return g.error;
const { tenantId } = g as { tenantId: string };
const id = req.nextUrl.searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'ID requerido' }, { status: 400 });
}
const { docId, urls } = await loadUrlsForTenant(tenantId);
const updatedUrls = urls.filter((u) => u.id !== id);
await saveUrlsForTenant(tenantId, docId, updatedUrls);
return NextResponse.json({
success: true,
urls: updatedUrls,
});
}

View File

@@ -11,9 +11,20 @@ import {
Loader2,
Trash2,
Upload,
Globe,
Link2,
Copy,
Check,
Edit2,
Plus,
Search,
Tag,
X,
Share2,
} from 'lucide-react';
import { PageHeader } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
import type { ClientUrlAsset } from '@/app/api/assets/urls/route';
type Asset = {
id: string;
@@ -93,7 +104,7 @@ export default function AssetsLibrary({
tenantLabel: string;
isAgency?: boolean;
}) {
const [tab, setTab] = useState<'portal' | 'drive'>('drive');
const [tab, setTab] = useState<'portal' | 'drive' | 'urls'>('drive');
const [assets, setAssets] = useState<Asset[]>([]);
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
const [driveFolder, setDriveFolder] = useState<string | null>(null);
@@ -116,6 +127,37 @@ export default function AssetsLibrary({
const [party, setParty] = useState<'agency' | 'client'>('client');
const [collabBusy, setCollabBusy] = useState(false);
// URLs del Cliente
const [clientUrls, setClientUrls] = useState<ClientUrlAsset[]>([]);
const [urlsLoading, setUrlsLoading] = useState(false);
const [showUrlForm, setShowUrlForm] = useState(false);
const [editingUrlId, setEditingUrlId] = useState<string | null>(null);
const [urlInput, setUrlInput] = useState('');
const [urlTitle, setUrlTitle] = useState('');
const [urlDescription, setUrlDescription] = useState('');
const [urlCategory, setUrlCategory] = useState('Estrategia');
const [savingUrl, setSavingUrl] = useState(false);
const [urlError, setUrlError] = useState<string | null>(null);
const [urlSuccess, setUrlSuccess] = useState<string | null>(null);
const [copiedUrlId, setCopiedUrlId] = useState<string | null>(null);
const [urlSearch, setUrlSearch] = useState('');
const [urlFilterCategory, setUrlFilterCategory] = useState<string>('Todas');
const loadUrls = useCallback(async () => {
setUrlsLoading(true);
try {
const res = await fetch('/api/assets/urls');
const data = await res.json();
if (res.ok && data.urls) {
setClientUrls(data.urls);
}
} catch {
// Ignorar error de red silencioso
} finally {
setUrlsLoading(false);
}
}, []);
const loadPortal = useCallback(async () => {
const res = await fetch('/api/assets');
const data = await res.json();
@@ -150,17 +192,22 @@ export default function AssetsLibrary({
setLoading(true);
setErr(null);
if (tab === 'portal') await loadPortal();
else if (tab === 'urls') await loadUrls();
else {
const current = drivePath[drivePath.length - 1]?.id;
await loadDrive(current);
}
setLoading(false);
}, [tab, loadPortal, loadDrive, drivePath]);
}, [tab, loadPortal, loadDrive, loadUrls, drivePath]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
void loadUrls();
}, [loadUrls]);
const folders = useMemo(() => {
const s = new Set<string>();
for (const a of assets) {
@@ -258,6 +305,124 @@ export default function AssetsLibrary({
await loadPortal();
}
const URL_CATEGORIES = [
'Estrategia',
'Web Corporativa',
'Documentación',
'Presentaciones',
'Competencia',
'Herramientas',
'Otro',
] as const;
const filteredUrls = useMemo(() => {
return clientUrls.filter((item) => {
const q = urlSearch.trim().toLowerCase();
const matchesSearch =
!q ||
item.title?.toLowerCase().includes(q) ||
item.url.toLowerCase().includes(q) ||
item.description?.toLowerCase().includes(q);
const matchesCategory =
urlFilterCategory === 'Todas' || item.category === urlFilterCategory;
return matchesSearch && matchesCategory;
});
}, [clientUrls, urlSearch, urlFilterCategory]);
const categoryCounts = useMemo(() => {
const map: Record<string, number> = { Todas: clientUrls.length };
for (const c of URL_CATEGORIES) {
map[c] = clientUrls.filter((u) => u.category === c).length;
}
return map;
}, [clientUrls]);
async function handleSaveUrl(e: React.FormEvent) {
e.preventDefault();
if (!urlInput.trim() || !urlDescription.trim()) {
setUrlError('La URL y el texto descriptivo son obligatorios.');
return;
}
setSavingUrl(true);
setUrlError(null);
setUrlSuccess(null);
try {
const isEdit = Boolean(editingUrlId);
const method = isEdit ? 'PUT' : 'POST';
const body = {
id: editingUrlId || undefined,
url: urlInput.trim(),
title: urlTitle.trim(),
description: urlDescription.trim(),
category: urlCategory,
};
const res = await fetch('/api/assets/urls', {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'No se pudo guardar la URL');
}
setClientUrls(data.urls || []);
setUrlSuccess(isEdit ? 'Enlace actualizado con éxito' : 'Enlace guardado en la biblioteca');
setUrlInput('');
setUrlTitle('');
setUrlDescription('');
setEditingUrlId(null);
setShowUrlForm(false);
setTimeout(() => setUrlSuccess(null), 3000);
} catch (err: any) {
setUrlError(err.message || 'Error al guardar');
} finally {
setSavingUrl(false);
}
}
async function handleDeleteUrl(id: string) {
if (!confirm('¿Seguro que deseas eliminar este enlace de la biblioteca?')) return;
try {
const res = await fetch(`/api/assets/urls?id=${encodeURIComponent(id)}`, {
method: 'DELETE',
});
const data = await res.json();
if (res.ok && data.urls) {
setClientUrls(data.urls);
}
} catch (err: any) {
setUrlError(err.message || 'Error al eliminar');
}
}
function handleCopyUrl(id: string, url: string) {
navigator.clipboard.writeText(url);
setCopiedUrlId(id);
setTimeout(() => setCopiedUrlId(null), 2000);
}
function handleEditUrl(item: ClientUrlAsset) {
setEditingUrlId(item.id);
setUrlInput(item.url);
setUrlTitle(item.title);
setUrlDescription(item.description);
setUrlCategory(item.category || 'Estrategia');
setShowUrlForm(true);
setUrlError(null);
}
function handleCancelEdit() {
setEditingUrlId(null);
setUrlInput('');
setUrlTitle('');
setUrlDescription('');
setShowUrlForm(false);
setUrlError(null);
}
return (
<div className="space-y-6">
<PageHeader
@@ -265,22 +430,22 @@ export default function AssetsLibrary({
title="Activos"
description={
isAgency
? `Réplica completa de Drive · carpeta «${tenantLabel}» (bajo Clientes) + subidas del portal.`
: `Archivos generados por el Centro de Mando para «${tenantLabel}»: creatividades, calendarios y carpetas de la app. El resto del Drive solo lo ve la agencia.`
? `Réplica completa de Drive · carpeta «${tenantLabel}» (bajo Clientes) + subidas del portal + URLs de referencia.`
: `Archivos y enlaces del Centro de Mando para «${tenantLabel}»: creatividades, calendarios, URLs estratégicas y carpetas de la app.`
}
actions={
isAgency ? <SyncNowButton forceModule="activos" /> : undefined
}
/>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
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.5 py-2 rounded-xl border font-medium transition-all cursor-pointer ${
tab === 'drive'
? 'border-brand-500/40 text-brand-600 bg-brand-50'
: 'border-gray-200 text-gray-500'
? 'border-brand-500/40 text-brand-600 bg-brand-50 shadow-xs'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<HardDrive className="w-3.5 h-3.5" />
@@ -290,14 +455,32 @@ export default function AssetsLibrary({
<button
type="button"
onClick={() => setTab('portal')}
className={`text-xs px-3 py-1.5 rounded-lg border ${
className={`inline-flex items-center gap-1.5 text-xs px-3.5 py-2 rounded-xl border font-medium transition-all cursor-pointer ${
tab === 'portal'
? 'border-brand-500/40 text-brand-600 bg-brand-50'
: 'border-gray-200 text-gray-500'
? 'border-brand-500/40 text-brand-600 bg-brand-50 shadow-xs'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<FileText className="w-3.5 h-3.5" />
{isAgency ? 'Subidas portal' : 'Archivos del portal'}
</button>
<button
type="button"
onClick={() => setTab('urls')}
className={`inline-flex items-center gap-1.5 text-xs px-3.5 py-2 rounded-xl border font-medium transition-all cursor-pointer ${
tab === 'urls'
? 'border-brand-500/40 text-brand-600 bg-brand-50 shadow-xs'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<Globe className="w-3.5 h-3.5" />
URLs y Enlaces del Cliente
{clientUrls.length > 0 && (
<span className="ml-1 px-1.5 py-0.2 rounded-full text-[10px] bg-brand-100 text-brand-700 font-bold">
{clientUrls.length}
</span>
)}
</button>
</div>
{warning && (
@@ -313,11 +496,11 @@ export default function AssetsLibrary({
<>
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
<div className="flex-1">
<label className="text-[11px] uppercase text-gray-500">
<label className="text-[11px] uppercase text-gray-500 font-semibold">
Carpeta
</label>
<input
className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-sm text-gray-900"
className="mt-1 w-full rounded-xl bg-white border border-gray-200 px-3.5 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
placeholder="ej. creatividades/meta o vacío = raíz"
value={folder}
onChange={(e) => setFolder(e.target.value)}
@@ -613,6 +796,343 @@ export default function AssetsLibrary({
</>
)}
{tab === 'urls' && (
<div className="space-y-6">
{/* Header bar with info and Add button */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-2xl bg-white border border-gray-200">
<div>
<h2 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<Globe className="w-4 h-4 text-brand-600" />
URLs y Enlaces de Referencia del Cliente
</h2>
<p className="text-xs text-gray-500 mt-0.5">
Almacena enlaces externos (estrategia en LinkedIn, web corporativa, herramientas, benchmarks o briefs) junto con su descripción.
</p>
</div>
{!showUrlForm && (
<button
type="button"
onClick={() => {
setEditingUrlId(null);
setUrlInput('');
setUrlTitle('');
setUrlDescription('');
setShowUrlForm(true);
}}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-brand-600 hover:bg-brand-700 rounded-xl transition-colors shrink-0 shadow-xs cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Añadir Enlace
</button>
)}
</div>
{/* Form when open */}
{showUrlForm && (
<form
onSubmit={handleSaveUrl}
className="p-5 rounded-2xl border border-brand-200 bg-brand-50/40 space-y-4"
>
<div className="flex items-center justify-between border-b border-brand-200/60 pb-3">
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<Link2 className="w-4 h-4 text-brand-600" />
{editingUrlId ? 'Editar enlace' : 'Nuevo enlace para el cliente'}
</h3>
<button
type="button"
onClick={handleCancelEdit}
className="text-gray-400 hover:text-gray-600 p-1"
>
<X className="w-4 h-4" />
</button>
</div>
{urlError && (
<p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-xl p-3">
{urlError}
</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
URL o Enlace <span className="text-red-500">*</span>
</label>
<div className="relative">
<Globe className="w-4 h-4 text-gray-400 absolute left-3 top-2.5" />
<input
type="url"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
placeholder="https://4ventures.es/estrategia-linkedin.html"
required
className="w-full text-xs pl-9 pr-3 py-2 rounded-xl border border-gray-200 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Título o Nombre identificativo (opcional)
</label>
<input
type="text"
value={urlTitle}
onChange={(e) => setUrlTitle(e.target.value)}
placeholder="Ej: Estrategia LinkedIn B2B 2026"
className="w-full text-xs px-3 py-2 rounded-xl border border-gray-200 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Categoría
</label>
<div className="flex flex-wrap gap-1.5">
{URL_CATEGORIES.map((cat) => (
<button
key={cat}
type="button"
onClick={() => setUrlCategory(cat)}
className={`text-xs px-3 py-1.5 rounded-lg border font-medium transition-colors cursor-pointer ${
urlCategory === cat
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'
}`}
>
{cat}
</button>
))}
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Texto descriptivo de la URL <span className="text-red-500">*</span>
</label>
<textarea
rows={3}
value={urlDescription}
onChange={(e) => setUrlDescription(e.target.value)}
placeholder="Explica qué contiene esta URL, su propósito o cómo debe ser tenida en cuenta (ej: Documento de posicionamiento y pilares de contenido para prospección B2B en LinkedIn)..."
required
className="w-full text-xs p-3 rounded-xl border border-gray-200 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 leading-relaxed"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-brand-200/60">
<button
type="button"
onClick={handleCancelEdit}
disabled={savingUrl}
className="px-3.5 py-2 text-xs font-medium text-gray-600 hover:text-gray-800 bg-white border border-gray-200 rounded-xl transition-colors cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
disabled={savingUrl}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-brand-600 hover:bg-brand-700 rounded-xl transition-colors cursor-pointer shadow-xs disabled:opacity-50"
>
{savingUrl ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Guardando…
</>
) : (
<>
<Check className="w-3.5 h-3.5" />
{editingUrlId ? 'Actualizar Enlace' : 'Guardar Enlace'}
</>
)}
</button>
</div>
</form>
)}
{urlSuccess && (
<div className="p-3 rounded-xl bg-emerald-50 border border-emerald-200 text-xs text-emerald-700 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0" />
<span>{urlSuccess}</span>
</div>
)}
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
<div className="relative flex-1 max-w-sm">
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-2.5" />
<input
type="text"
value={urlSearch}
onChange={(e) => setUrlSearch(e.target.value)}
placeholder="Buscar por título, enlace o descripción…"
className="w-full text-xs pl-9 pr-3 py-2 rounded-xl border border-gray-200 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500"
/>
</div>
<div className="flex flex-wrap gap-1.5 items-center">
{['Todas', ...URL_CATEGORIES].map((c) => {
const count = categoryCounts[c] ?? 0;
const active = urlFilterCategory === c;
return (
<button
key={c}
type="button"
onClick={() => setUrlFilterCategory(c)}
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border font-medium transition-colors cursor-pointer ${
active
? 'border-brand-500 bg-brand-50 text-brand-700 font-semibold'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
}`}
>
<span>{c}</span>
<span
className={`text-[10px] px-1.5 py-0.2 rounded-full font-bold ${
active
? 'bg-brand-600 text-white'
: 'bg-gray-100 text-gray-500'
}`}
>
{count}
</span>
</button>
);
})}
</div>
</div>
{/* Cards listing */}
{urlsLoading ? (
<div className="p-12 text-center text-gray-400 bg-white rounded-2xl border border-gray-200">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-brand-500" />
<p className="text-xs text-gray-500">Cargando enlaces del cliente…</p>
</div>
) : filteredUrls.length === 0 ? (
<div className="p-12 text-center bg-white rounded-2xl border border-gray-200">
<Globe className="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p className="text-sm font-semibold text-gray-800">
{urlSearch || urlFilterCategory !== 'Todas'
? 'No hay URLs que coincidan con la búsqueda'
: 'Sin enlaces guardados para este cliente'}
</p>
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">
{urlSearch || urlFilterCategory !== 'Todas'
? 'Prueba a cambiar los términos de búsqueda o el filtro de categoría.'
: 'Añade enlaces relevantes como perfiles de LinkedIn, páginas de competencia o briefs web para tenerlos siempre a mano.'}
</p>
{!showUrlForm && (
<button
type="button"
onClick={() => setShowUrlForm(true)}
className="mt-4 inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-brand-600 hover:bg-brand-700 rounded-xl transition-colors cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Añadir el primer enlace
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredUrls.map((item) => (
<div
key={item.id}
className="p-4 rounded-2xl border border-gray-200 bg-white hover:border-brand-300 transition-all shadow-xs flex flex-col justify-between"
>
<div>
{/* Header: Category + Date + Action buttons */}
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-brand-50 text-brand-700 border border-brand-200">
{item.category || 'Estrategia'}
</span>
{item.created_at && (
<span className="text-[10px] text-gray-400">
{new Date(item.created_at).toLocaleDateString('es-ES', {
day: '2-digit',
month: 'short',
year: 'numeric',
})}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleCopyUrl(item.id, item.url)}
title="Copiar URL"
className="p-1.5 text-gray-400 hover:text-brand-600 hover:bg-brand-50 rounded-lg transition-colors cursor-pointer"
>
{copiedUrlId === item.id ? (
<Check className="w-3.5 h-3.5 text-emerald-600" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
<button
type="button"
onClick={() => handleEditUrl(item)}
title="Editar"
className="p-1.5 text-gray-400 hover:text-brand-600 hover:bg-brand-50 rounded-lg transition-colors cursor-pointer"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDeleteUrl(item.id)}
title="Eliminar"
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Title */}
<h4 className="text-sm font-semibold text-gray-900 line-clamp-1">
{item.title || item.url}
</h4>
{/* URL link */}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 hover:underline mt-1 font-mono truncate max-w-full"
>
<span className="truncate">{item.url}</span>
<ExternalLink className="w-3 h-3 shrink-0" />
</a>
{/* Description Box */}
<div className="mt-3 p-3 rounded-xl bg-gray-50 border border-gray-100 text-xs text-gray-700 whitespace-pre-wrap leading-relaxed">
{item.description}
</div>
</div>
{/* Card footer */}
<div className="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between">
<span className="text-[10px] text-gray-400 truncate">
{item.created_by ? `Añadido por: ${item.created_by}` : ''}
</span>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-semibold text-brand-600 hover:text-brand-700 transition-colors"
>
Abrir enlace
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
))}
</div>
)}
</div>
)}
{detailId && (
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-lg rounded-2xl border border-gray-200 bg-white overflow-hidden">