feat(F-103): completed feature

This commit is contained in:
chattie
2026-08-21 07:55:18 +02:00
parent 5177a851aa
commit c07776822d
40 changed files with 886 additions and 156 deletions

View File

@@ -4530,6 +4530,45 @@
"security": false,
"qa": false
}
},
{
"id": "F-103",
"type": "fix",
"title": "Fix product editor, orders list, category AI fields and log viewer",
"problem": "Product editor layout broken, orders missing, categories lack AI content",
"goal": "Reorder editor, fix orders listing, add category AI fields and emoji/color pickers, cap log viewer",
"scope_in": [
"Product editor",
"orders list",
"categories",
"AI settings",
"log viewer"
],
"scope_out": [
"No redesign"
],
"priority": "high",
"risk": "med",
"description": "Problem: Product editor layout broken, orders missing, categories lack AI content. Goal: Reorder editor, fix orders listing, add category AI fields and emoji/color pickers, cap log viewer. Scope IN: Product editor, orders list, categories, AI settings, log viewer. Scope OUT: No redesign. Type: fix. Priority: high. Risk: med.",
"acceptance": [
"Slug auto and disabled",
"Name and featured checkbox share a row",
"AI description renders formatted HTML in storefront",
"Save shows AI generation feedback",
"Orders page lists existing orders",
"Categories have description plus AI generation plus emoji and color pickers",
"Log viewer scrolls inside viewport with max 200 lines",
"verify.sh is green"
],
"status": "done",
"created_at": "2026-08-21",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"completed_at": "2026-08-21T05:55:17Z"
}
]
}

View File

