feat(F-099): completed feature
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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(/ | /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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user