369 lines
20 KiB
TypeScript
369 lines
20 KiB
TypeScript
'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>
|
||
);
|
||
}
|