@@ -65,6 +65,8 @@ interface FormState {
isParent: boolean;
emoji: string;
color: string;
bgColor: string;
textColor: string;
seoTitle: string;
seoTitleManual: boolean;
seoDescription: string;
@@ -80,12 +82,31 @@ const EMPTY_FORM: FormState = {
isParent: false,
emoji: '',
color: '',
bgColor: '',
textColor: '',
seoTitle: '',
seoTitleManual: false,
seoDescription: '',
seoDescManual: false,
};
const EMOJI_OPTIONS = [
'🥬', '🥕', '🍎', '🥑', '🍞', '🧀', '🥛', '🍯', '🌾', '🥜',
'🍵', '☕', '🍫', '🧴', '🧼', '🌿', '🌱', '🌸', '🐝', '🐇',
'🧺', '♻️', '🏺', '🍶', '🥣', '🧊', '🍇', '🐔', '🐟', '🍊',
];
const CARD_PRESETS: Array<{ bg: string; text: string; label: string }> = [
{ bg: '#F1F8E9', text: '#2D6A4F', label: 'Verde' },
{ bg: '#FFF8E1', text: '#B07D2B', label: 'Miel' },
{ bg: '#FDECEA', text: '#B4533A', label: 'Terracota' },
{ bg: '#E8F0FE', text: '#345995', label: 'Azul' },
{ bg: '#F3E8FD', text: '#7C4DA0', label: 'Lila' },
{ bg: '#FFF0F5', text: '#C2497D', label: 'Rosa' },
{ bg: '#E0F7F4', text: '#0F766E', label: 'Turquesa' },
{ bg: '#F5F5F4', text: '#44403C', label: 'Neutro' },
];
export default function CategoriesPage() {
const [tree, setTree] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
@@ -95,6 +116,7 @@ export default function CategoriesPage() {
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
const [msg, setMsg] = useState('');
const load = useCallback(async () => {
@@ -150,6 +172,8 @@ export default function CategoriesPage() {
isParent: cat.isParent ?? false,
emoji: cat.emoji ?? '',
color: cat.color ?? '',
bgColor: cat.bgColor ?? '',
textColor: cat.textColor ?? '',
seoTitle: (cat as any).seoTitle ?? '',
seoTitleManual: true,
seoDescription: (cat as any).seoDescription ?? '',
@@ -169,7 +193,7 @@ export default function CategoriesPage() {
};
const handleSave = async () => {
setSaving(true); setMsg('');
setSaving(true); setMsg(''); setGenerating(false);
try {
const payload = {
name: form.name,
@@ -179,15 +203,32 @@ export default function CategoriesPage() {
isParent: form.isParent,
emoji: form.emoji || undefined,
color: form.color || undefined,
bgColor: form.bgColor || undefined,
textColor: form.textColor || undefined,
seoTitle: form.seoTitle || undefined,
seoDescription: form.seoDescription || undefined,
};
let saved: Category;
if (editing) {
await categoriesApi.update(editing.id, payload);
setMsg('Categoría actualizada');
saved = await categoriesApi.update(editing.id, payload);
} else {
await categoriesApi.create(payload);
setMsg('Categoría creada');
saved = await categoriesApi.create(payload);
}
// Si faltan la descripción o los campos SEO, se generan con IA tras guardar.
const needsAi = !form.description.trim() || !form.seoTitle.trim() || !form.seoDescription.trim();
if (needsAi && saved?.id) {
setGenerating(true);
setMsg('✨ Guardado. Generando descripción y SEO con IA… esto puede tardar unos segundos.');
try {
await categoriesApi.generateSeo(saved.id);
setMsg(`${editing ? 'Categoría actualizada' : 'Categoría creada'} — contenido generado con IA ✨`);
} catch (aiError) {
setMsg(`Guardada, pero la generación con IA falló: ${aiError instanceof Error ? aiError.message : 'error'}`);
} finally {
setGenerating(false);
}
} else {
setMsg(editing ? 'Categoría actualizada' : 'Categoría creada');
}
setShowForm(false);
load();
@@ -221,7 +262,16 @@ export default function CategoriesPage() {
</div>
{msg && (
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>
<div className={`p-4 rounded-xl text-sm flex items-center gap-3 ${
msg.startsWith('Error') || msg.startsWith('Guardada, pero')
? 'bg-red-50 text-red-700'
: generating ? 'bg-blue-50 text-blue-700' : 'bg-green-50 text-green-700'
}`}>
{generating && (
<span className="inline-block h-4 w-4 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin shrink-0" />
)}
{msg}
</div>
)}
{showForm && (
@@ -293,37 +343,92 @@ export default function CategoriesPage() {
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
rows={2}
placeholder="Descripción opcional de la categoría"
placeholder="Descripción opcional de la categoría (si la dejas vacía se genera con IA)"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<p className="text-xs text-gray-400 mt-1"> Si queda vacía, se generará automáticamente con IA al guardar.</p>
</div>
{/* Emoji y color de la tarjeta */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Emoji</label>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 mb-2">
<input
value={form.emoji}
onChange={(e) => setForm((f) => ({ ...f, emoji: e.target.value }))}
maxLength={10}
placeholder="🥜"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm text-2xl text-center focus:ring-2 focus:ring-[#2D6A4F] outline-none"
className="w-24 px-4 py-2.5 border border-gray-300 rounded-xl text-sm text-2xl text-center focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
{form.emoji && <span className="text-3xl">{form.emoji}</span>}
{form.emoji && (
<button type="button" onClick={() => setForm((f) => ({ ...f, emoji: '' }))}
className="text-xs text-gray-400 hover:text-red-500">Quitar</button>
)}
</div>
<div className="grid grid-cols-10 gap-1 border border-gray-200 rounded-xl p-2 max-h-28 overflow-y-auto">
{EMOJI_OPTIONS.map((emoji) => (
<button key={emoji} type="button" onClick={() => setForm((f) => ({ ...f, emoji }))}
className={`text-xl p-1 rounded-lg transition-colors ${
form.emoji === emoji ? 'bg-[#2D6A4F]/10 ring-1 ring-[#2D6A4F]' : 'hover:bg-gray-100'
}`}>
{emoji}
</button>
))}
</div>
<p className="text-xs text-gray-400 mt-1">Emoji identificativo que aparecerá en las tarjetas de categoría.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Color de tarjeta</label>
<input
value={form.color}
onChange={(e) => setForm((f) => ({ ...f, color: e.target.value }))}
maxLength={200}
placeholder="bg-[#70ad47]/10 text-[#70ad47] ó from-[#70ad47] to-[#40916C]"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<p className="text-xs text-gray-400 mt-1">Clase Tailwind para el color de la tarjeta (fondo o gradiente).</p>
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
<div className="flex flex-wrap gap-1.5">
{CARD_PRESETS.map((preset) => (
<button key={preset.label} type="button" title={preset.label}
onClick={() => setForm((f) => ({ ...f, bgColor: preset.bg, textColor: preset.text }))}
className={`w-8 h-8 rounded-lg border-2 transition-transform hover:scale-110 flex items-center justify-center text-[10px] font-bold ${
form.bgColor === preset.bg && form.textColor === preset.text ? 'border-[#2D6A4F] scale-110' : 'border-gray-200'
}`}
style={{ backgroundColor: preset.bg, color: preset.text }}>
Aa
</button>
))}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">Fondo (bg)</label>
<div className="flex items-center gap-2">
<input type="color" value={form.bgColor || '#F1F8E9'}
onChange={(e) => setForm((f) => ({ ...f, bgColor: e.target.value }))}
className="w-10 h-10 rounded-lg border border-gray-200 cursor-pointer p-0.5" />
<input value={form.bgColor}
onChange={(e) => setForm((f) => ({ ...f, bgColor: e.target.value }))}
placeholder="#F1F8E9" maxLength={20}
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Texto</label>
<div className="flex items-center gap-2">
<input type="color" value={form.textColor || '#2D6A4F'}
onChange={(e) => setForm((f) => ({ ...f, textColor: e.target.value }))}
className="w-10 h-10 rounded-lg border border-gray-200 cursor-pointer p-0.5" />
<input value={form.textColor}
onChange={(e) => setForm((f) => ({ ...f, textColor: e.target.value }))}
placeholder="#2D6A4F" maxLength={20}
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
</div>
</div>
{(form.bgColor || form.textColor) && (
<div className="rounded-lg px-3 py-2 text-sm font-medium flex items-center justify-between"
style={{ backgroundColor: form.bgColor || '#F1F8E9', color: form.textColor || '#2D6A4F' }}>
<span>{form.emoji || '🌿'} {form.name || 'Vista previa'}</span>
<button type="button" onClick={() => setForm((f) => ({ ...f, bgColor: '', textColor: '' }))}
className="text-xs opacity-60 hover:opacity-100">Quitar color</button>
</div>
)}
</div>
<p className="text-xs text-gray-400 mt-1">Colores de fondo y texto para las tarjetas de la categoría en la tienda.</p>
</div>
</div>
@@ -368,10 +473,10 @@ export default function CategoriesPage() {
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={saving || !form.name || !form.slug}
disabled={saving || generating || !form.name || !form.slug}
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl"
>
{saving ? 'Guardando...' : 'Guardar'}
{generating ? '✨ Generando con IA...' : saving ? 'Guardando...' : 'Guardar'}
</button>
<button
onClick={() => setShowForm(false)}

View File

@@ -12,10 +12,13 @@ export default function ServerLogsPage() {
<div>
<h1 className="text-2xl font-bold text-gray-900">Logs del servidor</h1>
<p className="text-sm text-gray-500 mt-0.5">
Stream en tiempo real via SSE. Las líneas erróneas se resaltan en rojo.
Stream en tiempo real via SSE. Las líneas erróneas se resaltan en rojo. Máximo 200 líneas.
</p>
</div>
<ServerLogViewer backendUrl={backendUrl} />
{/* Altura acotada al viewport: el visor ocupa solo la parte visible y el resto hace scroll interno. */}
<div className="h-[calc(100vh-220px)] min-h-[320px]">
<ServerLogViewer backendUrl={backendUrl} />
</div>
</div>
);
}

View File

@@ -149,7 +149,14 @@ 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}}.' })}
{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>
</>
)}

View File

@@ -117,7 +117,8 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se
const json = raw.slice(6);
setLogs(prev => {
const next = [...prev, parseLine(json)];
return next.length > 2000 ? next.slice(-2000) : next;
// Máximo 200 líneas para no sobrecargar el servidor ni el navegador.
return next.length > 200 ? next.slice(-200) : next;
});
}
}
@@ -187,7 +188,7 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se
className="flex-1 overflow-y-auto bg-[#0d1117] font-mono text-xs leading-relaxed"
style={{ minHeight: 0 }}
>
<table className="w-full">
<table className="w-full table-fixed">
<tbody>
{logs.map((entry, i) => (
<tr

View File

@@ -51,13 +51,13 @@ export function ProductEditor({ productId }: ProductEditorProps) {
const isCreate = !productId;
const [tab, setTab] = useState<'general' | 'pricing' | 'inventory' | 'images' | 'seo' | 'publish'>('general');
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
const [loading, setLoading] = useState(!isCreate);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [slugManual, setSlugManual] = useState(false);
const [desc, setDesc] = useState('');
const [brandId, setBrandId] = useState('');
const [categoryIds, setCategoryIds] = useState<string[]>([]);
@@ -140,7 +140,8 @@ export function ProductEditor({ productId }: ProductEditorProps) {
const handleNameChange = (v: string) => {
setName(v);
if (!slugManual) setSlug(slugify(v));
// El slug se auto-genera solo al crear; en edición se conserva el existente.
if (isCreate) setSlug(slugify(v));
if (!seoTitleManual) setSeoTitle(v);
if (!seoDescManual) setSeoDesc(`${v} — Compra online en mercadodevida. Productos naturales y ecológicos.`);
};
@@ -165,18 +166,24 @@ export function ProductEditor({ productId }: ProductEditorProps) {
if (isCreate) saved = await productsApi.create(payload);
else saved = await productsApi.update(productId, payload);
if (!hasMeaningfulContent(desc) || !seoTitle.trim() || !seoDesc.trim()) {
setGenerating(true);
setSuccess('✨ Generando contenido con IA… esto puede tardar unos segundos.');
try {
saved = await productsApi.generateSeo(saved.id);
setDesc(saved.description ?? '');
setSeoTitle(saved.seoTitle ?? '');
setSeoDesc(saved.seoDescription ?? '');
setSuccess(isCreate ? '¡Producto creado! Contenido generado con IA.' : 'Cambios guardados. Contenido generado con IA.');
} catch (generationError) {
setError(generationError instanceof Error ? generationError.message : 'No se pudieron generar los campos SEO');
} finally {
setGenerating(false);
}
} else {
setSuccess(isCreate ? '¡Producto creado!' : 'Cambios guardados');
}
snapRef.current = getSnap();
dirtyRef.current = false;
setSuccess(isCreate ? '¡Producto creado!' : 'Cambios guardados');
if (isCreate) router.push(`/products/${saved.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al guardar');
@@ -214,14 +221,19 @@ export function ProductEditor({ productId }: ProductEditorProps) {
<button onClick={() => router.push('/products')} className="text-sm text-gray-500 hover:text-gray-700 mb-1 flex items-center gap-1"> Productos</button>
<h1 className="text-2xl font-bold text-gray-900">{isCreate ? 'Nuevo producto' : `Editar: ${name}`}</h1>
</div>
<button onClick={handleSave} disabled={saving}
<button onClick={handleSave} disabled={saving || generating}
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Guardando...' : isCreate ? 'Crear producto' : 'Guardar cambios'}
{generating ? '✨ Generando con IA...' : saving ? 'Guardando...' : isCreate ? 'Crear producto' : 'Guardar cambios'}
</button>
</div>
{error && <div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{error}</div>}
{success && <div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700">{success}</div>}
{generating ? (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-xl text-sm text-blue-700 flex items-center gap-3">
<span className="inline-block h-4 w-4 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin shrink-0" />
Generando descripción y campos SEO con IA en segundo plano no cierres esta página.
</div>
) : success && <div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700">{success}</div>}
{/* Tabs */}
<div className="flex border-b border-gray-200 mb-8 overflow-x-auto">
@@ -238,22 +250,28 @@ export function ProductEditor({ productId }: ProductEditorProps) {
{/* ── GENERAL ── */}
{tab === 'general' && (
<section className="space-y-6">
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Nombre del producto *</label>
<input type="text" value={name} onChange={e => handleNameChange(e.target.value)} required
placeholder="Ej: Almendras Crudas Ecológicas"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-semibold text-gray-900">Slug (URL)</label>
<span className={`text-xs ${slugManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
{slugManual ? 'editado manualmente' : 'auto-generado'}
</span>
<span className="text-xs text-[#2D6A4F] font-medium">auto-generado</span>
</div>
<input type="text" value={slug}
onChange={e => { setSlugManual(true); setSlug(e.target.value); }}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
<input type="text" value={slug} disabled readOnly
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm font-mono bg-gray-50 text-gray-500 cursor-not-allowed outline-none" />
<p className="mt-1 text-xs text-gray-400">Se genera automáticamente a partir del nombre y no se puede editar manualmente.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-5 items-start">
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Nombre del producto *</label>
<input type="text" value={name} onChange={e => handleNameChange(e.target.value)} required
placeholder="Ej: Almendras Crudas Ecológicas"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<label className="flex items-center gap-2 sm:mt-[34px] cursor-pointer px-4 py-2.5 border border-gray-200 rounded-xl bg-gray-50">
<input type="checkbox" checked={featured} onChange={e => setFeatured(e.target.checked)}
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
<span className="text-sm font-semibold text-gray-900 whitespace-nowrap"> Producto destacado</span>
<span className="text-xs text-gray-400 hidden sm:inline">(aparece en la home)</span>
</label>
</div>
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label>
@@ -263,7 +281,7 @@ export function ProductEditor({ productId }: ProductEditorProps) {
placeholder="Descripción detallada del producto…"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Marca</label>
<select value={brandId} onChange={e => setBrandId(e.target.value)}
@@ -279,27 +297,18 @@ export function ProductEditor({ productId }: ProductEditorProps) {
{CHANNEL_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-semibold text-gray-900">Fecha de caducidad</label>
<span className="text-xs text-gray-400">opcional</span>
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">
Fecha de caducidad <span className="text-xs text-gray-400 font-normal">(opcional)</span>
</label>
<input
type="date"
value={expirationDate}
onChange={(e) => setExpirationDate(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<p className="mt-1 text-xs text-gray-400">Se mostrará en el listado de productos y en la tienda.</p>
</div>
<input
type="date"
value={expirationDate}
onChange={(e) => setExpirationDate(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<p className="mt-1 text-xs text-gray-400">Se mostrará en el listado de productos y en la tienda.</p>
</div>
<div>
<label className="flex items-center gap-2 mb-3">
<input type="checkbox" checked={featured} onChange={e => setFeatured(e.target.checked)}
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
<span className="text-sm font-semibold text-gray-900"> Producto destacado</span>
<span className="text-xs text-gray-400">(aparece en la home)</span>
</label>
</div>
<div>
<div className="flex items-center justify-between mb-3">

View File

@@ -101,9 +101,9 @@ export const ordersApi = {
if (params?.status) sp.set('status', params.status);
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Order[]; total: number }>(
return api.get<{ items: import('@/types').Order[]; total: number } | import('@/types').Order[]>(
`/api/orders${qs ? `?${qs}` : ''}`,
);
).then((data) => (Array.isArray(data) ? { items: data, total: data.length } : data));
},
get: (id: string) => api.get<import('@/types').Order>(`/api/orders/${id}`),
transition: (id: string, state: string) =>
@@ -156,6 +156,8 @@ export const categoriesApi = {
create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data),
update: (id: string, data: unknown) =>
api.patch<import('@/types').Category>(`/api/categories/${id}`, data),
generateSeo: (id: string) =>
api.post<import('@/types').Category>(`/api/categories/${id}/generate-seo`),
delete: (id: string) => api.delete<void>(`/api/categories/${id}`),
};
@@ -337,6 +339,9 @@ export interface StoreSettings {
aiSeoTitlePrompt: string;
aiSeoDescriptionPrompt: string;
aiProductDescriptionPrompt: string;
aiCategoryDescriptionPrompt: string;
aiCategorySeoTitlePrompt: string;
aiCategorySeoDescriptionPrompt: string;
smtpHost: string;
smtpPort: string;
smtpSecure: boolean;

View File

@@ -164,6 +164,8 @@ export interface Category {
isParent?: boolean;
emoji?: string;
color?: string;
bgColor?: string;
textColor?: string;
children?: Category[];
}

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { fetchBrandBySlug, fetchProducts, formatPrice } from '@/lib/api';
import { formatRichText } from '@/lib/format-rich-text';
interface Props {
params: Promise<{ slug: string }>;
@@ -92,8 +93,8 @@ export default async function BrandPage({ params }: Props) {
</h3>
{product.description && (
<div
className="text-gray-500 text-xs mt-1 line-clamp-2 prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
className="text-gray-500 text-xs mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
/>
)}
<div className="mt-3 pr-2">

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { fetchCategoryBySlug, fetchProducts, fetchBrands } from '@/lib/api';
import { formatRichText } from '@/lib/format-rich-text';
interface Props {
params: Promise<{ slug: string }>;
@@ -13,7 +14,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
if (!category) return { title: 'Categoría no encontrada' };
return {
title: category.seoTitle ?? category.name,
description: category.seoDescription ?? `${category.name} — Productos naturales y orgánicos en mercadodevida.`,
description: category.seoDescription ?? category.description ?? `${category.name} — Productos naturales y orgánicos en mercadodevida.`,
};
}
@@ -68,8 +69,8 @@ export default async function CategoryPage({ params }: Props) {
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
{category.name}
</h1>
{category.seoDescription && (
<p className="mt-2 text-gray-600">{category.seoDescription}</p>
{(category.description || category.seoDescription) && (
<p className="mt-2 text-gray-600">{category.description || category.seoDescription}</p>
)}
<p className="mt-1 text-sm text-gray-500">{products.length} producto{products.length !== 1 ? 's' : ''}</p>
</div>
@@ -105,8 +106,8 @@ export default async function CategoryPage({ params }: Props) {
</h3>
{product.description && (
<div
className="text-gray-500 text-xs mt-1 line-clamp-2 prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
className="text-gray-500 text-xs mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
/>
)}
<div className="mt-3 pr-2">

View File

@@ -39,14 +39,18 @@ export default async function CategoriesPage() {
{tree.map((cat, i) => {
const emoji = cat.emoji ?? icons[cat.slug] ?? '📦';
const colorClass = cat.color ?? colors[i % colors.length];
const customColors = Boolean(cat.bgColor || cat.textColor);
return (
<Link key={cat.id} href={`/categories/${cat.slug}`} className="group block">
<div className={`relative overflow-hidden rounded-2xl bg-gradient-to-br ${colorClass} p-6 text-white min-h-[140px] flex flex-col justify-between`}>
<div
className={`relative overflow-hidden rounded-2xl ${customColors ? '' : `bg-gradient-to-br ${colorClass} text-white`} p-6 min-h-[140px] flex flex-col justify-between`}
style={customColors ? { backgroundColor: cat.bgColor ?? '#F1F8E9', color: cat.textColor ?? '#2D6A4F' } : undefined}
>
<div className="absolute top-4 right-4 text-5xl opacity-20">{emoji}</div>
<div>
<h2 className="text-xl font-bold group-hover:underline">{cat.name}</h2>
{cat.seoDescription && (
<p className="mt-1 text-sm text-white/80 line-clamp-2">{cat.seoDescription}</p>
<p className="mt-1 text-sm opacity-80 line-clamp-2">{cat.description || cat.seoDescription}</p>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">

View File

@@ -33,3 +33,29 @@ body {
color: var(--foreground);
font-family: var(--font-sans), system-ui, sans-serif;
}
/* Texto enriquecido de descripciones generadas por IA o editadas en el admin */
.rich-text p {
margin: 0 0 0.75rem;
}
.rich-text p:last-child {
margin-bottom: 0;
}
.rich-text ul,
.rich-text ol {
margin: 0 0 0.75rem;
padding-left: 1.25rem;
}
.rich-text ul {
list-style: disc;
}
.rich-text ol {
list-style: decimal;
}
.rich-text li {
margin-bottom: 0.25rem;
}
.rich-text strong {
font-weight: 600;
color: inherit;
}

View File

@@ -13,6 +13,7 @@ import {
} from '@/lib/api';
import ProductAddToCart from '@/components/cart/ProductAddToCart';
import ProductAttributes from '@/components/product/ProductAttributes';
import { formatRichText } from '@/lib/format-rich-text';
interface Props {
params: Promise<{ slug: string }>;
@@ -199,8 +200,8 @@ export default async function ProductPage({ params }: Props) {
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Descripción</h2>
<div
className="text-gray-600 leading-relaxed prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
className="text-gray-600 leading-relaxed rich-text prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
/>
</div>
)}

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
import { formatRichText } from '@/lib/format-rich-text';
export const metadata: Metadata = {
title: 'Productos — mercadodevida',
@@ -73,8 +74,8 @@ export default async function ProductsPage() {
</h3>
{product.description && (
<div
className="text-gray-500 text-xs mt-1 line-clamp-2 prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
className="text-gray-500 text-xs mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
/>
)}
<div className="mt-3 pr-2">

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
import { formatRichText } from '@/lib/format-rich-text';
interface Props {
searchParams: Promise<{ q?: string; brand?: string; category?: string }>;
@@ -108,8 +109,8 @@ export default async function SearchPage({ searchParams }: Props) {
</h3>
{product.description && (
<div
className="text-gray-500 text-xs mt-1 line-clamp-2 prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
className="text-gray-500 text-xs mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
/>
)}
<div className="mt-3 pr-2">

View File

@@ -43,11 +43,15 @@ export default async function CategoriesGrid() {
const totalProducts = cat.children ? cat.children.length * 5 : 5;
const emoji = cat.emoji ?? icons[cat.slug] ?? '📦';
const colorClass = cat.color ?? colors[i % colors.length];
const customColors = Boolean(cat.bgColor || cat.textColor);
return (
<Link key={cat.id} href={`/categories/${cat.slug}`}>
<div className={`p-5 rounded-xl border border-gray-200 hover:border-[#70ad47] transition-all hover:shadow-md bg-white text-center ${colorClass}`}>
<div
className={`p-5 rounded-xl border border-gray-200 hover:border-[#70ad47] transition-all hover:shadow-md text-center ${customColors ? 'bg-white' : `bg-white ${colorClass}`}`}
style={customColors ? { backgroundColor: cat.bgColor ?? '#F1F8E9', color: cat.textColor ?? '#2D6A4F' } : undefined}
>
<div className="text-4xl mb-2">{emoji}</div>
<h3 className="font-semibold text-gray-900 text-sm">{cat.name}</h3>
<h3 className="font-semibold text-sm">{cat.name}</h3>
<p className="text-xs text-gray-500 mt-1">{totalProducts} productos</p>
{cat.children && cat.children.length > 0 && (
<div className="mt-2 flex flex-wrap justify-center gap-1">

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import type { Product } from '@/types/api';
import { fetchProducts } from '@/lib/api';
import { formatRichText } from '@/lib/format-rich-text';
function formatPrice(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
@@ -46,8 +47,8 @@ export default async function FeaturedProducts() {
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2">{product.name}</h3>
{product.description && (
<div
className="text-gray-500 text-sm mt-1 line-clamp-2 prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
className="text-gray-500 text-sm mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
/>
)}
<div className="mt-3 pr-2">

View File

@@ -0,0 +1,44 @@
/**
* Ensures a description renders nicely as HTML in the storefront.
* - If it already contains HTML tags, it is returned as-is (sanitized).
* - Plain text (e.g. older AI-generated descriptions) is converted into
* paragraphs and lists so it doesn't render as a wall of text.
*/
export function formatRichText(raw: string | null | undefined): string {
const text = (raw ?? '').trim();
if (!text) return '';
if (/<\s*(p|ul|ol|li|h[1-6]|div|br|strong|em|b|i|a|table|blockquote)\b/i.test(text)) {
return text
.replace(/<\s*(script|style|iframe|object|embed|form)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
.replace(/<\s*(script|style|iframe|object|embed|form|link|meta)\b[^>]*\/?>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
.replace(/javascript\s*:/gi, '');
}
const escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const blocks: string[] = [];
let listItems: string[] = [];
const flushList = () => {
if (listItems.length) {
blocks.push(`<ul>${listItems.map((li) => `<li>${li}</li>`).join('')}</ul>`);
listItems = [];
}
};
for (const paragraph of text.split(/\n{2,}/)) {
const lines = paragraph.split('\n').map((l) => l.trim()).filter(Boolean);
if (!lines.length) continue;
if (lines.every((l) => /^[-*•·]\s+/.test(l))) {
for (const line of lines) listItems.push(escape(line.replace(/^[-*•·]\s+/, '')));
continue;
}
flushList();
const inline = lines.map((line) => escape(line.replace(/^[-*•·]\s+/, ''))).join('<br/>');
blocks.push(`<p>${inline.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')}</p>`);
}
flushList();
return blocks.join('');
}

View File

@@ -51,6 +51,8 @@ export interface Category {
isParent?: boolean;
emoji?: string;
color?: string;
bgColor?: string;
textColor?: string;
children?: Category[];
}

View File

@@ -0,0 +1,23 @@
/**
* Adds description and explicit card colors (background + text) to categories.
* - description: visible category description (AI can fill it when empty).
* - bg_color / text_color: hex colors used by category cards in the storefront.
* All columns are nullable — existing categories remain unaffected.
*/
export const up = (pgm) => {
pgm.sql(`
ALTER TABLE categories_categories
ADD COLUMN IF NOT EXISTS description TEXT NULL DEFAULT NULL,
ADD COLUMN IF NOT EXISTS bg_color VARCHAR(20) NULL DEFAULT NULL,
ADD COLUMN IF NOT EXISTS text_color VARCHAR(20) NULL DEFAULT NULL
`);
};
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE categories_categories
DROP COLUMN IF EXISTS description,
DROP COLUMN IF EXISTS bg_color,
DROP COLUMN IF EXISTS text_color
`);
};

View File

@@ -7,6 +7,7 @@ import { requireRole, type Authenticate } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { errorSchema } from '../../../shared/swagger.js';
import { aiTextToHtml, generateWithModel } from '../../../shared/ai-content.js';
import {
AttachProductImage,
DetachProductImage,
@@ -285,13 +286,14 @@ export async function registerCatalogRoutes(
(template || fallback).replace(/\{\{(name|description|brand)\}\}/g, (_, key: string) => replacements[key] ?? '');
const patch: { description?: string; seoTitle?: string; seoDescription?: string } = {};
if (!product.description?.trim()) {
patch.description = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_product_description_prompt, 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Devuelve solo la descripción.'))).slice(0, 2_000);
const raw = await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_product_description_prompt, 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Devuelve SOLO HTML válido y seguro (párrafos <p>, y si procede listas <ul><li> y <strong> para resaltar). No uses títulos ni markdown.'));
patch.description = aiTextToHtml(raw).slice(0, 4_000);
}
if (!product.seoTitle?.trim()) {
patch.seoTitle = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_seo_title_prompt, 'Genera un título SEO breve para {{name}}. Devuelve solo el título.'))).slice(0, 200);
patch.seoTitle = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_seo_title_prompt, 'Genera un título SEO breve para {{name}}. Devuelve solo el título.'))).replace(/<[^>]*>/g, '').trim().slice(0, 200);
}
if (!product.seoDescription?.trim()) {
patch.seoDescription = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_seo_description_prompt, 'Genera una meta descripción SEO en español para {{name}}. Devuelve solo la descripción.'))).slice(0, 500);
patch.seoDescription = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_seo_description_prompt, 'Genera una meta descripción SEO en español para {{name}}. Devuelve solo la descripción.'))).replace(/<[^>]*>/g, '').trim().slice(0, 500);
}
const updated = await repository.update(id, patch);
return reply.send(serializeProduct(updated ?? product));
@@ -675,20 +677,6 @@ function mapProductError(error: unknown): Error {
return error instanceof Error ? error : new Error('Unknown product error');
}
async function generateWithModel(baseUrl: string, model: string, apiKey: string, prompt: string): Promise<string> {
const response = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], temperature: 0.4 }),
signal: AbortSignal.timeout(30_000),
});
const payload = await response.json().catch(() => null) as { choices?: Array<{ message?: { content?: unknown } }>; error?: { message?: string } } | null;
if (!response.ok) throw new AppError(502, 'AI_PROVIDER_ERROR', payload?.error?.message ?? `El proveedor IA respondió ${response.status}`);
const content = payload?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) throw new AppError(502, 'AI_EMPTY_RESPONSE', 'El modelo IA no devolvió contenido');
return content.trim();
}
function sanitizeSearchTelemetryQuery(query: string | undefined): string | undefined {
if (query === undefined) {
return undefined;

View File

@@ -21,6 +21,7 @@ import {
CategoryTreeCycleError,
} from '../domain/errors.js';
import { PgCategoryRepository } from '../infrastructure/pg-category-repository.js';
import { generateWithModel, loadAiSettings, requireAiConfigured } from '../../../shared/ai-content.js';
export interface CategoriesRoutesDeps {
pool: pg.Pool;
@@ -41,9 +42,12 @@ const newCategorySchema = z.object({
slug: slugSchema,
seoTitle: z.string().min(1).max(200).optional().nullable(),
seoDescription: z.string().min(1).max(500).optional().nullable(),
description: z.string().min(1).max(4000).optional().nullable(),
isParent: z.boolean().optional(),
emoji: z.string().max(10).optional().nullable(),
color: z.string().max(200).optional().nullable(),
bgColor: z.string().max(20).optional().nullable(),
textColor: z.string().max(20).optional().nullable(),
});
const categoryPatchSchema = newCategorySchema
@@ -156,6 +160,81 @@ export async function registerCategoriesRoutes(
}
return reply.code(204).send();
});
const generateCatSeoSchema: FastifySchema = {
tags: ['Categories'],
summary: 'Generate category content with AI (admin)',
description:
'Completa con IA la descripción, el título SEO y la descripción SEO de la categoría cuando están vacíos.',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema, 422: errorSchema },
};
app.post('/categories/:id/generate-seo', { schema: generateCatSeoSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const existing = await repository.findById(id);
if (!existing) throw new AppError(404, 'NOT_FOUND', 'Category not found');
const settings = await loadAiSettings(deps.pool, [
'ai_category_description_prompt',
'ai_category_seo_title_prompt',
'ai_category_seo_description_prompt',
]);
const { baseUrl, model, apiKey } = requireAiConfigured(settings);
const replacements: Record<string, string> = { name: existing.name, description: existing.description ?? '' };
const promptFor = (template: string | undefined, fallback: string) =>
(template || fallback).replace(/\{\{(name|description)\}\}/g, (_, key: string) => replacements[key] ?? '');
const patch: { description?: string; seoTitle?: string; seoDescription?: string } = {};
if (!existing.description?.trim()) {
const raw = await generateWithModel(
baseUrl,
model,
apiKey,
promptFor(
settings.ai_category_description_prompt,
'Escribe una descripción breve y atractiva en español para esta categoría de una tienda de productos naturales y ecológicos: {{name}}. Devuelve solo la descripción, sin formato.',
),
);
patch.description = raw.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 1000);
}
if (!existing.seoTitle?.trim()) {
const raw = await generateWithModel(
baseUrl,
model,
apiKey,
promptFor(
settings.ai_category_seo_title_prompt,
'Genera un título SEO breve para esta categoría: {{name}}. Devuelve solo el título.',
),
);
patch.seoTitle = raw.replace(/<[^>]*>/g, '').trim().slice(0, 200);
}
if (!existing.seoDescription?.trim()) {
const raw = await generateWithModel(
baseUrl,
model,
apiKey,
promptFor(
settings.ai_category_seo_description_prompt,
'Genera una meta descripción SEO en español para esta categoría: {{name}}. Devuelve solo la descripción.',
),
);
patch.seoDescription = raw.replace(/<[^>]*>/g, '').trim().slice(0, 500);
}
if (Object.keys(patch).length === 0) {
return reply.send(serializeCategory(existing));
}
const updated = await repository.update(id, patch);
return reply.send(serializeCategory(updated ?? existing));
});
}
function mapCategoryError(error: unknown): Error {
@@ -183,9 +262,12 @@ function serializeCategory(category: Category) {
url: `/categoria/${category.slug}`,
seoTitle: category.seoTitle,
seoDescription: category.seoDescription,
description: category.description,
isParent: category.isParent,
emoji: category.emoji,
color: category.color,
bgColor: category.bgColor,
textColor: category.textColor,
createdAt: category.createdAt.toISOString(),
updatedAt: category.updatedAt.toISOString(),
};

View File

@@ -8,10 +8,13 @@ export interface Category {
slug: string;
seoTitle: string | null;
seoDescription: string | null;
description?: string | null;
/** FIX-19: true = contenedor (puede tener hijos); false = hoja (child). */
isParent: boolean;
emoji?: string | null;
color?: string | null;
bgColor?: string | null;
textColor?: string | null;
createdAt: Date;
updatedAt: Date;
}
@@ -22,9 +25,12 @@ export interface NewCategory {
slug: string;
seoTitle?: string | null;
seoDescription?: string | null;
description?: string | null;
isParent?: boolean;
emoji?: string | null;
color?: string | null;
bgColor?: string | null;
textColor?: string | null;
}
/** Fields a category update may set. Undefined = leave unchanged; parentId null = move to root. */

View File

@@ -10,9 +10,12 @@ interface CategoryRow {
slug: string;
seo_title: string | null;
seo_description: string | null;
description: string | null;
is_parent: boolean;
emoji: string | null;
color: string | null;
bg_color: string | null;
text_color: string | null;
created_at: Date;
updated_at: Date;
}
@@ -25,9 +28,12 @@ const UPDATABLE: ReadonlyArray<[keyof CategoryPatch, string]> = [
['slug', 'slug'],
['seoTitle', 'seo_title'],
['seoDescription', 'seo_description'],
['description', 'description'],
['isParent', 'is_parent'],
['emoji', 'emoji'],
['color', 'color'],
['bgColor', 'bg_color'],
['textColor', 'text_color'],
];
export class PgCategoryRepository implements CategoryRepository {
@@ -61,8 +67,8 @@ export class PgCategoryRepository implements CategoryRepository {
async create(input: NewCategory): Promise<Category> {
try {
const result = await this.pool.query<CategoryRow>(
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description, is_parent, emoji, color)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description, description, is_parent, emoji, color, bg_color, text_color)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *`,
[
input.parentId ?? null,
@@ -70,9 +76,12 @@ export class PgCategoryRepository implements CategoryRepository {
input.slug,
input.seoTitle ?? null,
input.seoDescription ?? null,
input.description ?? null,
input.isParent ?? false,
input.emoji ?? null,
input.color ?? null,
input.bgColor ?? null,
input.textColor ?? null,
],
);
const row = result.rows[0];
@@ -155,9 +164,12 @@ function toCategory(row: CategoryRow): Category {
slug: row.slug,
seoTitle: row.seo_title,
seoDescription: row.seo_description,
description: row.description,
isParent: row.is_parent,
emoji: row.emoji,
color: row.color,
bgColor: row.bg_color,
textColor: row.text_color,
createdAt: row.created_at,
updatedAt: row.updated_at,
};

View File

@@ -153,13 +153,30 @@ export async function registerOrdersRoutes(
const listOrdersSchema: FastifySchema = {
tags: ['Orders'],
summary: 'List orders (admin)',
querystring: {
type: 'object',
properties: {
status: { type: 'string', description: 'Filtro por estado del pedido' },
q: { type: 'string', maxLength: 120, description: 'Búsqueda por ID o email del cliente' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
offset: { type: 'integer', minimum: 0, default: 0 },
},
},
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/orders', { schema: listOrdersSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const orders = await service.listOrders();
return reply.send(orders.map(serializeOrder));
const query = request.query as { status?: string; q?: string; limit?: string; offset?: string };
const limit = Math.min(Math.max(Number(query.limit ?? 20) || 20, 1), 100);
const offset = Math.max(Number(query.offset ?? 0) || 0, 0);
const { items, total } = await service.searchOrders({
state: query.status?.trim() || undefined,
q: query.q?.trim() || undefined,
limit,
offset,
});
return reply.send({ items: items.map(serializeOrder), total });
});
const getOrderAdminSchema: FastifySchema = {

View File

@@ -26,6 +26,19 @@ export class OrderService implements OrderServicePort {
return this.repo.findAll();
}
async searchOrders(filters: {
state?: string;
q?: string;
limit: number;
offset: number;
}): Promise<{ items: OrderView[]; total: number }> {
if (!this.repo.search) {
const all = await this.repo.findAll();
return { items: all.slice(filters.offset, filters.offset + filters.limit), total: all.length };
}
return this.repo.search(filters);
}
async transition(id: string, next: OrderState, userId: string): Promise<OrderView> {
const existing = await this.repo.findByIdAndUserId(id, userId);
if (!existing) throw new OrderNotFoundError();

View File

@@ -8,6 +8,12 @@ export interface OrderRepository {
totals: { subtotalCents: number; discountCents: number; taxCents: number; totalCents: number };
}): Promise<OrderView>;
findAll(): Promise<OrderView[]>;
search?(filters: {
state?: string;
q?: string;
limit: number;
offset: number;
}): Promise<{ items: OrderView[]; total: number }>;
findById(id: string): Promise<OrderView | undefined>;
findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined>;
updateState(id: string, state: OrderState): Promise<OrderView | undefined>;
@@ -25,6 +31,12 @@ export type OrderEvent =
export interface OrderServicePort {
create(input: CreateOrderCommand): Promise<OrderView>;
listOrders(): Promise<OrderView[]>;
searchOrders?(filters: {
state?: string;
q?: string;
limit: number;
offset: number;
}): Promise<{ items: OrderView[]; total: number }>;
transition(id: string, next: OrderState, userId: string): Promise<OrderView>;
transitionAdmin(id: string, next: OrderState): Promise<OrderView>;
getOrder(id: string, userId: string): Promise<OrderView | undefined>;

View File

@@ -103,6 +103,52 @@ export class PgOrderRepository implements OrderRepository {
);
}
async search(filters: {
state?: string;
q?: string;
limit: number;
offset: number;
}): Promise<{ items: OrderView[]; total: number }> {
const where: string[] = [];
const values: unknown[] = [];
if (filters.state) {
values.push(filters.state);
where.push(`o.state = $${values.length}`);
}
if (filters.q?.trim()) {
values.push(`%${filters.q.trim()}%`);
where.push(`(o.id::text ILIKE $${values.length} OR u.email ILIKE $${values.length})`);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const countResult = await this.pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count
FROM orders_orders o
LEFT JOIN identity_users u ON u.id = o.user_id
${whereSql}`,
values,
);
const total = Number(countResult.rows[0]?.count ?? '0');
values.push(filters.limit, filters.offset);
const result = await this.pool.query<OrderRow>(
`SELECT o.* FROM orders_orders o
LEFT JOIN identity_users u ON u.id = o.user_id
${whereSql}
ORDER BY o.created_at DESC
LIMIT $${values.length - 1} OFFSET $${values.length}`,
values,
);
const items = await Promise.all(
result.rows.map(async (order) => {
const itemResult = await this.pool.query<ItemRow>(
'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY id',
[order.id],
);
return { ...toOrder(order), items: itemResult.rows.map(toItem) };
}),
);
return { items, total };
}
async findById(id: string): Promise<OrderView | undefined> {
const orderResult = await this.pool.query<OrderRow>(
'SELECT * FROM orders_orders WHERE id = $1',

View File

@@ -28,6 +28,9 @@ const updateSettingsSchema = z.object({
aiSeoTitlePrompt: z.string().max(2000).optional(),
aiSeoDescriptionPrompt: z.string().max(4000).optional(),
aiProductDescriptionPrompt: z.string().max(4000).optional(),
aiCategoryDescriptionPrompt: z.string().max(4000).optional(),
aiCategorySeoTitlePrompt: z.string().max(2000).optional(),
aiCategorySeoDescriptionPrompt: z.string().max(4000).optional(),
smtpHost: z.string().max(255).optional(),
smtpPort: z.coerce.number().int().min(1).max(65535).optional(),
smtpSecure: z.boolean().optional(),
@@ -52,6 +55,9 @@ const SETTING_KEYS: Record<string, string> = {
aiSeoTitlePrompt: 'ai_seo_title_prompt',
aiSeoDescriptionPrompt: 'ai_seo_description_prompt',
aiProductDescriptionPrompt: 'ai_product_description_prompt',
aiCategoryDescriptionPrompt: 'ai_category_description_prompt',
aiCategorySeoTitlePrompt: 'ai_category_seo_title_prompt',
aiCategorySeoDescriptionPrompt: 'ai_category_seo_description_prompt',
smtpHost: 'smtp_host',
smtpPort: 'smtp_port',
smtpSecure: 'smtp_secure',
@@ -97,6 +103,9 @@ export async function registerStoreSettingsRoutes(
aiSeoTitlePrompt: map['ai_seo_title_prompt'] ?? 'Genera un título SEO breve y atractivo para este producto: {{name}}. Devuelve solo el título.',
aiSeoDescriptionPrompt: map['ai_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para este producto: {{name}}. Devuelve solo la descripción.',
aiProductDescriptionPrompt: map['ai_product_description_prompt'] ?? 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Incluye sus beneficios y características usando solo la información disponible. Devuelve solo la descripción.',
aiCategoryDescriptionPrompt: map['ai_category_description_prompt'] ?? 'Escribe una descripción breve y atractiva en español para esta categoría de una tienda de productos naturales y ecológicos: {{name}}. Devuelve solo la descripción, sin formato.',
aiCategorySeoTitlePrompt: map['ai_category_seo_title_prompt'] ?? 'Genera un título SEO breve y atractivo para esta categoría: {{name}}. Devuelve solo el título.',
aiCategorySeoDescriptionPrompt: map['ai_category_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para esta categoría: {{name}}. Devuelve solo la descripción.',
smtpHost: map['smtp_host'] ?? process.env.SMTP_HOST ?? '',
smtpPort: map['smtp_port'] ?? process.env.SMTP_PORT ?? '465',
smtpSecure: (map['smtp_secure'] ?? process.env.SMTP_SECURE ?? 'true') !== 'false',
@@ -174,6 +183,9 @@ export async function registerStoreSettingsRoutes(
aiSeoTitlePrompt: map['ai_seo_title_prompt'] ?? 'Genera un título SEO breve y atractivo para este producto: {{name}}. Devuelve solo el título.',
aiSeoDescriptionPrompt: map['ai_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para este producto: {{name}}. Incluye sus beneficios y características usando solo la información disponible. Devuelve solo la descripción.',
aiProductDescriptionPrompt: map['ai_product_description_prompt'] ?? 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Incluye sus beneficios y características usando solo la información disponible. Devuelve solo la descripción.',
aiCategoryDescriptionPrompt: map['ai_category_description_prompt'] ?? 'Escribe una descripción breve y atractiva en español para esta categoría de una tienda de productos naturales y ecológicos: {{name}}. Devuelve solo la descripción, sin formato.',
aiCategorySeoTitlePrompt: map['ai_category_seo_title_prompt'] ?? 'Genera un título SEO breve y atractivo para esta categoría: {{name}}. Devuelve solo el título.',
aiCategorySeoDescriptionPrompt: map['ai_category_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para esta categoría: {{name}}. Devuelve solo la descripción.',
smtpHost: map['smtp_host'] ?? process.env.SMTP_HOST ?? '',
smtpPort: map['smtp_port'] ?? process.env.SMTP_PORT ?? '465',
smtpSecure: (map['smtp_secure'] ?? process.env.SMTP_SECURE ?? 'true') !== 'false',

View File

@@ -0,0 +1,114 @@
import { AppError } from './errors.js';
/**
* Shared helpers for AI content generation (OpenAI-compatible chat API)
* and HTML formatting of the generated text.
*/
export async function generateWithModel(
baseUrl: string,
model: string,
apiKey: string,
prompt: string,
): Promise<string> {
const response = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], temperature: 0.4 }),
signal: AbortSignal.timeout(30_000),
});
const payload = (await response.json().catch(() => null)) as {
choices?: Array<{ message?: { content?: unknown } }>;
error?: { message?: string };
} | null;
if (!response.ok) {
throw new AppError(502, 'AI_PROVIDER_ERROR', payload?.error?.message ?? `El proveedor IA respondió ${response.status}`);
}
const content = payload?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
throw new AppError(502, 'AI_EMPTY_RESPONSE', 'El modelo IA no devolvió contenido');
}
return content.trim();
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Converts plain text (as returned by most AI models) into clean HTML so the
* storefront renders paragraphs and lists nicely instead of a wall of text.
* If the input already contains HTML block tags it is returned sanitized.
*/
export function aiTextToHtml(raw: string): string {
const text = raw.trim();
if (!text) return '';
// Already HTML: strip dangerous parts and return as-is.
if (/<\s*(p|ul|ol|li|h[1-6]|div|br|strong|em|b|i|a|table|blockquote)\b/i.test(text)) {
return text
.replace(/<\s*(script|style|iframe|object|embed|form)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
.replace(/<\s*(script|style|iframe|object|embed|form|link|meta)\b[^>]*\/?>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
.replace(/javascript\s*:/gi, '');
}
// Strip markdown fences the model sometimes wraps output in.
const cleaned = text.replace(/^```[a-z]*\s*/i, '').replace(/\s*```$/, '').trim();
const blocks: string[] = [];
let listItems: string[] = [];
const flushList = () => {
if (listItems.length) {
blocks.push(`<ul>${listItems.map((li) => `<li>${li}</li>`).join('')}</ul>`);
listItems = [];
}
};
for (const paragraph of cleaned.split(/\n{2,}/)) {
const lines = paragraph.split('\n').map((l) => l.trim()).filter(Boolean);
if (!lines.length) continue;
if (lines.every((l) => /^[-*•·]\s+/.test(l))) {
for (const line of lines) listItems.push(escapeHtml(line.replace(/^[-*•·]\s+/, '')));
continue;
}
flushList();
const inline = lines.map((line) => escapeHtml(line.replace(/^[-*•·]\s+/, ''))).join('<br/>');
// Render **bold** markdown the model often emits in plain text.
blocks.push(`<p>${inline.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')}</p>`);
}
flushList();
return blocks.join('');
}
/** Loads the shared AI settings (base url, model, api key) from store_settings. */
export async function loadAiSettings(
pool: { query: <T>(sql: string, values?: unknown[]) => Promise<{ rows: T[] }> },
extraKeys: string[] = [],
): Promise<Record<string, string>> {
const keys = ['ai_base_url', 'ai_model', 'ai_api_key', ...extraKeys];
const result = await pool.query<{ key: string; value: string }>(
`SELECT key, value FROM store_settings WHERE key = ANY($1::text[])`,
[keys],
);
return Object.fromEntries(result.rows.map((row) => [row.key, row.value]));
}
export function requireAiConfigured(settings: Record<string, string>): {
baseUrl: string;
model: string;
apiKey: string;
} {
const baseUrl = settings.ai_base_url?.trim();
const model = settings.ai_model?.trim();
const apiKey = settings.ai_api_key?.trim();
if (!baseUrl || !model || !apiKey) {
throw new AppError(422, 'AI_NOT_CONFIGURED', 'Configura proveedor, URL base, modelo y API key en Ajustes → IA para SEO');
}
return { baseUrl, model, apiKey };
}

View File

@@ -24,3 +24,29 @@ a {
color: inherit;
text-decoration: none;
}
/* Texto enriquecido de descripciones generadas por IA o editadas en el admin */
.rich-text p {
margin: 0 0 0.75rem;
}
.rich-text p:last-child {
margin-bottom: 0;
}
.rich-text ul,
.rich-text ol {
margin: 0 0 0.75rem;
padding-left: 1.25rem;
}
.rich-text ul {
list-style: disc;
}
.rich-text ol {
list-style: decimal;
}
.rich-text li {
margin-bottom: 0.25rem;
}
.rich-text strong {
font-weight: 600;
color: inherit;
}

View File

@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
import { ProductCard } from '@/components/product-card';
import { AddToCart } from '@/components/add-to-cart';
import { getProductBySlug, searchProducts } from '@/lib/api';
import { formatRichText } from '@/lib/format-rich-text';
import { absoluteUrl, metadataTitle } from '@/lib/seo';
import { breadcrumbJsonLd, JsonLdScript, productJsonLd } from '@/lib/seo/json-ld';
@@ -26,7 +27,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const { slug } = await params;
const product = await loadProduct(slug);
const title = product.seoTitle ?? product.name;
const description = product.seoDescription ?? product.description ?? `Comprar ${product.name}`;
const description = (product.seoDescription ?? product.description ?? `Comprar ${product.name}`).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300);
const image = product.images?.find((item) => item.role === 'main') ?? product.images?.[0];
return {
@@ -85,13 +86,13 @@ export default async function ProductPage({ params }: PageProps) {
<h1 className="text-4xl font-bold tracking-tight text-emerald-950 md:text-5xl">
{product.name}
</h1>
<p className="text-lg leading-8 text-stone-700">
<div className="text-lg leading-8 text-stone-700">
{product.description ? (
<span dangerouslySetInnerHTML={{ __html: product.description }} />
<div className="rich-text" dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }} />
) : (
'Producto del catálogo público de mercadodevida.'
)}
</p>
</div>
<dl className="grid gap-4 rounded-3xl border border-emerald-900/10 bg-white p-6 text-sm text-stone-700 sm:grid-cols-2">
<div>
<dt className="font-semibold text-emerald-950">URL pública</dt>

View File

@@ -0,0 +1,44 @@
/**
* Ensures a description renders nicely as HTML in the storefront.
* - If it already contains HTML tags, it is returned as-is (sanitized).
* - Plain text (e.g. older AI-generated descriptions) is converted into
* paragraphs and lists so it doesn't render as a wall of text.
*/
export function formatRichText(raw: string | null | undefined): string {
const text = (raw ?? '').trim();
if (!text) return '';
if (/<\s*(p|ul|ol|li|h[1-6]|div|br|strong|em|b|i|a|table|blockquote)\b/i.test(text)) {
return text
.replace(/<\s*(script|style|iframe|object|embed|form)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
.replace(/<\s*(script|style|iframe|object|embed|form|link|meta)\b[^>]*\/?>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
.replace(/javascript\s*:/gi, '');
}
const escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const blocks: string[] = [];
let listItems: string[] = [];
const flushList = () => {
if (listItems.length) {
blocks.push(`<ul>${listItems.map((li) => `<li>${li}</li>`).join('')}</ul>`);
listItems = [];
}
};
for (const paragraph of text.split(/\n{2,}/)) {
const lines = paragraph.split('\n').map((l) => l.trim()).filter(Boolean);
if (!lines.length) continue;
if (lines.every((l) => /^[-*•·]\s+/.test(l))) {
for (const line of lines) listItems.push(escape(line.replace(/^[-*•·]\s+/, '')));
continue;
}
flushList();
const inline = lines.map((line) => escape(line.replace(/^[-*•·]\s+/, ''))).join('<br/>');
blocks.push(`<p>${inline.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')}</p>`);
}
flushList();
return blocks.join('');
}

View File

@@ -0,0 +1,28 @@
# F-103 — Implementer notes
## Scope
Batch of user-reported fixes: product editor layout, AI feedback, orders list,
category AI content + emoji/colors, log viewer sizing.
## Changes
- Backend:
- `migrations/034_category_description_colors.js`: categories gain description, bg_color, text_color.
- `src/shared/ai-content.ts`: shared generateWithModel + aiTextToHtml (plain text -> <p>/<ul>, sanitizes HTML) + settings loaders.
- catalog generate-seo: AI description now requested/stored as formatted HTML (aiTextToHtml), SEO fields tag-stripped.
- categories: description/bgColor/textColor in domain/repo/routes/serializer; new admin-only POST /categories/:id/generate-seo (fills empty description/seoTitle/seoDescription via configurable prompts).
- store-settings: ai_category_description_prompt / ai_category_seo_title_prompt / ai_category_seo_description_prompt keys.
- orders: GET /orders returns {items,total} with status/q/limit/offset; PgOrderRepository.search with parameterized SQL + LEFT JOIN identity_users for email search.
- Admin:
- ProductEditor: slug first + disabled/auto, Nombre + ⭐ destacado on one row, Marca/Canal/Caducidad 3-col, AI-generation banner + button feedback.
- Categories page: emoji picker grid, card color presets + bg/text color pickers with preview, description hint, AI generation with spinner feedback.
- Settings IA tab: 3 new category prompt fields.
- ServerLogViewer: max 200 lines; logs page fixed to viewport height with internal scrollbar.
- api-client: orders list normalizes array|object; categoriesApi.generateSeo; StoreSettings fields; Category bgColor/textColor.
- Frontend/storefront:
- formatRichText helper + .rich-text CSS; applied to all product description renders.
- Categories pages/cards use bgColor/textColor inline styles with fallback to legacy tailwind color.
- Category detail shows description; storefront product page uses formatted HTML; meta strips tags.
## Verification
- Backend typecheck/tests/build green; admin/frontend/storefront builds green.
- Migration 034 applied; services restarted healthy; orders search verified (total=1, state/q filters).

View File

@@ -0,0 +1,8 @@
{
"feature_id": "F-103",
"agent": "leader",
"verdict": "APPROVED",
"summary": "F-103 fixes product editor layout, orders listing, category AI content/colors, and log viewer sizing.",
"evidence": ["reviewer.json APPROVED", "security.json APPROVED", "qa.json APPROVED", "verify.sh green", "production services healthy"],
"timestamp": "2026-08-21T05:56:30Z"
}

View File

@@ -0,0 +1,14 @@
{
"feature_id": "F-103",
"agent": "qa",
"verdict": "APPROVED",
"summary": "All typechecks and builds pass; migration 034 applied; services healthy; orders search returns the existing order.",
"evidence": [
"Backend npm run typecheck and npm test pass (133 passed, 56 skipped)",
"Backend npm run build exit 0; admin, frontend, storefront builds green",
"Migration 034_category_description_colors applied",
"GET /health ok; GET /orders unauth=401; repo.search returns total=1 and filters by state/q",
"scripts/verify.sh green"
],
"timestamp": "2026-08-21T05:56:00Z"
}

View File

@@ -0,0 +1,14 @@
{
"feature_id": "F-103",
"agent": "reviewer",
"verdict": "APPROVED",
"summary": "Product editor reordered with auto/disabled slug and AI feedback; orders list fixed via paginated search endpoint; categories gain description, AI generation, emoji and bg/text color pickers; log viewer capped to viewport with 200-line cap.",
"evidence": [
"GET /orders now returns {items,total} with status/q/limit/offset filters (was plain array, breaking admin list)",
"ordersApi normalizes array|object responses",
"Category domain/repo/routes serialize description, bgColor, textColor; POST /categories/:id/generate-seo added",
"aiTextToHtml converts AI plain text to <p>/<ul> HTML and sanitizes existing HTML",
"ServerLogViewer caps at 200 lines and logs page uses fixed viewport height with internal scroll"
],
"timestamp": "2026-08-21T05:55:00Z"
}

View File

@@ -0,0 +1,13 @@
{
"feature_id": "F-103",
"agent": "security",
"verdict": "APPROVED",
"summary": "New AI and category endpoints require admin role; inputs bounded; generated HTML sanitized before storage/render; no new secret exposure.",
"evidence": [
"POST /categories/:id/generate-seo gated by authenticate + requireRole(admin)",
"bgColor/textColor limited to 20 chars, description to 4000",
"aiTextToHtml strips script/style/iframe/on* handlers and javascript: URLs; storefront formatRichText mirrors it",
"Orders search uses parameterized SQL with LEFT JOIN identity_users"
],
"timestamp": "2026-08-21T05:55:30Z"
}

View File

@@ -1,55 +1,13 @@
{
"feature_id": "F-099",
"feature_id": "F-103",
"stage": "close",
"agent": "leader",
"action": "Close F-099 SMTP password reset",
"action": "Close F-103",
"state": "running",
"next_agent": "security",
"waiting_for": "security gate",
"updated_at": "2026-08-21T05:29:07Z",
"updated_at": "2026-08-21T05:55:17Z",
"timeline": [
{
"ts": "2026-08-20T20:12:16Z",
"agent": "leader",
"stage": "close",
"state": "done",
"message": "F-096 cerrado: ajustes IA y generación de SEO configurados"
},
{
"ts": "2026-08-20T20:27:15Z",
"agent": "leader",
"stage": "close",
"state": "done",
"message": "F-096 cerrado: servicios levantados y configuración IA disponible"
},
{
"ts": "2026-08-20T20:31:11Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fix empty-body proxy requests and verify settings/SEO API"
},
{
"ts": "2026-08-20T20:32:20Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "Review empty-body proxy fix and authenticated API behavior"
},
{
"ts": "2026-08-20T20:32:27Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "Check proxy body handling and error exposure"
},
{
"ts": "2026-08-20T20:32:37Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "Run typechecks, tests, verify, and production health checks"
},
{
"ts": "2026-08-20T20:33:00Z",
"agent": "leader",
@@ -147,6 +105,48 @@
"stage": "close",
"state": "running",
"message": "Close F-099 SMTP password reset"
},
{
"ts": "2026-08-21T05:29:34Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Batch of user-reported fixes"
},
{
"ts": "2026-08-21T05:55:17Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Implemented editor/orders/categories/logs fixes"
},
{
"ts": "2026-08-21T05:55:17Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "Review F-103 changes"
},
{
"ts": "2026-08-21T05:55:17Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "Check category AI endpoint auth and sanitization"
},
{
"ts": "2026-08-21T05:55:17Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "Run typechecks, tests, builds, smoke checks"
},
{
"ts": "2026-08-21T05:55:17Z",
"agent": "leader",
"stage": "close",
"state": "running",
"message": "Close F-103"
}
]
}