Files
mercadodevida/project/apps/admin/src/app/(dashboard)/settings/page.tsx
2026-08-25 22:08:20 +02:00

369 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { useState, useEffect } from 'react';
import Link from 'next/link';
import { settingsApi, type AboutInfo, type StoreSettings } from '@/lib/api-client';
type FormData = StoreSettings;
const TABS = [
{ id: 'general', label: 'General', icon: '⚙️' },
{ id: 'social', label: 'Redes sociales', icon: '🌐' },
{ id: 'footer', label: 'Footer', icon: '📄' },
{ id: 'ai', label: 'IA para SEO', icon: '✨' },
{ id: 'smtp', label: 'SMTP / Email', icon: '✉️' },
{ id: 'couriers', label: 'Transportistas', icon: '🚚' },
{ id: 'about', label: 'About', icon: '' },
] as const;
type TabId = (typeof TABS)[number]['id'];
export default function SettingsPage() {
const [data, setData] = useState<FormData | null>(null);
const [form, setForm] = useState<FormData | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
const [tab, setTab] = useState<TabId>('general');
const [couriersText, setCouriersText] = useState('');
const [about, setAbout] = useState<AboutInfo | null>(null);
const [aboutLoading, setAboutLoading] = useState(false);
const loadAbout = async () => {
setAboutLoading(true);
try {
setAbout(await settingsApi.about());
} catch {
setErr('Error al cargar información del sistema');
} finally {
setAboutLoading(false);
}
};
useEffect(() => {
Promise.all([
settingsApi.get().then(d => {
setData(d); setForm(d);
setCouriersText((d.couriers ?? []).join('\n'));
}),
loadAbout(),
]).catch(() => setErr('Error al cargar ajustes')).finally(() => setLoading(false));
}, []);
const handleCouriersChange = (text: string) => {
setCouriersText(text);
const list = text.split('\n').map(c => c.trim()).filter(Boolean).slice(0, 30);
setForm(f => f ? { ...f, couriers: list } : f);
};
const formatDuration = (seconds: number) => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
const infoRow = (label: string, value: string | number) => (
<div className="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">{label}</p>
<p className="mt-1 break-words text-sm font-semibold text-gray-800">{value}</p>
</div>
);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!form) return;
setSaving(true); setErr(''); setMsg('');
try {
const { aiApiKey, smtpPass, ...settingsWithoutSecrets } = form;
const updated = await settingsApi.update({
...settingsWithoutSecrets,
...(aiApiKey ? { aiApiKey } : {}),
...(smtpPass ? { smtpPass } : {}),
});
setData(updated); setForm(updated);
setMsg('Cambios guardados correctamente');
setTimeout(() => setMsg(''), 4000);
} catch (er) {
setErr(er instanceof Error ? er.message : 'Error al guardar');
} finally {
setSaving(false);
}
};
const field = (key: Exclude<keyof FormData, 'aiApiKeyConfigured' | 'smtpPassConfigured' | 'smtpSecure'>, label: string, opts?: { type?: string; placeholder?: string; rows?: number; hint?: string }) => (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
{opts?.rows ? (
<textarea value={form?.[key] ?? ''} onChange={e => setForm(f => f ? { ...f, [key]: e.target.value } : f)}
rows={opts.rows} placeholder={opts.placeholder}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none" />
) : (
<input type={opts?.type ?? 'text'} value={form?.[key] ?? ''}
onChange={e => setForm(f => f ? { ...f, [key]: e.target.value } : f)}
placeholder={opts?.placeholder} maxLength={key === 'contactAddress' ? 400 : 200}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
)}
{opts?.hint && <p className="text-xs text-gray-400 mt-1">{opts.hint}</p>}
</div>
);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Ajustes de tienda</h1>
<p className="text-sm text-gray-500 mt-0.5">Configuración general de la tienda visible para los clientes.</p>
</div>
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-3 rounded-xl border border-green-200">{msg}</div>}
{err && <div className="bg-red-50 text-red-700 text-sm px-4 py-3 rounded-xl border border-red-200">{err}</div>}
{loading ? (
<div className="bg-white rounded-2xl border border-gray-200 p-12 flex items-center justify-center text-gray-400 text-sm">Cargando...</div>
) : (
<div className="flex gap-6">
{/* Sidebar tabs */}
<nav className="w-48 shrink-0 space-y-1">
{TABS.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`w-full text-left px-4 py-2.5 rounded-xl text-sm font-medium transition-colors ${
tab === t.id
? 'bg-[#2D6A4F] text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="mr-2">{t.icon}</span>
{t.label}
</button>
))}
<div className="my-3 border-t border-gray-200" />
<p className="px-4 text-xs font-semibold uppercase tracking-wide text-gray-400">Sistema</p>
<Link href="/settings/tax-rates" className="flex w-full items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">
<span>💰</span> IVA
</Link>
<Link href="/settings/audit" className="flex w-full items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">
<span>📋</span> Auditoría
</Link>
<Link href="/settings/logs" className="flex w-full items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">
<span>🖥</span> Logs
</Link>
</nav>
{/* Form area */}
<form onSubmit={handleSave} className="flex-1 bg-white rounded-2xl border border-gray-200 overflow-hidden">
{tab === 'general' && (
<>
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Información general</h2>
<p className="text-xs text-gray-400 mt-0.5">Nombre y eslogan de la tienda.</p>
</div>
<div className="p-6 space-y-5">
{field('storeName', 'Nombre de la tienda', { placeholder: 'Mercado de Vida' })}
{field('storeTagline', 'Eslogan', { placeholder: 'Productos naturales y ecológicos' })}
</div>
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Contacto</h2>
<p className="text-xs text-gray-400 mt-0.5">Datos visibles en la página de contacto.</p>
</div>
<div className="p-6 space-y-5">
{field('contactEmail', 'Email de contacto', { type: 'email', placeholder: 'info@mercadodevida.es' })}
{field('contactPhone', 'Teléfono', { placeholder: '+34 600 000 000' })}
{field('contactAddress', 'Dirección', { placeholder: 'Calle ejemplo, ciudad', rows: 3 })}
</div>
</>
)}
{tab === 'social' && (
<>
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Redes sociales</h2>
<p className="text-xs text-gray-400 mt-0.5">Enlaces a perfiles sociales mostrados en el footer.</p>
</div>
<div className="p-6 space-y-5">
{field('facebookUrl', 'Facebook', { placeholder: 'https://facebook.com/...' })}
{field('instagramUrl', 'Instagram', { placeholder: 'https://instagram.com/...' })}
{field('twitterUrl', 'X (Twitter)', { placeholder: 'https://x.com/...' })}
{field('pinterestUrl', 'Pinterest', { placeholder: 'https://pinterest.com/...' })}
</div>
</>
)}
{tab === 'ai' && (
<>
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Modelo de IA para SEO</h2>
<p className="text-xs text-gray-400 mt-0.5">Se usa solo para completar campos SEO que estén vacíos.</p>
</div>
<div className="p-6 space-y-5">
{field('aiProvider', 'Proveedor', { placeholder: 'OpenAI compatible' })}
{field('aiBaseUrl', 'URL base de la API', { type: 'url', placeholder: 'https://api.openai.com/v1' })}
{field('aiModel', 'Modelo', { placeholder: 'gpt-4o-mini' })}
{field('aiApiKey', 'API key', { type: 'password', placeholder: form?.aiApiKeyConfigured ? 'API key configurada (escribe para reemplazar)' : 'sk-...' })}
{field('aiSeoTitlePrompt', 'Prompt para Título SEO', { rows: 4, hint: 'Usa {{name}}, {{description}} y {{brand}} como variables.' })}
{field('aiSeoDescriptionPrompt', 'Prompt para Descripción SEO (Google)', { rows: 5, hint: 'Usa {{name}}, {{description}} y {{brand}} como variables.' })}
{field('aiProductDescriptionPrompt', 'Prompt para Descripción del producto', { rows: 5, hint: 'Se usa solo cuando la descripción normal está vacía. Debe pedir HTML (párrafos y listas) para que se renderice bonito en la tienda. Variables: {{name}}, {{description}} y {{brand}}.' })}
<div className="pt-2 border-t border-gray-100">
<h3 className="text-sm font-semibold text-gray-800 mb-1">Categorías</h3>
<p className="text-xs text-gray-400 mb-4">Se usan cuando la descripción o los campos SEO de una categoría están vacíos. Variables: {'{{name}}'} y {'{{description}}'}.</p>
</div>
{field('aiCategoryDescriptionPrompt', 'Prompt para Descripción de categoría', { rows: 4, hint: 'Variables: {{name}} y {{description}}.' })}
{field('aiCategorySeoTitlePrompt', 'Prompt para Título SEO de categoría', { rows: 4, hint: 'Variables: {{name}} y {{description}}.' })}
{field('aiCategorySeoDescriptionPrompt', 'Prompt para Descripción SEO de categoría', { rows: 5, hint: 'Variables: {{name}} y {{description}}.' })}
</div>
</>
)}
{tab === 'smtp' && (
<>
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Correo SMTP</h2>
<p className="text-xs text-gray-400 mt-0.5">Se usa para enviar enlaces de recuperación de contraseña.</p>
</div>
<div className="p-6 space-y-5">
{field('smtpHost', 'Servidor SMTP', { placeholder: 'ssl0.ovh.net' })}
{field('smtpPort', 'Puerto', { type: 'number', placeholder: '465' })}
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={form?.smtpSecure ?? true} onChange={e => setForm(f => f ? { ...f, smtpSecure: e.target.checked } : f)} />
Conexión segura SSL/TLS
</label>
{field('smtpUser', 'Usuario / cuenta de correo', { type: 'email', placeholder: 'info@mercadodevida.es' })}
{field('smtpPass', 'Contraseña SMTP', { type: 'password', placeholder: form?.smtpPassConfigured ? 'Contraseña configurada (escribe para reemplazar)' : 'Contraseña del buzón' })}
{field('smtpFrom', 'Remitente', { type: 'email', placeholder: 'info@mercadodevida.es' })}
<div className="border-t border-gray-200 pt-4 mt-2">
<h3 className="mb-3 text-sm font-semibold text-gray-800">Reportes automáticos</h3>
{field('smtpReportEmail', 'Email destino de reportes', { type: 'email', placeholder: 'direccion@ejemplo.com' })}
<p className="text-xs text-gray-400 -mt-2">Recibirás el reporte de cierre de caja por email al cerrar cada sesión.</p>
</div>
</div>
</>
)}
{tab === 'couriers' && (
<>
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Transportistas</h2>
<p className="text-xs text-gray-400 mt-0.5">Lista editable de transportistas. Se usa al marcar un pedido como enviado y aparece en el email al cliente. Uno por línea.</p>
</div>
<div className="p-6 space-y-5">
<textarea
value={couriersText}
onChange={e => handleCouriersChange(e.target.value)}
rows={8}
placeholder={'Correos\nSEUR\nMRW'}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none font-mono"
/>
<p className="text-xs text-gray-400">Máximo 30 transportistas, cada nombre de hasta 60 caracteres.</p>
</div>
</>
)}
{tab === 'about' && (
<>
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 flex items-center justify-between gap-4">
<div>
<h2 className="text-base font-semibold text-gray-800">About / Sistema</h2>
<p className="text-xs text-gray-400 mt-0.5">Versiones de servicios e información segura del servidor.</p>
</div>
<button
type="button"
onClick={() => void loadAbout()}
disabled={aboutLoading}
className="rounded-xl border border-gray-300 px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-white disabled:opacity-50"
>
{aboutLoading ? 'Actualizando…' : 'Actualizar'}
</button>
</div>
<div className="p-6 space-y-6">
{about ? (
<>
<div className="rounded-2xl border border-[#2D6A4F]/20 bg-[#2D6A4F]/5 p-5">
<p className="text-xs font-medium uppercase tracking-wide text-[#2D6A4F]">Versión producto</p>
<p className="mt-1 text-3xl font-bold text-[#1B4332]">v{about.productVersion}</p>
<p className="mt-2 text-xs text-gray-500">Generado: {new Date(about.generatedAt).toLocaleString('es-ES')}</p>
</div>
<section>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Servicios</h3>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{about.services.map(service => (
<div key={service.id} className="rounded-xl border border-gray-200 p-4">
<p className="text-sm font-semibold text-gray-900">{service.name}</p>
<p className="mt-1 font-mono text-xs text-gray-500">{service.id}</p>
<p className="mt-3 inline-flex rounded-full bg-gray-100 px-2.5 py-1 font-mono text-xs font-semibold text-gray-700">v{service.version}</p>
</div>
))}
</div>
</section>
<section>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Servidor</h3>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{infoRow('Host', about.server.hostname)}
{infoRow('Sistema', `${about.server.platform} ${about.server.arch}`)}
{infoRow('Kernel / Release', about.server.release)}
{infoRow('Node.js', about.server.nodeVersion)}
{infoRow('PID backend', about.server.pid)}
{infoRow('Zona horaria', about.server.timezone)}
{infoRow('Uptime host', formatDuration(about.server.uptimeSeconds))}
{infoRow('Uptime proceso', formatDuration(about.server.processUptimeSeconds))}
{infoRow('Arranque proceso', new Date(about.server.processStartedAt).toLocaleString('es-ES'))}
{infoRow('CPU cores', about.server.cpuCount)}
{infoRow('RAM total', `${about.server.totalMemoryMb} MB`)}
{infoRow('RAM libre', `${about.server.freeMemoryMb} MB`)}
</div>
</section>
<section>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Proceso y base de datos</h3>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{infoRow('RSS proceso', `${about.server.processMemoryMb.rss} MB`)}
{infoRow('Heap usado', `${about.server.processMemoryMb.heapUsed} MB`)}
{infoRow('Heap total', `${about.server.processMemoryMb.heapTotal} MB`)}
{infoRow('PostgreSQL', about.database.postgresVersion)}
</div>
</section>
</>
) : (
<div className="rounded-xl border border-gray-200 p-8 text-center text-sm text-gray-500">
{aboutLoading ? 'Cargando información del sistema…' : 'No se pudo cargar la información del sistema.'}
</div>
)}
</div>
</>
)}
{tab === 'footer' && (
<>
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Footer</h2>
<p className="text-xs text-gray-400 mt-0.5">Texto del pie de página del sitio.</p>
</div>
<div className="p-6 space-y-5">
{field('footerText', 'Texto del pie de página', {
placeholder: '© {{year}} Mercado de Vida. Todos los derechos reservados.',
rows: 3,
hint: 'Usa {{year}} para insertar el año actual automáticamente.',
})}
</div>
</>
)}
{tab !== 'about' && (
<div className="px-6 py-5 bg-gray-50 border-t border-gray-200 flex justify-end">
<button type="submit" disabled={saving || !form}
className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Guardando...' : 'Guardar cambios'}
</button>
</div>
)}
</form>
</div>
)}
</div>
);
}