212 lines
11 KiB
TypeScript
212 lines
11 KiB
TypeScript
'use client';
|
|
import { useState, useEffect } from 'react';
|
|
import { settingsApi, 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: '✉️' },
|
|
] 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');
|
|
|
|
useEffect(() => {
|
|
settingsApi.get().then(d => {
|
|
setData(d); setForm(d);
|
|
}).catch(() => setErr('Error al cargar ajustes')).finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
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>
|
|
))}
|
|
</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/...' })}
|
|
</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>
|
|
</>
|
|
)}
|
|
|
|
{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>
|
|
</>
|
|
)}
|
|
|
|
<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>
|
|
);
|
|
}
|