feat(F-099): completed feature

This commit is contained in:
chattie
2026-08-21 07:29:07 +02:00
parent 3f1d08382f
commit 5177a851aa
41 changed files with 922 additions and 131 deletions

View File

@@ -9,6 +9,7 @@ const TABS = [
{ 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'];
@@ -32,8 +33,12 @@ export default function SettingsPage() {
if (!form) return;
setSaving(true); setErr(''); setMsg('');
try {
const { aiApiKey, ...settingsWithoutKey } = form;
const updated = await settingsApi.update(aiApiKey ? form : settingsWithoutKey);
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);
@@ -44,7 +49,7 @@ export default function SettingsPage() {
}
};
const field = (key: Exclude<keyof FormData, 'aiApiKeyConfigured'>, label: string, opts?: { type?: string; placeholder?: string; rows?: number; hint?: string }) => (
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 ? (
@@ -144,6 +149,27 @@ export default function SettingsPage() {
{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. Variables: {{name}}, {{description}} y {{brand}}.' })}
</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>
</>
)}

View File

@@ -17,6 +17,16 @@ export async function GET(req: NextRequest) {
const backendRes = await fetch(`${API}/${path}`, {
headers: { Cookie: cookies },
});
if (path === 'admin/logs/stream') {
return new Response(backendRes.body, {
status: backendRes.status,
headers: {
'Content-Type': backendRes.headers.get('content-type') ?? 'text/event-stream',
'Cache-Control': backendRes.headers.get('cache-control') ?? 'no-cache',
Connection: 'keep-alive',
},
});
}
const data = await backendRes.json().catch(() => null);
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
return resp;

View File

@@ -85,10 +85,9 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se
const connect = async () => {
try {
// Read cookie from document
const cookies = document.cookie;
const response = await fetch(`${backendUrl}/admin/logs/stream`, {
headers: { Cookie: cookies },
// Use the same-origin proxy so the httpOnly backoffice cookie is forwarded server-side.
const response = await fetch('/api/admin/logs/stream', {
credentials: 'include',
});
if (!response.ok || aborted) {

View File

@@ -256,18 +256,21 @@ function Toolbar({ disabled }: { disabled?: boolean }) {
function InitialHtmlPlugin({ initialHtml }: { initialHtml: string }) {
const [editor] = useLexicalComposerContext();
const applied = useRef(false);
useEffect(() => {
if (applied.current) return;
if (!initialHtml || !initialHtml.trim()) return;
applied.current = true;
let currentHtml = '';
editor.getEditorState().read(() => {
currentHtml = $generateHtmlFromNodes(editor, null);
});
if (currentHtml.trim() === initialHtml.trim()) return;
editor.update(() => {
const root = $getRoot();
root.clear();
if (!initialHtml.trim()) return;
const parser = new DOMParser();
const domDoc = parser.parseFromString(`<div>${initialHtml}</div>`, 'text/html');
const nodes = $generateNodesFromDOM(editor, domDoc.body);
const root = $getRoot();
root.clear();
const ensured: ElementNode[] = [];
nodes.forEach((node) => {
if ($isElementNode(node)) {
@@ -278,9 +281,7 @@ function InitialHtmlPlugin({ initialHtml }: { initialHtml: string }) {
ensured.push(p);
}
});
if (ensured.length === 0) {
ensured.push($createParagraphNode());
}
if (ensured.length === 0) ensured.push($createParagraphNode());
ensured.forEach((n) => root.append(n));
});
}, [editor, initialHtml]);

View File

@@ -42,6 +42,10 @@ function slugify(text: string): string {
return text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
function hasMeaningfulContent(value: string): boolean {
return value.replace(/<[^>]*>/g, '').replace(/&nbsp;|&#160;/gi, ' ').trim().length > 0;
}
export function ProductEditor({ productId }: ProductEditorProps) {
const router = useRouter();
const isCreate = !productId;
@@ -111,7 +115,8 @@ export function ProductEditor({ productId }: ProductEditorProps) {
setState(p.state);
setSeoTitle((p as any).seoTitle ?? ''); setSeoTitleManual(true);
setSeoDesc((p as any).seoDescription ?? ''); setSeoDescManual(true);
setExpirationDate((p as any).expirationDate ?? '');
const rawExpirationDate = String((p as any).expirationDate ?? '');
setExpirationDate(rawExpirationDate ? rawExpirationDate.slice(0, 10) : '');
snapRef.current = getSnapRef.current();
setLoading(false);
}).catch(() => {
@@ -145,7 +150,7 @@ export function ProductEditor({ productId }: ProductEditorProps) {
try {
const payload = {
name, slug,
description: desc || undefined,
description: hasMeaningfulContent(desc) ? desc.trim() : null,
brandId: brandId || undefined,
categoryIds,
channels,
@@ -159,9 +164,10 @@ export function ProductEditor({ productId }: ProductEditorProps) {
let saved: Product;
if (isCreate) saved = await productsApi.create(payload);
else saved = await productsApi.update(productId, payload);
if (!seoTitle.trim() || !seoDesc.trim()) {
if (!hasMeaningfulContent(desc) || !seoTitle.trim() || !seoDesc.trim()) {
try {
saved = await productsApi.generateSeo(saved.id);
setDesc(saved.description ?? '');
setSeoTitle(saved.seoTitle ?? '');
setSeoDesc(saved.seoDescription ?? '');
} catch (generationError) {

View File

@@ -27,10 +27,12 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
if (!res.ok) {
const body = await res.json().catch(() => ({ message: 'Request failed' }));
const envelope = body as { code?: string; message?: string; error?: { code?: string; message?: string } };
const error = envelope.error ?? envelope;
throw new ApiError(
res.status,
(body as { code?: string }).code ?? 'REQUEST_FAILED',
(body as { message?: string }).message ?? 'Request failed',
error.code ?? 'REQUEST_FAILED',
error.message ?? 'Request failed',
);
}
@@ -334,6 +336,14 @@ export interface StoreSettings {
aiApiKeyConfigured?: boolean;
aiSeoTitlePrompt: string;
aiSeoDescriptionPrompt: string;
aiProductDescriptionPrompt: string;
smtpHost: string;
smtpPort: string;
smtpSecure: boolean;
smtpUser: string;
smtpPass: string;
smtpPassConfigured?: boolean;
smtpFrom: string;
}
export const settingsApi = {

File diff suppressed because one or more lines are too long